Spaces:
Running on Zero
Running on Zero
| /* ============================================================ | |
| FitCheck — frontend logic (UI brick) | |
| Gathers inputs, calls the /api/advise connector, renders results. | |
| The backend currently returns input-aware PLACEHOLDER data; the | |
| real engine plugs into the same /api/advise contract later. | |
| ============================================================ */ | |
| // ---- Inline icon helpers (icons.js loads first) -------------------------- | |
| function ic(name) { return (window.ICONS && window.ICONS[name]) || ""; } | |
| function hydrate(root) { | |
| (root || document).querySelectorAll("[data-ic]").forEach(el => { | |
| if (!el.dataset.done) { el.innerHTML = ic(el.dataset.ic); el.dataset.done = "1"; } | |
| }); | |
| } | |
| // ---- Use-case taxonomy: the full realm, not just LLMs -------------------- | |
| const USE_CASES = [ | |
| { icon: "cat-text", name: "Text & chat", items: [ | |
| { id: "chat", icon: "chat", label: "Chatbot / assistant" }, | |
| { id: "writing", icon: "writing", label: "Writing & summarising" }, | |
| { id: "coding", icon: "coding", label: "Coding help" }, | |
| { id: "agents", icon: "agents", label: "Agents & tool use" }, | |
| { id: "rag", icon: "rag", label: "Chat with my documents" }, | |
| { id: "translate", icon: "translate", label: "Translation" }, | |
| ]}, | |
| { icon: "cat-vision", name: "See & understand images", items: [ | |
| { id: "vlm", icon: "classify", label: "Chat about images (VLM)" }, | |
| { id: "detect", icon: "detect", label: "Object detection (YOLO)" }, | |
| { id: "segment", icon: "segment", label: "Image segmentation" }, | |
| { id: "pose", icon: "pose", label: "Pose / 6-DoF (FoundationPose)" }, | |
| { id: "classify",icon: "classify", label: "Image classification" }, | |
| { id: "depth", icon: "depth", label: "Depth estimation" }, | |
| { id: "ocr", icon: "ocr", label: "Read text from images (OCR)" }, | |
| ]}, | |
| { icon: "cat-gen", name: "Create images & video", items: [ | |
| { id: "imagegen", icon: "imagegen", label: "Generate images (SD / Flux)" }, | |
| { id: "inpaint", icon: "inpaint", label: "Edit / inpaint images" }, | |
| { id: "upscale", icon: "upscale", label: "Upscale / restore" }, | |
| { id: "videogen", icon: "videogen", label: "Generate video" }, | |
| { id: "bgremove", icon: "bgremove", label: "Remove backgrounds" }, | |
| ]}, | |
| { icon: "cat-audio", name: "Audio & speech", items: [ | |
| { id: "stt", icon: "stt", label: "Speech to text (Whisper)" }, | |
| { id: "tts", icon: "tts", label: "Text to speech / voice" }, | |
| { id: "music", icon: "music", label: "Generate music" }, | |
| ]}, | |
| { icon: "cat-data", name: "Data & search", items: [ | |
| { id: "embed", icon: "embed", label: "Semantic search / embeddings" }, | |
| { id: "forecast", icon: "forecast", label: "Time-series forecasting" }, | |
| { id: "tabular", icon: "tabular", label: "Predict from spreadsheets" }, | |
| ]}, | |
| { icon: "cat-train", name: "Train your own", items: [ | |
| { id: "finetune", icon: "finetune", label: "Fine-tune an LLM (LoRA)" }, | |
| { id: "train-vision", icon: "train-vision", label: "Train a vision model" }, | |
| ]}, | |
| { icon: "cat-custom", name: "Something else", items: [ | |
| { id: "custom", icon: "custom", label: "Custom: describe it" }, | |
| ]}, | |
| ]; | |
| // ---- GPU lists per provider ---------------------------------------------- | |
| const GPUS = { | |
| none: [], | |
| unsure: [], | |
| nvidia: [ | |
| "RTX 5090 (32 GB)","RTX 5080 (16 GB)","RTX 5070 Ti (16 GB)","RTX 5070 (12 GB)","RTX 5060 (8 GB)", | |
| "RTX 4090 (24 GB)","RTX 4080 (16 GB)","RTX 4070 (12 GB)","RTX 4060 (8 GB)", | |
| "RTX 3090 (24 GB)","RTX 3080 (10 GB)","RTX 3070 (8 GB)","RTX 3060 (12 GB)","RTX 3050 (8 GB)", | |
| "GTX 1660 (6 GB)","GTX 1650 (4 GB)","Laptop RTX (not sure of model)", | |
| ], | |
| amd: ["RX 7900 XTX (24 GB)","RX 7800 XT (16 GB)","RX 7600 (8 GB)","RX 6700 XT (12 GB)","Built-in Radeon"], | |
| apple: ["M-series base (8 GB)","M-series Pro (16 GB)","M-series Max (32 GB)","M-series Ultra (64 GB)"], | |
| intel: ["Arc A770 (16 GB)","Arc A750 (8 GB)","Arc A380 (6 GB)","Built-in Intel graphics"], | |
| }; | |
| const $ = (s) => document.querySelector(s); | |
| const state = { mode: "have", computer: "Windows laptop", provider: "none", priority: "balanced", usecases: ["chat"], checked: false }; | |
| let lastAdvice = null; // the most recent /api/advise result — facts the model explains | |
| let multiCache = null; // {ucs, results} when several goals are checked at once | |
| // ---- Buy-vs-check mode ----------------------------------------------------- | |
| function applyMode() { | |
| const buy = state.mode === "buy"; | |
| ["#machine-step", "#priority-step", "#find-specs"].forEach(s => { | |
| const el = $(s); if (el) el.style.display = buy ? "none" : ""; | |
| }); | |
| const repo = $("#repo-field"); if (repo) repo.style.display = buy ? "none" : ""; | |
| $("#check-btn").innerHTML = (buy ? "What should I get? " : "Check my setup ") | |
| + '<span class="ic" data-ic="arrow"></span>'; | |
| hydrate($("#check-btn")); | |
| } | |
| // ---- Best-effort hardware hints from the browser (honest: vendor + floor) -- | |
| async function detectHardware() { | |
| const bits = []; | |
| try { | |
| if (navigator.gpu) { | |
| const ad = await navigator.gpu.requestAdapter(); | |
| const v = ((ad && ad.info && (ad.info.vendor || ad.info.description)) || "").toLowerCase(); | |
| for (const vendor of ["nvidia", "amd", "apple", "intel"]) { | |
| if (v.includes(vendor)) { | |
| state.provider = vendor; | |
| setActive("#provider-seg", vendor); | |
| fillGpu(); | |
| bits.push(`a ${vendor.toUpperCase().replace("APPLE","Apple")} GPU`); | |
| break; | |
| } | |
| } | |
| } | |
| } catch (e) { /* detection is best-effort only */ } | |
| if (navigator.deviceMemory) bits.push(`at least ${navigator.deviceMemory} GB of RAM`); | |
| if (bits.length) { | |
| const h = $("#detect-hint"); | |
| h.style.display = ""; | |
| h.textContent = `Your browser reports ${bits.join(" and ")} — browsers can't see exact specs, so please confirm below.`; | |
| } | |
| } | |
| // ---- Build the use-case picker ------------------------------------------- | |
| function buildPicker() { | |
| const wrap = $("#usecase-picker"); | |
| wrap.innerHTML = USE_CASES.map(g => ` | |
| <div class="uc-group"> | |
| <div class="uc-cat"><span class="ic" data-ic="${g.icon}"></span>${g.name}</div> | |
| <div class="uc-grid"> | |
| ${g.items.map(it => ` | |
| <button class="uc-pill${it.id === "chat" ? " active" : ""}" data-uc="${it.id}"> | |
| <span class="pic ic" data-ic="${it.icon}"></span>${it.label} | |
| </button>`).join("")} | |
| </div> | |
| </div>`).join(""); | |
| hydrate(wrap); | |
| // Pills toggle: pick one goal or several (several = checked together). | |
| wrap.querySelectorAll(".uc-pill").forEach(p => p.addEventListener("click", () => { | |
| const uc = p.dataset.uc; | |
| const i = state.usecases.indexOf(uc); | |
| if (i >= 0) { | |
| if (state.usecases.length > 1) { state.usecases.splice(i, 1); p.classList.remove("active"); } | |
| } else { | |
| state.usecases.push(uc); | |
| p.classList.add("active"); | |
| } | |
| $("#custom-uc-field").style.display = state.usecases.includes("custom") ? "block" : "none"; | |
| maybeLiveUpdate(); | |
| })); | |
| } | |
| // ---- Segmented controls --------------------------------------------------- | |
| function wireSegmented(id, key, after) { | |
| $(id).querySelectorAll(".seg-btn").forEach(b => b.addEventListener("click", () => { | |
| $(id).querySelectorAll(".seg-btn").forEach(x => x.classList.remove("active")); | |
| b.classList.add("active"); | |
| state[key] = b.dataset.val; | |
| if (after) after(); | |
| maybeLiveUpdate(); | |
| })); | |
| } | |
| function setActive(id, val) { | |
| $(id).querySelectorAll(".seg-btn").forEach(b => b.classList.toggle("active", b.dataset.val === val)); | |
| } | |
| // ---- GPU select depends on provider -------------------------------------- | |
| function fillGpu() { | |
| const list = GPUS[state.provider] || []; | |
| const sel = $("#gpu"); | |
| if (!list.length) { sel.style.display = "none"; sel.innerHTML = ""; } | |
| else { sel.style.display = "block"; sel.innerHTML = list.map(g => `<option>${g}</option>`).join(""); } | |
| } | |
| // Real machines only: a Windows laptop can't have Apple Silicon, a Pi can't | |
| // take a desktop card. Impossible provider buttons get disabled per computer. | |
| const PROVIDER_ALLOWED = { | |
| "Windows laptop": ["none", "nvidia", "amd", "intel", "unsure"], | |
| "Windows desktop": ["none", "nvidia", "amd", "intel", "unsure"], | |
| "Linux PC": ["none", "nvidia", "amd", "intel", "unsure"], | |
| "Mac": ["apple"], | |
| "Mini PC / Raspberry Pi": ["none", "nvidia", "unsure"], // nvidia = Jetson boards | |
| }; | |
| function syncProviderForComputer() { | |
| const allowed = PROVIDER_ALLOWED[state.computer] || ["none", "nvidia", "amd", "intel", "apple", "unsure"]; | |
| $("#provider-seg").querySelectorAll(".seg-btn").forEach(b => { | |
| const ok = allowed.includes(b.dataset.val); | |
| b.classList.toggle("disabled", !ok); | |
| b.disabled = !ok; | |
| }); | |
| if (!allowed.includes(state.provider)) { | |
| state.provider = allowed[0]; | |
| setActive("#provider-seg", state.provider); | |
| } | |
| fillGpu(); | |
| } | |
| // ---- Find-my-specs help text --------------------------------------------- | |
| function findSpecsText() { | |
| const c = state.computer; | |
| if (c === "Mac") return ` | |
| <ul style="margin:0;padding-left:18px"> | |
| <li>Click the Apple menu (top-left), then <b>About This Mac</b>.</li> | |
| <li>It shows your chip (e.g. <i>Apple M2</i>) and <b>Memory</b> (e.g. <i>16 GB</i>).</li> | |
| <li>On a Mac that one memory number is all you need. The graphics share it.</li> | |
| </ul>`; | |
| if (c === "Linux PC" || c.includes("Mini PC")) return ` | |
| <ul style="margin:0;padding-left:18px"> | |
| <li><b>RAM:</b> run <code>free -h</code> in a terminal.</li> | |
| <li><b>Graphics card:</b> <code>nvidia-smi</code> (NVIDIA) or <code>lspci | grep VGA</code>.</li> | |
| </ul>`; | |
| return ` | |
| <ul style="margin:0;padding-left:18px"> | |
| <li><b>RAM:</b> press <code>Ctrl + Shift + Esc</code>, then <b>Performance</b>, then <b>Memory</b>.</li> | |
| <li><b>Graphics card:</b> same window, <b>GPU</b>. The name is top-right (e.g. <i>NVIDIA RTX 3060</i>).</li> | |
| <li>No real GPU listed? You have built-in graphics. Pick “None / built-in”.</li> | |
| </ul>`; | |
| } | |
| // ---- Gather inputs & call the connector ---------------------------------- | |
| function gather() { | |
| const sel = $("#gpu"); | |
| return { | |
| computer: state.computer, | |
| ram_gb: parseFloat($("#ram").value), | |
| provider: state.provider, | |
| gpu: sel.style.display === "none" ? "" : sel.value, | |
| vram_gb: $("#vram").value ? parseFloat($("#vram").value) : null, | |
| paste: $("#paste").value.trim(), | |
| usecase: state.usecases[0], | |
| usecases: state.usecases.slice(), | |
| custom: $("#custom-uc").value.trim(), | |
| priority: state.priority, | |
| repo: $("#repo-check") ? $("#repo-check").value.trim() : "", | |
| }; | |
| } | |
| let debounce; | |
| function maybeLiveUpdate() { | |
| if (!state.checked) return; | |
| clearTimeout(debounce); | |
| debounce = setTimeout(check, 200); | |
| } | |
| async function check() { | |
| state.checked = true; | |
| const payload = gather(); | |
| try { | |
| if (state.mode === "buy") { | |
| const res = await fetch("/api/minspecs", { | |
| method: "POST", headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ usecases: state.usecases }), | |
| }); | |
| renderBuy(await res.json()); | |
| return; | |
| } | |
| if (state.usecases.length > 1) { | |
| const results = await Promise.all(state.usecases.map(u => | |
| fetch("/api/advise", { | |
| method: "POST", headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ ...payload, usecase: u }), | |
| }).then(r => r.json()))); | |
| multiCache = { ucs: state.usecases.slice(), results }; | |
| renderMulti(results); | |
| if (payload.repo) lookupRepo(payload); | |
| return; | |
| } | |
| multiCache = null; | |
| const res = await fetch("/api/advise", { | |
| method: "POST", headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify(payload), | |
| }); | |
| render(await res.json()); | |
| if (payload.repo) lookupRepo(payload); // optional live check, appended on top | |
| } catch (e) { | |
| $("#results").innerHTML = `<div class="empty-state"><div class="big"><span class="ic" data-ic="monitor"></span></div> | |
| <p>Couldn't reach the advisor: ${e && e.message ? e.message : e}</p></div>`; | |
| hydrate($("#results")); | |
| } | |
| } | |
| // ---- Live single-model lookup (the one online feature, labelled as such) --- | |
| async function lookupRepo(payload) { | |
| const holder = $("#lookup-result"); | |
| if (!holder) return; | |
| holder.innerHTML = `<div class="ans-loading"><span class="spinner"></span>Looking up ${payload.repo} on Hugging Face…</div>`; | |
| try { | |
| const res = await fetch("/api/lookup", { | |
| method: "POST", headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify(payload), | |
| }); | |
| const d = await res.json(); | |
| if (d.error) { | |
| holder.innerHTML = `<div class="ans-card ans-error"><h3>Couldn't check that model</h3><p>${d.error}</p></div>`; | |
| return; | |
| } | |
| const o = d.option || {}; | |
| const v = VMAP[o.verdict] || VMAP.tight; | |
| holder.innerHTML = ` | |
| <div class="lookup-card reveal" style="--status:${v.cls};--status-soft:${v.soft}"> | |
| <div class="lookup-head"> | |
| <span class="badge"><span class="dot"></span>${v.word}</span> | |
| <span class="live-tag">Live Hugging Face lookup</span> | |
| </div> | |
| <p class="lookup-explain">${d.explain || ""}</p> | |
| <div class="lookup-meta"> | |
| ${o.memory && o.memory !== "Too big" ? `<span><b>${o.memory}</b> needed (${o.setting})</span>` : `<span><b>Too big</b> for this machine</span>`} | |
| ${o.url ? `<a href="${o.url}" target="_blank" rel="noopener">View on Hugging Face</a>` : ""} | |
| </div> | |
| ${o.run && o.run.ollama ? `<div class="cmd-box" style="margin-top:10px"><div class="cmd-label">Run it<button class="copy-btn" data-code="${encodeURIComponent(o.run.ollama)}">Copy</button></div><pre><code>${o.run.ollama}</code></pre></div>` : ""} | |
| </div>`; | |
| holder.querySelectorAll(".copy-btn").forEach(b => b.addEventListener("click", () => { | |
| navigator.clipboard.writeText(decodeURIComponent(b.dataset.code)); | |
| b.textContent = "Copied ✓"; | |
| setTimeout(() => { b.textContent = "Copy"; }, 1500); | |
| })); | |
| } catch (e) { | |
| holder.innerHTML = `<div class="ans-card ans-error"><h3>Lookup failed</h3><p>${e && e.message ? e.message : e}</p></div>`; | |
| } | |
| } | |
| // ---- Multi-goal overview (several goals checked at once) ------------------- | |
| function renderMulti(results) { | |
| const ok = results.filter(d => d.verdict === "great").length; | |
| const cards = results.map((d, i) => { | |
| const v = VMAP[d.verdict] || VMAP.tight; | |
| const need = (d.gauge || {}).need_gb || ""; | |
| return ` | |
| <div class="goal-card" data-i="${i}" style="--status:${v.cls};--status-soft:${v.soft}"> | |
| <div class="goal-top"><span class="badge"><span class="dot"></span>${d.verdict_word || v.word}</span></div> | |
| <div class="goal-name">${d.use_case || ""}</div> | |
| <div class="goal-pick">${d.headline_model ? `→ ${d.headline_model}` : "Nothing realistic on this machine"}</div> | |
| <div class="goal-need">${need}</div> | |
| <div class="goal-more">See full breakdown</div> | |
| </div>`; | |
| }).join(""); | |
| $("#results").innerHTML = ` | |
| <div class="reveal"> | |
| <div id="lookup-result"></div> | |
| <div class="verdict" style="--status:var(--accent);--status-soft:var(--accent-soft)"> | |
| <span class="badge"><span class="dot"></span>${results.length} goals checked</span> | |
| <h2>${ok} of ${results.length} run great on this machine.</h2> | |
| <p>Each goal is checked independently with the same conservative engine. Click any card for the full honest breakdown, links and commands.</p> | |
| </div> | |
| <div class="goal-grid">${cards}</div> | |
| </div>`; | |
| hydrate($("#results")); | |
| $("#results").querySelectorAll(".goal-card").forEach(c => c.addEventListener("click", () => { | |
| render(multiCache.results[parseInt(c.dataset.i, 10)]); | |
| })); | |
| $("#cat-version").textContent = (results[0] || {}).catalogue_version || "—"; | |
| } | |
| // ---- Buy-advice render ------------------------------------------------------ | |
| function renderBuy(d) { | |
| const goalLines = (t) => (t.goals && t.goals.length > 1) | |
| ? `<ul class="goal-lines">${t.goals.map(g => | |
| `<li><span>${g.goal}</span><b>${g.model}</b>${g.verdict === "tight" ? " <i>(trade-offs)</i>" : ""}</li>`).join("")}</ul>` | |
| : `<div class="twhat">Runs: ${t.runs}${t.goals && t.goals[0] && t.goals[0].verdict === "tight" ? " (with trade-offs)" : ""}</div>`; | |
| const lane = (title, icon, lanes) => { | |
| const tier = (t, kind) => t ? ` | |
| <div class="tool" style="border-left:3px solid ${kind === "min" ? "var(--warn)" : "var(--ok)"}"> | |
| <div class="tool-head"><span class="tname">${kind === "min" ? "Minimum" : "Comfortable"}</span> | |
| <span class="tag ${kind === "min" ? "mid" : "best"}">${t.price}</span></div> | |
| <div class="twhat"><b>${t.label}</b></div> | |
| ${goalLines(t)} | |
| </div>` : ` | |
| <div class="tool"><div class="twhat">No tier on this ladder handles ${d.goals && d.goals.length > 1 ? "all of these together" : "it"} comfortably — this combination wants workstation hardware.</div></div>`; | |
| return ` | |
| <div class="section-title"><span class="ic" data-ic="${icon}"></span>${title}</div> | |
| <div class="tool-grid">${tier(lanes.minimum, "min")}${tier(lanes.comfortable, "comfy")}</div>`; | |
| }; | |
| $("#results").innerHTML = ` | |
| <div class="reveal"> | |
| <div class="verdict" style="--status:var(--accent);--status-soft:var(--accent-soft)"> | |
| <span class="badge"><span class="dot"></span>Buying advice</span> | |
| <h2>What you need for ${(d.use_case || "this").toLowerCase()}</h2> | |
| <p>Two honest tiers per platform: the cheapest setup that genuinely works, and the one that feels good daily.</p> | |
| ${d.note ? `<div class="note">${d.note}</div>` : ""} | |
| </div> | |
| ${lane("Windows / Linux PC", "brand-windows", d.pc || {})} | |
| ${lane("Mac (Apple Silicon)", "brand-apple", d.mac || {})} | |
| <p class="cmd-intro" style="margin-top:var(--s-4)">${d.disclaimer || ""}</p> | |
| </div>`; | |
| hydrate($("#results")); | |
| $("#cat-version").textContent = d.catalogue_version || "—"; | |
| } | |
| // ---- Render results ------------------------------------------------------- | |
| const VMAP = { | |
| great: { cls: "var(--ok)", soft: "var(--ok-soft)", word: "Runs great", em: "✓" }, | |
| tight: { cls: "var(--warn)", soft: "var(--warn-soft)", word: "Tight, but works", em: "≈" }, | |
| no: { cls: "var(--no)", soft: "var(--no-soft)", word: "Won't fit", em: "✕" }, | |
| }; | |
| function render(d) { | |
| lastAdvice = d; | |
| const v = VMAP[d.verdict] || VMAP.tight; | |
| const g = d.gauge || {}; | |
| $("#cat-version").textContent = d.catalogue_version || "—"; | |
| const licChip = (o) => { | |
| if (!o.license) return ""; | |
| const note = (o.license_note || "").toLowerCase(); | |
| const warn = note.includes("non-commercial") || note.includes("agpl") || note.includes("research"); | |
| const label = o.license.replace("apache-2.0", "Apache 2.0").replace("mit", "MIT") | |
| .replace("agpl-3.0", "AGPL").replace("cc-by-nc-4.0", "CC-NC"); | |
| return `<span class="lic${warn ? " warn" : ""}" title="${o.license_note || o.license}">${label}</span>` | |
| + (o.gated ? `<span class="lic gatechip" title="Accept the terms on Hugging Face once before downloading">gated</span>` : ""); | |
| }; | |
| const opts = (d.options || []).map(o => { | |
| const ov = VMAP[o.verdict] || VMAP.tight; | |
| const name = o.url | |
| ? `<a href="${o.url}" target="_blank" rel="noopener">${o.model}</a>` : o.model; | |
| return `<div class="opt" style="--status:${ov.cls};--status-soft:${ov.soft}"> | |
| <div class="vdot">${ov.em}</div> | |
| <div><div class="name">${name}${licChip(o)}</div><div class="desc">${o.desc}</div></div> | |
| <div class="meta"><b>${o.memory}</b><div class="feel">${o.setting}${o.feel && o.feel !== "—" ? " · " + o.feel : ""}</div></div> | |
| </div>`; | |
| }).join(""); | |
| const tools = (d.tools || []).map((t, i) => ` | |
| <div class="tool"> | |
| <div class="tool-head"><span class="tname">${t.name}</span> | |
| <span class="tag ${i===0?"best":"mid"}">${i===0?"Start here":t.tag}</span></div> | |
| <div class="twhat">${t.what}</div> | |
| <div class="tinstall"><span class="ic" data-ic="download"></span>${t.install}</div> | |
| </div>`).join(""); | |
| const cmds = (d.commands?.items || []).map((c) => ` | |
| <div class="cmd-box"> | |
| <div class="cmd-label">${c.label}<button class="copy-btn" data-code="${encodeURIComponent(c.code)}">Copy</button></div> | |
| <pre><code>${c.code}</code></pre> | |
| </div>`).join(""); | |
| $("#results").innerHTML = ` | |
| <div class="reveal"> | |
| <div id="lookup-result"></div> | |
| <div class="verdict" style="--status:${v.cls};--status-soft:${v.soft}"> | |
| <span class="badge"><span class="dot"></span>${d.verdict_word || v.word}</span> | |
| <h2>${d.headline || ""}</h2> | |
| <p>${d.detail || ""}</p> | |
| ${d.note ? `<div class="note">${d.note}</div>` : ""} | |
| </div> | |
| ${g.fill_pct != null ? ` | |
| <div class="gauge-card" style="--status:${v.cls}"> | |
| <div class="gauge-top"><span class="label" style="margin:0">Memory needed vs. what you have</span> | |
| <span class="need">${g.need_gb}</span></div> | |
| <div class="gauge" style="--pct:${g.fill_pct};--gcolor:${v.cls};--needpct:${g.mark_pct}"> | |
| <div class="gauge-fill"></div><div class="gauge-need"></div> | |
| </div> | |
| <div class="gauge-legend"> | |
| ${(g.breakdown||[]).map(b=>`<span class="item"><span class="sw" style="background:${b.color}"></span>${b.label}</span>`).join("")} | |
| <span class="item marker">Fast: ${g.fast_gb}</span> | |
| <span class="item marker">Total: ${g.total_gb}</span> | |
| </div> | |
| ${d.provenance ? `<div class="prov">${d.provenance}</div>` : ""} | |
| </div>` : ""} | |
| ${d.speed ? ` | |
| <details class="disc viz-disc" open> | |
| <summary><span class="ic sum-ic" data-ic="speed"></span>Why this speed? <span class="sub-inline">real benchmark runs, and where you land</span> <span class="chev ic" data-ic="chevron"></span></summary> | |
| <div class="disc-body"> | |
| <div id="roofline-chart" class="roofline-wrap"></div> | |
| <p class="viz-caption"> | |
| Every grey dot is a <b>real benchmark run</b> from the | |
| <a href="https://www.localscore.ai" target="_blank" rel="noopener">LocalScore</a> community database. | |
| Generation speed tracks <b>memory bandwidth</b> — that's the one number that matters most for local AI | |
| (<a href="https://kipp.ly/transformer-inference-arithmetic/" target="_blank" rel="noopener">why</a>). | |
| The dashed line is the theoretical ceiling for <b>${d.speed.model}</b> at your setting; | |
| your machine is the marked dot at ≈<b>${d.speed.tps} tok/s</b> | |
| (${d.speed.method === "measured-model" | |
| ? `predicted by a model trained on these measurements, following IBM's <a href="https://arxiv.org/abs/2410.02425" target="_blank" rel="noopener">LLM-Pilot</a> methodology` | |
| : `an analytical estimate; a learned predictor trained on these runs — <a href="https://arxiv.org/abs/2410.02425" target="_blank" rel="noopener">LLM-Pilot</a> methodology — takes over once trained`}). | |
| </p> | |
| </div> | |
| </details>` : ""} | |
| ${opts ? `<div class="section-title">What you can run <span class="sub">real models, biggest to smallest — names link to Hugging Face</span></div> | |
| <div class="opt-grid">${opts}</div>` : ""} | |
| ${tools ? `<div class="section-title">How to actually run it</div> | |
| <div class="tool-grid">${tools}</div>` : ""} | |
| ${cmds ? `<div class="section-title">Copy-paste to get started</div> | |
| <p class="cmd-intro">${d.commands.intro || ""}</p> | |
| <div class="cmd">${cmds}</div>` : ""} | |
| <div class="section-title">Ask a follow-up <span class="sub">explained in plain words, from the numbers above</span></div> | |
| <div class="ask"> | |
| <div class="ask-row"> | |
| <input id="ask-input" type="text" autocomplete="off" | |
| placeholder="e.g. Why not the bigger model? What does 4-bit mean?" /> | |
| <button id="ask-send" class="ask-btn" title="Ask"><span class="ic" data-ic="arrow"></span></button> | |
| </div> | |
| <div class="ask-chips"> | |
| <button class="ask-chip">Why this model?</button> | |
| <button class="ask-chip">What does the setting mean?</button> | |
| <button class="ask-chip">Will it feel fast?</button> | |
| </div> | |
| <div id="ask-answer" class="ask-answer" hidden></div> | |
| </div> | |
| </div>`; | |
| if (multiCache) { | |
| const back = document.createElement("button"); | |
| back.className = "back-link"; | |
| back.textContent = "← All goals"; | |
| back.addEventListener("click", () => renderMulti(multiCache.results)); | |
| $("#results").firstElementChild.prepend(back); | |
| } | |
| hydrate($("#results")); | |
| if (d.speed) drawRoofline(d.speed); | |
| $("#results").querySelectorAll(".copy-btn").forEach(b => b.addEventListener("click", () => { | |
| navigator.clipboard.writeText(decodeURIComponent(b.dataset.code)); | |
| b.textContent = "Copied ✓"; b.classList.add("done"); | |
| setTimeout(() => { b.textContent = "Copy"; b.classList.remove("done"); }, 1500); | |
| })); | |
| wireAsk(); | |
| } | |
| // ---- "Why this speed?" roofline scatter (real LocalScore runs) ------------ | |
| let _rooflinePts = null; | |
| async function getRooflinePoints() { | |
| if (_rooflinePts) return _rooflinePts; | |
| try { | |
| const r = await fetch("/static/roofline.json"); | |
| _rooflinePts = await r.json(); | |
| } catch (e) { _rooflinePts = { points: [] }; } | |
| return _rooflinePts; | |
| } | |
| async function drawRoofline(speed) { | |
| const host = $("#roofline-chart"); | |
| if (!host) return; | |
| const data = await getRooflinePoints(); | |
| const pts = (data.points || []).filter(p => p.bw > 0 && p.tps > 0.5); | |
| if (!pts.length && !speed) { host.innerHTML = ""; return; } | |
| const W = 720, H = 320, L = 52, R = 16, T = 14, B = 40; | |
| const xmin = 40, xmax = 2100, ymin = 0.8, ymax = 400; | |
| const lx = v => L + (Math.log10(v) - Math.log10(xmin)) / (Math.log10(xmax) - Math.log10(xmin)) * (W - L - R); | |
| const ly = v => H - B - (Math.log10(v) - Math.log10(ymin)) / (Math.log10(ymax) - Math.log10(ymin)) * (H - T - B); | |
| let s = `<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="Decode speed vs memory bandwidth, real benchmark runs">`; | |
| // gridlines + labels | |
| for (const gx of [50, 100, 200, 400, 800, 1600]) { | |
| s += `<line x1="${lx(gx)}" y1="${T}" x2="${lx(gx)}" y2="${H - B}" class="rl-grid"/>` + | |
| `<text x="${lx(gx)}" y="${H - B + 16}" class="rl-tick" text-anchor="middle">${gx}</text>`; | |
| } | |
| for (const gy of [1, 3, 10, 30, 100, 300]) { | |
| s += `<line x1="${L}" y1="${ly(gy)}" x2="${W - R}" y2="${ly(gy)}" class="rl-grid"/>` + | |
| `<text x="${L - 6}" y="${ly(gy) + 4}" class="rl-tick" text-anchor="end">${gy}</text>`; | |
| } | |
| s += `<text x="${(L + W - R) / 2}" y="${H - 6}" class="rl-axis" text-anchor="middle">memory bandwidth (GB/s, log)</text>`; | |
| s += `<text x="14" y="${(T + H - B) / 2}" class="rl-axis" text-anchor="middle" transform="rotate(-90 14 ${(T + H - B) / 2})">decode tok/s (log)</text>`; | |
| // real measurement dots, shaded by model size | |
| const shade = p => p.params_b <= 2 ? "rl-p1" : (p.params_b <= 9 ? "rl-p8" : "rl-p14"); | |
| for (const p of pts) { | |
| if (p.bw < xmin || p.tps < ymin) continue; | |
| s += `<circle cx="${lx(Math.min(p.bw, xmax)).toFixed(1)}" cy="${ly(Math.min(p.tps, ymax)).toFixed(1)}" r="2.6" class="rl-dot ${shade(p)}"><title>${p.accel} — ${p.model}: ${p.tps} tok/s</title></circle>`; | |
| } | |
| if (speed) { | |
| // theoretical ceiling for the recommended model: tps = 0.6 * bw / bytes | |
| const bytes = speed.bytes_gb || 5; | |
| const x1 = xmin, x2 = xmax; | |
| const f = bw => Math.min(Math.max(0.6 * bw / bytes, ymin), ymax); | |
| s += `<line x1="${lx(x1)}" y1="${ly(f(x1))}" x2="${lx(x2)}" y2="${ly(f(x2))}" class="rl-roof"/>`; | |
| // your machine | |
| const ux = lx(Math.min(Math.max(speed.eff_bw || speed.bw, xmin), xmax)); | |
| const uy = ly(Math.min(Math.max(speed.tps, ymin), ymax)); | |
| s += `<line x1="${ux}" y1="${ly(Math.min(Math.max(speed.lo, ymin), ymax))}" x2="${ux}" y2="${ly(Math.min(Math.max(speed.hi, ymin), ymax))}" class="rl-band"/>`; | |
| // flip the label to the left when the dot sits near the right edge | |
| const flip = ux > W * 0.72; | |
| s += `<circle cx="${ux}" cy="${uy}" r="6" class="rl-you"/>` + | |
| `<text x="${flip ? ux - 10 : ux + 10}" y="${uy + 4}" class="rl-you-label" text-anchor="${flip ? "end" : "start"}">you ≈${speed.tps} tok/s</text>`; | |
| } | |
| s += `</svg> | |
| <div class="rl-legend"> | |
| <span class="item"><span class="sw rl-p1-sw"></span>~1B model runs</span> | |
| <span class="item"><span class="sw rl-p8-sw"></span>~8B runs</span> | |
| <span class="item"><span class="sw rl-p14-sw"></span>~14B runs</span> | |
| <span class="item"><span class="sw rl-roof-sw"></span>theoretical ceiling (your pick)</span> | |
| <span class="item"><span class="sw rl-you-sw"></span>your machine</span> | |
| </div>`; | |
| host.innerHTML = s; | |
| } | |
| // ---- Live progress ticker (gradio-style elapsed seconds) ------------------- | |
| function startTicker(el, base) { | |
| const t0 = Date.now(); | |
| el.dataset.ticking = "1"; | |
| const tick = () => { | |
| if (el.dataset.ticking !== "1") return; | |
| const s = Math.round((Date.now() - t0) / 1000); | |
| el.innerHTML = `<span class="spinner"></span> ${base} — ${s}s` + | |
| (s > 8 ? " <span class='tick-note'>(cold start: the model is waking, up to ~1 min)</span>" : ""); | |
| setTimeout(tick, 500); | |
| }; | |
| tick(); | |
| return () => { el.dataset.ticking = "0"; }; | |
| } | |
| // ---- Paste box: the fine-tuned spec parser fills the form ----------------- | |
| async function parsePaste() { | |
| const text = $("#paste").value.trim(); | |
| const hint = $("#parse-hint"); | |
| const btn = $("#parse-btn"); | |
| if (!text) { hint.textContent = "Type or paste something first."; return; } | |
| if (btn.disabled) return; // one in-flight call, ever — GPU time is real money | |
| btn.disabled = true; | |
| const stop = startTicker(hint, "Reading your description"); | |
| try { | |
| const client = await getClient(); | |
| const r = await client.predict("/parse", { text }); | |
| const d = Array.isArray(r.data) ? r.data[0] : r.data; | |
| stop(); | |
| if (d.error) { hint.textContent = `Failed: ${d.error}`; return; } | |
| applyParsed(d, hint); | |
| } catch (e) { | |
| stop(); | |
| hint.textContent = `Failed: ${e && e.message ? e.message : e}`; | |
| } finally { | |
| btn.disabled = false; | |
| } | |
| } | |
| function applyParsed(d, hint) { | |
| const got = []; | |
| if (d.computer) { | |
| state.computer = d.computer; | |
| setActive("#computer-seg", d.computer); | |
| syncProviderForComputer(); | |
| got.push(d.computer); | |
| } | |
| if (d.provider) { | |
| state.provider = d.provider; | |
| setActive("#provider-seg", d.provider); | |
| fillGpu(); | |
| got.push(d.provider.toUpperCase()); | |
| } | |
| if (d.gpu) { | |
| const sel = $("#gpu"); | |
| const want = String(d.gpu).toLowerCase().replace(/\b(nvidia|geforce|amd|radeon|intel)\b/g, "").trim(); | |
| const match = [...sel.options].find(o => o.value.toLowerCase().includes(want)); | |
| if (match) { sel.value = match.value; got.push(match.value); } | |
| else if (d.vram_gb) { $("#vram").value = d.vram_gb; got.push(`${d.gpu} (${d.vram_gb} GB)`); } | |
| else got.push(d.gpu); | |
| } else if (d.vram_gb) { | |
| $("#vram").value = d.vram_gb; | |
| got.push(`${d.vram_gb} GB VRAM`); | |
| } | |
| if (d.ram_gb) { | |
| const ram = $("#ram"); | |
| const opt = [...ram.options].map(o => parseFloat(o.value)) | |
| .reduce((a, b) => Math.abs(b - d.ram_gb) < Math.abs(a - d.ram_gb) ? b : a); | |
| ram.value = `${opt} GB`; | |
| got.push(`${d.ram_gb} GB RAM`); | |
| } | |
| hint.textContent = got.length | |
| ? `Understood: ${got.join(", ")}. Anything you didn't mention stayed blank — confirm and press Check.` | |
| : "Couldn't find any specs in that text — nothing was filled in (the parser doesn't guess)."; | |
| maybeLiveUpdate(); | |
| } | |
| // ---- Follow-up: the model brick (grounded explainer) --------------------- | |
| function wireAsk() { | |
| const input = $("#ask-input"), send = $("#ask-send"); | |
| if (!input || !send) return; | |
| const go = () => askQuestion(input.value); | |
| send.addEventListener("click", go); | |
| input.addEventListener("keydown", e => { if (e.key === "Enter") go(); }); | |
| $("#results").querySelectorAll(".ask-chip").forEach(c => | |
| c.addEventListener("click", () => { input.value = c.textContent; askQuestion(c.textContent); })); | |
| } | |
| let askBusy = false; | |
| async function askQuestion(question) { | |
| question = (question || "").trim(); | |
| const box = $("#ask-answer"); | |
| if (!question || !box || askBusy) return; | |
| askBusy = true; | |
| box.hidden = false; | |
| box.innerHTML = `<div class="ans-loading" id="ask-tick"></div>`; | |
| const stop = startTicker($("#ask-tick"), "Thinking it through"); | |
| try { | |
| const a = await callAsk(question, JSON.stringify(lastAdvice || {})); | |
| stop(); | |
| renderAnswer(box, a); | |
| } catch (e) { | |
| stop(); | |
| // Surface the real error, never a generic stand-in. | |
| box.innerHTML = `<div class="ans-card ans-error"><h3>The explainer hit an error</h3><p>${e && e.message ? e.message : e}</p></div>`; | |
| } finally { | |
| askBusy = false; | |
| } | |
| } | |
| function renderAnswer(box, a) { | |
| a = a || {}; | |
| if (a.error) { // surface the real error from the model brick, not filler | |
| box.innerHTML = `<div class="ans-card ans-error"><h3>The explainer hit an error</h3><p>${a.error}</p></div>`; | |
| return; | |
| } | |
| box.innerHTML = ` | |
| <div class="ans-card reveal"> | |
| ${a.headline ? `<h3>${a.headline}</h3>` : ""} | |
| ${a.why ? `<p>${a.why}</p>` : ""} | |
| ${a.next_step ? `<div class="ans-next"><span class="ic" data-ic="arrow"></span><span>${a.next_step}</span></div>` : ""} | |
| </div>`; | |
| hydrate(box); | |
| } | |
| // On a ZeroGPU Space the JS client is REQUIRED (it forwards the HF iframe auth | |
| // headers ZeroGPU needs). Locally / non-ZeroGPU we fall back to the raw | |
| // two-step call so the chat still works with no internet to a CDN. | |
| let _gradioClient = null; | |
| async function getClient() { | |
| if (_gradioClient) return _gradioClient; | |
| const mod = await import("https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js"); | |
| const Client = mod.Client || mod.client; | |
| _gradioClient = await Client.connect(window.location.origin); | |
| return _gradioClient; | |
| } | |
| async function callAsk(question, facts) { | |
| try { | |
| const client = await getClient(); | |
| const r = await client.predict("/ask", { question, facts }); | |
| return Array.isArray(r.data) ? r.data[0] : r.data; | |
| } catch (e) { | |
| return await callAskRaw(question, facts); | |
| } | |
| } | |
| async function callAskRaw(question, facts) { | |
| const post = await fetch("/gradio_api/call/ask", { | |
| method: "POST", headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ data: [question, facts] }), | |
| }); | |
| const { event_id } = await post.json(); | |
| const res = await fetch(`/gradio_api/call/ask/${event_id}`); | |
| const text = await res.text(); | |
| const lines = [...text.matchAll(/data:\s*(.+)/g)]; // SSE data frames | |
| if (!lines.length) throw new Error("no data in stream"); | |
| const arr = JSON.parse(lines[lines.length - 1][1]); // last frame = result | |
| return Array.isArray(arr) ? arr[0] : arr; | |
| } | |
| // ---- Init ----------------------------------------------------------------- | |
| function init() { | |
| hydrate(document); | |
| buildPicker(); | |
| wireSegmented("#mode-seg", "mode", applyMode); | |
| wireSegmented("#computer-seg", "computer", () => { syncProviderForComputer(); $("#find-specs-body").innerHTML = findSpecsText(); }); | |
| wireSegmented("#provider-seg", "provider", fillGpu); | |
| wireSegmented("#priority-seg", "priority"); | |
| ["#ram","#gpu","#vram","#custom-uc","#repo-check"].forEach(s => { const el = $(s); if (el) el.addEventListener("change", maybeLiveUpdate); }); | |
| $("#paste").addEventListener("input", maybeLiveUpdate); | |
| $("#check-btn").addEventListener("click", check); | |
| const pb = $("#parse-btn"); if (pb) pb.addEventListener("click", parsePaste); | |
| syncProviderForComputer(); | |
| $("#find-specs-body").innerHTML = findSpecsText(); | |
| detectHardware(); | |
| // Pre-filled share/preview links: ?go renders immediately; optional | |
| // ?gpu=NVIDIA|RTX 3060 (12 GB)&ram=16&uc=chat pre-select a profile. | |
| const q = new URLSearchParams(location.search); | |
| if (q.has("gpu")) { | |
| const [vendor, label] = (q.get("gpu") || "").split("|"); | |
| if (vendor) { state.provider = vendor.toLowerCase(); setActive("#provider-seg", state.provider); fillGpu(); } | |
| if (label) { const sel = $("#gpu"); [...sel.options].forEach(o => { if (o.value === label) sel.value = label; }); } | |
| } | |
| if (q.has("ram")) $("#ram").value = `${q.get("ram")} GB`; | |
| if (q.has("uc")) { | |
| state.usecases = [q.get("uc")]; | |
| document.querySelectorAll(".uc-pill").forEach(p => | |
| p.classList.toggle("active", p.dataset.uc === q.get("uc"))); | |
| } | |
| if (q.has("go")) check(); | |
| } | |
| init(); | |