/* 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" &&
Modelvia OpenRouter
{onDemo &&
}
free · no charge to your key
{freeModels.map(modelRow)}
premium · billed to your key
{paidModels.map(modelRow)}
{!keyed &&
}
}
{menu === "key" &&
OpenRouter API keystays 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 (
{/* ChatView stays mounted across tab switches — unmounting would
destroy the conversation while the chat itself points users at
the Papers and Figures tabs. */}