tarekziade's picture
tarekziade HF Staff
higlight
e330b99
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();