From 08d3e3f642d42ce831690e7cd7a9f673f29d7171 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 03:30:22 +0100 Subject: [PATCH] feat(ai): assistant panel, AI settings and studio generator UI Co-Authored-By: Claude Opus 5.5 --- apps/web/src/features.ts | 1 + packages/feature-ai/package.json | 5 +- packages/feature-ai/scripts/dev.ts | 44 ++++ packages/feature-ai/src/feature.ts | 29 ++- packages/feature-ai/src/index.ts | 1 + packages/feature-ai/src/web/AccountAiPage.tsx | 84 ++++++++ packages/feature-ai/src/web/AdminAiPage.tsx | 164 ++++++++++++++ packages/feature-ai/src/web/AssistantPage.tsx | 204 ++++++++++++++++++ packages/feature-ai/src/web/GeneratePage.tsx | 195 +++++++++++++++++ packages/feature-ai/src/web/ai.css | 77 +++++++ packages/feature-ai/src/web/api.ts | 51 +++++ packages/feature-ai/src/web/components.tsx | 77 +++++++ packages/feature-ai/src/web/index.ts | 11 + packages/feature-ai/test/feature.test.ts | 1 + packages/feature-ai/test/web.test.tsx | 36 ++++ 15 files changed, 973 insertions(+), 7 deletions(-) create mode 100644 packages/feature-ai/scripts/dev.ts create mode 100644 packages/feature-ai/src/web/AccountAiPage.tsx create mode 100644 packages/feature-ai/src/web/AdminAiPage.tsx create mode 100644 packages/feature-ai/src/web/AssistantPage.tsx create mode 100644 packages/feature-ai/src/web/GeneratePage.tsx create mode 100644 packages/feature-ai/src/web/ai.css create mode 100644 packages/feature-ai/src/web/api.ts create mode 100644 packages/feature-ai/src/web/components.tsx create mode 100644 packages/feature-ai/src/web/index.ts create mode 100644 packages/feature-ai/test/web.test.tsx diff --git a/apps/web/src/features.ts b/apps/web/src/features.ts index 8b077c9..4a78195 100644 --- a/apps/web/src/features.ts +++ b/apps/web/src/features.ts @@ -2,3 +2,4 @@ import type { WebFeature } from "@stc/contracts"; // Phase 1 packages append one import line each and push into this list. export const webFeatures: WebFeature[] = []; +import { aiWeb } from "../../../packages/feature-ai/src/web"; webFeatures.push(aiWeb); diff --git a/packages/feature-ai/package.json b/packages/feature-ai/package.json index 0a7eb1e..639878d 100644 --- a/packages/feature-ai/package.json +++ b/packages/feature-ai/package.json @@ -4,5 +4,8 @@ "private": true, "type": "module", "license": "AGPL-3.0-only", - "main": "src/index.ts" + "exports": { + ".": "./src/index.ts", + "./web": "./src/web/index.ts" + } } diff --git a/packages/feature-ai/scripts/dev.ts b/packages/feature-ai/scripts/dev.ts new file mode 100644 index 0000000..4720fd5 --- /dev/null +++ b/packages/feature-ai/scripts/dev.ts @@ -0,0 +1,44 @@ +// Local dev server for the AI package until Phase 2 wires feature factories into apps/server. +// STC_PORT=3464 STC_DATA_DIR=/tmp/stc-ai-data STC_COURSE_DIRS=./fixtures/courses:./overlays bun packages/feature-ai/scripts/dev.ts +// STC_AI_DEV_USER=owner|learner|none picks the signed-in user (dev only). STC_AI_DEV_FAKE=1 uses a canned provider. +import type { AiProvider, AuthService, ServerContext } from "@stc/contracts"; +import { createApp, createJobQueue, loadConfig } from "@stc/core"; +import { createCourseRepository } from "@stc/course-format"; +import { connect, migrate } from "@stc/db"; +import indexHtml from "../../../apps/web/index.html"; +import { aiFeature } from "../src/feature"; + +const config = loadConfig(); +const who = process.env.STC_AI_DEV_USER ?? "owner"; +const auth: AuthService = { + userFromRequest: async () => + who === "none" ? null : { id: `dev-${who}`, email: `${who}@localhost`, name: who, roles: who === "owner" ? ["owner"] : ["learner"], groups: [] }, +}; + +const fake: AiProvider = { + id: "fake", + async *chat(messages) { + const sys = messages[0]?.content ?? ""; + const mentor = sys.includes("## Course mentor rules"); + const answer = mentor + ? "Before I explain: what does the failing test give as input, and what did you expect `add` to return?\n\nThe lesson's nudge asks **which operator combines two numbers**. Try it, run the tests, and tell me what happens. [lessons/04-exercise.md · Adding numbers]" + : "Here is the whole answer.\n\n```js\nexport const add = (a, b) => a + b\n```\n\nIt returns the sum of its arguments. [lessons/04-exercise.md · Adding numbers]"; + for (const part of answer.match(/.{1,12}/gs) ?? []) { await Bun.sleep(5); yield { text: part }; } + }, +}; + +const db = await connect(config.databaseUrl); +await migrate(db); +const courses = await createCourseRepository(config.courseDirs); +const ctx = { config, db, courses, auth } as ServerContext; +const feature = aiFeature(ctx, process.env.STC_AI_DEV_FAKE ? { providerFactory: () => fake } : {}); +const jobs = createJobQueue(db, feature.jobs ?? {}); +ctx.jobs = jobs; +const app = createApp(ctx, [feature]); +jobs.start(); +const server = Bun.serve({ + port: config.port, + routes: { ...app.routes, "/api/*": (req) => app.fetch(req), "/*": indexHtml }, + fetch: (req) => app.fetch(req), +}); +console.log(`STCourses (AI dev) on ${server.url} as ${who}`); diff --git a/packages/feature-ai/src/feature.ts b/packages/feature-ai/src/feature.ts index 02f59e2..f74aa04 100644 --- a/packages/feature-ai/src/feature.ts +++ b/packages/feature-ai/src/feature.ts @@ -171,13 +171,25 @@ export function aiFeature(ctx: ServerContext, opts: AiFeatureOptions = {}): Feat const rows = await ctx.db.sql`SELECT id, user_id, course_id, title, created_at FROM ai_conversations WHERE id = ${id} AND user_id = ${userId}`; return rows[0] as { id: string; user_id: string; course_id: string; title: string | null; created_at: string } | undefined; }; - const addMessage = async (conversationId: string, role: "user" | "assistant", content: string) => { + // Roles: "user", "assistant", and "sources" (JSON citations for the assistant message before it). + const addMessage = async (conversationId: string, role: "user" | "assistant" | "sources", content: string) => { await ctx.db.sql`INSERT INTO ai_messages (id, conversation_id, role, content, created_at) VALUES (${Bun.randomUUIDv7()}, ${conversationId}, ${role}, ${content}, ${new Date().toISOString()})`; }; const messagesOf = async (conversationId: string) => (await ctx.db.sql`SELECT id, role, content, created_at FROM ai_messages WHERE conversation_id = ${conversationId}`) .map((r: { id: string; role: string; content: string; created_at: string }) => ({ id: r.id, role: r.role, content: r.content, createdAt: r.created_at })) - .sort((a: { id: string }, b: { id: string }) => (a.id < b.id ? -1 : 1)) as { id: string; role: "user" | "assistant"; content: string; createdAt: string }[]; + .sort((a: { id: string }, b: { id: string }) => (a.id < b.id ? -1 : 1)) as { id: string; role: "user" | "assistant" | "sources"; content: string; createdAt: string }[]; + /** Chat turns with each assistant message's stored citations attached. */ + const turnsOf = async (conversationId: string) => { + const out: { id: string; role: "user" | "assistant"; content: string; createdAt: string; citations?: unknown[] }[] = []; + for (const m of await messagesOf(conversationId)) { + if (m.role === "sources") { + const last = out[out.length - 1]; + if (last?.role === "assistant") { try { last.citations = JSON.parse(m.content); } catch {} } + } else out.push({ ...m, role: m.role }); + } + return out; + }; const signedIn = (rc: RequestContext) => (rc.user ? null : err(401, "Sign in to use the assistant.")); const ownerOnly = (rc: RequestContext) => (!rc.user ? err(401, "Sign in first.") : rc.user.roles.includes("owner") ? null : err(403, "Only the owner can change AI settings.")); @@ -205,7 +217,7 @@ export function aiFeature(ctx: ServerContext, opts: AiFeatureOptions = {}): Feat conversationId = Bun.randomUUIDv7(); await ctx.db.sql`INSERT INTO ai_conversations (id, user_id, course_id, title, created_at) VALUES (${conversationId}, ${user.id}, ${course.id}, ${message.slice(0, 80)}, ${new Date().toISOString()})`; } - const history = (await messagesOf(conversationId)).slice(-HISTORY_TURNS); + const history = (await turnsOf(conversationId)).slice(-HISTORY_TURNS); await addMessage(conversationId, "user", message); const lessonId = str(b?.lessonId); @@ -235,13 +247,18 @@ export function aiFeature(ctx: ServerContext, opts: AiFeatureOptions = {}): Feat { role: "user", content: message }, ]; let answer = ""; + let cited: unknown[] = []; for await (const ev of runAssistant({ provider, messages, tools, signal: req.signal })) { if (ev.type === "done") { answer = ev.text; - send({ type: "done", text: ev.text, citations: citationsFor([...sources, ...tools.sources]) }); + cited = citationsFor([...sources, ...tools.sources]); + send({ type: "done", text: ev.text, citations: cited }); } else send(ev); } - if (answer) await addMessage(convId, "assistant", answer); + if (answer) { + await addMessage(convId, "assistant", answer); + await addMessage(convId, "sources", JSON.stringify(cited)); + } } catch (e) { send({ type: "error", message: (e as Error).message }); } finally { @@ -360,7 +377,7 @@ export function aiFeature(ctx: ServerContext, opts: AiFeatureOptions = {}): Feat if (deny) return deny; const conv = await conversationOf(rc.params.id ?? "", rc.user!.id); if (!conv) return err(404, "Conversation not found."); - return json({ id: conv.id, courseId: conv.course_id, title: conv.title, messages: await messagesOf(conv.id) }); + return json({ id: conv.id, courseId: conv.course_id, title: conv.title, messages: await turnsOf(conv.id) }); }, DELETE: async (_req, rc) => { const deny = signedIn(rc); diff --git a/packages/feature-ai/src/index.ts b/packages/feature-ai/src/index.ts index 62f5df7..405c90e 100644 --- a/packages/feature-ai/src/index.ts +++ b/packages/feature-ai/src/index.ts @@ -14,3 +14,4 @@ export * from "./generator/draft-sink"; export * from "./generator/generate"; export * from "./generator/templates"; export { aiFeature, type AiFeatureOptions } from "./feature"; +export { aiWeb } from "./web"; diff --git a/packages/feature-ai/src/web/AccountAiPage.tsx b/packages/feature-ai/src/web/AccountAiPage.tsx new file mode 100644 index 0000000..0b0d41f --- /dev/null +++ b/packages/feature-ai/src/web/AccountAiPage.tsx @@ -0,0 +1,84 @@ +import { useEffect, useState } from "react"; +import { Pill } from "@stc/ui"; +import { api, ApiError } from "./api"; +import { Field, PageHead } from "./components"; + +interface Account { + byok: { kind: "anthropic" | "openai"; model?: string; baseUrl?: string; hasApiKey: boolean } | null; + serverProvider: { kind: string; model: string | null } | null; +} + +export default function AccountAiPage() { + const [acct, setAcct] = useState(null); + const [denied, setDenied] = useState(null); + const [kind, setKind] = useState<"anthropic" | "openai">("anthropic"); + const [apiKey, setApiKey] = useState(""); + const [model, setModel] = useState(""); + const [baseUrl, setBaseUrl] = useState(""); + const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null); + + const load = () => api("/api/ai/account").then((a) => { + setAcct(a); + if (a.byok) { setKind(a.byok.kind); setModel(a.byok.model ?? ""); setBaseUrl(a.byok.baseUrl ?? ""); } + }, (e: ApiError) => setDenied(e.status === 401 ? "Sign in to manage your own AI key." : e.message)); + useEffect(() => { load(); }, []); + + const save = async () => { + setMsg(null); + try { + await api("/api/ai/account/key", { method: "PUT", body: { kind, apiKey, model, baseUrl } }); + setApiKey(""); + await load(); + setMsg({ ok: true, text: "Saved. Your questions now use your own key." }); + } catch (e) { setMsg({ ok: false, text: (e as Error).message }); } + }; + const remove = async () => { + await api("/api/ai/account/key", { method: "DELETE" }); + setApiKey(""); + await load(); + setMsg({ ok: true, text: "Your key is removed." }); + }; + + if (denied) return

{denied}

; + if (!acct) return
; + + return ( +
+ +

+ Bring your own key to use the course assistant and the studio generator with your own account. It is used only for your requests, stored encrypted, and never shown again.{" "} + {acct.serverProvider ? "This server also has a shared provider; your key takes priority while it is saved." : "This server has no shared provider, so AI is off for you until you add a key."} +

+
+ + {acct.byok ?

A {acct.byok.kind === "anthropic" ? "Anthropic" : "OpenAI-compatible"} key is saved{acct.byok.model ? ` (model ${acct.byok.model})` : ""}.

: null} + +
{ e.preventDefault(); save(); }}> +
+ + + + + setModel(e.target.value)} placeholder={kind === "anthropic" ? "claude-opus-5-5" : "model name"} /> + +
+ {kind === "openai" ? ( + + setBaseUrl(e.target.value)} placeholder="https://api.openai.com/v1" /> + + ) : null} + + setApiKey(e.target.value)} placeholder="paste key" /> + +
+ Save key + {acct.byok ? Remove my key : null} + {msg ? {msg.text} : null} +
+
+
+ ); +} diff --git a/packages/feature-ai/src/web/AdminAiPage.tsx b/packages/feature-ai/src/web/AdminAiPage.tsx new file mode 100644 index 0000000..0c3d0ca --- /dev/null +++ b/packages/feature-ai/src/web/AdminAiPage.tsx @@ -0,0 +1,164 @@ +import { useEffect, useState } from "react"; +import { Pill } from "@stc/ui"; +import { api, ApiError } from "./api"; +import { Field, PageHead } 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[]; url?: string; courseIds?: string[] } +interface Config { provider: ProviderOut | null; mcpServers: McpServer[] } + +type ServerDraft = { id: string; name: string; transport: "stdio" | "http"; command: string; args: string; url: string; courses: string; test?: string }; +const toDraft = (s: McpServer): ServerDraft => ({ id: s.id, name: s.name, transport: s.transport, command: s.command ?? "", args: (s.args ?? []).join(" "), url: s.url ?? "", courses: (s.courseIds ?? []).join(", ") }); +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+/) : [] } : { url: d.url }), + courseIds: d.courses.split(",").map((s) => s.trim()).filter(Boolean), +}); + +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 [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)); + }; + 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) } })); + 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.

+
+ +
{ 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} +
+ +
+

MCP servers

+ Extra tools for the assistant. Leave "Courses" empty to offer a server in every course. + {servers.map((s, i) => ( +
+
+ setServer(i, { id: e.target.value })} /> + setServer(i, { name: e.target.value })} /> + + + +
+ {s.transport === "http" ? ( + setServer(i, { url: e.target.value })} placeholder="https://mcp.example.com/mcp" /> + ) : ( +
+ setServer(i, { command: e.target.value })} placeholder="bunx" /> + setServer(i, { args: e.target.value })} placeholder="some-mcp-server --flag" /> +
+ )} + setServer(i, { courses: e.target.value })} /> +
+ testServer(i)}>Test connection + setServers((x) => x.filter((_, j) => j !== i))}>Remove + {s.test ? {s.test} : null} +
+
+ ))} +
+ setServers((x) => [...x, { id: `server-${x.length + 1}`, name: "", transport: "http", command: "", args: "", url: "", courses: "" }])}>Add MCP server +
+
+ +
+ Save AI settings + {msg ? {msg.text} : null} +
+
+
+ ); +} diff --git a/packages/feature-ai/src/web/AssistantPage.tsx b/packages/feature-ai/src/web/AssistantPage.tsx new file mode 100644 index 0000000..3d180a8 --- /dev/null +++ b/packages/feature-ai/src/web/AssistantPage.tsx @@ -0,0 +1,204 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { Link, useParams } from "react-router-dom"; +import { Pill, SegmentedToggle } from "@stc/ui"; +import { api, readNdjson, type ChatEvent, type Citation, type Status } from "./api"; +import { Markdown, MentorBanner, PageHead } from "./components"; + +type Turn = { role: "user" | "assistant"; text: string; citations?: Citation[]; tools?: string[]; error?: string; notices?: string[] }; +type ConvSummary = { id: string; title: string | null; createdAt: string }; + +const TOOL_LABEL: Record = { search_course: "searched the course", read_lesson: "read a lesson", read_file: "read a file", list_notes: "read your notes" }; +const toolLabel = (n: string) => TOOL_LABEL[n] ?? `used ${n.replace(/^mcp_[^_]+__/, "")}`; + +function Sources({ courseId, citations }: { courseId: string; citations: Citation[] }) { + // One row per file; a file shared by several lessons links to the first one. + const rows = [...new Map(citations.map((c) => [c.path, c] as const)).values()]; + if (!rows.length) return null; + return ( +
+ Sources +
    + {rows.map((c) => ( +
  • + {c.path} + {c.lessonId ? {c.lessonTitle ?? c.lessonId} : {c.kind}} +
  • + ))} +
+
+ ); +} + +export default function AssistantPage() { + const { courseId = "" } = useParams(); + const [status, setStatus] = useState(null); + const [loadError, setLoadError] = useState(null); + const [turns, setTurns] = useState([]); + const [conversationId, setConversationId] = useState(null); + const [history, setHistory] = useState([]); + const [draft, setDraft] = useState(""); + const [busy, setBusy] = useState(false); + const abortRef = useRef(null); + const lessonId = new URLSearchParams(typeof location === "undefined" ? "" : location.search).get("lesson") ?? undefined; + + const loadHistory = useCallback(async () => { + try { setHistory(await api(`/api/ai/courses/${encodeURIComponent(courseId)}/conversations`)); } catch { setHistory([]); } + }, [courseId]); + + useEffect(() => { + api(`/api/ai/status?courseId=${encodeURIComponent(courseId)}`).then(setStatus, (e) => setLoadError(e.message)); + loadHistory(); + const linked = new URLSearchParams(location.search).get("c"); + if (linked) openConversation(linked).catch(() => {}); + return () => abortRef.current?.abort(); + }, [courseId, loadHistory]); + + const mentor = status?.mentor ?? { available: false, on: true }; + + const setMentor = async (on: boolean) => { + if (!status) return; + setStatus({ ...status, mentor: { ...mentor, on } }); + try { + const r = await api<{ mentor: Status["mentor"] }>(`/api/ai/courses/${encodeURIComponent(courseId)}/mentor`, { method: "PUT", body: { off: !on } }); + setStatus((s) => (s ? { ...s, mentor: r.mentor } : s)); + } catch (e) { + setStatus({ ...status, mentor }); + setLoadError((e as Error).message); + } + }; + + async function openConversation(id: string) { + const c = await api<{ id: string; messages: { role: "user" | "assistant"; content: string; citations?: Citation[] }[] }>(`/api/ai/conversations/${id}`); + setConversationId(c.id); + setTurns(c.messages.map((m) => ({ role: m.role, text: m.content, citations: m.citations }))); + } + const deleteConversation = async (id: string) => { + await api(`/api/ai/conversations/${id}`, { method: "DELETE" }); + if (id === conversationId) { setConversationId(null); setTurns([]); } + loadHistory(); + }; + + const send = async () => { + const message = draft.trim(); + if (!message || busy) return; + setDraft(""); + setBusy(true); + const update = (fn: (t: Turn) => Turn) => setTurns((ts) => [...ts.slice(0, -1), fn(ts[ts.length - 1]!)]); + setTurns((ts) => [...ts, { role: "user", text: message }, { role: "assistant", text: "", tools: [] }]); + const ctl = new AbortController(); + abortRef.current = ctl; + try { + const res = await fetch(`/api/ai/courses/${encodeURIComponent(courseId)}/chat`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ message, conversationId, lessonId }), + signal: ctl.signal, + }); + if (!res.ok || !res.body) { + let msg = res.statusText; + try { msg = (await res.json()).error ?? msg; } catch {} + update((t) => ({ ...t, error: msg })); + return; + } + for await (const ev of readNdjson(res.body)) { + if (ev.type === "meta") { setConversationId(ev.conversationId); update((t) => ({ ...t, citations: ev.citations })); } + else if (ev.type === "text") update((t) => ({ ...t, text: t.text + ev.text })); + else if (ev.type === "tool") update((t) => ({ ...t, tools: [...(t.tools ?? []), ev.name] })); + else if (ev.type === "notice") update((t) => ({ ...t, notices: [...(t.notices ?? []), ev.message] })); + else if (ev.type === "error") update((t) => ({ ...t, error: ev.message })); + else if (ev.type === "done") update((t) => ({ ...t, text: ev.text || t.text, citations: ev.citations })); + } + loadHistory(); + } catch (e) { + if ((e as Error).name !== "AbortError") update((t) => ({ ...t, error: (e as Error).message })); + } finally { + setBusy(false); + abortRef.current = null; + } + }; + + const newConversation = () => { abortRef.current?.abort(); setConversationId(null); setTurns([]); }; + + const courseTitle = status?.course?.title ?? courseId; + const chap = {courseTitle}; + + if (loadError && !status) return

{loadError}

; + if (!status) return
; + if (!status.course) return

This course was not found.

; + if (!status.signedIn) { + return ( +
+ +

Sign in to ask the assistant about this course.

+
+ ); + } + if (!status.enabled) { + return ( +
+ +

+ The assistant is off. AI stays off until an admin sets up a server provider, or you add your own API key in Account, AI. Your key is used only for your own questions. +

+
+ ); + } + + return ( +
+ +

Answers come from this course's lessons, transcripts, slides, code and your notes, with the sources listed.{status.source === "byok" ? " Using your own key." : ""}

+
+ + {mentor.available ? ( +
+ setMentor(v === "on")} options={[{ value: "on", label: "Mentor on" }, { value: "off", label: "Mentor off" }]} /> + {mentor.on ? "Follows the course's tutoring rules: questions and hints first, no full solutions." : "The course's tutoring rules are off for you in this course."} +
+ ) : null} + + {loadError ?

{loadError}

: null} + + {turns.length ? ( +
    + {turns.map((t, i) => ( +
  1. + {t.role === "user" ? "You" : "Assistant"} + {t.text ? : t.role === "assistant" && busy && i === turns.length - 1 && !t.error ?

    Thinking…

    : null} + {t.tools?.length ?

    {[...new Set(t.tools.map(toolLabel))].join(", ").replace(/^./, (c) => c.toUpperCase())}.

    : null} + {t.notices?.map((n) =>

    {n}

    )} + {t.error ?

    {t.error}

    : null} + {t.role === "assistant" && t.citations ? : null} +
  2. + ))} +
+ ) : ( +

Ask about anything in the course: an idea you are unsure of, a failing test, or "quiz me on this module".

+ )} + +
{ e.preventDefault(); send(); }}> + +