import { useEffect, useState, type FC, type ReactNode } from "react"; import { ArrowUpDown, Bot, ChevronLeft, ChevronRight, Download, Filter, HardDrive, KeyRound, Pencil, RefreshCw, Search, Trash2, UserPlus, Users } from "lucide-react"; import { api } from "../lib/api.ts"; import type { DriverModel, User } from "../lib/types.ts"; import { useT, type StringKey } from "../lib/i18n.ts"; import { Avatar, Button, ConfirmDialog, CopyButton, Field, IconButton, Input, Modal, Picker, Spinner } from "./ui.tsx"; /** Curated Ollama library catalog (admin downloads only). Descriptions resolve via i18n (`ollama.cat.*`). */ const OLLAMA_CATALOG: { name: string; catKey: StringKey; sizes: string[] }[] = [ { name: "llama3.2", catKey: "ollama.cat.llama3_2", sizes: ["1b", "3b"] }, { name: "llama3.1", catKey: "ollama.cat.llama3_1", sizes: ["8b", "70b"] }, { name: "qwen2.5", catKey: "ollama.cat.qwen2_5", sizes: ["0.5b", "1.5b", "7b", "14b", "32b"] }, { name: "gemma2", catKey: "ollama.cat.gemma2", sizes: ["2b", "9b", "27b"] }, { name: "mistral", catKey: "ollama.cat.mistral", sizes: ["7b"] }, { name: "phi3", catKey: "ollama.cat.phi3", sizes: ["3.8b", "14b"] }, { name: "deepseek-r1", catKey: "ollama.cat.deepseek_r1", sizes: ["1.5b", "7b", "8b", "14b", "32b"] }, { name: "codellama", catKey: "ollama.cat.codellama", sizes: ["7b", "13b", "34b"] }, { name: "llava", catKey: "ollama.cat.llava", sizes: ["7b", "13b", "34b"] }, { name: "nomic-embed-text", catKey: "ollama.cat.nomic_embed_text", sizes: [] }, ]; /* ---------- Ollama model catalog (admin) ---------- */ export function OllamaModelsModal({ open, onClose }: { open: boolean; onClose: () => void }) { const { t } = useT(); const [installed, setInstalled] = useState([]); const [pullName, setPullName] = useState(""); const [pullStatus, setPullStatus] = useState(""); const [pullPct, setPullPct] = useState(null); const [pulling, setPulling] = useState(false); const [error, setError] = useState(""); const [deleteModel, setDeleteModel] = useState(null); const [sizeOverrides, setSizeOverrides] = useState>({}); async function refreshInstalled() { try { // 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 */ } } useEffect(() => { if (open) { setError(""); setPullStatus(""); setPullPct(null); setPulling(false); refreshInstalled(); } }, [open]); async function pull(fullName: string) { if (pulling || !fullName.trim()) return; setPulling(true); setError(""); setPullStatus("Contacting Ollama…"); setPullPct(null); try { await api.ollamaPull(fullName.trim(), (p) => { if (p.error) { setError(p.error); return; } setPullStatus(p.status || "Pulling…"); if (p.total && p.completed) setPullPct(Math.min(100, Math.round((p.completed / p.total) * 100))); else setPullPct(null); }); setPullStatus("Done"); setPullPct(100); await refreshInstalled(); } catch (err) { setError(err instanceof Error ? err.message : "Pull failed"); } finally { setPulling(false); } } async function removeModel(name: string) { try { await api.ollamaDelete(name); await refreshInstalled(); } catch (err) { setError(err instanceof Error ? err.message : "Delete failed"); } } return ( <> {} : onClose} title="Ollama models" icon={Bot}>

Download models from the Ollama library to your Ollama host, or remove local ones. Admin only.

{error &&

{error}

} {pulling && (

Pulling {pullName}

{pullStatus}

{pullPct !== null && (
)}
)}
setPullName(e.target.value)} placeholder="llama3.2:3b" disabled={pulling} onKeyDown={(e) => e.key === "Enter" && pull(pullName)} />

Installed ({installed.length})

    {installed.length === 0 &&
  • No local Ollama models yet.
  • } {installed.map((m) => (
  • {m.label || m.id}
  • ))}

Catalog

    {OLLAMA_CATALOG.map((m) => { const size = sizeOverrides[m.name] ?? (m.sizes.length > 0 ? m.sizes[0]! : ""); const tag = size ? `${m.name}:${size}` : m.name; return (
  • {m.name} {m.desc} {m.sizes.length > 0 && ( setSizeOverrides((prev) => ({ ...prev, [m.name]: v }))} align="right" options={m.sizes.map((s) => ({ value: s, label: s }))} /> )}
  • ); })}
setDeleteModel(null)} title={`Delete ${deleteModel}?`} message="The model is removed from the Ollama host. Pull it again anytime." onConfirm={() => deleteModel && removeModel(deleteModel)} /> ); } export function AdminPanel({ open, onClose, bare }: { open: boolean; onClose: () => void; bare?: boolean }) { const [users, setUsers] = useState([]); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const [showCreate, setShowCreate] = useState(false); const [freshKey, setFreshKey] = useState<{ username: string; key: string } | null>(null); const [deleteTarget, setDeleteTarget] = useState(null); const [editTarget, setEditTarget] = useState(null); const [modelsOpen, setModelsOpen] = useState(false); // Listing: search + sort + filter + pagination (server-side). const [q, setQ] = useState(""); const [debouncedQ, setDebouncedQ] = useState(""); const [sort, setSort] = useState("newest"); const [filter, setFilter] = useState("all"); const [page, setPage] = useState(1); const [total, setTotal] = useState(0); const [pages, setPages] = useState(1); const PER_PAGE = 8; // create form const [username, setUsername] = useState(""); const [displayName, setDisplayName] = useState(""); const [email, setEmail] = useState(""); async function refresh() { setBusy(true); setError(""); try { const res = await api.adminList({ q: debouncedQ || undefined, sort, filter, page, per: PER_PAGE }); setUsers(res.users); setTotal(res.total); setPages(res.pages); if (res.page !== page) setPage(res.page); } catch (err) { setError(err instanceof Error ? err.message : "Load failed"); } finally { setBusy(false); } } useEffect(() => { if (open || bare) { refresh(); setShowCreate(false); setFreshKey(null); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, bare ]); // Debounced search (resets to first page); sort/filter reload too. useEffect(() => { const t = setTimeout(() => { setDebouncedQ(q); setPage(1); }, 300); return () => clearTimeout(t); }, [q ]); useEffect(() => { if (open || bare) refresh(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [debouncedQ, sort, filter, page ]); async function create(e: React.FormEvent) { e.preventDefault(); setError(""); try { const res = await api.adminCreate({ username: username.trim().toLowerCase(), displayName: displayName.trim() || undefined, email: email.trim() || undefined }); setFreshKey({ username: res.user.username, key: res.key }); setUsername(""); setDisplayName(""); setEmail(""); setShowCreate(false); await refresh(); } catch (err) { setError(err instanceof Error ? err.message : "Create failed"); } } async function regenerate(u: User) { setError(""); try { const res = await api.adminRegenerate(u.id); setFreshKey({ username: u.username, key: res.key }); } catch (err) { setError(err instanceof Error ? err.message : "Regenerate failed"); } } async function remove(u: User) { try { await api.adminDelete(u.id); await refresh(); } catch (err) { setError(err instanceof Error ? err.message : "Delete failed"); } } // Bare mode embeds the same manager in the /admin Center page instead // of a modal — no logic duplicated. const Shell: FC<{ children: ReactNode }> = bare ? ({ children }) =>
{children}
: ({ children }) => {children}; return ( <>

No-KYC accounts: you create a username, hand the access key to its owner once. Keys are stored hashed — regenerate to rotate.

{error &&

{error}

} {freshKey && (

Key for @{freshKey.username} — shown once, copy it now:

{freshKey.key}
)} {!showCreate ? (
) : (
setUsername(e.target.value)} placeholder="alice" required />
setDisplayName(e.target.value)} placeholder="Alice" />
setEmail(e.target.value)} placeholder="alice@example.com" inputMode="email" />
)}
setQ(e.target.value)} placeholder="Search accounts…" aria-label="Search accounts" className="pl-10" />
{ setSort(v); setPage(1); }} align="right" options={[ { value: "newest", label: "Newest first" }, { value: "oldest", label: "Oldest first" }, { value: "az", label: "A → Z" }, { value: "za", label: "Z → A" }, ]} /> { setFilter(v); setPage(1); }} align="right" options={[ { value: "all", label: "All accounts" }, { value: "with-email", label: "With recovery email" }, { value: "no-email", label: "No recovery email" }, ]} />
{busy ? (

Loading…

) : (
    {users.map((u) => (
  • {u.displayName} @{u.username}

    {u.email || "no recovery email"} · {new Date(u.createdAt).toLocaleDateString()}

    {!u.isAdmin && (
    )}
  • ))}
)}
{total} account{total === 1 ? "" : "s"} · page {page} of {pages} {pages > 1 && (
setPage((p) => Math.max(1, p - 1))} > = pages} onClick={() => setPage((p) => p + 1)} >
)}
setDeleteTarget(null)} title={`Delete @${deleteTarget?.username}?`} message="This permanently removes the account, all its conversations and its shares." onConfirm={() => deleteTarget && remove(deleteTarget)} /> setEditTarget(null)} onSaved={refresh} /> setModelsOpen(false)} /> ); } function EditUserDialog({ user, onClose, onSaved }: { user: User | null; onClose: () => void; onSaved: () => void }) { const [displayName, setDisplayName] = useState(""); const [avatarUrl, setAvatarUrl] = useState(""); const [email, setEmail] = useState(""); const [theme, setTheme] = useState("auto"); const [error, setError] = useState(""); useEffect(() => { if (user) { setDisplayName(user.displayName); setAvatarUrl(user.avatarUrl); setEmail(user.email); setTheme(user.theme); setError(""); } }, [user]); async function save(e: React.FormEvent) { e.preventDefault(); if (!user) return; try { await api.adminPatch(user.id, { displayName: displayName.trim(), avatarUrl: avatarUrl.trim(), email: email.trim(), theme }); onSaved(); onClose(); } catch (err) { setError(err instanceof Error ? err.message : "Save failed"); } } return (
setDisplayName(e.target.value)} maxLength={60} required /> setAvatarUrl(e.target.value)} placeholder="https://…" /> setEmail(e.target.value)} placeholder="alice@example.com" inputMode="email" /> {error &&

{error}

}
); }