feat(ai): assistant panel, AI settings and studio generator UI
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a57222ecc3
commit
08d3e3f642
15 files changed
+973
-7
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user