/* CHAT VIEW — conversation + live retrieval panel (the hero). Wired to the real backend via window.RAG: condense → /query → OpenRouter BYOK stream → renumbered citations. The retrieval panel reflects the actual chunks the server returned. */ // A real RetrievalResult chunk → the flat shape the panel/cards render. function toCand(c) { const pages = c.page_numbers || []; return { chunk_id: c.chunk_id, paper: c.paper_id, page: pages[0], pages, score: typeof c.score === "number" ? c.score : 0, kind: c.source === "visual" ? "visual" : "text", bbox: (c.metadata && c.metadata.bbox) || null, text: c.text || "", }; } function previewQuote(raw, max = 180) { const t = String(raw || "").replace(/\s+/g, " ").trim(); return t.length > max ? t.slice(0, max).trim() + "…" : t; } function AdvancedPanel({ settings, set, papers, routingAvailable }) { // When the server runs without the multimodal router (no GPU visual leg), // force_route/routing_mode are no-ops — grey them out rather than offering // switches that silently do nothing. "agentic" stays: DCI is its own path. const noRouter = routingAvailable === false; const offTitle = "Needs the multimodal router (GPU visual leg) — not available on this deployment"; return (
set("route", v)} options={[{ value: "auto", label: "auto" }, { value: "text", label: "text" }, { value: "visual", label: "visual", disabled: noRouter, disabledTitle: offTitle }, { value: "agentic", label: "agentic" }]} /> {noRouter && visual routing needs the GPU leg — off on this CPU deployment (offline: +35% recall over text-only); figure questions still work via page images }
set("routingMode", v)} options={[{ value: "", label: "default" }, { value: "category", label: "category", disabled: noRouter, disabledTitle: offTitle }, { value: "cascade", label: "cascade", disabled: noRouter, disabledTitle: offTitle }]} />
set("topk", +e.target.value)} /> {settings.topk}
); } function EmptyState({ onAsk, routingAvailable }) { // Without the multimodal router (CPU-only deploys) every turn retrieves // text-side, so don't advertise per-question routes on the chips. const noRouter = routingAvailable === false; return (

Ask across the corpus

{noRouter ? "20 research papers, indexed by text and figure. Watch retrieval rank the evidence live in the panel on the right." : "20 research papers, indexed by text and figure. Every turn re-retrieves against the right modality — watch it route in the panel on the right."}

{window.RAG.SUGGESTIONS.map((s, i) => ( ))}
); } function AiMessage({ msg, onCite, onFig, paperTitle, pendingLabel }) { const done = !msg.streaming; const figs = (msg.candidates || []).filter((c) => c.kind === "visual"); const tokens = msg.usage ? (msg.usage.prompt_tokens || 0) + (msg.usage.completion_tokens || 0) : 0; // KaTeX over the finished answer only: a done message's props never change, // so React won't fight the DOM mutation (same pattern as the figure // captions in figures.jsx). During streaming the raw $...$ stays visible. const bodyRef = useRef(null); useEffect(() => { if (!done || msg.error || !bodyRef.current) return; if (typeof window.renderMathInElement !== "function") return; try { window.renderMathInElement(bodyRef.current, { delimiters: [ { left: "$$", right: "$$", display: true }, { left: "\\[", right: "\\]", display: true }, { left: "$", right: "$", display: false }, { left: "\\(", right: "\\)", display: false }, ], throwOnError: false, }); } catch (_) { /* leave the raw text on a KaTeX error */ } }, [done, msg.error, msg.answer]); return (
SpectraRAG {msg.route &&
}
{!done && !msg.answer ? ( {pendingLabel || "routing query → re-retrieving"} ) : ( {!done && } )}
{done && !msg.error && figs.length > 0 && (
{figs.slice(0, 4).map((f, i) => ( ))}
)} {done && !msg.error && (msg.candidates || []).length > 0 && (
{msg.candidates.length} chunks {typeof msg.latencyMs === "number" && {(msg.latencyMs / 1000).toFixed(2)}s} {tokens > 0 && {tokens} tok} {msg.demo && free demo model}
)}
); } function Composer({ onAsk, busy }) { const [val, setVal] = useState(""); const ref = useRef(); const submit = () => { const v = val.trim(); if (!v || busy) return; onAsk(v); setVal(""); if (ref.current) ref.current.style.height = "auto"; }; return (