import { useEffect, useMemo, useRef, useState } from "react"; import type { Artifact, Citation, FlashcardData, MindNode, ReportBlock, ReportSuggestion } from "../api/types"; import { FlashcardDeck } from "./Flashcards"; import { InfographicFrame } from "./Infographic"; import { Markdown } from "./Markdown"; import { MindMapExplorer } from "./MindMap"; import { QuizRunner } from "./Quiz"; /** What the owner can do to a report: add a suggested visual, or remove one. Never given on a shared page. */ export type ReportEditor = { onAdd: (s: ReportSuggestion) => void; onRemove: (blockId: string) => void; /** A visual is being added: no second one is started meanwhile. */ busy: boolean; /** The suggestion being built right now (its card shows a loader), and what the job is doing. */ adding?: string | null; addingMessage?: string | null; /** The block id being removed right now (its Remove button shows a loader). */ removing?: string | null; }; const KIND_LABEL = { mind_map: "Mind Constellation", flashcards: "Flashcards", quiz: "Quiz", table: "Comparison table", timeline: "Timeline", key_terms: "Key terms", infographic: "Infographic", } as const; type Props = { content: Record; /** The artifact's id, which a mind map uses to seed its layout. */ artifactId: string; onCite?: (c: Citation) => void; }; function MindMapBlock({ block, artifactId }: { block: Extract; artifactId: string }) { const [open, setOpen] = useState(false); // The explorer reads an artifact's content: the map's own tree is that content here. const asArtifact = useMemo(() => ({ id: `${artifactId}:${block.id}`, title: block.title ?? "Mind Constellation", content: { root: block.root as MindNode } }) as unknown as Artifact, [artifactId, block]); // The map itself, exactly as its own page shows it, in a frame in the report; the corner button opens it full screen. return ( <> undefined} onExpand={() => setOpen(true)} />

Drag to move around, click a star to read it. Hold Ctrl or Cmd and scroll to zoom.

{open && setOpen(false)} />} ); } function Block({ block, content, artifactId, onCite }: { block: ReportBlock } & Props) { switch (block.type) { case "prose": return ; case "callout": return ( ); case "key_terms": return (

{block.title || "Key terms"}

{block.terms.map((t) => (
{t.term}
{t.definition}
))}
); case "table": return (
{block.title &&

{block.title}

}
{block.headers.map((h, i) => )} {block.rows.map((r, i) => {r.map((c, j) => )})}
{h}
{c}
); case "timeline": return (
{block.title &&

{block.title}

}
    {block.events.map((e, i) => (
  1. {e.when} {e.title} {e.detail &&

    {e.detail}

    }
  2. ))}
); case "mind_map": return (
); case "flashcards": return (

{block.title || "Flashcards"}

); case "quiz": return (

{block.title || "Test yourself"}

); case "infographic": return (
{block.title &&

{block.title}

} {block.html ? (
) : block.status === "failed" || block.status === "missing" ? (

{block.status === "missing" ? "This infographic was deleted." : "This infographic could not be made."}

) : (

)}
); default: return null; } } /** The sections of a report, for its contents list: each prose block that opens with a heading. */ function contents(blocks: ReportBlock[], withSources: boolean): { id: string; title: string }[] { const list = blocks.flatMap((b) => { const title = b.type === "prose" ? b.text.match(/^#{1,3}\s+(.+)/)?.[1]?.trim() : undefined; return title ? [{ id: b.id, title }] : []; }); return withSources && list.length ? [...list, { id: "sources", title: "Sources" }] : list; } type SourceRow = { title: string; url?: string | null; document_id?: string }; /** What a report was made from, in a drop-down at its end: the prompt it was written to (the owner's view only: a shared page does * not carry it) and every post it was made from, each once (a link when it has a public address), and how many chats were used. A * report cites nothing in its text. Older reports that still have numbered citations show them as chips beside their posts. */ function SourcesSection({ content, onCite }: { content: Record; onCite?: (c: Citation) => void }) { const [open, setOpen] = useState(false); const [filter, setFilter] = useState(""); const rows = (content.sources ?? []) as SourceRow[]; const chats = Number(content.chat_count ?? (content.source?.chat_ids?.length ?? 0)); const prompt = typeof content.instructions === "string" ? content.instructions.trim() : ""; const cites = (content.citations ?? []) as Citation[]; if (!rows.length && !chats) return null; const citedBy = (r: SourceRow) => cites.filter((c) => (r.document_id ? c.document_id === r.document_id : c.title === r.title)); const needle = filter.trim().toLowerCase(); const shown = needle ? rows.filter((r) => r.title.toLowerCase().includes(needle)) : rows; const label = `${prompt ? "View prompt and " : "View "}${rows.length} source${rows.length === 1 ? "" : "s"}${chats > 0 ? ` and ${chats} chat${chats === 1 ? "" : "s"}` : ""}`; return (
{open && (
{prompt && (

Prompt

{prompt}

)}

Sources {rows.length}

{rows.length > 8 && ( setFilter(e.target.value)} aria-label="Filter sources" /> )}
    {shown.map((r) => { const n = rows.indexOf(r) + 1; const title = ( <> {r.title} {r.url && ( )} ); return (
  1. {r.url ? {title} : {title}} {onCite && citedBy(r).map((c) => ( ))}
  2. ); })} {chats > 0 && !needle && (
  3. {chats} notebook chat{chats === 1 ? "" : "s"}
  4. )}
{shown.length === 0 &&

No source matches "{filter}".

}
)}
); } /** A report's blocks, in order, with a numbered contents list beside them that follows the reader down the page. Used * in the app and, read only, on the shared page. */ export function ReportView({ content, artifactId, onCite, editor }: Props & { editor?: ReportEditor }) { const blocks = (content.blocks ?? []) as ReportBlock[]; const suggestions = (content.suggestions ?? []) as ReportSuggestion[]; const hasSources = ((content.sources ?? []) as unknown[]).length > 0 || Number(content.chat_count ?? (content.source?.chat_ids?.length ?? 0)) > 0; const toc = useMemo(() => contents(blocks, hasSources), [blocks, hasSources]); const [active, setActive] = useState(toc[0]?.id ?? null); // The section nearest the top of the window is the one the contents list marks. useEffect(() => { if (toc.length < 2 || typeof IntersectionObserver === "undefined") return; const seen = new Map(); const io = new IntersectionObserver( (entries) => { for (const e of entries) seen.set(e.target.id.replace("rp-", ""), e.isIntersecting); const first = toc.find((t) => seen.get(t.id)); if (first) setActive(first.id); }, { rootMargin: "-12% 0px -70% 0px" }, ); toc.forEach((t) => { const el = document.getElementById(`rp-${t.id}`); if (el) io.observe(el); }); return () => io.disconnect(); }, [toc]); // How far down the report the reader is, for the line that runs beside it. const pathRef = useRef(null); const [progress, setProgress] = useState(0); useEffect(() => { const onScroll = () => { const el = pathRef.current; if (!el) return; const rect = el.getBoundingClientRect(); const total = rect.height - window.innerHeight * 0.4; setProgress(Math.max(0, Math.min(1, (window.innerHeight * 0.45 - rect.top) / Math.max(total, 1)))); }; onScroll(); window.addEventListener("scroll", onScroll, { passive: true }); return () => window.removeEventListener("scroll", onScroll); }, [blocks.length]); const activeIdx = toc.findIndex((t) => t.id === active); const stop = (id: string) => toc.findIndex((t) => t.id === id); const jump = (id: string) => { setActive(id); document.getElementById(`rp-${id}`)?.scrollIntoView({ behavior: "smooth", block: "start" }); }; return (
1 ? " has-toc" : ""}`}> {toc.length > 1 && ( )}
); } /** A report as Markdown text, for downloading. Visual blocks are written out as plain lists. */ export function reportMarkdown(content: Record): string { const lines = [`# ${content.title ?? "Report"}`, ""]; for (const b of (content.blocks ?? []) as ReportBlock[]) { if (b.type === "prose") lines.push(b.text, ""); else if (b.type === "callout") lines.push(`> ${b.title ? `**${b.title}** ` : ""}${b.text}`, ""); else if (b.type === "key_terms") lines.push(`## ${b.title || "Key terms"}`, ...b.terms.map((t) => `- **${t.term}**: ${t.definition}`), ""); else if (b.type === "table") { lines.push(...(b.title ? [`## ${b.title}`, ""] : []), `| ${b.headers.join(" | ")} |`, `| ${b.headers.map(() => "---").join(" | ")} |`, ...b.rows.map((r) => `| ${r.join(" | ")} |`), ""); } else if (b.type === "timeline") lines.push(`## ${b.title || "Timeline"}`, ...b.events.map((e) => `- **${e.when}** ${e.title}${e.detail ? `: ${e.detail}` : ""}`), ""); else if (b.type === "flashcards") lines.push(`## ${b.title || "Flashcards"}`, ...b.cards.map((c) => `- **${c.front}**: ${c.back}`), ""); else if (b.type === "infographic") lines.push(`## ${b.title || "Infographic"}`, ""); else if (b.type === "quiz") lines.push(`## ${b.title || "Quiz"}`, ...b.questions.map((q, i) => `${i + 1}. ${q.question}`), ""); } const cites = (content.citations ?? []) as Citation[]; if (cites.length) lines.push("## Sources", ...cites.map((c) => `[${c.marker}] ${c.title}, lines ${c.line_start} to ${c.line_end}`), ""); return lines.join("\n"); }