/* APP SHELL — sidebar nav, theme toggle, tab routing */ const NAV = [ { id: "chat", label: "Chat", icon: "chat" }, { id: "inspection", label: "Inspection", icon: "inspect" }, { id: "papers", label: "Papers", icon: "papers" }, { id: "figures", label: "Figures", icon: "figures" }, { id: "why", label: "Why multimodal?", icon: "why" }]; function Sidebar({ tab, setTab, theme, setTheme, layout, setLayout, stats, open }) { return ( ); } const CRUMB = { chat: { t: "Chat", s: "Ask follow-up questions; each turn re-retrieves against the right context." }, inspection: { t: "Inspection", s: "Trace a query through routing, retrieval, and reranking." }, papers: { t: "Papers", s: "The 20-paper corpus, indexed by text and figure." }, figures: { t: "Figures", s: "Every figure extracted from the corpus, searchable." }, why: { t: "Why multimodal?", s: "Where text-only RAG breaks — and what visual retrieval recovers." } }; const ACCENTS = { "#3b82f6": { a2: "#2563eb" }, "#8b5cf6": { a2: "#7c3aed" }, "#14b8a6": { a2: "#0d9488" }, "#e0993a": { a2: "#c87f24" } }; function hexToRgba(hex, a) { const n = parseInt(hex.slice(1), 16); return `rgba(${n >> 16 & 255}, ${n >> 8 & 255}, ${n & 255}, ${a})`; } const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ "accent": "#3b82f6", "density": "regular", "answerFont": "sans", "defaultLayout": "split" } /*EDITMODE-END*/; /* Two top-bar pills: model picker and key entry. Separate menus, because switching models is a mid-session action and key entry is one-time setup — one popover for both autofocused the password field on every model switch. The model menu groups the slate into free and premium. Every browser-direct call needs the visitor's key (OpenRouter requires auth even on :free models), so keyless rows hand off to the key menu; keyless chat itself runs on the server's demo path where the server picks the model (ADR 0027). */ function ConnectionControl({ apiKey, setApiKey, model, setModel, demoAvailable }) { const [menu, setMenu] = useState(null); // null | "model" | "key" const ref = useRef(); const keyed = apiKey.trim().length > 0; const cur = window.RAG.MODELS.find((m) => m.id === model) || window.RAG.MODELS[0]; const onDemo = !keyed && demoAvailable; const shortModel = onDemo ? "free (auto)" : cur.id.split("/").pop(); const freeModels = window.RAG.MODELS.filter((m) => m.id.endsWith(":free")); const paidModels = window.RAG.MODELS.filter((m) => !m.id.endsWith(":free")); useEffect(() => { if (!menu) return; const onDown = (e) => {if (ref.current && !ref.current.contains(e.target)) setMenu(null);}; const onEsc = (e) => {if (e.key === "Escape") setMenu(null);}; document.addEventListener("mousedown", onDown); document.addEventListener("keydown", onEsc); return () => {document.removeEventListener("mousedown", onDown);document.removeEventListener("keydown", onEsc);}; }, [menu]); const toggle = (which) => setMenu(menu === which ? null : which); const modelRow = (m) => ; return (
{menu === "model" &&
Model via OpenRouter
{onDemo && }
free · no charge to your key
{freeModels.map(modelRow)}
premium · billed to your key
{paidModels.map(modelRow)}
{!keyed && }
} {menu === "key" &&
OpenRouter API key stays in this browser
setApiKey(e.target.value)} autoFocus /> {keyed ? "key stored locally · ready" : demoAvailable ? "no key · chat runs on the shared free demo model" : "add a key to run live queries"} Your key goes straight to OpenRouter, never to this server.{" "} Create one
}
); } /* Shown when a turn needs the visitor's own OpenRouter key: the keyless demo hit its daily cap, or they tried agentic search (which runs on their key). The key never touches the server — it lives in localStorage and goes browser-direct to OpenRouter. */ const KEY_MODAL_COPY = { quota: { h: "Today's free demo is used up", p: "Answers here run on a shared free model with a daily cap, and it just ran out. Add your own OpenRouter key to keep chatting — and to switch to stronger models like GPT-4o or Claude.", }, agentic: { h: "Agentic search needs your key", p: "The search agent runs server-side on your OpenRouter key, so it isn't part of the free demo. Add a key to try it — regular chat keeps working without one.", }, }; function KeyModal({ open, onSave, onClose }) { const [val, setVal] = useState(""); // Reset on every open: a stale half-typed key from a previous open must not // be one Enter press away from becoming the active key. useEffect(() => { if (open) setVal(""); }, [open]); useEffect(() => { if (!open) return; const onEsc = (e) => { if (e.key === "Escape") onClose(); }; document.addEventListener("keydown", onEsc); return () => document.removeEventListener("keydown", onEsc); }, [open, onClose]); if (!open) return null; const copy = KEY_MODAL_COPY[open] || KEY_MODAL_COPY.quota; const valid = val.trim().length > 0; const save = () => valid && onSave(val.trim()); return (
e.stopPropagation()}>

{copy.h}

{copy.p}

setVal(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") save(); }} />

Your key stays in this browser and goes straight to OpenRouter — it never touches this server. No key yet? Creating one takes about a minute.

); } function App() { const [theme, setThemeRaw] = useState(() => localStorage.getItem("sr-theme") || "dark"); const [tab, setTab] = useState(() => { // Deep-link support: /#inspection etc. (the legacy *.html pages redirect here). const h = (location.hash || "").replace(/^#/, ""); const valid = ["chat", "inspection", "papers", "figures", "why"]; return (valid.includes(h) && h) || localStorage.getItem("sr-tab") || "chat"; }); const [layout, setLayout] = useState(() => localStorage.getItem("sr-layout") || "split"); const [navOpen, setNavOpen] = useState(false); const [model, setModel] = useState("openai/gpt-4o-mini"); const [apiKey, setApiKeyRaw] = useState(() => localStorage.getItem("sr-key") || ""); const setApiKey = (v) => {setApiKeyRaw(v);localStorage.setItem("sr-key", v);}; const [settings, setSettings] = useState({ route: "auto", routingMode: "", topk: 5, paper: "" }); const set = (k, v) => setSettings((s) => ({ ...s, [k]: v })); const [t, setTweak] = useTweaks(TWEAK_DEFAULTS); const [papers, setPapers] = useState([]); const [figures, setFigures] = useState(null); const [pagesAvailable, setPagesAvailable] = useState(false); const [demoAvailable, setDemoAvailable] = useState(false); const [routingAvailable, setRoutingAvailable] = useState(true); const [keyModalOpen, setKeyModalOpen] = useState(false); const setTheme = (th) => {setThemeRaw(th);localStorage.setItem("sr-theme", th);}; useEffect(() => {document.documentElement.setAttribute("data-theme", theme);}, [theme]); useEffect(() => { localStorage.setItem("sr-tab", tab); // Keep the hash in sync — a stale deep-link hash would otherwise override // the saved tab on every reload. if ((location.hash || "").replace(/^#/, "") !== tab) history.replaceState(null, "", "#" + tab); }, [tab]); useEffect(() => {localStorage.setItem("sr-layout", layout);}, [layout]); // Real corpus data: the paper list (feeds the paper filter) and whether page // PNGs are mounted (gates vision generation). Best-effort; on failure the // defaults (empty list, no images) keep the UI working. useEffect(() => { window.RAG.loadPapers().then(setPapers); window.RAG.loadFigures().then(setFigures); // routing_available must be POSITIVELY confirmed — a failed /health (or // an older server without the field) should not leave routing controls // offered on a deployment that can't honor them. window.RAG.loadHealth().then((h) => { setPagesAvailable(!!h.pages_available); setDemoAvailable(!!h.demo_available); setRoutingAvailable(h.routing_available === true); }); }, []); // apply tweaks → CSS useEffect(() => { const root = document.documentElement; const ac = ACCENTS[t.accent] || ACCENTS["#3b82f6"]; root.style.setProperty("--accent", t.accent); root.style.setProperty("--accent-2", ac.a2); root.style.setProperty("--accent-soft", hexToRgba(t.accent, theme === "light" ? 0.10 : 0.14)); root.style.setProperty("--accent-line", hexToRgba(t.accent, 0.34)); }, [t.accent, theme]); useEffect(() => {document.documentElement.setAttribute("data-density", t.density);}, [t.density]); useEffect(() => {document.documentElement.setAttribute("data-answerfont", t.answerFont);}, [t.answerFont]); // Apply the layout tweak only when it actually changes: running on mount // would clobber the persisted sr-layout choice with the tweak default. The // tweak speaks "focus"; the layout state machine speaks "single". const layoutTweakFirst = useRef(true); useEffect(() => { if (layoutTweakFirst.current) { layoutTweakFirst.current = false; return; } setLayout(t.defaultLayout === "focus" ? "single" : t.defaultLayout); /* eslint-disable-next-line */ }, [t.defaultLayout]); const crumb = CRUMB[tab]; const stats = { papers: papers.length, figures: figures ? figures.length : 0 }; // Tapping a nav item also dismisses the mobile drawer. const selectTab = (id) => { setTab(id); setNavOpen(false); }; return (
{navOpen &&
setNavOpen(false)}>
}
{crumb.t}
{crumb.s}
{(tab === "chat" || tab === "inspection") && } {tab === "chat" && } 1024-d embeddings
{/* ChatView stays mounted across tab switches — unmounting would destroy the conversation while the chat itself points users at the Papers and Figures tabs. */}
setKeyModalOpen(reason || "quota")} />
{tab === "inspection" && } {tab === "papers" && } {tab === "figures" && } {tab === "why" && }
setKeyModalOpen(false)} onSave={(k) => { setApiKey(k); setKeyModalOpen(false); }} /> setTweak("accent", v)} /> setTweak("defaultLayout", v)} /> setTweak("density", v)} /> setTweak("answerFont", v)} />
); } ReactDOM.createRoot(document.getElementById("root")).render();