/* 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 (
Routing
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
}
Routing mode
set("routingMode", v)}
options={[{ value: "", label: "default" },
{ value: "category", label: "category", disabled: noRouter, disabledTitle: offTitle },
{ value: "cascade", label: "cascade", disabled: noRouter, disabledTitle: offTitle }]} />
Force paper filter
set("paper", e.target.value)}>
All papers
{papers.map((p) => {p.title ? `${p.title.slice(0, 32)}…` : p.paper_id} )}
);
}
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) => (
onAsk(s.q)}>
{s.q}
{!noRouter && }
))}
);
}
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) => (
onFig(f)}>
p.{f.page} {previewQuote(f.text, 64)}
))}
)}
{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 (
Enter send · Shift+Enter newline
);
}
/* ---- retrieval panel ---- */
function RetrievalPanel({ turn, highlight, settings, paperTitle, routingAvailable }) {
const [pageItem, setPageItem] = useState(null);
if (!turn || !turn.candidates) {
return (
Retrieval
No turn yet. Ask a question and the live routing decision, ranked candidates, and retrieved figures appear here.
);
}
const cands = turn.candidates;
// Rerank scores are logits (any sign, any magnitude) — min-max scale the
// bars within the set so they stay comparative; all-negative sets would
// otherwise render every bar empty.
const scores = cands.map((c) => c.score || 0);
const sMax = Math.max(...scores), sMin = Math.min(...scores);
const relScore = (s) => (sMax === sMin ? 1 : (s - sMin) / (sMax - sMin));
const vis = cands.filter((c) => c.kind === "visual");
const total = cands.length;
const visShare = total ? Math.round((vis.length / total) * 100) : 0;
const txtShare = 100 - visShare;
const citedNum = (c) => {
const ct = (turn.citations || []).find((x) => x.id === c.chunk_id);
return ct ? ct.n : null;
};
return (
Retrieval
live
Routing decision
{routingAvailable === false ? (
router off on this CPU-only deployment — offline it measures +35% recall over text-only retrieval on MMLongBench (results ). Here every turn retrieves text-side; figure questions read the page images at generation.
) : (
mode: {turn.mode || settings.route}
)}
{/* Caption chunks injected for figures/tables the question names —
context the model saw and can cite, but not retrieval output, so
they get their own labeled section instead of a ranked row. */}
{turn.injected && turn.injected.length > 0 && (
Named-figure evidence {turn.injected.length}
{turn.injected.map((f, i) => {
const ct = (turn.citations || []).find((x) => x.id === f.chunkId);
return (
setPageItem({ chunk_id: f.chunkId, paper: f.paperId, page: f.page, pages: [f.page], kind: "visual", bbox: f.bbox || null, text: f.caption || "" })}
title="View source region on page">
{ct ? ct.n : "·"}
{f.paperId} · p.{f.page}
added
{f.caption &&
{previewQuote(f.caption)}
}
your question names this figure, so its caption joined the evidence directly
);
})}
)}
Ranked candidates {total} chunks
{cands.map((c, i) => {
const num = citedNum(c);
const hl = highlight && num && String(num) === String(highlight);
return (
setPageItem(c)} title="View source region on page">
{num || (c.kind === "visual" ? "IMG" : "·")}
{c.paper} · p.{c.page}
{c.score.toFixed(3)}
{c.text &&
{previewQuote(c.text)}
}
{previewQuote(paperTitle(c.paper), 30)}
region
);
})}
{vis.length > 0 && (
Retrieved figures {vis.length}
{vis.map((c, i) => (
setPageItem(c)} title="View source region on page">
))}
)}
setPageItem(null)} paperTitle={paperTitle} />
);
}
function ChatView({ settings, set, layout, resetSignal, apiKey, model, papers, figures, pagesAvailable, demoAvailable, routingAvailable, onNeedKey }) {
const [turns, setTurns] = useState([]);
const [busy, setBusy] = useState(false);
const [advOpen, setAdvOpen] = useState(false);
const [highlight, setHighlight] = useState(null);
const [status, setStatus] = useState("");
const [pageItem, setPageItem] = useState(null);
const scrollRef = useRef();
const turnsRef = useRef(turns);
useEffect(() => { turnsRef.current = turns; }, [turns]);
const paperTitle = useCallback(
(id) => { const p = papers.find((x) => x.paper_id === id); return (p && p.title) || id; },
[papers]
);
const lastAssistant = useMemo(() => [...turns].reverse().find((t) => t.role === "assistant"), [turns]);
const mounted = useRef(false);
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
if (!mounted.current) { el.scrollTop = 0; mounted.current = true; return; }
el.scrollTop = el.scrollHeight;
}, [turns]);
// Patch the most recent assistant turn (the live one).
const updateLast = (patch) => setTurns((ts) => {
const next = ts.slice();
for (let i = next.length - 1; i >= 0; i--) {
if (next[i].role === "assistant") {
next[i] = { ...next[i], ...(typeof patch === "function" ? patch(next[i]) : patch) };
break;
}
}
return next;
});
// Incremented by New chat: an in-flight ask compares its captured value and
// stops writing, so a zombie stream can't leak into the next conversation.
const runSeq = useRef(0);
const ask = useCallback(async (q) => {
if (busy) return;
const myRun = ++runSeq.current;
const live = (fn) => { if (runSeq.current === myRun) fn(); };
const upd = (patch) => live(() => updateLast(patch));
setHighlight(null);
setStatus("");
setBusy(true);
const priorTurns = turnsRef.current
// Error and notice turns are UI copy ("add your key…"), not assistant
// answers — feeding them to condense/generation pollutes the history.
.filter((t) => t.role === "user" || (t.role === "assistant" && t.answer && !t.error && !t.notice))
.map((t) => ({ role: t.role, text: t.role === "user" ? t.text : t.answer }));
setTurns((ts) => [
...ts,
{ role: "user", text: q },
{ role: "assistant", q, answer: "", streaming: true, candidates: null, citations: [], route: null, mode: settings.route },
]);
try {
// Agentic search can't run keyless: the server-side agent spends the
// caller's OpenRouter key. Stop before retrieval with a notice instead
// of letting the thrown error render as a generic failure.
if (settings.route === "agentic" && !(apiKey && apiKey.trim())) {
upd({
answer: "Agentic search runs a search agent server-side on your OpenRouter key, so it needs one — add yours (top-right) to try it. The standard retrieval modes work without a key.",
streaming: false,
notice: true,
});
onNeedKey && onNeedKey("agentic");
return;
}
// Condense follow-ups into a standalone query: BYOK browser-direct, or
// through the demo path keyless (one extra demo call; falls back to the
// raw message on failure). First turns retrieve as typed.
const hasKeyForCondense = !!(apiKey && apiKey.trim());
let searchQuery = q;
if (priorTurns.length) {
if (hasKeyForCondense) {
// A transient 429/5xx on this 80-token call must not kill the turn:
// retrieval needs no key — fall back to the raw message, like the
// keyless condenseDemo does.
try {
searchQuery = await window.RAG.condense(apiKey, model, priorTurns, q);
} catch {
searchQuery = q;
}
} else if (demoAvailable) {
setStatus("Condensing the follow-up into a search query…");
searchQuery = await window.RAG.condenseDemo(priorTurns, q);
live(() => setStatus(""));
}
}
upd({ searchedFor: searchQuery });
// Retrieve.
const t0 = performance.now();
const { results, routing, trace } = await window.RAG.retrieve(searchQuery, {
topK: settings.topk,
forceRoute: settings.route === "text" ? "text" : settings.route === "visual" ? "hybrid" : "",
routingMode: settings.routingMode || "",
paperId: settings.paper || "",
dci: settings.route === "agentic",
apiKey,
onStatus: (s) => live(() => setStatus(s)),
});
live(() => setStatus(""));
const tRetrieve = performance.now() - t0;
const candidates = results.map(toCand);
// With no router on the deployment the route label is always "text" —
// noise, not information. Suppress the per-message pill there.
// Agentic turns are their own path (DCI), not a router decision; the
// pill should say so rather than defaulting to "text route".
upd({
candidates,
route: settings.route === "agentic" ? "agentic" : routingAvailable === false ? null : window.RAG.routeLabel(routing),
routing, trace,
});
if (results.length === 0) {
upd({ answer: "No chunks retrieved. The corpus may not cover this query.", streaming: false, latencyMs: Math.round(tRetrieve) });
return;
}
// Generation: browser-direct with the visitor's key (BYOK) when one is
// set, else the server's keyless demo path (free model, daily-capped).
// Only when the server has no demo key either does this stop at
// retrieval with the bring-a-key notice.
const hasKey = !!(apiKey && apiKey.trim());
if (!hasKey && !demoAvailable) {
live(() => setStatus("Add your OpenRouter key (top-right) to generate a cited answer."));
upd({
answer: "Retrieved the chunks shown on the right. Add your OpenRouter key (top-right) to generate a cited answer from them.",
streaming: false,
notice: true,
latencyMs: Math.round(tRetrieve),
});
return;
}
// The demo chain is all vision-capable models, so keyless turns always
// attach page images when pages are served.
const useImages = pagesAvailable && (hasKey ? window.RAG.supportsVision(model) : true);
if (useImages) live(() => setStatus(hasKey ? "Reading the retrieved page images…" : "Free demo model is reading the retrieved pages…"));
const { messages, injected } = await window.RAG.buildMessages(priorTurns, q, results, useImages, figures);
if (injected && injected.length) upd({ injected });
const tGen = performance.now();
const onDelta = (delta) => upd((prev) => ({ answer: prev.answer + delta }));
const { text, usage } = hasKey
? await window.RAG.streamChat(apiKey, model, messages, onDelta)
: await window.RAG.streamDemoChat(messages, onDelta);
// Renumber the model's chunk-id citations → [1][2] and build the list.
const { newText, ids } = window.RAG.renumberCitations(text);
const byId = new Map(results.map((c) => [c.chunk_id, c]));
const citations = ids.map((id, i) => {
// Page-image citations (`paper::pN::page`) point at an attached page,
// not a retrieved chunk — the model cites them for figure claims.
const pm = id.match(/^(.+)::p(\d+)::page$/);
if (pm) return { n: i + 1, id, paper: pm[1], page: +pm[2], quote: null, kind: "visual", page_cite: true };
const c = byId.get(id);
if (!c) {
// Injected figure/table caption (buildMessages adds it when the
// question names the element) — resolve through the figure index.
const fg = (figures || []).find((g) => g.chunk_id === id);
if (fg) return { n: i + 1, id, paper: fg.paper_id, page: fg.page_number, quote: previewQuote(fg.caption || ""), kind: "visual", fig_cite: true, bbox: fg.bbox || null };
}
const pages = c ? c.page_numbers || [] : [];
return { n: i + 1, id, paper: c ? c.paper_id : id, page: pages[0], quote: c ? previewQuote(c.text) : null, kind: c && c.source === "visual" ? "visual" : "text" };
});
upd({
answer: newText,
streaming: false,
citations,
usage,
demo: !hasKey,
latencyMs: Math.round(performance.now() - tGen + tRetrieve),
});
} catch (err) {
if (err && err.code === "demo_quota") {
// The shared free quota ran out for today: hand off to the key modal
// instead of rendering it as a failure — retrieval still worked.
onNeedKey && onNeedKey();
upd({
answer: "Today's free demo answers are used up, but retrieval still works — the chunks are on the right. Add your own OpenRouter key (top-right) to keep generating answers.",
streaming: false,
notice: true,
});
} else if (err && (err.code === "demo_down" || err.code === "stream_error")) {
// Upstream model failure, not the visitor's fault — say so without
// blaming a key they may not even have, and keep any partial answer.
upd((prev) => ({
answer: prev.answer
? `${prev.answer}\n\nGeneration stopped early: ${(err && err.message) || "the model provider failed."}`
: `${(err && err.message) || "The model provider failed."} Retrieval still worked — the chunks are on the right.`,
streaming: false,
notice: true,
}));
} else {
// Keep whatever streamed before the failure — wiping a half-answer is
// worse than showing it with an honest interruption note.
upd((prev) => ({
answer: prev.answer
? `${prev.answer}\n\nGeneration interrupted: ${(err && err.message) || err}`
: `Request failed: ${(err && err.message) || err}. Either the server isn't reachable, or your OpenRouter key is invalid.`,
streaming: false,
error: !prev.answer,
notice: !!prev.answer,
}));
}
live(() => setStatus(""));
} finally {
live(() => setBusy(false));
}
}, [busy, apiKey, model, settings, figures, pagesAvailable, demoAvailable, routingAvailable, onNeedKey]);
// Bumping runSeq orphans any in-flight ask: its guarded writes become no-ops.
const newChat = () => { runSeq.current += 1; setTurns([]); setHighlight(null); setBusy(false); setStatus(""); };
// The retrieval panel is hidden in focus layout and at phone width, so a
// highlight there would be invisible — open the source-page modal instead.
// Page-image citations always open the modal: they have no panel row.
const onCite = (tag, msg) => {
if (tag[0] === "F") return;
const cit = msg && msg.citations ? msg.citations.find((c) => String(c.n) === String(tag)) : null;
if (cit && cit.page_cite) {
setPageItem({ chunk_id: cit.id, paper: cit.paper, page: cit.page, pages: [cit.page], kind: "visual", bbox: null, text: "", page_cite: true });
return;
}
if (cit && cit.fig_cite) {
// Injected figure caption: open its page with the region box.
setPageItem({ chunk_id: cit.id, paper: cit.paper, page: cit.page, pages: [cit.page], kind: "visual", bbox: cit.bbox || null, text: cit.quote || "" });
return;
}
// The panel only ever shows the LAST turn's evidence, so a highlight is
// wrong for citations in older messages — open the modal for those too.
const panelHidden = layout === "single" || (window.matchMedia && window.matchMedia("(max-width: 760px)").matches);
const isLastTurn = msg === lastAssistant;
if ((panelHidden || !isLastTurn) && cit && msg.candidates) {
const cand = msg.candidates.find((cd) => cd.chunk_id === cit.id);
if (cand) { setPageItem(cand); return; }
}
if (!isLastTurn) return;
setHighlight(tag);
};
const openFig = (cand) => setPageItem(cand);
const firstReset = useRef(true);
useEffect(() => {
if (firstReset.current) { firstReset.current = false; return; }
newChat();
}, [resetSignal]);
return (
setAdvOpen((o) => !o)}>
Advanced retrieval settings
{settings.route}{settings.routingMode ? " · " + settings.routingMode : ""} · ctx={settings.topk}
New chat
{advOpen &&
}
{turns.length === 0 ? (
) : (
{turns.map((t, i) =>
t.role === "user" ? (
) : (
onCite(tag, t)} onFig={openFig} paperTitle={paperTitle}
pendingLabel={status || (routingAvailable === false ? "retrieving…" : undefined)} />
)
)}
)}
{status && turns.length === 0 &&
{status}
}
{layout !== "single" &&
}
setPageItem(null)} paperTitle={paperTitle} />
);
}
window.ChatView = ChatView;