/* ============================================================ VLPS · Vision Lab — frontend logic ============================================================ */ const $ = (s, r = document) => r.querySelector(s); const $$ = (s, r = document) => [...r.querySelectorAll(s)]; const TOOLS = [ { id: "retrieve", label: "Retrieve" }, { id: "vqa", label: "Visual QA" }, { id: "ocr", label: "OCR Lab" }, { id: "browse", label: "Index Browser" }, { id: "agent", label: "Agent" }, ]; /* ---------- tool switching ---------- */ const nav = $("#nav"), rail = $("#toolRail"), readout = $("#vpReadout"); TOOLS.forEach((t, i) => { const n = document.createElement("button"); n.textContent = t.label; n.dataset.tool = t.id; nav.appendChild(n); const r = document.createElement("button"); r.dataset.tool = t.id; r.innerHTML = `${t.label}0${i + 1}`; rail.appendChild(r); }); function selectTool(id) { $$(".panel").forEach(p => p.classList.toggle("active", p.dataset.tool === id)); $$("#nav button, #toolRail button").forEach(b => b.classList.toggle("on", b.dataset.tool === id)); const label = TOOLS.find(t => t.id === id)?.label || id; readout.textContent = `${label.toUpperCase()} · idle`; window.scrollTo({ top: $(".console").offsetTop - 70, behavior: "smooth" }); } $$("#nav button, #toolRail button").forEach(b => b.onclick = () => selectTool(b.dataset.tool)); /* ---------- segmented controls ---------- */ $$(".seg").forEach(seg => seg.onclick = e => { const b = e.target.closest("button"); if (!b) return; $$("button", seg).forEach(x => x.classList.toggle("on", x === b)); }); const segVal = seg => $(".on", seg)?.dataset.v; /* ---------- busy / fetch helpers ---------- */ function busy(on, label) { readout.classList.toggle("busy", on); if (on && label) readout.textContent = `${label} · working…`; } async function postJSON(path, body) { const r = await fetch(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); return r.json(); } async function postForm(path, form) { const r = await fetch(path, { method: "POST", body: form }); return r.json(); } /* ---------- grid rendering ---------- */ function renderGrid(container, items, kind) { container.innerHTML = ""; items.forEach(it => { const cell = document.createElement("div"); cell.className = "cell"; const cap = kind === "browse" ? (it.question || "") : (it.caption || ""); const score = (kind === "search" && it.score != null) ? `${it.score}` : ""; cell.innerHTML = `
${score}
${it.image_id}${escapeHtml(cap)}
`; cell.onclick = () => openDrawer(it, kind); container.appendChild(cell); }); } const escapeHtml = s => String(s).replace(/[&<>"]/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); // COCO ids may live in val2014 or train2014 — try the other folder before giving up. window.cocoFallback = function (img) { if (!img.dataset.tried && /val2014/.test(img.src)) { img.dataset.tried = "1"; img.src = img.src.replace(/val2014/g, "train2014"); } else { img.style.opacity = ".15"; } }; /* ---------- drawer ---------- */ const drawer = $("#drawer"), scrim = $("#scrim"); function openDrawer(it, kind) { $("#drawerImg").src = it.image_url; const rows = [["Image id", it.image_id]]; if (it.question) rows.push(["Question", it.question]); if (it.answers?.length) rows.push(["Ground-truth answers", it.answers.join(" · ")]); if (it.caption) rows.push(["Caption", it.caption]); if (it.score != null) rows.push(["Score", it.score]); if (it.ocr_text) rows.push(["Extracted OCR text", it.ocr_text || "—"]); if (it.language) rows.push(["Language", it.language]); $("#drawerBody").innerHTML = rows.map(([k, v]) => `
${k}
${escapeHtml(v)}
`).join(""); drawer.classList.add("on"); scrim.classList.add("on"); } const closeDrawer = () => { drawer.classList.remove("on"); scrim.classList.remove("on"); }; $("#drawerX").onclick = closeDrawer; scrim.onclick = closeDrawer; /* ---------- file dropzones (one image, shared by Visual QA + OCR Lab) ---------- */ let sharedImage = null; function setSharedImage(f) { if (!f) return; sharedImage = f; const url = URL.createObjectURL(f); ["#vqaDrop", "#ocrDrop"].forEach(sel => { const z = $(sel); if (!z) return; z.classList.add("has-img"); let img = $("img", z); if (!img) { img = document.createElement("img"); z.appendChild(img); } img.src = url; }); } function wireDrop(zoneSel, inputSel) { const zone = $(zoneSel), input = $(inputSel); zone.onclick = () => input.click(); input.onchange = () => setSharedImage(input.files[0]); ["dragover", "dragenter"].forEach(ev => zone.addEventListener(ev, e => { e.preventDefault(); zone.classList.add("drag"); })); ["dragleave", "drop"].forEach(ev => zone.addEventListener(ev, e => { e.preventDefault(); zone.classList.remove("drag"); })); zone.addEventListener("drop", e => { if (e.dataTransfer.files[0]) setSharedImage(e.dataTransfer.files[0]); }); } wireDrop("#vqaDrop", "#vqaFile"); wireDrop("#ocrDrop", "#ocrFile"); /* ---------- RETRIEVE ---------- */ $("#searchK").oninput = e => $("#searchKv").textContent = e.target.value; async function runSearch() { const q = $("#searchInput").value.trim(); if (!q) { $("#searchStatus").textContent = "enter a query."; return; } busy(true, "RETRIEVE"); $("#searchGrid").classList.add("loading"); $("#searchStatus").textContent = "scanning index…"; const res = await postJSON("/api/search", { query: q, mode: segVal($("#searchMode")), top_k: +$("#searchK").value }); busy(false); $("#searchGrid").classList.remove("loading"); if (res.error) { $("#searchStatus").textContent = "⚠ " + res.error; return; } renderGrid($("#searchGrid"), res.results || [], "search"); $("#searchStatus").textContent = `${(res.results || []).length} frames · mode ${segVal($("#searchMode"))}`; readout.textContent = "RETRIEVE · " + (res.results || []).length + " hits"; } $("#searchGo").onclick = runSearch; $("#searchInput").addEventListener("keydown", e => { if (e.key === "Enter") runSearch(); }); /* ---------- VISUAL QA ---------- */ $("#vqaGo").onclick = async () => { const f = sharedImage; const q = $("#vqaQ").value.trim(); if (!f) { $("#vqaAnswer").textContent = "drop an image first."; return; } if (!q) { $("#vqaAnswer").textContent = "ask a question first."; return; } busy(true, "VISUAL QA"); $("#vqaAnswer").classList.add("loading"); $("#vqaAnswer").textContent = "looking…"; const fd = new FormData(); fd.append("image", f); fd.append("question", q); fd.append("system_prompt", $("#vqaSys").value); const res = await postForm("/api/vqa", fd); busy(false); $("#vqaAnswer").classList.remove("loading"); $("#vqaAnswer").textContent = res.error ? "⚠ " + res.error : res.answer; }; /* ---------- OCR LAB ---------- */ $("#ocrGo").onclick = async () => { const f = sharedImage; const q = $("#ocrQ").value.trim(); if (!f) { $(".val", $("#ocrText")).textContent = "drop an image first."; return; } if (!q) { $(".val", $("#ocrText")).textContent = "ask a question first."; return; } busy(true, "OCR LAB"); $("#ocrText").classList.add("loading"); $(".val", $("#ocrText")).textContent = "reading…"; $("#ocrAnsOcr").textContent = "…"; $("#ocrAnsVis").textContent = "…"; const fd = new FormData(); fd.append("image", f); fd.append("question", q); fd.append("lang", segVal($("#ocrLang"))); const res = await postForm("/api/ocr_qa", fd); busy(false); $("#ocrText").classList.remove("loading"); if (res.error) { $(".val", $("#ocrText")).textContent = "⚠ " + res.error; return; } $(".val", $("#ocrText")).textContent = res.ocr_text || "(no text detected)"; $("#ocrAnsOcr").textContent = res.answer_ocr || "—"; $("#ocrAnsVis").textContent = res.answer_vision || "—"; }; /* ---------- INDEX BROWSER ---------- */ async function browse(mode) { const ds = segVal($("#browseDs")); busy(true, "INDEX BROWSER"); $("#browseGrid").classList.add("loading"); let res; if (mode === "search") { const q = $("#browseQ").value.trim(); if (!q) { $("#browseStatus").textContent = "enter text to search."; busy(false); $("#browseGrid").classList.remove("loading"); return; } $("#browseStatus").textContent = "searching OCR text…"; res = await postJSON("/api/dataset/search", { dataset: ds, query: q, k: 12 }); } else { $("#browseStatus").textContent = "loading index…"; res = await fetch(`/api/dataset?dataset=${ds}&limit=12`).then(r => r.json()); } busy(false); $("#browseGrid").classList.remove("loading"); if (res.error) { $("#browseStatus").textContent = "⚠ " + res.error; return; } const items = res.items || []; renderGrid($("#browseGrid"), items, "browse"); $("#browseStatus").textContent = items.length ? `${items.length} records · ${ds}` : "no records (is the index built?)"; } $("#browseLoad").onclick = () => browse("load"); $("#browseGo").onclick = () => browse("search"); $("#browseQ").addEventListener("keydown", e => { if (e.key === "Enter") browse("search"); }); /* ---------- AGENT ---------- */ function agentSession() { let s = localStorage.getItem("vlps_session"); if (!s) { s = (crypto.randomUUID ? crypto.randomUUID() : "s" + Date.now() + Math.random()); localStorage.setItem("vlps_session", s); } return s; } $("#agentGo").onclick = runAgent; $("#agentQ").addEventListener("keydown", e => { if (e.key === "Enter") runAgent(); }); $("#agentReset").onclick = async () => { const old = agentSession(); await postJSON("/api/agent/reset", { session: old }); localStorage.removeItem("vlps_session"); $("#chatLog").innerHTML = ""; }; async function runAgent() { const q = $("#agentQ").value.trim(); if (!q) return; const log = $("#chatLog"); log.insertAdjacentHTML("beforeend", `
${escapeHtml(q)}
`); $("#agentQ").value = ""; const think = document.createElement("div"); think.className = "msg think"; think.textContent = "orchestrating tools…"; log.appendChild(think); log.scrollTop = log.scrollHeight; busy(true, "AGENT"); const res = await postJSON("/api/agent", { message: q, session: agentSession() }); busy(false); think.remove(); let html = escapeHtml(res.error ? "⚠ " + res.error : res.answer); if (res.tools && res.tools.length) { html += `
` + res.tools.map(t => { const a = t.args || {}; const detail = a.query ? `"${a.query}"` : (a.image_id ? `#${a.image_id}` : ""); return `${escapeHtml(t.tool)}${detail ? " · " + escapeHtml(detail) : ""}`; }).join("") + `
`; } if (res.images && res.images.length) { html += `
` + res.images.map(im => `
` + `
${escapeHtml(im.image_id)}${im.caption ? " · " + escapeHtml(im.caption) : ""}
` ).join("") + `
`; } log.insertAdjacentHTML("beforeend", `
${html}
`); log.scrollTop = log.scrollHeight; } /* ---------- boot ---------- */ fetch("/api/config").then(r => r.json()).then(c => { $("#modelBadge").textContent = "◍ " + (c.model || "online"); $("#modelBadge").classList.add("live"); $("#footModel").textContent = (c.model || "") + " · " + (c.user || ""); }).catch(() => { $("#modelBadge").textContent = "◍ offline"; }); selectTool("retrieve");