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]>
196 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|