// HF Hub autocomplete — wraps any text input with a search-as-you-type // dropdown that hits https://huggingface.co/api/models. Browser-only, no auth. // // Usage: // import { attachHfAutocomplete } from "./hf_autocomplete.js"; // attachHfAutocomplete(document.getElementById("my-id-input"), { // pipeline: "text-generation", // filter (or null for all) // onSelect: (id) => { ... }, // }); // // Idempotent: calling twice on same input is a no-op. const ATTACHED = new WeakSet(); // LRU-ish cache: same query within 5 min → no extra fetch. Reduces HF API // pressure by ~50% for users who delete/retype, and shields us from rate limits. const CACHE = new Map(); const CACHE_TTL_MS = 5 * 60 * 1000; const CACHE_MAX = 50; function cacheGet(q) { const e = CACHE.get(q); if (!e) return null; if (Date.now() - e.t > CACHE_TTL_MS) { CACHE.delete(q); return null; } CACHE.delete(q); CACHE.set(q, e); // re-insert = LRU bump return e.r; } function cacheSet(q, r) { if (CACHE.size >= CACHE_MAX) CACHE.delete(CACHE.keys().next().value); CACHE.set(q, { r, t: Date.now() }); } function escapeHtml(s) { return String(s).replace(/[&<>"']/g, c => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c])); } function formatDownloads(n) { if (n === null || n === undefined) return "?"; if (n >= 1e9) return (n / 1e9).toFixed(1) + "B"; if (n >= 1e6) return (n / 1e6).toFixed(1) + "M"; if (n >= 1e3) return (n / 1e3).toFixed(1) + "K"; return String(n); } export function attachHfAutocomplete(inputEl, options = {}) { if (!inputEl || ATTACHED.has(inputEl)) return; ATTACHED.add(inputEl); const { pipeline = "text-generation", limit = 15, debounceMs = 300, minChars = 2, onSelect = null, } = options; // Floating dropdown attached to body so it never gets clipped by parents. const dropdown = document.createElement("div"); dropdown.className = "hf-autocomplete-dropdown"; dropdown.style.display = "none"; document.body.appendChild(dropdown); let timeoutId = null; let activeIndex = -1; let results = []; let lastQuery = ""; function positionDropdown() { const rect = inputEl.getBoundingClientRect(); dropdown.style.position = "fixed"; dropdown.style.left = rect.left + "px"; dropdown.style.top = (rect.bottom + 2) + "px"; dropdown.style.width = Math.max(rect.width, 280) + "px"; dropdown.style.zIndex = "10000"; } function render(notice = null) { if (!results.length && !notice) { dropdown.style.display = "none"; return; } const rows = results.map((r, i) => `
${escapeHtml(r.id)} ⬇ ${formatDownloads(r.downloads)} · ❤ ${formatDownloads(r.likes)}${r.library_name ? " · " + escapeHtml(r.library_name) : ""}
`).join(""); const noticeHtml = notice ? `
${escapeHtml(notice)}
` : ""; // Privacy footer (always visible when dropdown is showing). const t = (window.__taf_t || (k => null)); const privacyText = t("hf_auto.privacy") || "🔒 Queries sent to huggingface.co/api · cached locally 5 min"; const privacyHtml = `
${escapeHtml(privacyText)}
`; dropdown.innerHTML = rows + noticeHtml + privacyHtml; positionDropdown(); dropdown.style.display = "block"; } function close() { dropdown.style.display = "none"; activeIndex = -1; } function pick(id) { inputEl.value = id; close(); if (onSelect) onSelect(id); inputEl.dispatchEvent(new Event("change", { bubbles: true })); } async function search(q) { // Empty q is allowed: returns top-N most-downloaded models so a focused-but-empty // input still shows a useful initial dropdown ("desplegable" UX), not just // search-as-you-type. Below minChars but non-empty → wait for more chars. if (q && q.length < minChars) { results = []; render(); return; } const cacheKey = q || "__top__"; if (cacheKey === lastQuery) return; // dedupe rapid typing lastQuery = cacheKey; // Cache hit → skip network entirely const cached = cacheGet(cacheKey); if (cached) { results = cached; activeIndex = -1; render(); return; } const params = new URLSearchParams({ limit: String(limit), sort: "downloads", direction: "-1", }); if (q) params.set("search", q); if (pipeline) params.set("filter", pipeline); try { const resp = await fetch(`https://huggingface.co/api/models?${params}`); if (resp.status === 429) { const t = (window.__taf_t || (k => null)); results = []; render(t("hf_auto.rate_limited") || "⚠ HuggingFace rate limit — try again in a moment"); return; } if (!resp.ok) { results = []; render(); return; } const data = await resp.json(); results = (Array.isArray(data) ? data : []) .filter(r => r.id && typeof r.id === "string") .slice(0, limit); cacheSet(cacheKey, results); activeIndex = -1; render(); } catch (e) { // Network failure → silent; user can still type the id manually. results = []; render(); } } inputEl.addEventListener("input", (e) => { clearTimeout(timeoutId); timeoutId = setTimeout(() => search(e.target.value.trim()), debounceMs); }); inputEl.addEventListener("focus", (e) => { // Always show dropdown on focus: either filtered (if user already typed) // or the global top-most-downloaded models (empty query). const v = e.target.value.trim(); search(v); }); // Click on a result picks it. Use mousedown to fire before input loses focus. dropdown.addEventListener("mousedown", (e) => { e.preventDefault(); const item = e.target.closest(".hf-result"); if (item) pick(item.dataset.id); }); // Keyboard nav inputEl.addEventListener("keydown", (e) => { if (dropdown.style.display === "none" || !results.length) return; if (e.key === "ArrowDown") { e.preventDefault(); activeIndex = Math.min(activeIndex + 1, results.length - 1); render(); } else if (e.key === "ArrowUp") { e.preventDefault(); activeIndex = Math.max(activeIndex - 1, -1); render(); } else if (e.key === "Enter" && activeIndex >= 0) { e.preventDefault(); pick(results[activeIndex].id); } else if (e.key === "Escape") { close(); } }); // Click outside or blur → close (small delay so click on dropdown still fires) inputEl.addEventListener("blur", () => setTimeout(close, 150)); // Reposition on scroll/resize when dropdown is open window.addEventListener("scroll", () => { if (dropdown.style.display === "block") positionDropdown(); }, true); window.addEventListener("resize", () => { if (dropdown.style.display === "block") positionDropdown(); }); } // Convenience: attach to all known HF-id inputs in TAF Agent. // NIAH was added in v0.7.6, LongScore in v0.8.8 — keep this list in sync when adding new modes. export function attachAllHfAutocompletes() { const ids = [ "hf-id", "profile-hf-id", "unmask-id", "template-id", "quant-id", "niah-id", "spec-target-id", "spec-draft-id", "longscore-input", ]; for (const id of ids) { const el = document.getElementById(id); if (el) attachHfAutocomplete(el); } }