From 48fb137c13116dc3c26befcc11df3e8693d605c4 Mon Sep 17 00:00:00 2001
From: Anais Saraiva
Date: Mon, 21 Sep 2026 16:31:55 +0200
Subject: [PATCH] fixes
---
docs/ARCHITECTURE.md | 5 ++++
docs/FRONTEND.md | 4 ++-
frontend/src/components/AdminPanel.tsx | 3 +-
frontend/src/components/SharePage.tsx | 2 +-
frontend/src/components/dialogs.tsx | 10 +++----
frontend/src/components/ui.tsx | 4 ++-
frontend/src/lib/api.ts | 3 +-
frontend/src/lib/types.ts | 1 +
src/db.ts | 3 +-
src/drivers/registry.ts | 38 ++++++++++++++++++++++++++
src/index.ts | 19 +++++++++++--
11 files changed, 78 insertions(+), 14 deletions(-)
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 0ed2df7..8adfbc0 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -103,6 +103,11 @@ Models are addressed as `"driver:model"` (split on the first `:`).
- `OllamaDriver`: model discovery via `GET {OLLAMA_HOST}/api/tags`, chat via
`POST /api/chat` with NDJSON streaming.
+- `GET /api/models` is served from an in-memory cache in `DriverRegistry`
+ (`MODEL_CACHE_TTL_MS` = 60 s) so logins don't hit Ollama every time.
+ Concurrent misses share one in-flight upstream fetch. Admins pass
+ `?refresh=1` to force a fresh fetch (used by the "Ollama models" view);
+ the flag is ignored for non-admins. Admin pull/delete invalidate the cache.
- `ApiDriverBase`: shared OpenAI-compatible `/models` + `/chat/completions`
(SSE) implementation; concrete drivers only set base URL, key, and fallback
model list. A driver is enabled only when its `*_ENABLED` flag is set **and**
diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md
index 4d231b6..c54fa90 100644
--- a/docs/FRONTEND.md
+++ b/docs/FRONTEND.md
@@ -50,7 +50,9 @@ read-only `SharePage` (no auth), everything else renders the authenticated app.
tabbed hub (Accounts / Features / Mail) reusing `AdminPanel`, server
feature `Switch`es (`registrationEnabled`, `ocrEnabled` via
`/api/admin/settings`), SMTP status card. Follows the platform theme.
-- `SharePage.tsx` — public read-only view, fetches `/api/share/:id` unauthenticated.
+- `SharePage.tsx` — public read-only view, fetches `/api/share/:id` unauthenticated
+ (`{ title, topic, model, authorName, authorAvatarUrl, sharedAt, messages }`);
+ user messages render the author's avatar via `Avatar` (URL or initials).
- `Markdown.tsx` — `react-markdown` + GFM; code blocks get a language label and
a copy button.
diff --git a/frontend/src/components/AdminPanel.tsx b/frontend/src/components/AdminPanel.tsx
index f3338cf..bfb8f5d 100644
--- a/frontend/src/components/AdminPanel.tsx
+++ b/frontend/src/components/AdminPanel.tsx
@@ -32,7 +32,8 @@ export function OllamaModelsModal({ open, onClose }: { open: boolean; onClose: (
async function refreshInstalled() {
try {
- const res = await api.models();
+ // Admin view: force a fresh upstream fetch, bypassing the 60s user cache.
+ const res = await api.models({ refresh: true });
setInstalled(res.models.filter((m) => m.driver === "ollama"));
} catch { /* ignore */ }
}
diff --git a/frontend/src/components/SharePage.tsx b/frontend/src/components/SharePage.tsx
index 8b7d146..270c656 100644
--- a/frontend/src/components/SharePage.tsx
+++ b/frontend/src/components/SharePage.tsx
@@ -65,7 +65,7 @@ export function SharePage({ publicId }: { publicId: string }) {
{chat?.messages.map((m, i) => (
{m.role === "user"
- ?
+ ?
:
}
diff --git a/frontend/src/components/dialogs.tsx b/frontend/src/components/dialogs.tsx
index 31eaad8..cd30d10 100644
--- a/frontend/src/components/dialogs.tsx
+++ b/frontend/src/components/dialogs.tsx
@@ -794,7 +794,7 @@ function SecuritySection({ onKeyRotated }: { onKeyRotated?: () => void }) {
Access key
Rotate your access key. Old sessions are revoked immediately — you'll log back in with the new key.
-
@@ -805,7 +805,7 @@ function SecuritySection({ onKeyRotated }: { onKeyRotated?: () => void }) {
{totpOn === false && !secret && (
<>
Add a 6-digit code from your authenticator app on top of your access key.
-
Enable two-factor
+
Enable two-factor
>
)}
{secret && (
@@ -825,7 +825,7 @@ function SecuritySection({ onKeyRotated }: { onKeyRotated?: () => void }) {
inputMode="numeric"
className="text-center tracking-[0.4em]"
/>
-
Confirm
+
Confirm
)}
@@ -843,7 +843,7 @@ function SecuritySection({ onKeyRotated }: { onKeyRotated?: () => void }) {
/>
-
+
Disable 2FA
@@ -863,7 +863,7 @@ function SecuritySection({ onKeyRotated }: { onKeyRotated?: () => void }) {
Danger zone
Permanently delete your account with all chats, shares and sessions. Cannot be undone.
- setConfirmDelete(true)} className="!text-red-600 dark:!text-red-400">
+ setConfirmDelete(true)} className="!text-red-600 dark:!text-red-400">
Delete my account
diff --git a/frontend/src/components/ui.tsx b/frontend/src/components/ui.tsx
index 86c9884..0506698 100644
--- a/frontend/src/components/ui.tsx
+++ b/frontend/src/components/ui.tsx
@@ -11,9 +11,10 @@ type ButtonProps = React.ButtonHTMLAttributes & {
size?: "sm" | "md" | "lg" | "icon";
};
-export function Button({ variant = "primary", size = "md", className, ...props }: ButtonProps) {
+export function Button({ variant = "primary", size = "md", type = "button", className, ...props }: ButtonProps) {
return (
{
navigator.clipboard.writeText(text).then(() => {
setDone(true);
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index 7d418ad..2e2aba8 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -139,7 +139,8 @@ export const api = {
unshare: (id: string) => req<{ ok: boolean }>(`/api/conversations/${id}/share`, { method: "DELETE" }),
publicShare: (publicId: string) => req(`/api/share/${publicId}`, {}, false),
- models: () => req<{ models: DriverModel[] }>("/api/models"),
+ models: (opts: { refresh?: boolean } = {}) =>
+ req<{ models: DriverModel[] }>(`/api/models${opts.refresh ? "?refresh=1" : ""}`),
/** Access-request wishlist (public, no auth). */
accessRequest: (a: { username: string; email: string; message: string }) =>
diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts
index 204615f..ed3adeb 100644
--- a/frontend/src/lib/types.ts
+++ b/frontend/src/lib/types.ts
@@ -50,6 +50,7 @@ export interface SharedChat {
topic: string;
model: string;
authorName: string;
+ authorAvatarUrl: string;
sharedAt: number;
messages: ChatMessage[];
}
diff --git a/src/db.ts b/src/db.ts
index 906df86..d6c5a33 100644
--- a/src/db.ts
+++ b/src/db.ts
@@ -387,7 +387,7 @@ export function deleteShare(convId: string): void {
getDb().query("DELETE FROM shares WHERE conv_id = ?").run(convId);
}
-export function getShareByPublic(publicId: string): { conv: ConversationRow; messages: MessageRow[]; authorName: string; sharedAt: number } | null {
+export function getShareByPublic(publicId: string): { conv: ConversationRow; messages: MessageRow[]; authorName: string; authorAvatarUrl: string; sharedAt: number } | null {
const d = getDb();
const s = d.query("SELECT * FROM shares WHERE public_id = ?").get(publicId) as { conv_id: string; created_at: number } | null;
if (!s) return null;
@@ -398,6 +398,7 @@ export function getShareByPublic(publicId: string): { conv: ConversationRow; mes
conv,
messages: getMessages(conv.id).filter((m) => m.role !== "system"),
authorName: author?.display_name || "Someone",
+ authorAvatarUrl: author?.avatar_url || "",
sharedAt: s.created_at,
};
}
diff --git a/src/drivers/registry.ts b/src/drivers/registry.ts
index 90717f7..8c4b7e6 100644
--- a/src/drivers/registry.ts
+++ b/src/drivers/registry.ts
@@ -10,8 +10,16 @@ import type { DriverModel, LLMDriver } from "./types.ts";
*/
const PRIORITY = ["ollama", "mistral", "glm", "puppeteer-openai", "deepseek", "anthropic", "openai"];
+/** How long the user-facing model list stays in memory before re-hitting upstream. */
+export const MODEL_CACHE_TTL_MS = 60_000;
+
export class DriverRegistry {
private drivers: Map;
+ // In-memory model list cache: user requests are served from here so we
+ // don't hit Ollama (/api/tags) on every login. Refreshed at most once
+ // per MODEL_CACHE_TTL_MS; admins can force a refresh.
+ private modelsCache: { models: DriverModel[]; at: number } | null = null;
+ private modelsInflight: Promise | null = null;
constructor() {
const all: LLMDriver[] = [
@@ -49,6 +57,36 @@ export class DriverRegistry {
return out;
}
+ /**
+ * Cached variant of listAllModels for user-facing requests.
+ * - Serves the in-memory list when fresher than MODEL_CACHE_TTL_MS.
+ * - Otherwise refetches upstream once (concurrent callers share the
+ * same in-flight promise instead of hammering Ollama).
+ * - `force=true` bypasses the cache (admin "Ollama models" view).
+ */
+ async listAllModelsCached(force = false): Promise {
+ const now = Date.now();
+ if (!force && this.modelsCache && now - this.modelsCache.at < MODEL_CACHE_TTL_MS) {
+ return this.modelsCache.models;
+ }
+ if (!force && this.modelsInflight) return this.modelsInflight;
+ const p = this.listAllModels().then((models) => {
+ this.modelsCache = { models, at: Date.now() };
+ return models;
+ }).finally(() => {
+ if (this.modelsInflight === p) this.modelsInflight = null;
+ });
+ // Only share the promise for non-forced loads; a forced admin refresh
+ // always goes upstream even if a user load is in flight.
+ if (!force) this.modelsInflight = p;
+ return p;
+ }
+
+ /** Drop the cached model list (e.g. after an admin pull/delete). */
+ invalidateModelsCache(): void {
+ this.modelsCache = null;
+ }
+
/**
* Split a fully-qualified "driver:model" id.
* "ollama:llama3.1:8b" -> driver "ollama", model "llama3.1:8b".
diff --git a/src/index.ts b/src/index.ts
index e6bddeb..b226d66 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -198,6 +198,7 @@ const server = Bun.serve({
topic: s.conv.topic,
model: s.conv.model,
authorName: s.authorName,
+ authorAvatarUrl: s.authorAvatarUrl,
sharedAt: s.sharedAt,
messages: s.messages.map((m) => ({ role: m.role, content: m.content })),
});
@@ -768,16 +769,21 @@ const server = Bun.serve({
// Passthrough of the NDJSON progress lines (status/digest/completed/total).
const dec = new TextDecoder();
let buf = "";
+ let pulledOk = true;
for await (const chunk of res.body) {
- if (req.signal.aborted) break;
+ if (req.signal.aborted) { pulledOk = false; break; }
buf += dec.decode(chunk, { stream: true });
let nl: number;
while ((nl = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, nl).trim();
buf = buf.slice(nl + 1);
- if (line && !send(JSON.parse(line))) break;
+ if (line && !send(JSON.parse(line))) { pulledOk = false; break; }
}
+ if (!pulledOk) break;
}
+ // A new model landed on the Ollama host: drop the cached list
+ // so the next user request picks it up (subject to TTL).
+ if (pulledOk && !req.signal.aborted) registry.invalidateModelsCache();
send({ status: "done" });
} catch (e) {
if (!req.signal.aborted && !clientGone) send({ error: (e as Error).message });
@@ -812,6 +818,7 @@ const server = Bun.serve({
signal: AbortSignal.timeout(15_000),
});
if (!res.ok) return json({ error: `ollama /api/delete failed: ${res.status}` }, 502);
+ registry.invalidateModelsCache();
return json({ ok: true });
} catch (e) {
return json({ error: (e as Error).message }, 502);
@@ -967,7 +974,13 @@ const server = Bun.serve({
}
if (path === "/api/models" && req.method === "GET") {
- const models = await registry.listAllModels();
+ // User-facing list is served from an in-memory cache (TTL 60s) so
+ // every login doesn't hit Ollama. Admins pass ?refresh=1 to force
+ // a fresh upstream fetch (Ollama models view).
+ const wantRefresh = /^(1|true|yes)$/i.test(
+ url.searchParams.get("refresh") ?? url.searchParams.get("force") ?? "",
+ );
+ const models = await registry.listAllModelsCached(wantRefresh && admin);
return json({ models });
}