- A user's own key goes only to https://api.anthropic.com, https://api.openai.com/v1 or an origin listed in STC_AI_ALLOWED_BASE_URLS / the server config's allowedBaseUrls; anything else is a 400, and a stored key whose origin was later removed stops being used. - User-scope provider calls never follow redirects, and their errors reach the user only as the HTTP status plus a fixed message; the full text is logged. - The admin's server-scope provider may still use any base URL. - Admin UI: allowed origins field; MCP stdio env editable, HTTP headers kept. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
474 lines
27 KiB
TypeScript
474 lines
27 KiB
TypeScript
import { readdir } from "node:fs/promises";
|
|
import { extname, join, relative, sep } from "node:path";
|
|
import type { AiProvider, AuthUser, ChatMessage, Feature, JobContext, RequestContext, ServerContext } from "@stc/contracts";
|
|
import { canView } from "@stc/core";
|
|
import type { LoadedCourse } from "@stc/course-format";
|
|
import { aclRepo, notesRepo, progressRepo } from "@stc/db";
|
|
import { buildSystemPrompt, citationsFor, readMentorText } from "./assistant/prompt";
|
|
import { combineTools, courseTools, mcpTools, type ToolSet } from "./assistant/tools";
|
|
import { runAssistant } from "./assistant/loop";
|
|
import { createSecretBox } from "./crypto";
|
|
import { createFsDraftSink, type DraftSink } from "./generator/draft-sink";
|
|
import { generateDraft, type GenerateRequest, type GenerateSource } from "./generator/generate";
|
|
import { connectMcp, mcpServersFor } from "./mcp/client";
|
|
import { createProvider } from "./providers";
|
|
import { PRESET_BASE_URLS, byokBaseUrl, noRedirectFetch, parseAllowedOrigins, publicProviderError, sanitizeProviderErrors } from "./endpoints";
|
|
import { getCourseIndex, noteChunks } from "./retrieval/course-index";
|
|
import { aiSettingsRepo, type McpServerConfig, type ProviderConfig } from "./settings";
|
|
|
|
export interface AiFeatureOptions {
|
|
/** Used by providers and HTTP MCP servers. Tests pass a fake; production uses global fetch. */
|
|
fetch?: typeof fetch;
|
|
/** Where generated drafts go. Phase 2 passes the studio's createDraftWriter(ctx); default writes under <dataDir>/drafts. */
|
|
draftSink?: DraftSink;
|
|
/** Overrides provider construction (tests use a fake AiProvider). */
|
|
providerFactory?: (cfg: ProviderConfig, apiKey: string | null) => AiProvider;
|
|
mcpTimeoutMs?: number;
|
|
/** Environment to read STC_AI_ALLOWED_BASE_URLS from. Default: process.env. */
|
|
env?: Record<string, string | undefined>;
|
|
/** Server-side log for provider errors that are not shown to users. Default: console.error. */
|
|
log?: (message: string) => void;
|
|
}
|
|
|
|
const json = (body: unknown, status = 200) => Response.json(body, { status });
|
|
const err = (status: number, message: string) => json({ error: message }, status);
|
|
const KINDS = new Set(["anthropic", "openai"]);
|
|
const EFFORTS = new Set(["low", "medium", "high", "xhigh", "max"]);
|
|
const HISTORY_TURNS = 20;
|
|
const MAX_MESSAGE = 8_000;
|
|
|
|
async function body(req: Request): Promise<Record<string, unknown> | null> {
|
|
try {
|
|
const b = await req.json();
|
|
return b && typeof b === "object" && !Array.isArray(b) ? (b as Record<string, unknown>) : null;
|
|
} catch { return null; }
|
|
}
|
|
const str = (v: unknown): string | undefined => (typeof v === "string" && v.trim() ? v.trim() : undefined);
|
|
|
|
function parseProvider(v: unknown, scope: "server" | "user"): (ProviderConfig & { apiKey?: string; clearApiKey?: boolean }) | null | "invalid" {
|
|
if (v === null || v === undefined) return null;
|
|
if (typeof v !== "object") return "invalid";
|
|
const p = v as Record<string, unknown>;
|
|
if (!KINDS.has(p.kind as string)) return "invalid";
|
|
if (p.effort !== undefined && p.effort !== "" && !EFFORTS.has(p.effort as string)) return "invalid";
|
|
const out: ProviderConfig & { apiKey?: string; clearApiKey?: boolean } = { kind: p.kind as ProviderConfig["kind"] };
|
|
if (str(p.model)) out.model = str(p.model);
|
|
if (str(p.baseUrl)) {
|
|
try { new URL(str(p.baseUrl)!); } catch { return "invalid"; }
|
|
out.baseUrl = str(p.baseUrl);
|
|
}
|
|
if (str(p.effort)) out.effort = str(p.effort) as ProviderConfig["effort"];
|
|
if (str(p.apiKey)) out.apiKey = str(p.apiKey);
|
|
if (p.clearApiKey === true) out.clearApiKey = true;
|
|
// Server scope: any endpoint, but OpenAI-compatible needs one. User scope: an empty base URL means the official endpoint.
|
|
if (out.kind === "openai" && (!out.model || (scope === "server" && !out.baseUrl))) return "invalid";
|
|
return out;
|
|
}
|
|
|
|
function parseMcp(v: unknown): McpServerConfig[] | "invalid" {
|
|
if (v === undefined || v === null) return [];
|
|
if (!Array.isArray(v)) return "invalid";
|
|
const out: McpServerConfig[] = [];
|
|
const ids = new Set<string>();
|
|
for (const raw of v) {
|
|
if (!raw || typeof raw !== "object") return "invalid";
|
|
const s = raw as Record<string, unknown>;
|
|
const id = str(s.id);
|
|
if (!id || !/^[a-zA-Z0-9_-]{1,40}$/.test(id) || ids.has(id)) return "invalid";
|
|
ids.add(id);
|
|
const transport = s.transport === "stdio" || s.transport === "http" ? s.transport : null;
|
|
if (!transport) return "invalid";
|
|
const cfg: McpServerConfig = { id, name: str(s.name) ?? id, transport };
|
|
if (transport === "stdio") {
|
|
if (!str(s.command)) return "invalid";
|
|
cfg.command = str(s.command);
|
|
if (Array.isArray(s.args)) cfg.args = s.args.map(String);
|
|
if (s.env && typeof s.env === "object") cfg.env = Object.fromEntries(Object.entries(s.env).map(([k, val]) => [k, String(val)]));
|
|
} else {
|
|
const url = str(s.url);
|
|
if (!url) return "invalid";
|
|
try { new URL(url); } catch { return "invalid"; }
|
|
cfg.url = url;
|
|
if (s.headers && typeof s.headers === "object") cfg.headers = Object.fromEntries(Object.entries(s.headers).map(([k, val]) => [k, String(val)]));
|
|
}
|
|
if (Array.isArray(s.courseIds)) cfg.courseIds = s.courseIds.map(String).filter(Boolean);
|
|
out.push(cfg);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function parseGenerate(b: Record<string, unknown>): GenerateRequest | null {
|
|
const courseId = str(b.courseId);
|
|
const topic = str(b.topic);
|
|
if (!courseId || !topic) return null;
|
|
if (!["document", "exercise", "questions"].includes(b.kind as string)) return null;
|
|
if (!["pure-snake", "systemic-design"].includes(b.style as string)) return null;
|
|
if (!Array.isArray(b.sources)) return null;
|
|
const sources: GenerateSource[] = [];
|
|
for (const s of b.sources) {
|
|
if (!s || typeof s !== "object") return null;
|
|
const x = s as Record<string, unknown>;
|
|
if (x.kind === "lesson" && str(x.lessonId)) sources.push({ kind: "lesson", lessonId: str(x.lessonId)! });
|
|
else if (x.kind === "file" && str(x.path)) sources.push({ kind: "file", path: str(x.path)! });
|
|
else if (x.kind === "cache" && str(x.path)) sources.push({ kind: "cache", path: str(x.path)! });
|
|
else if (x.kind === "text" && typeof x.text === "string" && x.text.trim()) sources.push({ kind: "text", label: str(x.label) ?? "Pasted text", text: x.text });
|
|
else return null;
|
|
}
|
|
return { courseId, topic: topic.slice(0, 200), kind: b.kind as GenerateRequest["kind"], style: b.style as GenerateRequest["style"], sources, instructions: typeof b.instructions === "string" ? b.instructions.slice(0, 8_000) : undefined };
|
|
}
|
|
|
|
const LISTABLE = new Set([".md", ".markdown", ".txt", ".js", ".mjs", ".ts", ".tsx", ".jsx", ".py", ".rs", ".scala", ".fs", ".go", ".java", ".sh", ".json", ".yaml", ".yml", ".sql", ".html", ".css", ".toml", ".vtt", ".srt"]);
|
|
async function listFiles(root: string, limit = 800, keep: (rel: string) => boolean = () => true): Promise<string[]> {
|
|
const out: string[] = [];
|
|
const walk = async (dir: string, depth: number) => {
|
|
if (depth > 8 || out.length >= limit) return;
|
|
let entries;
|
|
try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; }
|
|
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
for (const e of entries) {
|
|
if (out.length >= limit) return;
|
|
if (e.name.startsWith(".") || e.name === "node_modules" || e.name === "target" || e.name === "dist") continue;
|
|
const abs = join(dir, e.name);
|
|
if (e.isDirectory()) await walk(abs, depth + 1);
|
|
else if (e.isFile() && LISTABLE.has(extname(e.name).toLowerCase())) {
|
|
const rel = relative(root, abs).split(sep).join("/");
|
|
if (keep(rel)) out.push(rel);
|
|
}
|
|
}
|
|
};
|
|
await walk(root, 0);
|
|
return out;
|
|
}
|
|
|
|
export function aiFeature(ctx: ServerContext, opts: AiFeatureOptions = {}): Feature {
|
|
const settings = aiSettingsRepo(ctx.db, createSecretBox(ctx.config.secret));
|
|
const acl = aclRepo(ctx.db);
|
|
const notes = notesRepo(ctx.db);
|
|
const progress = progressRepo(ctx.db);
|
|
const sink = opts.draftSink ?? createFsDraftSink(ctx.config.dataDir);
|
|
const log = opts.log ?? ((m: string) => console.error(`[ai] ${m}`));
|
|
const env = opts.env ?? process.env;
|
|
const allowedOrigins = async () => [...new Set([...parseAllowedOrigins(env.STC_AI_ALLOWED_BASE_URLS ?? ""), ...(await settings.getServer()).allowedBaseUrls])];
|
|
/**
|
|
* Providers for a user's own key never follow redirects, and their errors reach the user only as the HTTP
|
|
* status plus a fixed sentence (the full text is logged here). Server-scope providers are the admin's own.
|
|
*/
|
|
const makeProvider = (r: { source: "byok" | "server"; cfg: ProviderConfig; key: string | null }, userId: string): AiProvider => {
|
|
if (r.source === "server") return opts.providerFactory ? opts.providerFactory(r.cfg, r.key) : createProvider(r.cfg, r.key, opts.fetch);
|
|
const p = opts.providerFactory ? opts.providerFactory(r.cfg, r.key) : createProvider(r.cfg, r.key, noRedirectFetch(opts.fetch ?? fetch));
|
|
return sanitizeProviderErrors(p, (m) => log(`provider error for user ${userId} (own key): ${m}`));
|
|
};
|
|
|
|
/** BYOK first ("used only for their own requests"), then the server provider. */
|
|
const resolveProvider = async (userId: string | null): Promise<{ source: "byok" | "server"; cfg: ProviderConfig; key: string | null } | null> => {
|
|
if (userId) {
|
|
const u = await settings.getUser(userId);
|
|
if (u.byok) {
|
|
// Re-checked on every use: an origin the admin removed from the allowlist stops working at once.
|
|
const base = byokBaseUrl(u.byok.kind, u.byok.baseUrl, await allowedOrigins());
|
|
const key = base ? await settings.userApiKey(userId).catch(() => null) : null;
|
|
if (key && base) return { source: "byok", cfg: { ...u.byok, baseUrl: base }, key };
|
|
}
|
|
}
|
|
const s = await settings.getServer();
|
|
if (!s.provider) return null;
|
|
if (s.provider.kind === "anthropic" && !s.provider.hasApiKey) return null;
|
|
const key = s.provider.hasApiKey ? await settings.serverApiKey().catch(() => null) : null;
|
|
if (s.provider.kind === "anthropic" && !key) return null;
|
|
return { source: "server", cfg: s.provider, key };
|
|
};
|
|
|
|
const viewable = async (user: AuthUser | null, courseId: string): Promise<LoadedCourse | null> => {
|
|
const c = ctx.courses.get(courseId);
|
|
if (!c || c.status !== "ok") return null;
|
|
const ok = canView(user, c, await acl.listForCourse(c.id), user ? await acl.isAuthor(c.id, user.id) : false);
|
|
return ok ? c : null;
|
|
};
|
|
const canAuthor = async (user: AuthUser, courseId: string) =>
|
|
user.roles.includes("owner") || user.roles.includes("author") || (await acl.isAuthor(courseId, user.id));
|
|
|
|
const conversationOf = async (id: string, userId: string) => {
|
|
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;
|
|
};
|
|
// 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" | "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."));
|
|
|
|
const chat = async (req: Request, rc: RequestContext): Promise<Response> => {
|
|
const deny = signedIn(rc);
|
|
if (deny) return deny;
|
|
const user = rc.user!;
|
|
const course = await viewable(user, rc.params.courseId ?? "");
|
|
if (!course) return err(404, "Course not found.");
|
|
const b = await body(req);
|
|
const message = typeof b?.message === "string" ? b.message.trim() : "";
|
|
if (!message) return err(400, "Write a message.");
|
|
if (message.length > MAX_MESSAGE) return err(400, "That message is too long.");
|
|
const resolved = await resolveProvider(user.id);
|
|
if (!resolved) return err(409, "AI is not set up. An admin can configure a server provider, or you can add your own key under Account, AI.");
|
|
let provider: AiProvider;
|
|
try { provider = makeProvider(resolved, user.id); } catch (e) { return err(409, (e as Error).message); }
|
|
|
|
let conversationId = str(b?.conversationId);
|
|
if (conversationId) {
|
|
const conv = await conversationOf(conversationId, user.id);
|
|
if (!conv || conv.course_id !== course.id) return err(404, "Conversation not found.");
|
|
} else {
|
|
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 turnsOf(conversationId)).slice(-HISTORY_TURNS);
|
|
await addMessage(conversationId, "user", message);
|
|
|
|
const lessonId = str(b?.lessonId);
|
|
const lesson = lessonId ? course.lessonIndex.get(lessonId)?.lesson : undefined;
|
|
const index = await getCourseIndex(course, ctx.config.dataDir);
|
|
const userNotes = await notes.list(user.id, course.id);
|
|
const sources = index.search(lesson ? `${message} ${lesson.title}` : message, 6, noteChunks(course, userNotes));
|
|
const mentorText = await readMentorText(ctx.courses, course);
|
|
const mentorOn = !(await settings.isMentorOff(user.id, course.id));
|
|
const convId = conversationId;
|
|
|
|
const stream = new ReadableStream<Uint8Array>({
|
|
async start(controller) {
|
|
const enc = new TextEncoder();
|
|
const send = (ev: unknown) => { try { controller.enqueue(enc.encode(JSON.stringify(ev) + "\n")); } catch {} };
|
|
send({ type: "meta", conversationId: convId, mentorOn, mentorAvailable: !!mentorText, source: resolved.source, citations: citationsFor(sources) });
|
|
let tools: ToolSet | null = null;
|
|
try {
|
|
const course_ = courseTools({ repo: ctx.courses, course, index, listNotes: (lid) => notes.list(user.id, course.id, lid), getProgress: () => progress.get(user.id, course.id) });
|
|
const servers = mcpServersFor(course.id, (await settings.getServer()).mcpServers);
|
|
const mcp = servers.length ? await mcpTools(servers, { fetch: opts.fetch, timeoutMs: opts.mcpTimeoutMs }) : null;
|
|
for (const w of mcp?.warnings ?? []) send({ type: "notice", message: w });
|
|
tools = combineTools(mcp ? [course_, mcp] : [course_]);
|
|
const messages: ChatMessage[] = [
|
|
{ role: "system", content: buildSystemPrompt({ course, mentorText, mentorOn, sources, lesson: lesson ? { id: lesson.id, title: lesson.title } : null, extraTools: mcp?.names }) },
|
|
...history.map((m) => ({ role: m.role, content: m.content }) as ChatMessage),
|
|
{ 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;
|
|
cited = citationsFor([...sources, ...tools.sources]);
|
|
send({ type: "done", text: ev.text, citations: cited });
|
|
} else send(ev);
|
|
}
|
|
if (answer) {
|
|
await addMessage(convId, "assistant", answer);
|
|
await addMessage(convId, "sources", JSON.stringify(cited));
|
|
}
|
|
} catch (e) {
|
|
const m = (e as Error).message;
|
|
if (resolved.source === "byok") log(`chat error for user ${user.id} (own key): ${m}`);
|
|
send({ type: "error", message: resolved.source === "byok" ? publicProviderError(m) : m });
|
|
} finally {
|
|
await tools?.close?.().catch(() => {});
|
|
try { controller.close(); } catch {}
|
|
}
|
|
},
|
|
});
|
|
return new Response(stream, { headers: { "content-type": "application/x-ndjson; charset=utf-8", "cache-control": "no-store" } });
|
|
};
|
|
|
|
const generateJob = async (payload: unknown, jc: JobContext) => {
|
|
const p = payload as { req: GenerateRequest; userId: string };
|
|
const resolved = await resolveProvider(p.userId);
|
|
if (!resolved) throw new Error("AI is not set up for this user.");
|
|
const provider = makeProvider(resolved, p.userId);
|
|
return generateDraft({ provider, repo: ctx.courses, req: p.req, sink, authorId: p.userId, dataDir: ctx.config.dataDir, signal: jc.signal, onProgress: (f, m) => jc.reportProgress(f, m) });
|
|
};
|
|
|
|
return {
|
|
name: "ai",
|
|
jobs: { "ai.generate": generateJob },
|
|
routes: {
|
|
"/api/ai/status": {
|
|
GET: async (_req, rc) => {
|
|
const resolved = rc.user ? await resolveProvider(rc.user.id) : null;
|
|
const out: Record<string, unknown> = { enabled: !!resolved, source: resolved?.source ?? null, signedIn: !!rc.user };
|
|
const courseId = rc.url.searchParams.get("courseId");
|
|
if (courseId) {
|
|
const course = await viewable(rc.user, courseId);
|
|
if (course) {
|
|
out.course = { id: course.id, title: course.manifest?.title ?? course.id };
|
|
out.mentor = { available: !!course.manifest?.mentor, on: rc.user ? !(await settings.isMentorOff(rc.user.id, course.id)) : true };
|
|
}
|
|
}
|
|
return json(out);
|
|
},
|
|
},
|
|
"/api/ai/admin/config": {
|
|
GET: async (_req, rc) => ownerOnly(rc) ?? json(await settings.getServer()),
|
|
PUT: async (req, rc) => {
|
|
const deny = ownerOnly(rc);
|
|
if (deny) return deny;
|
|
const b = await body(req);
|
|
if (!b) return err(400, "Invalid JSON.");
|
|
const provider = parseProvider(b.provider, "server");
|
|
const mcpServers = parseMcp(b.mcpServers);
|
|
if (provider === "invalid") return err(400, "Invalid provider. OpenAI-compatible endpoints need a base URL and a model.");
|
|
if (mcpServers === "invalid") return err(400, "Invalid MCP server list.");
|
|
let allowedBaseUrls: string[] | undefined;
|
|
if (b.allowedBaseUrls !== undefined) {
|
|
const list = Array.isArray(b.allowedBaseUrls) ? b.allowedBaseUrls.map(String).map((x) => x.trim()).filter(Boolean) : null;
|
|
if (!list) return err(400, "allowedBaseUrls must be a list of origins.");
|
|
allowedBaseUrls = parseAllowedOrigins(list);
|
|
if (allowedBaseUrls.length !== new Set(list.map((x) => x.replace(/\/+$/, ""))).size) return err(400, "Each allowed base URL must be an origin such as http://ollama.internal:11434.");
|
|
}
|
|
await settings.saveServer({ provider, mcpServers, allowedBaseUrls });
|
|
return json(await settings.getServer());
|
|
},
|
|
},
|
|
"/api/ai/admin/mcp/test": {
|
|
POST: async (req, rc) => {
|
|
const deny = ownerOnly(rc);
|
|
if (deny) return deny;
|
|
const b = await body(req);
|
|
const parsed = parseMcp(b ? [b] : null);
|
|
if (parsed === "invalid" || !parsed.length) return err(400, "Invalid MCP server.");
|
|
try {
|
|
const c = await connectMcp(parsed[0]!, { fetch: opts.fetch, timeoutMs: opts.mcpTimeoutMs ?? 15_000 });
|
|
try { return json({ ok: true, tools: (await c.tools()).map((t) => ({ name: t.name, description: t.description })) }); } finally { await c.close(); }
|
|
} catch (e) {
|
|
return json({ ok: false, error: (e as Error).message });
|
|
}
|
|
},
|
|
},
|
|
"/api/ai/account": {
|
|
GET: async (_req, rc) => {
|
|
if (!rc.user) return err(401, "Sign in first.");
|
|
const u = await settings.getUser(rc.user.id);
|
|
const s = await settings.getServer();
|
|
return json({ byok: u.byok, serverProvider: s.provider ? { kind: s.provider.kind, model: s.provider.model ?? null } : null });
|
|
},
|
|
},
|
|
"/api/ai/account/key": {
|
|
PUT: async (req, rc) => {
|
|
if (!rc.user) return err(401, "Sign in first.");
|
|
const b = await body(req);
|
|
const p = b ? parseProvider(b, "user") : "invalid";
|
|
if (!p || p === "invalid" || !p.apiKey) return err(400, "Choose a provider and paste your key. OpenAI-compatible endpoints also need a model.");
|
|
const base = byokBaseUrl(p.kind, p.baseUrl, await allowedOrigins());
|
|
if (!base) return err(400, "That base URL is not allowed for your own key. Use the official endpoint, or ask an admin to allow it.");
|
|
await settings.saveUserKey(rc.user.id, { ...p, baseUrl: base === PRESET_BASE_URLS[p.kind] ? undefined : base, apiKey: p.apiKey });
|
|
return json((await settings.getUser(rc.user.id)).byok);
|
|
},
|
|
DELETE: async (_req, rc) => {
|
|
if (!rc.user) return err(401, "Sign in first.");
|
|
await settings.removeUserKey(rc.user.id);
|
|
return json({ ok: true });
|
|
},
|
|
},
|
|
"/api/ai/courses/:courseId/mentor": {
|
|
PUT: async (req, rc) => {
|
|
const deny = signedIn(rc);
|
|
if (deny) return deny;
|
|
const course = await viewable(rc.user, rc.params.courseId ?? "");
|
|
if (!course) return err(404, "Course not found.");
|
|
const b = await body(req);
|
|
if (typeof b?.off !== "boolean") return err(400, "Send { off: true | false }.");
|
|
await settings.setMentorOff(rc.user!.id, course.id, b.off);
|
|
return json({ mentor: { available: !!course.manifest?.mentor, on: !b.off } });
|
|
},
|
|
},
|
|
"/api/ai/courses/:courseId/chat": { POST: chat },
|
|
"/api/ai/courses/:courseId/conversations": {
|
|
GET: async (_req, rc) => {
|
|
const deny = signedIn(rc);
|
|
if (deny) return deny;
|
|
const rows = await ctx.db.sql`SELECT id, title, created_at FROM ai_conversations WHERE user_id = ${rc.user!.id} AND course_id = ${rc.params.courseId ?? ""}`;
|
|
return json(rows.map((r: { id: string; title: string | null; created_at: string }) => ({ id: r.id, title: r.title, createdAt: r.created_at })).sort((a: { createdAt: string }, b: { createdAt: string }) => (a.createdAt < b.createdAt ? 1 : -1)));
|
|
},
|
|
},
|
|
"/api/ai/conversations/:id": {
|
|
GET: async (_req, rc) => {
|
|
const deny = signedIn(rc);
|
|
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 turnsOf(conv.id) });
|
|
},
|
|
DELETE: async (_req, rc) => {
|
|
const deny = signedIn(rc);
|
|
if (deny) return deny;
|
|
const conv = await conversationOf(rc.params.id ?? "", rc.user!.id);
|
|
if (!conv) return err(404, "Conversation not found.");
|
|
await ctx.db.sql.begin(async (tx) => {
|
|
await tx`DELETE FROM ai_messages WHERE conversation_id = ${conv.id}`;
|
|
await tx`DELETE FROM ai_conversations WHERE id = ${conv.id}`;
|
|
});
|
|
return json({ ok: true });
|
|
},
|
|
},
|
|
"/api/ai/studio/courses": {
|
|
GET: async (_req, rc) => {
|
|
if (!rc.user) return err(401, "Sign in first.");
|
|
const out = [];
|
|
for (const c of ctx.courses.list()) {
|
|
if (c.status === "ok" && (await canAuthor(rc.user, c.id))) out.push({ id: c.id, title: c.manifest?.title ?? c.id, tracks: c.manifest?.tracks ?? [] });
|
|
}
|
|
return json(out);
|
|
},
|
|
},
|
|
"/api/ai/studio/sources": {
|
|
GET: async (_req, rc) => {
|
|
if (!rc.user) return err(401, "Sign in first.");
|
|
const courseId = rc.url.searchParams.get("courseId") ?? "";
|
|
const course = ctx.courses.get(courseId);
|
|
if (!course || course.status !== "ok") return err(404, "Course not found.");
|
|
if (!(await canAuthor(rc.user, course.id))) return err(403, "Only authors can generate drafts.");
|
|
const lessons = (course.manifest?.modules ?? []).flatMap((m) => m.lessons.map((l) => ({ id: l.id, title: l.title, type: l.type, module: m.title, hasText: l.type !== "video" || !!l.transcript })));
|
|
const files = await listFiles(course.root, 800); // authors may use any course file, solutions included
|
|
const cache = await listFiles(join(ctx.config.dataDir, "cache", course.id), 400);
|
|
return json({ lessons, files, cache });
|
|
},
|
|
},
|
|
"/api/ai/studio/generate": {
|
|
POST: async (req, rc) => {
|
|
if (!rc.user) return err(401, "Sign in first.");
|
|
const b = await body(req);
|
|
const g = b ? parseGenerate(b) : null;
|
|
if (!g) return err(400, "Choose a course, a kind (document, exercise or questions), a style and a topic.");
|
|
const course = ctx.courses.get(g.courseId);
|
|
if (!course || course.status !== "ok") return err(404, "Course not found.");
|
|
if (!(await canAuthor(rc.user, course.id))) return err(403, "Only authors can generate drafts.");
|
|
if (!(await resolveProvider(rc.user.id))) return err(409, "AI is not set up.");
|
|
const jobId = await ctx.jobs.enqueue("ai.generate", { req: g, userId: rc.user.id });
|
|
return json({ jobId }, 202);
|
|
},
|
|
},
|
|
"/api/ai/studio/jobs/:id": {
|
|
GET: async (_req, rc) => {
|
|
if (!rc.user) return err(401, "Sign in first.");
|
|
const job = await ctx.jobs.get(rc.params.id ?? "");
|
|
const owner = (job?.payload as { userId?: string } | null)?.userId;
|
|
if (!job || job.kind !== "ai.generate" || (owner !== rc.user.id && !rc.user.roles.includes("owner"))) return err(404, "Job not found.");
|
|
return json({ id: job.id, status: job.status, progress: job.progress, message: job.message, result: job.result, error: job.error });
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|