feat(ai): assistant panel, AI settings and studio generator UI
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
a57222ecc3
commit
08d3e3f642
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<Account | null>(null);
|
||||
const [denied, setDenied] = useState<string | null>(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<Account>("/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 <div className="ai-page"><PageHead chap="Account" title="Your AI key" /><p className="ai-note">{denied}</p></div>;
|
||||
if (!acct) return <div className="ai-page"><PageHead chap="Account" title="Your AI key" /></div>;
|
||||
|
||||
return (
|
||||
<div className="ai-page">
|
||||
<PageHead chap="Account" title="Your AI key">
|
||||
<p>
|
||||
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."}
|
||||
</p>
|
||||
</PageHead>
|
||||
|
||||
{acct.byok ? <p className="ai-banner" role="status">A {acct.byok.kind === "anthropic" ? "Anthropic" : "OpenAI-compatible"} key is saved{acct.byok.model ? ` (model ${acct.byok.model})` : ""}.</p> : null}
|
||||
|
||||
<form onSubmit={(e) => { e.preventDefault(); save(); }}>
|
||||
<div className="ai-grid">
|
||||
<Field label="Provider">
|
||||
<select className="ai-select" value={kind} onChange={(e) => setKind(e.target.value as "anthropic" | "openai")}>
|
||||
<option value="anthropic">Anthropic</option>
|
||||
<option value="openai">OpenAI-compatible</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Model" hint={kind === "anthropic" ? "Leave empty for claude-opus-5-5." : undefined}>
|
||||
<input className="ai-input" value={model} onChange={(e) => setModel(e.target.value)} placeholder={kind === "anthropic" ? "claude-opus-5-5" : "model name"} />
|
||||
</Field>
|
||||
</div>
|
||||
{kind === "openai" ? (
|
||||
<Field label="Base URL" hint="The address that ends before /chat/completions.">
|
||||
<input className="ai-input ai-mono" value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} placeholder="https://api.openai.com/v1" />
|
||||
</Field>
|
||||
) : null}
|
||||
<Field label="API key" hint={acct.byok ? "Paste a new key to replace the saved one." : undefined}>
|
||||
<input className="ai-input ai-mono" type="password" autoComplete="off" value={apiKey} onChange={(e) => setApiKey(e.target.value)} placeholder="paste key" />
|
||||
</Field>
|
||||
<div className="ai-footer">
|
||||
<Pill variant="primary" type="submit" disabled={!apiKey.trim()}>Save key</Pill>
|
||||
{acct.byok ? <Pill onClick={remove}>Remove my key</Pill> : null}
|
||||
{msg ? <span className={msg.ok ? "ai-hint ai-ok" : "ai-error"} role="status" style={{ margin: 0 }}>{msg.text}</span> : null}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<Config | null>(null);
|
||||
const [denied, setDenied] = useState<string | null>(null);
|
||||
const [kind, setKind] = useState<Kind>("");
|
||||
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<ServerDraft[]>([]);
|
||||
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<Config>("/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<Config>("/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<ServerDraft>) => 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 <div className="ai-page"><PageHead title="AI settings" /><p className="ai-note">{denied}</p></div>;
|
||||
if (!loaded) return <div className="ai-page"><PageHead title="AI settings" /></div>;
|
||||
|
||||
return (
|
||||
<div className="ai-page">
|
||||
<PageHead title="AI settings">
|
||||
<p>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.</p>
|
||||
</PageHead>
|
||||
|
||||
<form onSubmit={(e) => { e.preventDefault(); save(); }}>
|
||||
<section className="ai-section">
|
||||
<h2>Server provider</h2>
|
||||
<span className="ai-hint">Used for everyone who has not added their own key.</span>
|
||||
<div className="ai-grid">
|
||||
<Field label="Provider">
|
||||
<select className="ai-select" value={kind} onChange={(e) => setKind(e.target.value as Kind)}>
|
||||
<option value="">None (AI off)</option>
|
||||
<option value="anthropic">Anthropic</option>
|
||||
<option value="openai">OpenAI-compatible (OpenAI, Ollama, vLLM …)</option>
|
||||
</select>
|
||||
</Field>
|
||||
{kind ? (
|
||||
<Field label="Model" hint={kind === "anthropic" ? "Leave empty for claude-opus-5-5." : "For example llama3.1 on Ollama."}>
|
||||
<input className="ai-input" value={model} onChange={(e) => setModel(e.target.value)} placeholder={kind === "anthropic" ? "claude-opus-5-5" : "model name"} />
|
||||
</Field>
|
||||
) : null}
|
||||
</div>
|
||||
{kind ? (
|
||||
<>
|
||||
<Field label={kind === "anthropic" ? "Base URL (optional)" : "Base URL"} hint={kind === "openai" ? "The address that ends before /chat/completions, for example http://ollama:11434/v1." : "Leave empty for api.anthropic.com."}>
|
||||
<input className="ai-input ai-mono" value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} placeholder={kind === "openai" ? "http://localhost:11434/v1" : "https://api.anthropic.com"} />
|
||||
</Field>
|
||||
<div className="ai-grid">
|
||||
<Field label="API key" hint={loaded.provider?.hasApiKey ? "A key is saved. Leave empty to keep it." : kind === "openai" ? "Optional for a local Ollama." : undefined}>
|
||||
<input className="ai-input ai-mono" type="password" autoComplete="off" value={apiKey} onChange={(e) => setApiKey(e.target.value)} placeholder={loaded.provider?.hasApiKey ? "••••••••" : "paste key"} />
|
||||
</Field>
|
||||
{kind === "anthropic" ? (
|
||||
<Field label="Effort" hint="How hard the model thinks. Empty uses the model's default.">
|
||||
<select className="ai-select" value={effort} onChange={(e) => setEffort(e.target.value)}>
|
||||
<option value="">Default</option>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
</select>
|
||||
</Field>
|
||||
) : null}
|
||||
</div>
|
||||
{loaded.provider?.hasApiKey ? (
|
||||
<label className="ai-check"><input type="checkbox" checked={clearKey} onChange={(e) => setClearKey(e.target.checked)} /> Remove the saved key</label>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="ai-section">
|
||||
<h2>MCP servers</h2>
|
||||
<span className="ai-hint">Extra tools for the assistant. Leave "Courses" empty to offer a server in every course.</span>
|
||||
{servers.map((s, i) => (
|
||||
<div className="ai-server" key={i}>
|
||||
<div className="ai-grid">
|
||||
<Field label="Id"><input className="ai-input ai-mono" value={s.id} onChange={(e) => setServer(i, { id: e.target.value })} /></Field>
|
||||
<Field label="Name"><input className="ai-input" value={s.name} onChange={(e) => setServer(i, { name: e.target.value })} /></Field>
|
||||
<Field label="Transport">
|
||||
<select className="ai-select" value={s.transport} onChange={(e) => setServer(i, { transport: e.target.value as "stdio" | "http" })}>
|
||||
<option value="http">Streamable HTTP</option>
|
||||
<option value="stdio">Local command (stdio)</option>
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
{s.transport === "http" ? (
|
||||
<Field label="URL"><input className="ai-input ai-mono" value={s.url} onChange={(e) => setServer(i, { url: e.target.value })} placeholder="https://mcp.example.com/mcp" /></Field>
|
||||
) : (
|
||||
<div className="ai-grid">
|
||||
<Field label="Command"><input className="ai-input ai-mono" value={s.command} onChange={(e) => setServer(i, { command: e.target.value })} placeholder="bunx" /></Field>
|
||||
<Field label="Arguments"><input className="ai-input ai-mono" value={s.args} onChange={(e) => setServer(i, { args: e.target.value })} placeholder="some-mcp-server --flag" /></Field>
|
||||
</div>
|
||||
)}
|
||||
<Field label="Courses" hint="Course ids, separated by commas."><input className="ai-input ai-mono" value={s.courses} onChange={(e) => setServer(i, { courses: e.target.value })} /></Field>
|
||||
<div className="ai-footer" style={{ margin: "0 0 10px" }}>
|
||||
<Pill onClick={() => testServer(i)}>Test connection</Pill>
|
||||
<Pill variant="ghost" onClick={() => setServers((x) => x.filter((_, j) => j !== i))}>Remove</Pill>
|
||||
{s.test ? <span className="ai-hint">{s.test}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="ai-footer">
|
||||
<Pill onClick={() => setServers((x) => [...x, { id: `server-${x.length + 1}`, name: "", transport: "http", command: "", args: "", url: "", courses: "" }])}>Add MCP server</Pill>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="ai-footer">
|
||||
<Pill variant="primary" type="submit">Save AI settings</Pill>
|
||||
{msg ? <span className={msg.ok ? "ai-hint ai-ok" : "ai-error"} role="status" style={{ margin: 0 }}>{msg.text}</span> : null}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string, string> = { 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 (
|
||||
<div className="ai-sources">
|
||||
<span>Sources</span>
|
||||
<ul className="ai-rows">
|
||||
{rows.map((c) => (
|
||||
<li key={c.path}>
|
||||
<span className="ai-path">{c.path}</span>
|
||||
{c.lessonId ? <Link className="ai-meta" to={`/c/${encodeURIComponent(courseId)}/${encodeURIComponent(c.lessonId)}`}>{c.lessonTitle ?? c.lessonId}</Link> : <span className="ai-meta">{c.kind}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AssistantPage() {
|
||||
const { courseId = "" } = useParams();
|
||||
const [status, setStatus] = useState<Status | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [turns, setTurns] = useState<Turn[]>([]);
|
||||
const [conversationId, setConversationId] = useState<string | null>(null);
|
||||
const [history, setHistory] = useState<ConvSummary[]>([]);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const lessonId = new URLSearchParams(typeof location === "undefined" ? "" : location.search).get("lesson") ?? undefined;
|
||||
|
||||
const loadHistory = useCallback(async () => {
|
||||
try { setHistory(await api<ConvSummary[]>(`/api/ai/courses/${encodeURIComponent(courseId)}/conversations`)); } catch { setHistory([]); }
|
||||
}, [courseId]);
|
||||
|
||||
useEffect(() => {
|
||||
api<Status>(`/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<ChatEvent>(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 = <Link to={`/c/${encodeURIComponent(courseId)}`}>{courseTitle}</Link>;
|
||||
|
||||
if (loadError && !status) return <div className="ai-page"><PageHead chap={chap} title="Course assistant" /><p className="ai-error">{loadError}</p></div>;
|
||||
if (!status) return <div className="ai-page"><PageHead chap={chap} title="Course assistant" /></div>;
|
||||
if (!status.course) return <div className="ai-page"><PageHead title="Course assistant" /><p className="ai-note">This course was not found.</p></div>;
|
||||
if (!status.signedIn) {
|
||||
return (
|
||||
<div className="ai-page">
|
||||
<PageHead chap={chap} title="Course assistant" />
|
||||
<p className="ai-note">Sign in to ask the assistant about this course.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!status.enabled) {
|
||||
return (
|
||||
<div className="ai-page">
|
||||
<PageHead chap={chap} title="Course assistant" />
|
||||
<p className="ai-note">
|
||||
The assistant is off. AI stays off until an admin sets up a server provider, or you add your own API key in <Link to="/account/ai">Account, AI</Link>. Your key is used only for your own questions.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ai-page">
|
||||
<PageHead chap={chap} title="Course assistant">
|
||||
<p>Answers come from this course's lessons, transcripts, slides, code and your notes, with the sources listed.{status.source === "byok" ? " Using your own key." : ""}</p>
|
||||
</PageHead>
|
||||
|
||||
{mentor.available ? (
|
||||
<div className="ai-bar">
|
||||
<SegmentedToggle label="Mentor mode" value={mentor.on ? "on" : "off"} onChange={(v) => setMentor(v === "on")} options={[{ value: "on", label: "Mentor on" }, { value: "off", label: "Mentor off" }]} />
|
||||
<span className="ai-hint">{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."}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<MentorBanner on={mentor.on} available={mentor.available} />
|
||||
{loadError ? <p className="ai-error">{loadError}</p> : null}
|
||||
|
||||
{turns.length ? (
|
||||
<ol className="ai-thread" aria-live="polite">
|
||||
{turns.map((t, i) => (
|
||||
<li key={i} className={`ai-turn ai-turn--${t.role}`}>
|
||||
<span className="ai-who">{t.role === "user" ? "You" : "Assistant"}</span>
|
||||
{t.text ? <Markdown text={t.text} /> : t.role === "assistant" && busy && i === turns.length - 1 && !t.error ? <p className="ai-activity">Thinking…</p> : null}
|
||||
{t.tools?.length ? <p className="ai-activity">{[...new Set(t.tools.map(toolLabel))].join(", ").replace(/^./, (c) => c.toUpperCase())}.</p> : null}
|
||||
{t.notices?.map((n) => <p key={n} className="ai-activity">{n}</p>)}
|
||||
{t.error ? <p className="ai-error">{t.error}</p> : null}
|
||||
{t.role === "assistant" && t.citations ? <Sources courseId={courseId} citations={t.citations} /> : null}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
) : (
|
||||
<p className="ai-note">Ask about anything in the course: an idea you are unsure of, a failing test, or "quiz me on this module".</p>
|
||||
)}
|
||||
|
||||
<form className="ai-composer" onSubmit={(e) => { e.preventDefault(); send(); }}>
|
||||
<label className="sr" htmlFor="ai-message">Your question</label>
|
||||
<textarea id="ai-message" className="ai-textarea" value={draft} onChange={(e) => setDraft(e.target.value)} placeholder="Ask a question about this course" rows={4} />
|
||||
<div className="ai-actions">
|
||||
{turns.length ? <Pill variant="ghost" onClick={newConversation}>New conversation</Pill> : null}
|
||||
{busy ? <Pill onClick={() => abortRef.current?.abort()}>Stop</Pill> : null}
|
||||
<Pill variant="primary" type="submit" disabled={busy || !draft.trim()}>Send</Pill>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{history.length ? (
|
||||
<section className="ai-history">
|
||||
<h2>Earlier conversations</h2>
|
||||
<ul className="ai-rows">
|
||||
{history.map((h) => (
|
||||
<li key={h.id}>
|
||||
<button type="button" className="ai-linkbtn" aria-current={h.id === conversationId} onClick={() => openConversation(h.id)}>{h.title || "Untitled"}</button>
|
||||
<Pill variant="ghost" onClick={() => deleteConversation(h.id)} aria-label={`Delete conversation ${h.title ?? ""}`}>Delete</Pill>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Pill, ProgressBar, SegmentedToggle } from "@stc/ui";
|
||||
import { api, ApiError, type Status } from "./api";
|
||||
import { Field, PageHead } from "./components";
|
||||
|
||||
interface CourseOpt { id: string; title: string }
|
||||
interface Sources { lessons: { id: string; title: string; type: string; module: string; hasText: boolean }[]; files: string[]; cache: string[] }
|
||||
interface Job { id: string; status: "queued" | "running" | "done" | "failed"; progress: number; message: string | null; error: string | null; result: { location: string; paths: string[]; warnings: string[] } | null }
|
||||
|
||||
const KINDS = [
|
||||
{ value: "document", label: "Document" },
|
||||
{ value: "exercise", label: "Exercise" },
|
||||
{ value: "questions", label: "Questions" },
|
||||
];
|
||||
const STYLES = [
|
||||
{ value: "pure-snake", label: "Module shape" },
|
||||
{ value: "systemic-design", label: "Observe to Expansion Drop" },
|
||||
];
|
||||
const STYLE_HINT: Record<string, string> = {
|
||||
"pure-snake": "Pure Snake's shape: why, objectives, concepts, a puzzle before the explanation, exercises with nudge, stronger hint and near-solution, and check questions.",
|
||||
"systemic-design": "Systemic Design's beats: Observe, Predict, Spec, Build, Name it, Expansion Drop, Further reading.",
|
||||
};
|
||||
|
||||
export default function GeneratePage() {
|
||||
const [courses, setCourses] = useState<CourseOpt[] | null>(null);
|
||||
const [denied, setDenied] = useState<string | null>(null);
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
const [courseId, setCourseId] = useState("");
|
||||
const [src, setSrc] = useState<Sources | null>(null);
|
||||
const [kind, setKind] = useState("document");
|
||||
const [style, setStyle] = useState("pure-snake");
|
||||
const [topic, setTopic] = useState("");
|
||||
const [instructions, setInstructions] = useState("");
|
||||
const [lessons, setLessons] = useState<Set<string>>(new Set());
|
||||
const [files, setFiles] = useState<Set<string>>(new Set());
|
||||
const [cache, setCache] = useState<Set<string>>(new Set());
|
||||
const [pasted, setPasted] = useState("");
|
||||
const [filter, setFilter] = useState("");
|
||||
const [job, setJob] = useState<Job | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api<CourseOpt[]>("/api/ai/studio/courses").then((c) => {
|
||||
setCourses(c);
|
||||
if (c[0]) setCourseId(c[0].id);
|
||||
}, (e: ApiError) => setDenied(e.status === 401 ? "Sign in as an author to generate drafts." : e.message));
|
||||
api<Status>("/api/ai/status").then((s) => setEnabled(s.enabled), () => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!courseId) return;
|
||||
setSrc(null);
|
||||
setLessons(new Set()); setFiles(new Set()); setCache(new Set());
|
||||
api<Sources>(`/api/ai/studio/sources?courseId=${encodeURIComponent(courseId)}`).then(setSrc, (e) => setError(e.message));
|
||||
}, [courseId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!job || job.status === "done" || job.status === "failed") return;
|
||||
const t = setTimeout(() => api<Job>(`/api/ai/studio/jobs/${job.id}`).then(setJob, (e) => setError(e.message)), 1000);
|
||||
return () => clearTimeout(t);
|
||||
}, [job]);
|
||||
|
||||
const toggle = (set: Set<string>, setter: (s: Set<string>) => void, v: string) => {
|
||||
const n = new Set(set);
|
||||
if (n.has(v)) n.delete(v); else n.add(v);
|
||||
setter(n);
|
||||
};
|
||||
const byModule = useMemo(() => {
|
||||
const m = new Map<string, Sources["lessons"]>();
|
||||
for (const l of src?.lessons ?? []) m.set(l.module, [...(m.get(l.module) ?? []), l]);
|
||||
return [...m];
|
||||
}, [src]);
|
||||
const shownFiles = (src?.files ?? []).filter((f) => !filter || f.toLowerCase().includes(filter.toLowerCase())).slice(0, 200);
|
||||
const sourceCount = lessons.size + files.size + cache.size + (pasted.trim() ? 1 : 0);
|
||||
|
||||
const generate = async () => {
|
||||
setError(null);
|
||||
setJob(null);
|
||||
const sources = [
|
||||
...[...lessons].map((lessonId) => ({ kind: "lesson", lessonId })),
|
||||
...[...files].map((path) => ({ kind: "file", path })),
|
||||
...[...cache].map((path) => ({ kind: "cache", path })),
|
||||
...(pasted.trim() ? [{ kind: "text", label: "Pasted text", text: pasted }] : []),
|
||||
];
|
||||
try {
|
||||
const { jobId } = await api<{ jobId: string }>("/api/ai/studio/generate", { method: "POST", body: { courseId, kind, style, topic, instructions, sources } });
|
||||
setJob({ id: jobId, status: "queued", progress: 0, message: "Queued", error: null, result: null });
|
||||
} catch (e) { setError((e as Error).message); }
|
||||
};
|
||||
|
||||
if (denied) return <div className="ai-page"><PageHead chap="Studio" title="Generate a draft" /><p className="ai-note">{denied}</p></div>;
|
||||
if (!courses) return <div className="ai-page"><PageHead chap="Studio" title="Generate a draft" /></div>;
|
||||
if (!courses.length) return <div className="ai-page"><PageHead chap="Studio" title="Generate a draft" /><p className="ai-note">You are not an author of any course yet.</p></div>;
|
||||
|
||||
const running = job && (job.status === "queued" || job.status === "running");
|
||||
return (
|
||||
<div className="ai-page">
|
||||
<PageHead chap="Studio" title="Generate a draft">
|
||||
<p>Drafts a document, an exercise or knowledge-check questions from material you choose. The result is written as draft files for you to review and edit. Nothing is published.</p>
|
||||
</PageHead>
|
||||
{!enabled ? <p className="ai-banner">AI is not set up. Ask the owner to configure a provider, or add your own key in Account, AI.</p> : null}
|
||||
|
||||
<form onSubmit={(e) => { e.preventDefault(); generate(); }}>
|
||||
<div className="ai-grid">
|
||||
<Field label="Course">
|
||||
<select className="ai-select" value={courseId} onChange={(e) => setCourseId(e.target.value)}>
|
||||
{courses.map((c) => <option key={c.id} value={c.id}>{c.title}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Topic">
|
||||
<input className="ai-input" value={topic} onChange={(e) => setTopic(e.target.value)} placeholder="For example: Folding time" required />
|
||||
</Field>
|
||||
</div>
|
||||
<div className="ai-bar">
|
||||
<SegmentedToggle label="What to draft" value={kind} onChange={setKind} options={KINDS} />
|
||||
<SegmentedToggle label="House style" value={style} onChange={setStyle} options={STYLES} />
|
||||
</div>
|
||||
<p className="ai-hint" style={{ margin: "-6px 0 18px" }}>{STYLE_HINT[style]}</p>
|
||||
|
||||
<section className="ai-section">
|
||||
<h2>Material</h2>
|
||||
<span className="ai-hint">Lessons and transcripts, code and text files, slide text from imports, or text you paste. {sourceCount ? `${sourceCount} selected.` : ""}</span>
|
||||
{!src ? <p className="ai-hint">Loading…</p> : (
|
||||
<>
|
||||
<div className="ai-checklist" role="group" aria-label="Lessons">
|
||||
{byModule.map(([mod, ls]) => (
|
||||
<div key={mod}>
|
||||
<div className="ai-group">{mod}</div>
|
||||
{ls.map((l) => (
|
||||
<label className="ai-check" key={l.id}>
|
||||
<input type="checkbox" disabled={!l.hasText} checked={lessons.has(l.id)} onChange={() => toggle(lessons, setLessons, l.id)} />
|
||||
<span>{l.title} <small>{l.type}{l.hasText ? "" : ", no transcript"}</small></span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{src.files.length ? (
|
||||
<>
|
||||
<Field label="Files">
|
||||
<input className="ai-input" value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="Filter files" />
|
||||
</Field>
|
||||
<div className="ai-checklist" role="group" aria-label="Files">
|
||||
{shownFiles.map((f) => (
|
||||
<label className="ai-check" key={f}><input type="checkbox" checked={files.has(f)} onChange={() => toggle(files, setFiles, f)} /><span className="ai-mono">{f}</span></label>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
{src.cache.length ? (
|
||||
<div className="ai-checklist" role="group" aria-label="Slide text and transcripts">
|
||||
<div className="ai-group">Slide text and transcripts</div>
|
||||
{src.cache.map((f) => (
|
||||
<label className="ai-check" key={f}><input type="checkbox" checked={cache.has(f)} onChange={() => toggle(cache, setCache, f)} /><span className="ai-mono">{f}</span></label>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
<Field label="Pasted text" hint="A transcript, notes or anything else to draw on.">
|
||||
<textarea className="ai-textarea" value={pasted} onChange={(e) => setPasted(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Instructions" hint="Optional. For example: aim at Module 5, two exercises, JavaScript only.">
|
||||
<textarea className="ai-textarea" value={instructions} onChange={(e) => setInstructions(e.target.value)} rows={3} />
|
||||
</Field>
|
||||
</section>
|
||||
|
||||
<div className="ai-footer">
|
||||
<Pill variant="primary" type="submit" disabled={!enabled || !topic.trim() || !!running}>Generate draft</Pill>
|
||||
{error ? <span className="ai-error" style={{ margin: 0 }}>{error}</span> : null}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{job ? (
|
||||
<section className="ai-section" aria-live="polite">
|
||||
<h2>{job.status === "done" ? "Draft written" : job.status === "failed" ? "Generation failed" : "Generating"}</h2>
|
||||
{running ? <div className="ai-progress"><ProgressBar value={job.progress} label="Generation progress" /><span>{job.message ?? "Queued"}</span></div> : null}
|
||||
{job.status === "failed" ? <p className="ai-error">{job.error}</p> : null}
|
||||
{job.result ? (
|
||||
<>
|
||||
<span className="ai-hint">Saved as a draft at <span className="ai-mono">{job.result.location}</span>. Review it in the studio before publishing.</span>
|
||||
<ul className="ai-rows">{job.result.paths.map((p) => <li key={p}><span className="ai-path">{p}</span><span className="ai-meta">draft</span></li>)}</ul>
|
||||
{job.result.warnings.length ? (
|
||||
<div className="ai-section">
|
||||
<h2>Check these</h2>
|
||||
<ul className="ai-rows">{job.result.warnings.map((w) => <li key={w}><span>{w}</span><span /></li>)}</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/* AI feature: assistant panel, settings and generator. Tokens only (packages/ui/src/tokens.css). */
|
||||
.ai-page { max-width: 760px; margin: 0 auto 64px; font-size: 14px; }
|
||||
.ai-head { margin: 0 0 22px; padding-bottom: 20px; border-bottom: 1px solid var(--line); }
|
||||
.ai-chap { font-size: 13px; font-weight: 500; color: var(--muted); margin: 0 0 10px; }
|
||||
.ai-chap a { text-decoration: none; }
|
||||
.ai-chap a:hover { color: var(--head); }
|
||||
.ai-title { font: 600 28px/1.15 var(--sans); color: var(--head); margin: 0; letter-spacing: -.01em; }
|
||||
.ai-lede { margin: 10px 0 0; color: var(--muted); font-size: 14px; line-height: 1.55; }
|
||||
.ai-lede p { margin: 0; }
|
||||
|
||||
.ai-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px; margin: 0 0 18px; }
|
||||
.ai-bar .ai-hint { flex: 1; min-width: 200px; }
|
||||
.ai-banner { margin: 0 0 18px; padding: 10px 14px; border: 1px solid var(--line2); border-radius: 10px; background: var(--side); color: var(--head); font-size: 13.5px; }
|
||||
.ai-note { margin: 0 0 18px; color: var(--muted); font-size: 13.5px; line-height: 1.55; }
|
||||
.ai-note a { color: var(--text); }
|
||||
.ai-error { margin: 0 0 14px; color: var(--bad); font-size: 13.5px; }
|
||||
.ai-ok { color: var(--ok); }
|
||||
|
||||
/* conversation */
|
||||
.ai-thread { list-style: none; margin: 0 0 18px; padding: 0; border-top: 1px solid var(--line); }
|
||||
.ai-turn { padding: 16px 2px; border-bottom: 1px solid var(--line); }
|
||||
.ai-who { display: block; font-size: 12px; color: var(--faint); margin: 0 0 6px; }
|
||||
.ai-turn--user .ai-md { color: var(--head); }
|
||||
.ai-md { font-size: 15px; line-height: 1.6; color: var(--text); overflow-wrap: anywhere; }
|
||||
.ai-md p, .ai-md ul, .ai-md ol { margin: 0 0 .8em; }
|
||||
.ai-md > :last-child { margin-bottom: 0; }
|
||||
.ai-md li { margin-bottom: .25em; }
|
||||
.ai-md .ai-md-h { font-weight: 600; color: var(--head); }
|
||||
.ai-md :not(pre) > code { font-size: .85em; padding: .08em .32em; background: color-mix(in srgb, var(--raised) 75%, transparent); border-radius: 4px; }
|
||||
.ai-md pre.code, pre.code { font-size: 13px; line-height: 1.6; background: var(--side); border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; margin: 0 0 .8em; overflow-x: auto; white-space: pre; }
|
||||
.ai-activity { margin: 8px 0 0; font-size: 12.5px; color: var(--faint); }
|
||||
.ai-sources { margin: 12px 0 0; }
|
||||
.ai-sources > span { display: block; font-size: 12px; color: var(--faint); margin: 0 0 4px; }
|
||||
|
||||
/* rows (same rhythm as the mockup's .rows) */
|
||||
.ai-rows { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--line); }
|
||||
.ai-rows li { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 10px; align-items: center; padding: 9px 2px; border-bottom: 1px solid var(--line); font-size: 13.5px; }
|
||||
.ai-rows .ai-path { font: 12.5px/1.4 var(--mono); color: var(--text); overflow-wrap: anywhere; }
|
||||
.ai-rows .ai-meta { font-size: 12px; color: var(--faint); white-space: nowrap; }
|
||||
.ai-rows a { text-decoration: none; }
|
||||
.ai-rows a:hover { text-decoration: underline; text-underline-offset: 3px; }
|
||||
|
||||
/* composer and forms */
|
||||
.ai-composer { display: grid; gap: 10px; }
|
||||
.ai-composer .ai-actions { display: flex; gap: 8px; justify-content: flex-end; }
|
||||
.ai-input, .ai-select, .ai-textarea { width: 100%; font-family: inherit; background: var(--side); border: 1px solid var(--line2); border-radius: 10px; padding: 9px 12px; color: var(--text); font-size: 14px; line-height: 1.5; }
|
||||
.ai-textarea { min-height: 96px; resize: vertical; }
|
||||
.ai-input:focus, .ai-select:focus, .ai-textarea:focus { outline: 0; border-color: var(--faint); }
|
||||
.ai-input:focus-visible, .ai-select:focus-visible, .ai-textarea:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
.ai-mono { font-family: var(--mono); font-size: 13px; }
|
||||
.ai-field { display: grid; gap: 5px; margin: 0 0 14px; align-content: start; }
|
||||
.ai-label { font-size: 12.5px; font-weight: 500; color: var(--muted); }
|
||||
.ai-hint { font-size: 12.5px; color: var(--faint); line-height: 1.45; }
|
||||
.ai-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 0 16px; align-items: start; }
|
||||
.ai-section { margin: 30px 0 0; }
|
||||
.ai-section > h2 { font: 600 15px/1.3 var(--sans); color: var(--head); margin: 0 0 4px; }
|
||||
.ai-section > .ai-hint { display: block; margin: 0 0 12px; }
|
||||
.ai-check { display: flex; gap: 10px; align-items: baseline; padding: 6px 2px; font-size: 13.5px; color: var(--text); cursor: pointer; }
|
||||
.ai-check input { accent-color: var(--accent); }
|
||||
.ai-check small { color: var(--faint); font-size: 12px; }
|
||||
.ai-checklist { max-height: 300px; overflow-y: auto; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); padding: 4px 0; margin: 0 0 14px; }
|
||||
.ai-group { font-size: 12px; color: var(--faint); padding: 8px 2px 2px; }
|
||||
.ai-server { border-top: 1px solid var(--line); padding: 14px 0 4px; }
|
||||
.ai-server:last-of-type { border-bottom: 1px solid var(--line); }
|
||||
.ai-footer { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 22px 0 0; }
|
||||
.ai-progress { display: flex; align-items: center; gap: 12px; margin: 18px 0 0; font-size: 13px; color: var(--muted); }
|
||||
.ai-progress .stc-bar { max-width: 260px; }
|
||||
.ai-history { margin: 34px 0 0; }
|
||||
.ai-history h2 { font: 600 15px/1.3 var(--sans); color: var(--head); margin: 0 0 10px; }
|
||||
.ai-linkbtn { background: none; border: 0; padding: 0; color: var(--text); text-align: left; font-size: 13.5px; }
|
||||
.ai-linkbtn:hover { text-decoration: underline; text-underline-offset: 3px; }
|
||||
.ai-linkbtn[aria-current="true"] { color: var(--head); font-weight: 500; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.ai-title { font-size: 24px; }
|
||||
.ai-rows li { grid-template-columns: minmax(0, 1fr); }
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
export class ApiError extends Error {
|
||||
constructor(public status: number, message: string) { super(message); }
|
||||
}
|
||||
|
||||
export async function api<T>(path: string, init: { method?: string; body?: unknown } = {}): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
method: init.method ?? "GET",
|
||||
headers: init.body === undefined ? undefined : { "content-type": "application/json" },
|
||||
body: init.body === undefined ? undefined : JSON.stringify(init.body),
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (!res.ok) {
|
||||
let msg = res.statusText;
|
||||
try { msg = (await res.json()).error ?? msg; } catch {}
|
||||
throw new ApiError(res.status, msg);
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
/** Parses a newline-delimited JSON stream into objects. */
|
||||
export async function* readNdjson<T = unknown>(body: ReadableStream<Uint8Array>): AsyncIterable<T> {
|
||||
const reader = body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = "";
|
||||
for (;;) {
|
||||
const { value, done } = await reader.read();
|
||||
if (value) buf += decoder.decode(value, { stream: true });
|
||||
let nl: number;
|
||||
while ((nl = buf.indexOf("\n")) !== -1) {
|
||||
const line = buf.slice(0, nl).trim();
|
||||
buf = buf.slice(nl + 1);
|
||||
if (line) yield JSON.parse(line) as T;
|
||||
}
|
||||
if (done) break;
|
||||
}
|
||||
buf += decoder.decode();
|
||||
if (buf.trim()) yield JSON.parse(buf) as T;
|
||||
}
|
||||
|
||||
export interface Citation { path: string; kind: string; lessonId?: string; lessonTitle?: string }
|
||||
export interface Status {
|
||||
enabled: boolean; source: "byok" | "server" | null; signedIn: boolean;
|
||||
course?: { id: string; title: string }; mentor?: { available: boolean; on: boolean };
|
||||
}
|
||||
export type ChatEvent =
|
||||
| { type: "meta"; conversationId: string; mentorOn: boolean; citations: Citation[] }
|
||||
| { type: "text"; text: string }
|
||||
| { type: "tool"; name: string }
|
||||
| { type: "notice"; message: string }
|
||||
| { type: "error"; message: string }
|
||||
| { type: "done"; text: string; citations: Citation[] };
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Fragment, type ReactNode } from "react";
|
||||
import "./ai.css";
|
||||
|
||||
/** Shown whenever the learner has switched the course's mentor rules off. */
|
||||
export function MentorBanner({ on, available }: { on: boolean; available: boolean }) {
|
||||
if (on || !available) return null;
|
||||
return <p className="ai-banner" role="status">Mentor mode off: I'll give full answers</p>;
|
||||
}
|
||||
|
||||
export function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
|
||||
return (
|
||||
<label className="ai-field">
|
||||
<span className="ai-label">{label}</span>
|
||||
{children}
|
||||
{hint ? <span className="ai-hint">{hint}</span> : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function PageHead({ chap, title, children }: { chap?: ReactNode; title: string; children?: ReactNode }) {
|
||||
return (
|
||||
<header className="ai-head">
|
||||
{chap ? <p className="ai-chap">{chap}</p> : null}
|
||||
<h1 className="ai-title">{title}</h1>
|
||||
{children ? <div className="ai-lede">{children}</div> : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function inline(text: string, key: string): ReactNode[] {
|
||||
const out: ReactNode[] = [];
|
||||
const re = /(`[^`]+`|\*\*[^*]+\*\*)/g;
|
||||
let last = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
let i = 0;
|
||||
while ((m = re.exec(text))) {
|
||||
if (m.index > last) out.push(text.slice(last, m.index));
|
||||
const tok = m[0];
|
||||
out.push(tok.startsWith("`") ? <code key={`${key}-${i++}`}>{tok.slice(1, -1)}</code> : <strong key={`${key}-${i++}`}>{tok.slice(2, -2)}</strong>);
|
||||
last = m.index + tok.length;
|
||||
}
|
||||
if (last < text.length) out.push(text.slice(last));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A small, safe Markdown subset for assistant answers: paragraphs, lists, headings, fenced code, `code`, **bold**. */
|
||||
export function Markdown({ text }: { text: string }) {
|
||||
const blocks: ReactNode[] = [];
|
||||
const lines = text.replace(/\r\n/g, "\n").split("\n");
|
||||
let i = 0;
|
||||
let k = 0;
|
||||
while (i < lines.length) {
|
||||
const line = lines[i]!;
|
||||
if (/^\s*```/.test(line)) {
|
||||
const code: string[] = [];
|
||||
i++;
|
||||
while (i < lines.length && !/^\s*```/.test(lines[i]!)) code.push(lines[i++]!);
|
||||
i++;
|
||||
blocks.push(<pre className="code" key={k++}>{code.join("\n")}</pre>);
|
||||
} else if (/^\s*([-*]|\d+\.)\s+/.test(line)) {
|
||||
const ordered = /^\s*\d+\./.test(line);
|
||||
const items: string[] = [];
|
||||
while (i < lines.length && /^\s*([-*]|\d+\.)\s+/.test(lines[i]!)) items.push(lines[i++]!.replace(/^\s*([-*]|\d+\.)\s+/, ""));
|
||||
const kids = items.map((it, j) => <li key={j}>{inline(it, `${k}-${j}`)}</li>);
|
||||
blocks.push(ordered ? <ol key={k++}>{kids}</ol> : <ul key={k++}>{kids}</ul>);
|
||||
} else if (/^#{1,6}\s/.test(line)) {
|
||||
blocks.push(<p className="ai-md-h" key={k++}>{inline(line.replace(/^#+\s*/, ""), String(k))}</p>);
|
||||
i++;
|
||||
} else if (!line.trim()) i++;
|
||||
else {
|
||||
const para: string[] = [];
|
||||
while (i < lines.length && lines[i]!.trim() && !/^\s*(```|[-*]\s|\d+\.\s|#{1,6}\s)/.test(lines[i]!)) para.push(lines[i++]!);
|
||||
blocks.push(<p key={k++}>{para.map((p, j) => <Fragment key={j}>{j ? <br /> : null}{inline(p, `${k}-${j}`)}</Fragment>)}</p>);
|
||||
}
|
||||
}
|
||||
return <div className="ai-md">{blocks}</div>;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { WebFeature } from "@stc/contracts";
|
||||
|
||||
export const aiWeb: WebFeature = {
|
||||
name: "ai",
|
||||
routes: [
|
||||
{ path: "/c/:courseId/assistant", element: () => import("./AssistantPage") },
|
||||
{ path: "/admin/ai", element: () => import("./AdminAiPage"), nav: { label: "AI", area: "admin" } },
|
||||
{ path: "/account/ai", element: () => import("./AccountAiPage") },
|
||||
{ path: "/studio/generate", element: () => import("./GeneratePage") },
|
||||
],
|
||||
};
|
||||
@@ -101,6 +101,7 @@ forEachDb("chat cites course files, applies mentor rules, and stores the convers
|
||||
expect(convs).toHaveLength(1);
|
||||
const conv = await (await call("GET", `/api/ai/conversations/${convs[0].id}`, "ann")).json();
|
||||
expect(conv.messages.map((m: { role: string }) => m.role)).toEqual(["user", "assistant"]);
|
||||
expect(conv.messages[1].citations.some((c: { path: string }) => c.path === "lessons/04-exercise.md")).toBe(true);
|
||||
expect((await call("GET", `/api/ai/conversations/${convs[0].id}`, "bob")).status).toBe(404);
|
||||
|
||||
// Learner switches mentor mode off for this course: rules leave the prompt.
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { renderToString } from "react-dom/server";
|
||||
import { readNdjson } from "../src/web/api";
|
||||
import { MentorBanner, Markdown } from "../src/web/components";
|
||||
import { aiWeb } from "../src/web";
|
||||
|
||||
test("readNdjson splits events across chunk boundaries", async () => {
|
||||
const text = '{"type":"meta","n":1}\n{"type":"text","text":"héllo"}\n{"type":"done"}\n';
|
||||
const bytes = new TextEncoder().encode(text);
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(c) { for (let i = 0; i < bytes.length; i += 5) c.enqueue(bytes.slice(i, i + 5)); c.close(); },
|
||||
});
|
||||
const out: unknown[] = [];
|
||||
for await (const ev of readNdjson(stream)) out.push(ev);
|
||||
expect(out).toEqual([{ type: "meta", n: 1 }, { type: "text", text: "héllo" }, { type: "done" }]);
|
||||
});
|
||||
|
||||
test("the mentor banner says so plainly when mentor mode is off", () => {
|
||||
expect(renderToString(<MentorBanner on={false} available />)).toContain("Mentor mode off: I'll give full answers");
|
||||
expect(renderToString(<MentorBanner on available />)).toBe("");
|
||||
expect(renderToString(<MentorBanner on={false} available={false} />)).toBe("");
|
||||
});
|
||||
|
||||
test("markdown renders code and text without raw HTML", () => {
|
||||
const html = renderToString(<Markdown text={"Use **this**:\n\n```js\nconst a = 1 < 2\n```\n\n- one `x`\n- <b>two</b>"} />);
|
||||
expect(html).toContain("<strong>this</strong>");
|
||||
expect(html).toContain('<pre class="code">');
|
||||
expect(html).toContain("const a = 1 < 2");
|
||||
expect(html).toContain("<code>x</code>");
|
||||
expect(html).toContain("<b>two</b>");
|
||||
});
|
||||
|
||||
test("web routes", () => {
|
||||
expect(aiWeb.routes.map((r) => r.path)).toEqual(["/c/:courseId/assistant", "/admin/ai", "/account/ai", "/studio/generate"]);
|
||||
expect(aiWeb.routes.find((r) => r.path === "/admin/ai")!.nav).toEqual({ label: "AI", area: "admin" });
|
||||
});
|
||||
Reference in New Issue
Block a user