Spaces:
Runtime error
Runtime error
Download frontend/src/pages/NotebookView.tsx from firebird-technologies/NoteStack-Test: direct link, hf CLI and curl.
- Browser
- Download file 28.7 kB
-
https://huggingface.co/spaces/firebird-technologies/NoteStack-Test/resolve/52ec279e6108c8a40d96dbfbb4d5573a02472df3/frontend/src/pages/NotebookView.tsx
- Command line
-
hf download hf://spaces/firebird-technologies/NoteStack-Test@52ec279e6108c8a40d96dbfbb4d5573a02472df3/frontend/src/pages/NotebookView.tsx
-
curl -L -o NotebookView.tsx https://huggingface.co/spaces/firebird-technologies/NoteStack-Test/resolve/52ec279e6108c8a40d96dbfbb4d5573a02472df3/frontend/src/pages/NotebookView.tsx
28.7 kB
| 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 { AnswerFeedback as Feedback, Artifact, ChatSummary, Citation, MemoryChange, Notebook } from "../api/types"; | |
| import { AnswerFeedback } from "../components/AnswerFeedback"; | |
| import { ArtifactCard, CitationList } from "../components/ArtifactCard"; | |
| import { Markdown } from "../components/Markdown"; | |
| import { DocPicker } from "../components/DocPicker"; | |
| import { TelescopeIcon } from "../components/icons/Icons"; | |
| import { MindMapDialog, MindMapExplorer, MindMapRow } from "../components/MindMap"; | |
| import { Reader } from "../components/Reader"; | |
| import { ConfirmButton, errorMessage, formatDate, Loading, Modal, Tabs } from "../components/ui"; | |
| type Turn = { | |
| id?: string; // the saved message, once there is one: what a thumbs up or down is attached to | |
| feedback?: Feedback | null; | |
| role: "user" | "assistant"; | |
| text: string; | |
| citations?: Citation[]; | |
| status?: string; | |
| steps?: string[]; | |
| saved?: MemoryChange[]; | |
| }; | |
| type Pane = "create" | "posts"; | |
| const STARTERS = ["What are the strongest ideas across these posts?", "Where do I contradict myself?", "Which post is most worth updating, and why?"]; | |
| const CHAT_RAIL_KEY = "ns_notebook_chat_rail"; | |
| function readChatRailOpen(): boolean { | |
| try { | |
| return localStorage.getItem(CHAT_RAIL_KEY) !== "closed"; | |
| } catch { | |
| return true; | |
| } | |
| } | |
| /** The Create tab: one click per format with sensible defaults, options folded away. */ | |
| function StudioPanel({ notebookId, docs, disabled }: { notebookId: string; docs: { id: string; title: string }[]; disabled: boolean }) { | |
| const [artifacts, setArtifacts] = useState<Artifact[] | null>(null); | |
| const [format, setFormat] = useState<"deep_dive" | "brief" | "debate">("deep_dive"); | |
| const [minutes, setMinutes] = useState(6); | |
| const [error, setError] = useState<string | null>(null); | |
| const [busy, setBusy] = useState(false); | |
| const [reading, setReading] = useState<{ id: string; start: number; end: number } | null>(null); | |
| const [mapDialog, setMapDialog] = useState(false); | |
| const [mapBusy, setMapBusy] = useState(false); | |
| const [mapError, setMapError] = useState<string | null>(null); | |
| const [waitingMap, setWaitingMap] = useState<string | null>(null); // a map the user just asked for: open it when ready | |
| const [exploring, setExploring] = useState<Artifact | null>(null); | |
| const load = useCallback(() => notebooksApi.artifacts(notebookId).then(setArtifacts), [notebookId]); | |
| useEffect(() => { | |
| load(); | |
| }, [load]); | |
| const make = async (body: Omit<GenerateBody, "notebook_id">) => { | |
| 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 makeMap = async (documentIds: string[], focus: string) => { | |
| setMapBusy(true); | |
| setMapError(null); | |
| try { | |
| const a = await artifactsApi.generate({ | |
| type: "mind_map", | |
| notebook_id: notebookId, | |
| document_ids: documentIds.length === docs.length ? undefined : documentIds, | |
| focus: focus || undefined, | |
| }); | |
| setArtifacts((list) => [a, ...(list ?? [])]); | |
| setWaitingMap(a.id); | |
| setMapDialog(false); | |
| } catch (e) { | |
| setMapError(errorMessage(e)); | |
| } finally { | |
| setMapBusy(false); | |
| } | |
| }; | |
| const maps = artifacts?.filter((a) => a.type === "mind_map") ?? []; | |
| const others = artifacts?.filter((a) => a.type !== "mind_map"); | |
| 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 ( | |
| <div className="stack"> | |
| <div className="nbv-make"> | |
| <button className="nbv-make-main" disabled={off} onClick={() => make({ type: "audio_overview", format, minutes })}> | |
| <strong>Audio overview</strong> | |
| <span className="muted small"> | |
| {format === "deep_dive" ? "Deep dive" : format === "brief" ? "Brief" : "Debate"} · {minutes} min | |
| </span> | |
| </button> | |
| <button disabled={off} onClick={() => make({ type: "video", style: "short" })}> | |
| Short video | |
| </button> | |
| <button disabled={off} onClick={() => make({ type: "video", style: "explainer" })}> | |
| Explainer | |
| </button> | |
| <button disabled={off} onClick={() => make({ type: "summary" })}> | |
| Summary | |
| </button> | |
| <button disabled={off} onClick={() => make({ type: "quote_card" })}> | |
| Quote card | |
| </button> | |
| <button className="nbv-make-map" disabled={off} onClick={() => setMapDialog(true)}> | |
| <strong>Mind Constellation</strong> | |
| <span className="muted small">A galaxy of your ideas. Zoom in on any orbit.</span> | |
| </button> | |
| </div> | |
| <details className="delivery"> | |
| <summary className="mono muted small">Audio options</summary> | |
| <div className="row"> | |
| <select className="input input-sm" value={format} onChange={(e) => setFormat(e.target.value as typeof format)} aria-label="Audio format"> | |
| <option value="deep_dive">Deep dive</option> | |
| <option value="brief">Brief</option> | |
| <option value="debate">Debate</option> | |
| </select> | |
| <select className="input input-sm" value={minutes} onChange={(e) => setMinutes(Number(e.target.value))} aria-label="Length"> | |
| {[3, 6, 10, 15].map((m) => ( | |
| <option key={m} value={m}> | |
| {m} min | |
| </option> | |
| ))} | |
| </select> | |
| </div> | |
| </details> | |
| {disabled && <p className="muted small">Add posts to this notebook to start creating.</p>} | |
| {error && <p className="error-text">{error}</p>} | |
| {maps.length > 0 && ( | |
| <section className="mm-list" aria-label="Mind Constellations"> | |
| <p className="nbv-chat-list-label"> | |
| Mind Constellations <span>{maps.length}</span> | |
| </p> | |
| {maps.map((a) => ( | |
| <MindMapRow | |
| key={a.id} | |
| artifact={a} | |
| autoOpen={waitingMap === a.id} | |
| onOpen={(m) => { | |
| setWaitingMap(null); | |
| setExploring(m); | |
| }} | |
| onRemoved={(id) => setArtifacts((list) => (list ?? []).filter((x) => x.id !== id))} | |
| /> | |
| ))} | |
| </section> | |
| )} | |
| <div className="studio-list"> | |
| {!artifacts && <Loading />} | |
| {others?.map((a) => ( | |
| <ArtifactCard | |
| key={a.id} | |
| artifact={a} | |
| onCite={cite} | |
| onRemoved={(id) => setArtifacts((list) => (list ?? []).filter((x) => x.id !== id))} | |
| actions={(art) => | |
| art.type === "audio_overview" && art.status === "ready" ? ( | |
| <button className="btn btn-small" onClick={() => make({ type: "video", style: "audiogram", audio_artifact_id: art.id })}> | |
| Audiogram | |
| </button> | |
| ) : null | |
| } | |
| /> | |
| ))} | |
| </div> | |
| {reading && <Reader documentId={reading.id} highlight={{ start: reading.start, end: reading.end }} onClose={() => setReading(null)} />} | |
| {mapDialog && <MindMapDialog docs={docs} busy={mapBusy} error={mapError} onClose={() => setMapDialog(false)} onCreate={makeMap} />} | |
| {exploring && <MindMapExplorer artifact={exploring} onClose={() => setExploring(null)} />} | |
| </div> | |
| ); | |
| } | |
| export default function NotebookView() { | |
| const { id = "" } = useParams(); | |
| const [nb, setNb] = useState<Notebook | null>(null); | |
| const [loadError, setLoadError] = useState<string | null>(null); | |
| const [turns, setTurns] = useState<Turn[]>([]); | |
| const [q, setQ] = useState(""); | |
| const [chats, setChats] = useState<ChatSummary[]>([]); | |
| const [busy, setBusy] = useState(false); | |
| const [loadingChatId, setLoadingChatId] = useState<string | null>(null); | |
| const [adding, setAdding] = useState(false); | |
| const [toAdd, setToAdd] = useState<string[]>([]); | |
| const [addError, setAddError] = useState<string | null>(null); | |
| const [reading, setReading] = useState<{ id: string; start?: number; end?: number } | null>(null); | |
| const [quoted, setQuoted] = useState<Citation | null>(null); // a chat citation: the earlier words, shown in a popup | |
| const [editingTitle, setEditingTitle] = useState(false); | |
| const [title, setTitle] = useState(""); | |
| const [pane, setPane] = useState<Pane>("create"); | |
| const [historyOpen, setHistoryOpen] = useState(readChatRailOpen); | |
| const [params, setParams] = useSearchParams(); | |
| // The open chat lives in the URL (?chat=<id>), not only in React state: a state reset, a reload or a hot reload can no | |
| // longer turn the next message into a new chat. `shown` is the chat whose messages are on screen right now. | |
| const chatId = params.get("chat"); | |
| const shown = useRef<string | null>(null); | |
| const chatLoad = useRef(0); | |
| const asked = useRef(false); | |
| const bottom = useRef<HTMLDivElement>(null); | |
| // Auto-save the title shortly after the user stops typing. | |
| useEffect(() => { | |
| if (!editingTitle || !nb) return; | |
| const next = title.trim(); | |
| if (!next || next === nb.title) return; | |
| const t = setTimeout(() => { | |
| notebooksApi.update(nb.id, { title: next }).then(() => setNb((cur) => (cur ? { ...cur, title: next } : cur))); | |
| }, 800); | |
| return () => clearTimeout(t); | |
| }, [title, editingTitle, nb]); | |
| 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(); | |
| }, [load, loadChats]); | |
| // A different notebook starts with an empty transcript. (Its URL has no ?chat, so no chat is selected either.) | |
| useEffect(() => { | |
| chatLoad.current += 1; | |
| setLoadingChatId(null); | |
| setTurns([]); | |
| shown.current = null; | |
| }, [id]); | |
| useEffect(() => { | |
| bottom.current?.scrollIntoView({ block: "end" }); | |
| }, [turns]); | |
| /** Select a chat, or none, by writing it into the URL. Other query params (like ?q) are left alone. */ | |
| const selectChat = (cid: string | null) => | |
| setParams( | |
| (p) => { | |
| const next = new URLSearchParams(p); | |
| if (cid) next.set("chat", cid); | |
| else next.delete("chat"); | |
| return next; | |
| }, | |
| { replace: true }, | |
| ); | |
| const openChat = async (cid: string) => { | |
| const request = ++chatLoad.current; | |
| shown.current = cid; | |
| selectChat(cid); | |
| setLoadingChatId(cid); | |
| setTurns([]); | |
| let msgs; | |
| try { | |
| msgs = await notebooksApi.messages(cid); | |
| } catch { | |
| if (request !== chatLoad.current) return; | |
| // A chat that is gone (deleted, or from another account): fall back to a clean new chat. | |
| shown.current = null; | |
| selectChat(null); | |
| setTurns([]); | |
| return; | |
| } finally { | |
| if (request === chatLoad.current) setLoadingChatId(null); | |
| } | |
| // If the user selected another conversation while this request was running, ignore this older response. | |
| if (request !== chatLoad.current) return; | |
| const history: Turn[] = msgs.map((m) => ({ id: m.id, feedback: m.feedback, role: m.role, text: m.text, citations: m.citations })); | |
| // Early chats saved the answer but not the opening user message. Their title is the original question, | |
| // so restore it in the transcript instead of showing an assistant answer with no visible prompt. | |
| if (history[0]?.role === "assistant") { | |
| const openingQuestion = chats.find((c) => c.id === cid)?.title?.trim(); | |
| if (openingQuestion) history.unshift({ role: "user", text: openingQuestion }); | |
| } | |
| setTurns(history); | |
| }; | |
| // A chat named in the URL that is not on screen yet (a reload, the back button, a shared link) is opened. | |
| useEffect(() => { | |
| if (chatId && chatId !== shown.current) void openChat(chatId); | |
| // eslint-disable-next-line react-hooks/exhaustive-deps | |
| }, [chatId]); | |
| const newChat = () => { | |
| chatLoad.current += 1; | |
| setLoadingChatId(null); | |
| shown.current = null; | |
| selectChat(null); | |
| setTurns([]); | |
| setQ(""); | |
| }; | |
| const removeChat = async (cid: string) => { | |
| await notebooksApi.removeChat(cid); | |
| if (cid === chatId) newChat(); | |
| await loadChats(); | |
| }; | |
| const setChatRail = (open: boolean) => { | |
| setHistoryOpen(open); | |
| try { | |
| localStorage.setItem(CHAT_RAIL_KEY, open ? "open" : "closed"); | |
| } catch { | |
| /* Private browsing can prevent persistence; the control still works for this visit. */ | |
| } | |
| }; | |
| const ask = async (text: string) => { | |
| const question = text.trim(); | |
| if (!question || busy || loadingChatId) return; | |
| setQ(""); | |
| setBusy(true); | |
| setTurns((t) => [...t, { role: "user", text: question }, { role: "assistant", text: "", status: "Scanning" }]); | |
| const patchLast = (p: Partial<Turn>) => setTurns((t) => [...t.slice(0, -1), { ...t[t.length - 1], ...p }]); | |
| const answerAt = turns.length + 1; // where this answer sits, so a late "saved" note lands on the right message | |
| let answered = ""; | |
| try { | |
| await streamSSE( | |
| `/api/notebooks/${id}/chat`, | |
| (event, data) => { | |
| const d = data as Record<string, unknown>; | |
| if (event === "status") { | |
| if (d.chat_id) { | |
| shown.current = d.chat_id as string; // its messages are already on screen: do not reload them | |
| selectChat(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") { | |
| answered = d.text as string; | |
| patchLast({ id: d.message_id as string, text: answered, citations: d.citations as Citation[], status: undefined }); | |
| setBusy(false); // the answer is here; the "saved to memory" note may follow a moment later | |
| } else if (event === "memory") { | |
| const saved = d.saved as MemoryChange[]; | |
| setTurns((t) => t.map((x, i) => (i === answerAt && x.role === "assistant" && x.text === answered ? { ...x, saved } : x))); | |
| } | |
| }, | |
| // Read at send time from the URL, the one place a stale closure or a state reset cannot change. | |
| { method: "POST", body: JSON.stringify({ question, chat_id: new URLSearchParams(window.location.search).get("chat") }) }, | |
| ); | |
| } 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( | |
| (p) => { | |
| const next = new URLSearchParams(p); | |
| next.delete("q"); | |
| return next; | |
| }, | |
| { replace: true }, | |
| ); | |
| void ask(pending); | |
| // eslint-disable-next-line react-hooks/exhaustive-deps | |
| }, [nb]); | |
| if (loadError) return <p className="error-text">{loadError}</p>; | |
| if (!nb) return <Loading label="Opening notebook" />; | |
| const cite = (c: Citation) => { | |
| if (c.kind === "chat") setQuoted(c); | |
| else if (c.document_id) setReading({ id: c.document_id, start: c.line_start, end: c.line_end }); | |
| else window.open(c.url, "_blank"); | |
| }; | |
| return ( | |
| <div className={`nbv${historyOpen ? "" : " nbv-history-closed"}`}> | |
| {historyOpen && <aside className="nbv-pane nbv-history" aria-label="Notebook chats"> | |
| <Link to="/app/notebooks" className="nbv-history-back"> | |
| <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"> | |
| <path d="m15 18-6-6 6-6" /> | |
| </svg> | |
| All notebooks | |
| </Link> | |
| <div className="nbv-history-head"> | |
| <div> | |
| <h2>Chats</h2> | |
| <span className="muted">In this notebook</span> | |
| </div> | |
| <div className="nbv-history-actions"> | |
| <button type="button" className="nbv-new-chat" onClick={newChat} disabled={busy} aria-label="New chat" title="New chat"> | |
| <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" aria-hidden="true"> | |
| <path d="M12 5v14M5 12h14" /> | |
| </svg> | |
| </button> | |
| <button type="button" className="nbv-close-history" onClick={() => setChatRail(false)} aria-label="Close chat sidebar" title="Close chat sidebar"> | |
| <svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"> | |
| <rect x="3.5" y="4.5" width="17" height="15" rx="2.5" /> | |
| <path d="M9 4.5v15M15 10l-2 2 2 2" /> | |
| </svg> | |
| </button> | |
| </div> | |
| </div> | |
| <nav className="nbv-chat-list" aria-label="Chat history"> | |
| {chats.length === 0 ? ( | |
| <p className="nbv-history-empty muted small">Your conversations in this notebook will appear here.</p> | |
| ) : ( | |
| <> | |
| <p className="nbv-chat-list-label"> | |
| Recent <span>{chats.length}</span> | |
| </p> | |
| {chats.map((c) => { | |
| const chatTitle = c.title?.trim() || "Untitled chat"; | |
| return ( | |
| <div key={c.id} className={`nbv-chat-item${chatId === c.id ? " active" : ""}`}> | |
| <button | |
| type="button" | |
| className="nbv-chat-link" | |
| onClick={() => void openChat(c.id)} | |
| disabled={busy} | |
| aria-current={chatId === c.id ? "page" : undefined} | |
| aria-label={`${chatTitle}, updated ${formatDate(c.updated_at)}`} | |
| > | |
| <span>{chatTitle}</span> | |
| </button> | |
| <ConfirmButton | |
| className="nbv-chat-delete" | |
| confirmLabel="✓" | |
| onConfirm={() => removeChat(c.id)} | |
| > | |
| <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"> | |
| <path d="M4 7h16M9 7V4h6v3M7 7l1 13h8l1-13M10 11v5M14 11v5" /> | |
| </svg> | |
| <span className="sr-only">Delete {chatTitle}</span> | |
| </ConfirmButton> | |
| </div> | |
| ); | |
| })} | |
| </> | |
| )} | |
| </nav> | |
| </aside>} | |
| <section className="card nbv-pane nbv-chat"> | |
| <div className="chat-bar"> | |
| {!historyOpen && ( | |
| <button type="button" className="nbv-show-history" onClick={() => setChatRail(true)} aria-label="Show chat sidebar" title="Show chat sidebar"> | |
| <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"> | |
| <rect x="3.5" y="4.5" width="17" height="15" rx="2.5" /> | |
| <path d="M9 4.5v15M13 10l2 2-2 2" /> | |
| </svg> | |
| </button> | |
| )} | |
| <div className="nbv-titlebar"> | |
| {!historyOpen && ( | |
| <Link to="/app/notebooks" className="mono muted small-link"> | |
| Notebooks / | |
| </Link> | |
| )} | |
| {editingTitle ? ( | |
| <form | |
| onSubmit={async (e) => { | |
| e.preventDefault(); | |
| await notebooksApi.update(nb.id, { title }); | |
| setEditingTitle(false); | |
| load(); | |
| }} | |
| > | |
| <input | |
| className="input input-sm" | |
| autoFocus | |
| value={title} | |
| onChange={(e) => setTitle(e.target.value)} | |
| onBlur={async () => { | |
| setEditingTitle(false); | |
| if (title.trim() && title !== nb.title) { | |
| await notebooksApi.update(nb.id, { title }); | |
| load(); | |
| } else { | |
| setTitle(nb.title); | |
| } | |
| }} | |
| aria-label="Notebook title" | |
| /> | |
| </form> | |
| ) : ( | |
| <h2 className="nbv-title" onClick={() => setEditingTitle(true)} title="Click to rename"> | |
| {nb.title} | |
| </h2> | |
| )} | |
| </div> | |
| </div> | |
| <div className="nbv-turns" aria-busy={Boolean(loadingChatId)}> | |
| {loadingChatId ? ( | |
| <div className="nbv-chat-loading"> | |
| <Loading label="Loading conversation" /> | |
| </div> | |
| ) : turns.length === 0 ? ( | |
| <div className="nbv-empty"> | |
| <TelescopeIcon size={32} /> | |
| <p className="muted">Ask anything about {nb.documents.length} posts. Answers cite the passage they came from.</p> | |
| {nb.documents.length > 0 && ( | |
| <div className="nbv-starters"> | |
| {STARTERS.map((s) => ( | |
| <button key={s} type="button" className="nbv-starter" onClick={() => ask(s)}> | |
| {s} | |
| </button> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| ) : null} | |
| {!loadingChatId && turns.map((t, i) => ( | |
| <div key={i} className={`turn turn-${t.role}`}> | |
| {t.steps && t.steps.length > 0 && ( | |
| <details className="agent-steps mono"> | |
| <summary>{t.status ? `${t.status}...` : `${t.steps.length} steps through the archive`}</summary> | |
| <ol> | |
| {t.steps.map((s, j) => ( | |
| <li key={j}>{s}</li> | |
| ))} | |
| </ol> | |
| </details> | |
| )} | |
| {t.status && !t.steps?.length && <p className="mono muted">{t.status}...</p>} | |
| {t.text && | |
| (t.role === "assistant" ? ( | |
| <Markdown text={t.text} citations={t.citations ?? []} onCite={cite} className="turn-md" /> | |
| ) : ( | |
| <p className="turn-text">{t.text}</p> | |
| ))} | |
| {t.citations && <CitationList citations={t.citations} onCite={cite} collapsible />} | |
| {t.role === "assistant" && t.id && t.text && !t.status && <AnswerFeedback key={t.id} messageId={t.id} initial={t.feedback} />} | |
| {t.saved && t.saved.length > 0 && ( | |
| <p className="mono muted small"> | |
| {t.saved | |
| .map((c) => (c.op === "delete" ? `Removed note: ${c.key}` : `Saved note: ${c.key} = ${c.value}`)) | |
| .join(" · ")}{" "} | |
| (edit in Settings) | |
| </p> | |
| )} | |
| </div> | |
| ))} | |
| <div ref={bottom} /> | |
| </div> | |
| <form | |
| onSubmit={(e: FormEvent) => { | |
| e.preventDefault(); | |
| void ask(q); | |
| }} | |
| className="inline-form nbv-ask" | |
| > | |
| <input | |
| className="input" | |
| value={q} | |
| onChange={(e) => 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 || Boolean(loadingChatId)} | |
| /> | |
| <button | |
| className="btn btn-primary nbv-send" | |
| disabled={busy || Boolean(loadingChatId) || !nb.documents.length} | |
| aria-label={loadingChatId ? "Loading conversation" : busy ? "Researching" : "Send message"} | |
| title={loadingChatId ? "Loading conversation" : busy ? "Researching" : "Send message"} | |
| > | |
| {busy || loadingChatId ? ( | |
| <span className="nbv-send-spinner" aria-hidden="true" /> | |
| ) : ( | |
| <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"> | |
| <path d="M12 19V5M6 11l6-6 6 6" /> | |
| </svg> | |
| )} | |
| </button> | |
| </form> | |
| </section> | |
| <aside className="card nbv-pane nbv-side" aria-label="Notebook tools"> | |
| <Tabs<Pane> | |
| tabs={[ | |
| { id: "create", label: "Create" }, | |
| { id: "posts", label: "Posts", count: nb.documents.length }, | |
| ]} | |
| value={pane} | |
| onChange={setPane} | |
| /> | |
| {pane === "create" && <StudioPanel notebookId={nb.id} docs={nb.documents.map((d) => ({ id: d.id, title: d.title }))} disabled={nb.documents.length === 0} />} | |
| {pane === "posts" && ( | |
| <> | |
| {nb.is_archive ? ( | |
| <p className="muted small">Every indexed post. New posts join automatically.</p> | |
| ) : ( | |
| <button className="btn btn-small" onClick={() => setAdding(true)}> | |
| Add posts | |
| </button> | |
| )} | |
| <ul className="nbv-docs"> | |
| {nb.documents.map((d) => ( | |
| <li key={d.id} className="nbv-doc"> | |
| <button className="link-btn" onClick={() => setReading({ id: d.id })}> | |
| {d.title} | |
| </button> | |
| <span className="mono muted">{formatDate(d.published_at)}</span> | |
| {!nb.is_archive && ( | |
| <button | |
| className="icon-btn nbv-remove" | |
| aria-label={`Remove ${d.title}`} | |
| title="Remove from notebook" | |
| onClick={async () => { | |
| await notebooksApi.removeDoc(nb.id, d.id); | |
| load(); | |
| }} | |
| > | |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6"> | |
| <path d="M6 6l12 12M18 6L6 18" /> | |
| </svg> | |
| </button> | |
| )} | |
| </li> | |
| ))} | |
| </ul> | |
| </> | |
| )} | |
| </aside> | |
| {adding && ( | |
| <Modal title="Add posts" onClose={() => setAdding(false)} wide> | |
| <DocPicker selected={toAdd} onChange={setToAdd} exclude={nb.documents.map((d) => d.id)} /> | |
| {addError && <p className="error-text">{addError}</p>} | |
| <div className="row end"> | |
| <button | |
| className="btn btn-primary" | |
| disabled={!toAdd.length} | |
| onClick={async () => { | |
| try { | |
| await notebooksApi.addDocs(nb.id, toAdd); | |
| setToAdd([]); | |
| setAdding(false); | |
| load(); | |
| } catch (e) { | |
| setAddError(errorMessage(e)); | |
| } | |
| }} | |
| > | |
| Add {toAdd.length} posts | |
| </button> | |
| </div> | |
| </Modal> | |
| )} | |
| {reading && <Reader documentId={reading.id} highlight={reading.start ? { start: reading.start, end: reading.end ?? reading.start } : undefined} onClose={() => setReading(null)} />} | |
| {quoted && ( | |
| <Modal title={quoted.title} onClose={() => setQuoted(null)}> | |
| <p className="mono muted small"> | |
| lines {quoted.line_start} | |
| {quoted.line_end > quoted.line_start ? ` to ${quoted.line_end}` : ""} | |
| </p> | |
| <blockquote className="chat-quote">{quoted.span}</blockquote> | |
| </Modal> | |
| )} | |
| </div> | |
| ); | |
| } | |