Files
STCourses/packages/feature-ai/src/web/GeneratePage.tsx
T
Lucas WintherandClaude Opus 5.5 6fb0019cd9 feat(ai): read the importer's deck and transcript cache; studio DraftWriter fits DraftSink as is
Retrieval now reads <dataDir>/cache/decks/<courseId>/<deckId>/text.json and
<dataDir>/cache/transcripts/<courseId>/<lessonId>.json (skipped when the lesson
has a transcript file). DraftSink returns { location?, branch?, commit? }, so
the studio's createDraftWriter(ctx) needs no adapter; generateDraft computes
paths itself and reports the draft branch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 04:36:48 +01:00

196 lines
10 KiB
TypeScript

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; branch?: string; commit?: 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">{job.result.branch ? "Committed to your draft branch " : "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>
);
}