Spaces:
Sleeping
Sleeping
| const state = { | |
| modalities: [], | |
| groups: {}, | |
| selected: new Set(), | |
| }; | |
| const els = { | |
| form: document.getElementById("search-form"), | |
| q: document.getElementById("q"), | |
| modalities: document.getElementById("modalities"), | |
| board: document.getElementById("board"), | |
| statusTotal: document.getElementById("status-total"), | |
| statusMods: document.getElementById("status-mods"), | |
| statusTime: document.getElementById("status-time"), | |
| }; | |
| const COLUMN_ICONS = { | |
| "semantic-text": "✶", | |
| "bm25": "≣", | |
| "image": "▣", | |
| "video": "▶", | |
| }; | |
| async function loadModalities() { | |
| const r = await fetch("/api/modalities"); | |
| const data = await r.json(); | |
| state.modalities = data.modalities; | |
| state.groups = data.groups || {}; | |
| state.modalities.forEach((m) => state.selected.add(m)); | |
| renderPills(); | |
| } | |
| function renderPills() { | |
| els.modalities.innerHTML = ""; | |
| // group aliases first (e.g. `text`) | |
| Object.keys(state.groups).forEach((g) => { | |
| const members = state.groups[g]; | |
| const allOn = members.every((m) => state.selected.has(m)); | |
| const pill = makePill(g, allOn, true); | |
| pill.addEventListener("click", () => { | |
| const turnOn = !members.every((m) => state.selected.has(m)); | |
| members.forEach((m) => | |
| turnOn ? state.selected.add(m) : state.selected.delete(m), | |
| ); | |
| renderPills(); | |
| rerunIfQuery(); | |
| }); | |
| els.modalities.appendChild(pill); | |
| }); | |
| state.modalities.forEach((m) => { | |
| const pill = makePill(m, state.selected.has(m), false); | |
| pill.addEventListener("click", () => { | |
| if (state.selected.has(m)) state.selected.delete(m); | |
| else state.selected.add(m); | |
| renderPills(); | |
| rerunIfQuery(); | |
| }); | |
| els.modalities.appendChild(pill); | |
| }); | |
| } | |
| function rerunIfQuery() { | |
| if (els.q.value.trim()) runSearch(els.q.value); | |
| } | |
| function makePill(label, on, isGroup) { | |
| const el = document.createElement("button"); | |
| el.type = "button"; | |
| el.className = "pill" + (on ? " on" : "") + (isGroup ? " group" : ""); | |
| el.innerHTML = `<span class="pill-dot"></span>${label}`; | |
| return el; | |
| } | |
| let inflight = null; // AbortController for the currently running search | |
| async function runSearch(query) { | |
| if (!query.trim()) { | |
| fadeOutBoard(); | |
| els.statusTotal.textContent = "—"; | |
| els.statusMods.textContent = "—"; | |
| els.statusTime.textContent = "—"; | |
| return; | |
| } | |
| const mods = [...state.selected]; | |
| if (mods.length === 0) { | |
| els.board.innerHTML = `<div class="empty">Pick at least one modality.</div>`; | |
| return; | |
| } | |
| // Cancel any in-flight searches so out-of-order responses can't clobber a newer one. | |
| if (inflight) inflight.abort(); | |
| inflight = new AbortController(); | |
| const myController = inflight; | |
| initBoard(mods); | |
| const t0 = performance.now(); | |
| let totalHits = 0; | |
| let done = 0; | |
| // Fan out one request per modality. Each column updates as soon as its | |
| // modality returns, so fast searchers (bm25) show before slow ones (CLIP). | |
| await Promise.all( | |
| mods.map(async (m) => { | |
| const url = new URL(`/api/search/${encodeURIComponent(m)}`, window.location.origin); | |
| url.searchParams.set("q", query); | |
| url.searchParams.set("k", "8"); | |
| let res; | |
| try { | |
| const r = await fetch(url, { signal: myController.signal }); | |
| res = await r.json(); | |
| } catch (e) { | |
| if (e.name === "AbortError") return; | |
| res = { modality: m, kind: "text", error: String(e), hits: [] }; | |
| } | |
| if (myController !== inflight) return; | |
| totalHits += (res.hits || []).length; | |
| done += 1; | |
| renderColumn(m, res); | |
| els.statusTotal.textContent = String(totalHits); | |
| els.statusTime.textContent = `${Math.round(performance.now() - t0)} ms${done < mods.length ? " …" : ""}`; | |
| }), | |
| ); | |
| } | |
| function fadeOutBoard() { | |
| withTransition(() => { | |
| els.board.innerHTML = ""; | |
| }); | |
| } | |
| function withTransition(mutate) { | |
| // Cross-fade old → new board state. View Transitions API where available, | |
| // direct render otherwise. | |
| if (document.startViewTransition) { | |
| document.startViewTransition(mutate); | |
| } else { | |
| mutate(); | |
| } | |
| } | |
| function initBoard(mods) { | |
| withTransition(() => { | |
| els.board.innerHTML = ""; | |
| mods.forEach((m) => { | |
| const col = makeColumn(m, "—"); | |
| col.dataset.modality = m; | |
| const body = document.createElement("div"); | |
| body.className = "column-body"; | |
| body.innerHTML = `<div class="empty"><span class="loading"></span> searching…</div>`; | |
| col.appendChild(body); | |
| els.board.appendChild(col); | |
| }); | |
| }); | |
| els.statusTotal.textContent = "0"; | |
| els.statusMods.textContent = String(mods.length); | |
| els.statusTime.textContent = "…"; | |
| } | |
| function renderColumn(modality, res) { | |
| const col = els.board.querySelector(`[data-modality="${cssEscape(modality)}"]`); | |
| if (!col) return; | |
| const body = col.querySelector(".column-body"); | |
| const count = col.querySelector(".column-count"); | |
| const hits = res.hits || []; | |
| count.textContent = hits.length; | |
| withTransition(() => { | |
| body.innerHTML = ""; | |
| if (res.error) { | |
| const err = document.createElement("div"); | |
| err.className = "empty"; | |
| err.textContent = `error: ${res.error}`; | |
| body.appendChild(err); | |
| return; | |
| } | |
| if (hits.length === 0) { | |
| const e = document.createElement("div"); | |
| e.className = "empty"; | |
| e.textContent = "no hits"; | |
| body.appendChild(e); | |
| return; | |
| } | |
| hits.forEach((h, i) => { | |
| // One dark card per column, like the highlighted card in the reference. | |
| const dark = i === 0 && hits.length > 1; | |
| const card = makeCard(h, res.kind, modality, dark); | |
| card.style.setProperty("--stagger", `${i * 35}ms`); | |
| body.appendChild(card); | |
| }); | |
| }); | |
| } | |
| function cssEscape(s) { | |
| return window.CSS && CSS.escape ? CSS.escape(s) : s.replace(/"/g, '\\"'); | |
| } | |
| function makeColumn(name, count) { | |
| const col = document.createElement("div"); | |
| col.className = "column"; | |
| const head = document.createElement("div"); | |
| head.className = "column-head"; | |
| head.innerHTML = ` | |
| <div class="column-title"><span class="column-icon"></span>${name}</div> | |
| <span class="column-count">${count}</span> | |
| `; | |
| col.appendChild(head); | |
| return col; | |
| } | |
| function makeCard(hit, kind, modality, dark) { | |
| const card = document.createElement("div"); | |
| card.className = "card" + (dark ? " dark" : ""); | |
| // Video meta entries are stored as "<path> @ <t>s" — split them so the | |
| // file request uses the real path and the player can seek to that frame. | |
| const rawPath = hit.path; | |
| let filePath = rawPath; | |
| let timestamp = null; | |
| if (kind === "video") { | |
| const m = rawPath.match(/^(.*) @ (\d+(?:\.\d+)?)s$/); | |
| if (m) { | |
| filePath = m[1]; | |
| timestamp = parseFloat(m[2]); | |
| } | |
| } | |
| const name = rawPath.split("/").pop(); | |
| const parent = filePath.replace(/\/[^/]+$/, ""); | |
| if (kind === "text" && hit.excerpt) { | |
| card.appendChild(makeExcerpt(hit.excerpt)); | |
| } | |
| if (kind === "image") { | |
| const thumb = document.createElement("a"); | |
| thumb.className = "thumb"; | |
| thumb.href = fileUrl(filePath); | |
| thumb.target = "_blank"; | |
| thumb.rel = "noopener"; | |
| const img = document.createElement("img"); | |
| img.loading = "lazy"; | |
| img.alt = name; | |
| img.addEventListener("load", () => img.classList.add("loaded")); | |
| img.src = fileUrl(filePath); | |
| thumb.appendChild(img); | |
| card.appendChild(thumb); | |
| } else if (kind === "video") { | |
| const thumb = document.createElement("a"); | |
| thumb.className = "thumb"; | |
| const frag = timestamp != null ? `#t=${timestamp}` : ""; | |
| thumb.href = fileUrl(filePath) + frag; | |
| thumb.target = "_blank"; | |
| thumb.rel = "noopener"; | |
| const v = document.createElement("video"); | |
| v.muted = true; | |
| v.preload = "auto"; | |
| if (timestamp != null) { | |
| v.addEventListener("loadedmetadata", () => { | |
| try { v.currentTime = timestamp; } catch (_) {} | |
| }); | |
| v.addEventListener("seeked", () => v.classList.add("loaded")); | |
| } else { | |
| v.addEventListener("loadeddata", () => v.classList.add("loaded")); | |
| } | |
| v.src = fileUrl(filePath) + frag; | |
| thumb.appendChild(v); | |
| card.appendChild(thumb); | |
| } | |
| const nameEl = document.createElement("a"); | |
| nameEl.className = "card-name"; | |
| nameEl.href = fileUrl(filePath); | |
| nameEl.target = "_blank"; | |
| nameEl.rel = "noopener"; | |
| nameEl.style.color = "inherit"; | |
| nameEl.style.textDecoration = "none"; | |
| nameEl.textContent = name; | |
| card.appendChild(nameEl); | |
| const sub = document.createElement("div"); | |
| sub.className = "card-sub"; | |
| sub.textContent = parent; | |
| card.appendChild(sub); | |
| const meta = document.createElement("div"); | |
| meta.className = "card-meta"; | |
| const chip = document.createElement("span"); | |
| chip.className = "chip"; | |
| chip.textContent = modality; | |
| const score = document.createElement("span"); | |
| score.className = "score"; | |
| score.textContent = formatScore(hit.score); | |
| meta.appendChild(chip); | |
| meta.appendChild(score); | |
| card.appendChild(meta); | |
| return card; | |
| } | |
| function makeExcerpt({ before, match, after }) { | |
| // Three text spans: plain context, yellow-highlighted match, plain context. | |
| // The .match span is the only part the modality actually matched on. | |
| const pre = document.createElement("pre"); | |
| pre.className = "excerpt"; | |
| const addText = (text, cls) => { | |
| if (!text) return null; | |
| const span = document.createElement("span"); | |
| if (cls) span.className = cls; | |
| span.textContent = text; | |
| pre.appendChild(span); | |
| return span; | |
| }; | |
| if (before) { | |
| addText(before, ""); | |
| addText("\n", ""); | |
| } | |
| const matchSpan = addText(match || "", "match"); | |
| if (after) { | |
| addText("\n", ""); | |
| addText(after, ""); | |
| } | |
| // After layout, scroll the highlighted match into the visible window so it | |
| // never sits behind the top/bottom mask-fade. Runs on rAF because the | |
| // element hasn't been laid out yet at this point. | |
| if (matchSpan) { | |
| requestAnimationFrame(() => { | |
| const preRect = pre.getBoundingClientRect(); | |
| const matchRect = matchSpan.getBoundingClientRect(); | |
| const relTop = matchRect.top - preRect.top + pre.scrollTop; | |
| const target = relTop - pre.clientHeight / 2 + matchRect.height / 2; | |
| pre.scrollTop = Math.max(0, target); | |
| }); | |
| } | |
| return pre; | |
| } | |
| function fileUrl(path) { | |
| return `/api/file?path=${encodeURIComponent(path)}`; | |
| } | |
| function formatScore(s) { | |
| if (s == null || Number.isNaN(s)) return "—"; | |
| // dense models return ~[-1, 1]; bm25 can be much larger | |
| return Math.abs(s) >= 10 ? s.toFixed(1) : s.toFixed(3); | |
| } | |
| // Debounced live search: fire 500ms after the last keystroke. Submit/Enter | |
| // bypasses the debounce. | |
| const DEBOUNCE_MS = 500; | |
| let debounceTimer = null; | |
| function scheduleSearch() { | |
| if (debounceTimer) clearTimeout(debounceTimer); | |
| debounceTimer = setTimeout(() => { | |
| debounceTimer = null; | |
| runSearch(els.q.value); | |
| }, DEBOUNCE_MS); | |
| } | |
| els.q.addEventListener("input", scheduleSearch); | |
| els.form.addEventListener("submit", (e) => { | |
| e.preventDefault(); | |
| if (debounceTimer) { | |
| clearTimeout(debounceTimer); | |
| debounceTimer = null; | |
| } | |
| runSearch(els.q.value); | |
| }); | |
| loadModalities(); | |