import { useEffect, useState } from "react"; import { Pill } from "@stc/ui"; import { api, ApiError } from "./api"; import { Field, PageHead, UnreadableSecretsWarning, type UnreadableSecretItem } from "./components"; type Kind = "" | "anthropic" | "openai"; interface ProviderOut { kind: "anthropic" | "openai"; model?: string; baseUrl?: string; effort?: string; hasApiKey: boolean } interface McpServer { id: string; name: string; transport: "stdio" | "http"; command?: string; args?: string[]; env?: Record; url?: string; headers?: Record; courseIds?: string[]; clearSecrets?: ("env" | "headers")[] } interface Config { provider: ProviderOut | null; mcpServers: McpServer[]; allowedBaseUrls: string[]; unreadableSecrets?: UnreadableSecretItem[] } type ServerDraft = { id: string; name: string; transport: "stdio" | "http"; command: string; args: string; env: string; url: string; headers?: Record; courses: string; test?: string; clearSecrets?: ("env" | "headers")[] }; const toDraft = (s: McpServer): ServerDraft => ({ id: s.id, name: s.name, transport: s.transport, command: s.command ?? "", args: (s.args ?? []).join(" "), env: Object.entries(s.env ?? {}).map(([k, v]) => `${k}=${v}`).join("\n"), url: s.url ?? "", headers: s.headers, courses: (s.courseIds ?? []).join(", "), }); const parseEnv = (text: string): Record => Object.fromEntries(text.split("\n").map((l) => l.trim()).filter((l) => l.includes("=")).map((l) => [l.slice(0, l.indexOf("=")).trim(), l.slice(l.indexOf("=") + 1)])); const fromDraft = (d: ServerDraft): McpServer => ({ id: d.id, name: d.name || d.id, transport: d.transport, ...(d.transport === "stdio" ? { command: d.command, args: d.args.trim() ? d.args.trim().split(/\s+/) : [], env: parseEnv(d.env) } : { url: d.url, ...(d.headers ? { headers: d.headers } : {}) }), courseIds: d.courses.split(",").map((s) => s.trim()).filter(Boolean), ...(d.clearSecrets?.length ? { clearSecrets: d.clearSecrets } : {}), }); export default function AdminAiPage() { const [loaded, setLoaded] = useState(null); const [denied, setDenied] = useState(null); const [kind, setKind] = useState(""); const [model, setModel] = useState(""); const [baseUrl, setBaseUrl] = useState(""); const [effort, setEffort] = useState(""); const [apiKey, setApiKey] = useState(""); const [clearKey, setClearKey] = useState(false); const [servers, setServers] = useState([]); const [allowed, setAllowed] = useState(""); const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null); const apply = (c: Config) => { setLoaded(c); setKind(c.provider?.kind ?? ""); setModel(c.provider?.model ?? ""); setBaseUrl(c.provider?.baseUrl ?? ""); setEffort(c.provider?.effort ?? ""); setApiKey(""); setClearKey(false); setServers(c.mcpServers.map(toDraft)); setAllowed((c.allowedBaseUrls ?? []).join("\n")); }; useEffect(() => { api("/api/ai/admin/config").then(apply, (e: ApiError) => setDenied(e.status === 401 ? "Sign in as the owner to change AI settings." : e.message)); }, []); const save = async () => { setMsg(null); try { const provider = kind ? { kind, model, baseUrl, effort, apiKey: apiKey || undefined, clearApiKey: clearKey } : null; apply(await api("/api/ai/admin/config", { method: "PUT", body: { provider, mcpServers: servers.map(fromDraft), allowedBaseUrls: allowed.split(/[\s,]+/).filter(Boolean) } })); setMsg({ ok: true, text: kind ? "Saved. The assistant is available to learners." : "Saved. AI is off for everyone without their own key." }); } catch (e) { setMsg({ ok: false, text: (e as Error).message }); } }; const setServer = (i: number, patch: Partial) => setServers((s) => s.map((x, j) => (j === i ? { ...x, ...patch } : x))); const testServer = async (i: number) => { setServer(i, { test: "Connecting…" }); try { const r = await api<{ ok: boolean; tools?: { name: string }[]; error?: string }>("/api/ai/admin/mcp/test", { method: "POST", body: fromDraft(servers[i]!) }); setServer(i, { test: r.ok ? `Connected. Tools: ${r.tools!.map((t) => t.name).join(", ") || "none"}` : `Failed: ${r.error}` }); } catch (e) { setServer(i, { test: `Failed: ${(e as Error).message}` }); } }; if (denied) return

{denied}

; if (!loaded) return
; return (

AI is off until you choose a provider here. Learners and authors can also add their own key, which is used only for their own requests. Keys are encrypted on the server and never shown again.

{ if (item.kind !== "mcp") return Paste the key again below, or tick "Remove the saved key".; const i = servers.findIndex((x) => x.id === item.serverId); const marked = i >= 0 && !!servers[i]!.clearSecrets?.includes(item.field); if (i < 0) return null; return marked ? Will be discarded when you save. : ( setServer(i, { clearSecrets: [...(servers[i]!.clearSecrets ?? []), item.field] })}>Discard stored value ); }} />
{ e.preventDefault(); save(); }}>

Server provider

Used for everyone who has not added their own key.
{kind ? ( setModel(e.target.value)} placeholder={kind === "anthropic" ? "claude-opus-5-5" : "model name"} /> ) : null}
{kind ? ( <> setBaseUrl(e.target.value)} placeholder={kind === "openai" ? "http://localhost:11434/v1" : "https://api.anthropic.com"} />
setApiKey(e.target.value)} placeholder={loaded.provider?.hasApiKey ? "••••••••" : "paste key"} /> {kind === "anthropic" ? ( ) : null}
{loaded.provider?.hasApiKey ? ( ) : null} ) : null}

Endpoints for learners' own keys

Own keys go to api.anthropic.com or api.openai.com only. To let people use another endpoint, such as a shared Ollama, list its origin here (one per line, for example http://ollama.internal:11434). STC_AI_ALLOWED_BASE_URLS adds more.