import { useCallback, useEffect, useRef, useState, type FormEvent } from "react"; import { Link, useParams, useSearchParams } from "react-router-dom"; import { artifactsApi, notebooksApi, type GenerateBody } from "../api/endpoints"; import { streamSSE } from "../api/stream"; import type { Artifact, ChatSummary, Citation, Notebook } from "../api/types"; import { ArtifactCard, CitationList } from "../components/ArtifactCard"; import { Markdown } from "../components/Markdown"; import { DocPicker } from "../components/DocPicker"; import { Dropdown } from "../components/Dropdown"; import { ChevronIcon, TelescopeIcon } from "../components/icons/Icons"; import { Reader } from "../components/Reader"; import { seedDocs } from "../components/video/sourceCache"; import { ConfirmButton, errorMessage, formatDate, Loading, Modal } from "../components/ui"; import { VideoCreateForm } from "./VideoCreate"; type Turn = { role: "user" | "assistant"; text: string; citations?: Citation[]; status?: string; steps?: string[] }; const STARTERS = ["What are the strongest ideas across these posts?", "Where do I contradict myself?", "Which post is most worth updating, and why?"]; /** Create: one click per format with sensible defaults, options folded away; then what was made from this notebook. */ function StudioPanel({ notebookId, disabled, onCreateVideo }: { notebookId: string; disabled: boolean; onCreateVideo: () => void }) { const [artifacts, setArtifacts] = useState(null); const [format, setFormat] = useState<"deep_dive" | "brief" | "debate">("deep_dive"); const [minutes, setMinutes] = useState(6); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); const [reading, setReading] = useState<{ id: string; start: number; end: number } | null>(null); const [audioOpen, setAudioOpen] = useState(false); // the Audio overview tile is opened to its options const load = useCallback(() => notebooksApi.artifacts(notebookId).then(setArtifacts), [notebookId]); useEffect(() => { load(); }, [load]); const make = async (body: Omit) => { setBusy(true); setError(null); try { const a = await artifactsApi.generate({ ...body, notebook_id: notebookId }); setArtifacts((list) => [a, ...(list ?? [])]); } catch (e) { setError(errorMessage(e)); } finally { setBusy(false); } }; const cite = (c: Citation) => c.document_id && setReading({ id: c.document_id, start: c.line_start, end: c.line_end }); const off = disabled || busy; return (
Create
{/* Opens its options (format, length) right under it; the audio is made from there */} {audioOpen && (
Format label="Audio format" value={format} onChange={setFormat} options={[ { value: "deep_dive", label: "Deep dive" }, { value: "brief", label: "Brief" }, { value: "debate", label: "Debate" }, ]} />
Length label="Length" value={String(minutes)} onChange={(v) => setMinutes(Number(v))} options={[3, 6, 10, 15].map((m) => ({ value: String(m), label: `${m} min` }))} />
)}
{disabled &&

Add posts to this notebook to start creating.

} {error &&

{error}

} {(!artifacts || artifacts.length > 0) && Made from this notebook} {(!artifacts || artifacts.length > 0) &&
{!artifacts && } {artifacts?.map((a) => ( setArtifacts((list) => (list ?? []).filter((x) => x.id !== id))} actions={(art) => art.type === "audio_overview" && art.status === "ready" ? ( ) : null } /> ))}
} {reading && setReading(null)} />}
); } export default function NotebookView() { const { id = "" } = useParams(); const [nb, setNb] = useState(null); const [loadError, setLoadError] = useState(null); const [turns, setTurns] = useState([]); const [q, setQ] = useState(""); const [chatId, setChatId] = useState(null); const [chats, setChats] = useState([]); const [busy, setBusy] = useState(false); const [adding, setAdding] = useState(false); const [makingVideo, setMakingVideo] = useState(false); // the video wizard is open in a modal const [toAdd, setToAdd] = useState([]); const [addError, setAddError] = useState(null); const [reading, setReading] = useState<{ id: string; start?: number; end?: number } | null>(null); const [editingTitle, setEditingTitle] = useState(false); const [title, setTitle] = useState(""); const [params, setParams] = useSearchParams(); const asked = useRef(false); const bottom = useRef(null); const load = useCallback( () => notebooksApi.get(id).then( (n) => { setNb(n); setTitle(n.title); }, () => setLoadError("Notebook not found."), ), [id], ); const loadChats = useCallback(() => notebooksApi.chats(id).then(setChats), [id]); useEffect(() => { load(); loadChats(); setTurns([]); setChatId(null); }, [load, loadChats]); useEffect(() => { bottom.current?.scrollIntoView({ block: "end" }); }, [turns]); const openChat = async (cid: string) => { setChatId(cid); const msgs = await notebooksApi.messages(cid); setTurns(msgs.map((m) => ({ role: m.role, text: m.text, citations: m.citations }))); }; const ask = async (text: string) => { const question = text.trim(); if (!question || busy) return; setQ(""); setBusy(true); setTurns((t) => [...t, { role: "user", text: question }, { role: "assistant", text: "", status: "Scanning" }]); const patchLast = (p: Partial) => setTurns((t) => [...t.slice(0, -1), { ...t[t.length - 1], ...p }]); try { await streamSSE( `/api/notebooks/${id}/chat`, (event, data) => { const d = data as Record; if (event === "status") { if (d.chat_id) setChatId(d.chat_id as string); patchLast({ status: d.message as string }); } else if (event === "step") { setTurns((t) => { const last = t[t.length - 1]; return [...t.slice(0, -1), { ...last, status: d.message as string, steps: [...(last.steps ?? []), d.message as string] }]; }); } else if (event === "error") { patchLast({ text: d.message as string, status: undefined }); } else if (event === "answer") { patchLast({ text: d.text as string, citations: d.citations as Citation[], status: undefined }); } }, { method: "POST", body: JSON.stringify({ question, chat_id: chatId }) }, ); } catch { patchLast({ text: "Lost signal. Try again.", status: undefined }); } finally { setBusy(false); loadChats(); } }; // A question passed in the link (from the Home ask box) is asked as soon as the notebook opens. useEffect(() => { const pending = params.get("q"); if (!nb || !pending || asked.current || !nb.documents.length) return; asked.current = true; setParams({}, { replace: true }); void ask(pending); // eslint-disable-next-line react-hooks/exhaustive-deps }, [nb]); if (loadError) return

{loadError}

; if (!nb) return ; const cite = (c: Citation) => c.document_id ? setReading({ id: c.document_id, start: c.line_start, end: c.line_end }) : window.open(c.url, "_blank"); return (
Notebooks / {editingTitle ? (
{ e.preventDefault(); await notebooksApi.update(nb.id, { title }); setEditingTitle(false); load(); }} > setTitle(e.target.value)} onBlur={() => setEditingTitle(false)} aria-label="Notebook title" />
) : (

setEditingTitle(true)} title="Double click to rename"> {nb.title}

)}
{(chats.length > 0 || chatId) && ( )} {chatId && ( { await notebooksApi.removeChat(chatId); setChatId(null); setTurns([]); loadChats(); }} > Delete chat )}
{turns.length === 0 && (

Ask anything about {nb.documents.length} posts. Answers cite the passage they came from.

{nb.documents.length > 0 && (
{STARTERS.map((s) => ( ))}
)}
)} {turns.map((t, i) => (
{t.steps && t.steps.length > 0 && (
{t.status ? `${t.status}...` : `${t.steps.length} steps through the archive`}
    {t.steps.map((s, j) => (
  1. {s}
  2. ))}
)} {t.status && !t.steps?.length &&

{t.status}...

} {t.text && (t.role === "assistant" ? ( ) : (

{t.text}

))} {t.citations && }
))}
{ e.preventDefault(); void ask(q); }} className="inline-form nbv-ask" > setQ(e.target.value)} placeholder={nb.documents.length ? "What have I written about pricing?" : "Add posts to start asking"} aria-label="Ask your notebook" disabled={!nb.documents.length} />
{makingVideo && ( setMakingVideo(false)} wide> )} {adding && ( setAdding(false)} wide> d.id)} /> {addError &&

{addError}

}
)} {reading && setReading(null)} />}
); }