fixes
This commit is contained in:
@@ -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
|
- `OllamaDriver`: model discovery via `GET {OLLAMA_HOST}/api/tags`, chat via
|
||||||
`POST /api/chat` with NDJSON streaming.
|
`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`
|
- `ApiDriverBase`: shared OpenAI-compatible `/models` + `/chat/completions`
|
||||||
(SSE) implementation; concrete drivers only set base URL, key, and fallback
|
(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**
|
model list. A driver is enabled only when its `*_ENABLED` flag is set **and**
|
||||||
|
|||||||
+3
-1
@@ -50,7 +50,9 @@ read-only `SharePage` (no auth), everything else renders the authenticated app.
|
|||||||
tabbed hub (Accounts / Features / Mail) reusing `AdminPanel`, server
|
tabbed hub (Accounts / Features / Mail) reusing `AdminPanel`, server
|
||||||
feature `Switch`es (`registrationEnabled`, `ocrEnabled` via
|
feature `Switch`es (`registrationEnabled`, `ocrEnabled` via
|
||||||
`/api/admin/settings`), SMTP status card. Follows the platform theme.
|
`/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
|
- `Markdown.tsx` — `react-markdown` + GFM; code blocks get a language label and
|
||||||
a copy button.
|
a copy button.
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ export function OllamaModelsModal({ open, onClose }: { open: boolean; onClose: (
|
|||||||
|
|
||||||
async function refreshInstalled() {
|
async function refreshInstalled() {
|
||||||
try {
|
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"));
|
setInstalled(res.models.filter((m) => m.driver === "ollama"));
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export function SharePage({ publicId }: { publicId: string }) {
|
|||||||
{chat?.messages.map((m, i) => (
|
{chat?.messages.map((m, i) => (
|
||||||
<div key={i} className="flex gap-3">
|
<div key={i} className="flex gap-3">
|
||||||
{m.role === "user"
|
{m.role === "user"
|
||||||
? <Avatar name={chat.authorName} size={32} />
|
? <Avatar name={chat.authorName} url={chat.authorAvatarUrl || undefined} size={32} />
|
||||||
: <AssistantAvatar size={32} />}
|
: <AssistantAvatar size={32} />}
|
||||||
<div className="min-w-0 max-w-[90%] flex-1">
|
<div className="min-w-0 max-w-[90%] flex-1">
|
||||||
<div className="mb-1 flex items-center gap-1.5 text-xs opacity-60">
|
<div className="mb-1 flex items-center gap-1.5 text-xs opacity-60">
|
||||||
|
|||||||
@@ -794,7 +794,7 @@ function SecuritySection({ onKeyRotated }: { onKeyRotated?: () => void }) {
|
|||||||
<div>
|
<div>
|
||||||
<p className="mb-1 flex items-center gap-1.5 text-sm font-medium"><KeyRound size={14} className="opacity-60" /> Access key</p>
|
<p className="mb-1 flex items-center gap-1.5 text-sm font-medium"><KeyRound size={14} className="opacity-60" /> Access key</p>
|
||||||
<p className="mb-2 text-xs opacity-60">Rotate your access key. Old sessions are revoked immediately — you'll log back in with the new key.</p>
|
<p className="mb-2 text-xs opacity-60">Rotate your access key. Old sessions are revoked immediately — you'll log back in with the new key.</p>
|
||||||
<Button size="sm" variant="secondary" disabled={busy} onClick={rotate}>
|
<Button size="sm" variant="secondary" type="button" disabled={busy} onClick={rotate}>
|
||||||
{armingRotate ? "Click again to confirm rotation" : "Rotate my access key"}
|
{armingRotate ? "Click again to confirm rotation" : "Rotate my access key"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -805,7 +805,7 @@ function SecuritySection({ onKeyRotated }: { onKeyRotated?: () => void }) {
|
|||||||
{totpOn === false && !secret && (
|
{totpOn === false && !secret && (
|
||||||
<>
|
<>
|
||||||
<p className="mb-2 text-xs opacity-60">Add a 6-digit code from your authenticator app on top of your access key.</p>
|
<p className="mb-2 text-xs opacity-60">Add a 6-digit code from your authenticator app on top of your access key.</p>
|
||||||
<Button size="sm" variant="secondary" disabled={busy} onClick={startTotp}>Enable two-factor</Button>
|
<Button size="sm" variant="secondary" type="button" disabled={busy} onClick={startTotp}>Enable two-factor</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{secret && (
|
{secret && (
|
||||||
@@ -825,7 +825,7 @@ function SecuritySection({ onKeyRotated }: { onKeyRotated?: () => void }) {
|
|||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
className="text-center tracking-[0.4em]"
|
className="text-center tracking-[0.4em]"
|
||||||
/>
|
/>
|
||||||
<Button size="sm" disabled={busy || code.length !== 6} onClick={confirmTotp}>Confirm</Button>
|
<Button size="sm" type="button" disabled={busy || code.length !== 6} onClick={confirmTotp}>Confirm</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -843,7 +843,7 @@ function SecuritySection({ onKeyRotated }: { onKeyRotated?: () => void }) {
|
|||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" variant="secondary" disabled={busy || code.length !== 6} onClick={disableTotp} className="!text-red-600 dark:!text-red-400">
|
<Button size="sm" variant="secondary" type="button" disabled={busy || code.length !== 6} onClick={disableTotp} className="!text-red-600 dark:!text-red-400">
|
||||||
Disable 2FA
|
Disable 2FA
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -863,7 +863,7 @@ function SecuritySection({ onKeyRotated }: { onKeyRotated?: () => void }) {
|
|||||||
<Trash2 size={14} /> Danger zone
|
<Trash2 size={14} /> Danger zone
|
||||||
</p>
|
</p>
|
||||||
<p className="mb-2 text-xs opacity-60">Permanently delete your account with all chats, shares and sessions. Cannot be undone.</p>
|
<p className="mb-2 text-xs opacity-60">Permanently delete your account with all chats, shares and sessions. Cannot be undone.</p>
|
||||||
<Button size="sm" variant="secondary" disabled={busy} onClick={() => setConfirmDelete(true)} className="!text-red-600 dark:!text-red-400">
|
<Button size="sm" variant="secondary" type="button" disabled={busy} onClick={() => setConfirmDelete(true)} className="!text-red-600 dark:!text-red-400">
|
||||||
Delete my account
|
Delete my account
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,9 +11,10 @@ type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
|
|||||||
size?: "sm" | "md" | "lg" | "icon";
|
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 (
|
return (
|
||||||
<button
|
<button
|
||||||
|
type={type}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center justify-center gap-2 rounded-full font-medium transition active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-50",
|
"inline-flex items-center justify-center gap-2 rounded-full font-medium transition active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
size === "sm" && "px-3.5 py-1.5 text-sm",
|
size === "sm" && "px-3.5 py-1.5 text-sm",
|
||||||
@@ -566,6 +567,7 @@ export function CopyButton({ text, label }: { text: string; label?: string }) {
|
|||||||
const [done, setDone] = useState(false);
|
const [done, setDone] = useState(false);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigator.clipboard.writeText(text).then(() => {
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
setDone(true);
|
setDone(true);
|
||||||
|
|||||||
@@ -139,7 +139,8 @@ export const api = {
|
|||||||
unshare: (id: string) => req<{ ok: boolean }>(`/api/conversations/${id}/share`, { method: "DELETE" }),
|
unshare: (id: string) => req<{ ok: boolean }>(`/api/conversations/${id}/share`, { method: "DELETE" }),
|
||||||
publicShare: (publicId: string) => req<SharedChat>(`/api/share/${publicId}`, {}, false),
|
publicShare: (publicId: string) => req<SharedChat>(`/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). */
|
/** Access-request wishlist (public, no auth). */
|
||||||
accessRequest: (a: { username: string; email: string; message: string }) =>
|
accessRequest: (a: { username: string; email: string; message: string }) =>
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export interface SharedChat {
|
|||||||
topic: string;
|
topic: string;
|
||||||
model: string;
|
model: string;
|
||||||
authorName: string;
|
authorName: string;
|
||||||
|
authorAvatarUrl: string;
|
||||||
sharedAt: number;
|
sharedAt: number;
|
||||||
messages: ChatMessage[];
|
messages: ChatMessage[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -387,7 +387,7 @@ export function deleteShare(convId: string): void {
|
|||||||
getDb().query("DELETE FROM shares WHERE conv_id = ?").run(convId);
|
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 d = getDb();
|
||||||
const s = d.query("SELECT * FROM shares WHERE public_id = ?").get(publicId) as { conv_id: string; created_at: number } | null;
|
const s = d.query("SELECT * FROM shares WHERE public_id = ?").get(publicId) as { conv_id: string; created_at: number } | null;
|
||||||
if (!s) return null;
|
if (!s) return null;
|
||||||
@@ -398,6 +398,7 @@ export function getShareByPublic(publicId: string): { conv: ConversationRow; mes
|
|||||||
conv,
|
conv,
|
||||||
messages: getMessages(conv.id).filter((m) => m.role !== "system"),
|
messages: getMessages(conv.id).filter((m) => m.role !== "system"),
|
||||||
authorName: author?.display_name || "Someone",
|
authorName: author?.display_name || "Someone",
|
||||||
|
authorAvatarUrl: author?.avatar_url || "",
|
||||||
sharedAt: s.created_at,
|
sharedAt: s.created_at,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,16 @@ import type { DriverModel, LLMDriver } from "./types.ts";
|
|||||||
*/
|
*/
|
||||||
const PRIORITY = ["ollama", "mistral", "glm", "puppeteer-openai", "deepseek", "anthropic", "openai"];
|
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 {
|
export class DriverRegistry {
|
||||||
private drivers: Map<string, LLMDriver>;
|
private drivers: Map<string, LLMDriver>;
|
||||||
|
// 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<DriverModel[]> | null = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
const all: LLMDriver[] = [
|
const all: LLMDriver[] = [
|
||||||
@@ -49,6 +57,36 @@ export class DriverRegistry {
|
|||||||
return out;
|
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<DriverModel[]> {
|
||||||
|
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.
|
* Split a fully-qualified "driver:model" id.
|
||||||
* "ollama:llama3.1:8b" -> driver "ollama", model "llama3.1:8b".
|
* "ollama:llama3.1:8b" -> driver "ollama", model "llama3.1:8b".
|
||||||
|
|||||||
+16
-3
@@ -198,6 +198,7 @@ const server = Bun.serve({
|
|||||||
topic: s.conv.topic,
|
topic: s.conv.topic,
|
||||||
model: s.conv.model,
|
model: s.conv.model,
|
||||||
authorName: s.authorName,
|
authorName: s.authorName,
|
||||||
|
authorAvatarUrl: s.authorAvatarUrl,
|
||||||
sharedAt: s.sharedAt,
|
sharedAt: s.sharedAt,
|
||||||
messages: s.messages.map((m) => ({ role: m.role, content: m.content })),
|
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).
|
// Passthrough of the NDJSON progress lines (status/digest/completed/total).
|
||||||
const dec = new TextDecoder();
|
const dec = new TextDecoder();
|
||||||
let buf = "";
|
let buf = "";
|
||||||
|
let pulledOk = true;
|
||||||
for await (const chunk of res.body) {
|
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 });
|
buf += dec.decode(chunk, { stream: true });
|
||||||
let nl: number;
|
let nl: number;
|
||||||
while ((nl = buf.indexOf("\n")) >= 0) {
|
while ((nl = buf.indexOf("\n")) >= 0) {
|
||||||
const line = buf.slice(0, nl).trim();
|
const line = buf.slice(0, nl).trim();
|
||||||
buf = buf.slice(nl + 1);
|
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" });
|
send({ status: "done" });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!req.signal.aborted && !clientGone) send({ error: (e as Error).message });
|
if (!req.signal.aborted && !clientGone) send({ error: (e as Error).message });
|
||||||
@@ -812,6 +818,7 @@ const server = Bun.serve({
|
|||||||
signal: AbortSignal.timeout(15_000),
|
signal: AbortSignal.timeout(15_000),
|
||||||
});
|
});
|
||||||
if (!res.ok) return json({ error: `ollama /api/delete failed: ${res.status}` }, 502);
|
if (!res.ok) return json({ error: `ollama /api/delete failed: ${res.status}` }, 502);
|
||||||
|
registry.invalidateModelsCache();
|
||||||
return json({ ok: true });
|
return json({ ok: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return json({ error: (e as Error).message }, 502);
|
return json({ error: (e as Error).message }, 502);
|
||||||
@@ -967,7 +974,13 @@ const server = Bun.serve({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (path === "/api/models" && req.method === "GET") {
|
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 });
|
return json({ models });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user