import { useMemo, useState, type ReactNode } from "react"; import type { GenerateBody } from "../api/endpoints"; import type { Artifact, ChatSummary, QuizQuestion, QuizQuestionType } from "../api/types"; import { ArtifactFeedback } from "./ArtifactFeedback"; import { CheckIcon, ChevronIcon } from "./icons/Icons"; import { Pills, SourceFocusFields, sourceSummary, type SourceSelection } from "./SourceFocusFields"; import { StudyOverlay } from "./StudyOverlay"; import { Modal } from "./ui"; const LEVELS = [ { id: "easy", label: "Easy" }, { id: "medium", label: "Medium (Default)" }, { id: "hard", label: "Hard" }, ] as const; const TYPES: { id: QuizQuestionType; label: string }[] = [ { id: "multiple_choice", label: "Multiple Choice" }, { id: "fill_blank", label: "Fill in the Blank" }, { id: "short_answer", label: "Short Answer" }, ]; export type QuizRequest = Omit; /** The quiz settings, laid out like the video wizard's first step: what it is made from, what it should focus on, then * how it is asked. */ export function QuizDialog({ notebookId, notebookTitle, chats, currentChatId, busy, error, onClose, onCreate }: { notebookId: string; notebookTitle?: string; chats: ChatSummary[]; /** The chat open beside the dialog, ticked to start with. */ currentChatId: string | null; busy: boolean; error: string | null; onClose: () => void; onCreate: (body: QuizRequest) => void; }) { const [sel, setSel] = useState(null); const [level, setLevel] = useState<(typeof LEVELS)[number]["id"]>("medium"); const [types, setTypes] = useState>(new Set(["multiple_choice"])); const toggleType = (t: QuizQuestionType) => setTypes((cur) => { const next = new Set(cur); if (next.has(t)) next.delete(t); else next.add(t); return next.size ? next : cur; // at least one stays on }); const submit = () => sel && onCreate({ ...sel.request, difficulty: level, question_types: TYPES.map((t) => t.id).filter((t) => types.has(t)), }); return (
Level of difficulty
Question types
{TYPES.map((t) => ( ))}
{!sel?.ready &&

Select at least one post or chat to generate a quiz.

} {error &&

{error}

}
{/* Waits for the focus cards (shown, or none to show), so they are never skipped past */}
); } /* ---------- Taking a quiz ---------- */ const norm = (s: string) => s.toLowerCase().replace(/[^\p{L}\p{N}\s]/gu, "").replace(/\s+/g, " ").trim(); type Answer = { choice: number[]; text: string; checked: boolean; correct: boolean | null }; const blank = (): Answer => ({ choice: [], text: "", checked: false, correct: null }); /** Whether a choice or fill-in answer is right. A short answer has no automatic grade: the reader marks it. */ function grade(q: QuizQuestion, a: Answer): boolean | null { if (q.type === "short_answer") return null; if (q.type === "fill_blank") { const given = norm(a.text); return !!given && [q.answer ?? "", ...(q.accepted ?? [])].some((x) => norm(x) === given); } const right = [...(q.correct ?? [])].sort().join(","); return [...a.choice].sort().join(",") === right; } const TYPE_LABEL: Record = { multiple_choice: "Multiple choice", multiple_select: "Select all that apply", fill_blank: "Fill in the blank", short_answer: "Short answer", }; /** How the score reads: a medal for a strong result, plain encouragement for a weak one. */ function resultTier(pct: number): { tone: "gold" | "silver" | "bronze" | "low"; title: string; note: string } { if (pct >= 90) return { tone: "gold", title: "Outstanding!", note: "You know this material inside out." }; if (pct >= 70) return { tone: "silver", title: "Great job!", note: "A solid grasp. Review the ones you missed." }; if (pct >= 50) return { tone: "bronze", title: "Good effort", note: "You are getting there. Another pass will help." }; return { tone: "low", title: "Back to the launchpad", note: "Read the material again, then relaunch." }; } const STAR_PATH = "m0-9 2.6 5.6 6.1.7-4.5 4.2 1.2 6L0 4.2-5.4 7.5l1.2-6-4.5-4.2 6.1-.7z"; const ROCKET_PATH = "M0-11c4 2.700 6 7 6 11.500v6H-6v-6c0-4.500 2-8.800 6-11.500zM-6 1l-3.500 4v4L-6 7M6 1l3.500 4v4L6 7"; /** The result as a small galaxy: a medal-planet with a ring and a moon, one star per question on a wide orbit around it * (lit for each right answer), and a field of stars behind. The colours come from the tone (see .quiz-hero in the CSS). */ export function ResultScene({ score, total, tone }: { score: number; total: number; tone: string }) { const n = Math.min(Math.max(total, 1), 14); const lit = Math.round((score / Math.max(total, 1)) * n); const orbit = Array.from({ length: n }, (_, i) => { const a = Math.PI + (i / n) * Math.PI * 2; return { i, x: 200 + Math.cos(a) * 168, y: 90 + Math.sin(a) * 40, front: Math.sin(a) > 0 }; }); const field = (() => { let seed = 7; const rnd = () => ((seed = (seed * 16807) % 2147483647) / 2147483647); return Array.from({ length: 46 }, (_, k) => ({ x: rnd() * 400, y: rnd() * 180, r: 0.5 + rnd() * 1.1, d: (rnd() * 4).toFixed(2), big: k % 11 === 0 })); })(); const dot = (p: (typeof orbit)[number]) => ( {p.i < lit && } ); return ( ); } /** The quiz itself, without a frame: in a modal (QuizPlayer) or inside a report. `onExit` adds a Close button. */ export function QuizRunner({ questions, onExit, feedback }: { questions: QuizQuestion[]; onExit?: () => void; feedback?: ReactNode }) { const [i, setI] = useState(0); const [answers, setAnswers] = useState(() => questions.map(blank)); const [done, setDone] = useState(false); const [finished, setFinished] = useState(false); // the results have been reached: a question opened from them leads back to them const [detail, setDetail] = useState(false); // the per-question results, under the score const q = questions[i]; const a = answers[i]; const patch = (p: Partial) => setAnswers((all) => all.map((x, k) => (k === i ? { ...x, ...p } : x))); const score = answers.filter((x) => x.correct).length; const restart = () => { setAnswers(questions.map(blank)); setI(0); setDone(false); setFinished(false); }; if (!q) return

This quiz has no questions.

; if (done) { const total = questions.length; const pct = total ? Math.round((score / total) * 100) : 0; const t = resultTier(pct); return ( <>

{t.title}

{score} / {total}

{t.note}

{pct}% {score} correct {total - score} to review
{detail && (
    {questions.map((x, k) => (
  1. ))}
)} {feedback}
{onExit && }
); } const last = i === questions.length - 1; const choiceDone = q.type === "multiple_choice" || q.type === "multiple_select"; const canCheck = choiceDone ? a.choice.length > 0 : a.text.trim().length > 0; const check = () => patch({ checked: true, correct: grade(q, a) }); const next = () => { if (!last) return setI(i + 1); setFinished(true); setDone(true); }; const pick = (k: number) => { if (a.checked) return; if (q.type === "multiple_choice") patch({ choice: [k] }); else patch({ choice: a.choice.includes(k) ? a.choice.filter((x) => x !== k) : [...a.choice, k] }); }; return ( <>

Question {i + 1} of {questions.length} · {TYPE_LABEL[q.type]}

{score} / {questions.length}

{q.question}

{choiceDone && (
    {(q.options ?? []).map((opt, k) => { const on = a.choice.includes(k); const right = (q.correct ?? []).includes(k); const cls = a.checked ? (right ? " right" : on ? " wrong" : "") : on ? " on" : ""; return (
  • ); })}
)} {q.type === "fill_blank" && ( patch({ text: e.target.value })} onKeyDown={(e) => e.key === "Enter" && canCheck && !a.checked && check()} /> )} {q.type === "short_answer" && (