| |
| |
| |
| |
| |
|
|
|
|
|
|
| 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"; }
|
| });
|
| }
|
|
|
|
|
| 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-custom", name: "Something else", items: [
|
| { id: "custom", icon: "custom", label: "Custom: describe it" },
|
| ]},
|
| ];
|
|
|
|
|
| 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)",
|
| ],
|
|
|
|
|
|
|
|
|
| nvidia_laptop: [
|
| "RTX 5090 Laptop (24 GB)","RTX 5080 Laptop (16 GB)","RTX 5070 Ti Laptop (12 GB)",
|
| "RTX 5070 Laptop (8 GB)","RTX 5060 Laptop (8 GB)","RTX 5050 Laptop (8 GB)",
|
| "RTX 4090 Laptop (16 GB)","RTX 4080 Laptop (12 GB)","RTX 4070 Laptop (8 GB)",
|
| "RTX 4060 Laptop (8 GB)","RTX 4050 Laptop (6 GB)",
|
| "RTX 3080 Ti Laptop (16 GB)","RTX 3080 Laptop (16 GB)","RTX 3070 Ti Laptop (8 GB)",
|
| "RTX 3070 Laptop (8 GB)","RTX 3060 Laptop (6 GB)","RTX 3050 Ti Laptop (4 GB)",
|
| "RTX 3050 Laptop (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","M-series Pro","M-series Max","M-series Ultra"],
|
| intel: ["Arc A770 (16 GB)","Arc A750 (8 GB)","Arc A380 (6 GB)","Built-in Intel graphics"],
|
| };
|
|
|
| const $ = (s) => document.querySelector(s);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| const _ESC = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" };
|
| function esc(s) { return String(s == null ? "" : s).replace(/[&<>"']/g, c => _ESC[c]); }
|
| function safeUrl(u) {
|
| const s = String(u == null ? "" : u).trim();
|
| return /^https?:\/\//i.test(s) ? s : "#";
|
| }
|
| const _ALLOWED = { B: [], I: [], EM: [], STRONG: [], CODE: [], PRE: [], UL: [],
|
| OL: [], LI: [], BR: [], P: [], SPAN: ["class"], DIV: ["class"], A: ["href", "target", "rel"] };
|
| function sanitizeHtml(html) {
|
| const tpl = document.createElement("template");
|
| tpl.innerHTML = String(html == null ? "" : html);
|
| tpl.content.querySelectorAll("*").forEach(el => {
|
| if (!el.parentNode) return;
|
| const allow = _ALLOWED[el.tagName];
|
| if (!allow) { el.replaceWith(document.createTextNode(el.textContent || "")); return; }
|
| [...el.attributes].forEach(a => {
|
| if (!allow.includes(a.name)) el.removeAttribute(a.name);
|
| else if (a.name === "href" && !/^https?:\/\//i.test(a.value.trim())) el.removeAttribute("href");
|
| });
|
| });
|
| return tpl.innerHTML;
|
| }
|
| const state = { mode: "have", task: "run", computer: "Windows laptop", provider: "none", priority: "balanced", usecases: ["chat"], checked: false };
|
| let lastAdvice = null;
|
| let multiCache = null;
|
|
|
|
|
| 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" : "";
|
| });
|
| syncCustomRepo();
|
|
|
|
|
| $("#check-btn").innerHTML = (buy ? "What should I get? " : "Check my setup ")
|
| + '<span class="ic" data-ic="arrow"></span>';
|
| hydrate($("#check-btn"));
|
| }
|
|
|
|
|
| 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);
|
|
|
|
|
| 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");
|
| }
|
| syncCustomRepo();
|
| maybeLiveUpdate();
|
| }));
|
| }
|
|
|
|
|
|
|
|
|
| function syncCustomRepo() {
|
| const show = state.usecases.includes("custom") && state.mode !== "buy";
|
| const field = $("#repo-field");
|
| const input = $("#repo-check");
|
| if (field) field.style.display = show ? "" : "none";
|
| if (input && !show && input.value) input.value = "";
|
| }
|
|
|
|
|
| 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));
|
| }
|
|
|
|
|
| function wireTaskToggle() {
|
| const tog = $("#task-toggle");
|
| if (!tog) return;
|
| tog.querySelectorAll(".tt-opt").forEach(b => b.addEventListener("click", () => {
|
| state.task = b.dataset.task;
|
| tog.querySelectorAll(".tt-opt").forEach(x => x.classList.toggle("active", x === b));
|
| tog.classList.toggle("train", state.task === "finetune");
|
| maybeLiveUpdate();
|
| }));
|
| }
|
|
|
|
|
| function fillGpu() {
|
|
|
| const key = (state.provider === "nvidia" && /laptop/i.test(state.computer))
|
| ? "nvidia_laptop" : state.provider;
|
| const list = GPUS[key] || 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(""); }
|
| }
|
|
|
|
|
| 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"],
|
| };
|
|
|
| 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();
|
|
|
|
|
|
|
| const isMac = state.computer === "Mac";
|
| const ramLabel = $("#ram-label");
|
| if (ramLabel) ramLabel.textContent = isMac ? "Unified memory" : "Memory (RAM)";
|
| const vram = $("#vram");
|
| if (vram) {
|
| vram.disabled = isMac;
|
| if (isMac) vram.value = "";
|
| vram.placeholder = isMac ? "Not applicable on Apple Silicon" : "e.g. 8";
|
| }
|
| const vramHint = $("#vram-hint");
|
| if (vramHint) {
|
| vramHint.textContent = isMac
|
| ? "Not used on Apple Silicon: it shares one unified memory pool, set above."
|
| : "Overrides the GPU picker above. Leave blank to use the picker.";
|
| }
|
| }
|
|
|
|
|
| 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>`;
|
| }
|
|
|
|
|
| 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(),
|
| priority: state.priority,
|
| mode: state.task,
|
| 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, mode: state.task }),
|
| });
|
| renderBuy(await res.json());
|
| return;
|
| }
|
|
|
|
|
| if (payload.repo) { multiCache = null; await lookupRepo(payload); 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);
|
| return;
|
| }
|
| multiCache = null;
|
| const res = await fetch("/api/advise", {
|
| method: "POST", headers: { "Content-Type": "application/json" },
|
| body: JSON.stringify(payload),
|
| });
|
| render(await res.json());
|
| } 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: ${esc(e && e.message ? e.message : e)}</p></div>`;
|
| hydrate($("#results"));
|
| }
|
| }
|
|
|
|
|
|
|
|
|
|
|
| async function lookupRepo(payload) {
|
| $("#results").innerHTML = `<div class="reveal"><div class="ans-loading"><span class="spinner"></span> Looking up ${esc(payload.repo)} on Hugging Face...</div></div>`;
|
| let d;
|
| try {
|
| const res = await fetch("/api/lookup", {
|
| method: "POST", headers: { "Content-Type": "application/json" },
|
| body: JSON.stringify(payload),
|
| });
|
| d = await res.json();
|
| } catch (e) {
|
| $("#results").innerHTML = `<div class="ans-card ans-error"><h3>Lookup failed</h3><p>${esc(e && e.message ? e.message : e)}</p></div>`;
|
| hydrate($("#results")); return;
|
| }
|
| if (d.error) {
|
| $("#results").innerHTML = `<div class="ans-card ans-error"><h3>Couldn't check that model</h3><p>${esc(d.error)}</p></div>`;
|
| hydrate($("#results")); return;
|
| }
|
|
|
|
|
| if (d.match === "catalogue" && d.focus_name) {
|
| const uc = d.uc_key || (lastAdvice && lastAdvice.usecase) || state.usecases[0];
|
| const res = await fetch("/api/advise", {
|
| method: "POST", headers: { "Content-Type": "application/json" },
|
| body: JSON.stringify({ ...payload, usecase: uc, focus: d.focus_name }),
|
| });
|
| render(await res.json());
|
| const wrap = $("#results").firstElementChild;
|
| if (wrap) {
|
| const banner = document.createElement("div");
|
| banner.className = "lookup-banner";
|
| banner.innerHTML = `<span class="live-tag">Live Hugging Face lookup</span><p>${sanitizeHtml(d.explain || "")}</p>`;
|
| wrap.prepend(banner);
|
| }
|
| return;
|
| }
|
|
|
|
|
| if (d.match === "estimate" && d.advice) {
|
| render(d.advice);
|
| const wrap = $("#results").firstElementChild;
|
| if (wrap) {
|
| const banner = document.createElement("div");
|
| banner.className = "lookup-banner estimate";
|
| banner.innerHTML = `<span class="live-tag">Live Hugging Face lookup / estimate</span><p>${sanitizeHtml(d.explain || "")}</p>`;
|
| wrap.prepend(banner);
|
| }
|
| return;
|
| }
|
|
|
| $("#results").innerHTML = `<div class="ans-card"><h3>${esc(payload.repo)}</h3><p>${sanitizeHtml(d.explain || "No estimate available.")}</p></div>`;
|
| hydrate($("#results"));
|
| }
|
|
|
|
|
| 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>${esc(d.verdict_word || v.word)}</span></div>
|
| <div class="goal-name">${esc(d.use_case || "")}</div>
|
| <div class="goal-pick">${d.headline_model ? `<span class="ic" data-ic="arrow"></span>${esc(d.headline_model)}` : "Nothing realistic on this machine"}</div>
|
| <div class="goal-need">${esc(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 || "local";
|
| }
|
|
|
|
|
|
|
| function specParts(spec) {
|
| return String(spec || "").split(/\s*,\s*/).filter(Boolean).map(part => {
|
| const p = part.toLowerCase();
|
| let icon = "custom";
|
| if (/nvme|ssd|hdd|storage|\btb\b/.test(p)) icon = "storage";
|
| else if (/rtx|radeon|geforce|\barc\b|gpu|vram|\brx ?\d|nvidia|graphics/.test(p)) icon = "gpu";
|
| else if (/ddr|\bram\b|memory/.test(p)) icon = "ram";
|
| else if (/core|\bcpu\b|ryzen|threadripper|processor|\bi[3579]\b/.test(p)) icon = "cpu";
|
| return `<li><span class="ic" data-ic="${icon}"></span><span>${esc(part)}</span></li>`;
|
| }).join("");
|
| }
|
|
|
|
|
| function renderBuy(d) {
|
| const ft = d.mode === "finetune";
|
| const verb = ft ? "fine-tune" : "run";
|
| const multi = (d.goals || []).length > 1;
|
| const tierCard = (t, kind) => {
|
| if (!t) return `<div class="buy-card empty">
|
| <div class="bc-head"><span class="bc-kind ${kind}">${kind === "min" ? "Minimum" : "Comfortable"}</span></div>
|
| <p class="bc-none">No build on this platform ${ft ? "trains" : "runs"} ${multi ? "all of these" : "this"}${ft ? "" : " well"}, it wants more than this ladder offers.</p>
|
| </div>`;
|
| if (t.ceiling) return `<div class="buy-card ceiling">
|
| <div class="bc-head"><span class="bc-kind ${kind}">Comfortable</span><span class="ic" data-ic="check"></span></div>
|
| <p class="bc-none">The minimum build is already the most capable ${ft ? "training " : ""}box on this platform for ${multi ? "these goals" : "this goal"}. Nothing bigger is needed here.</p>
|
| </div>`;
|
| const goalsHtml = multi
|
| ? `<ul class="bc-goals">${t.goals.map(g => `<li><span>${esc(g.goal)}</span><b>${esc(g.model)}</b>${g.verdict === "tight" ? " <i>(tight)</i>" : ""}</li>`).join("")}</ul>`
|
| : `<div class="bc-runs">${ft ? "Can train up to" : "Runs"} <b>${esc(t.runs)}</b>${t.goals[0] && t.goals[0].verdict === "tight" ? " <i>(tight)</i>" : ""}</div>`;
|
| return `<div class="buy-card">
|
| <div class="bc-head"><span class="bc-kind ${kind}">${kind === "min" ? "Minimum" : "Comfortable"}</span><span class="tag ${kind === "min" ? "mid" : "best"}">${esc(t.price)}</span></div>
|
| <div class="bc-label">${esc(t.label)}</div>
|
| ${goalsHtml}
|
| <button class="bc-more">What to look for <span class="chev ic" data-ic="chevron"></span></button>
|
| <div class="bc-detail" hidden>
|
| <div class="bc-detail-label">Build</div>
|
| <ul class="bc-spec">${specParts(t.spec)}</ul>
|
| <div class="bc-detail-label">Why this build</div>
|
| <p>${esc(t.detail)}</p>
|
| ${t.link ? `<a class="bc-link" href="${safeUrl(t.link)}" target="_blank" rel="noopener noreferrer">Check current price <span class="bc-link-meta">our estimate ${esc(t.price)}, ${esc(t.price_checked || "")}</span><span class="ic" data-ic="arrow"></span></a>` : ""}
|
| </div>
|
| </div>`;
|
| };
|
| const platforms = (d.platforms || []).map(p => `
|
| <div class="buy-platform">
|
| <div class="bp-head"><span class="ic" data-ic="${esc(p.icon)}"></span><span class="bp-name">${esc(p.name)}</span></div>
|
| <div class="bp-blurb">${esc(p.blurb)}</div>
|
| <div class="buy-grid">${tierCard(p.minimum, "min")}${tierCard(p.comfortable, "comfy")}</div>
|
| </div>`).join("");
|
| $("#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, to ${verb}</span>
|
| <h2>What to buy to ${verb} ${esc((d.use_case || "this").toLowerCase())}</h2>
|
| <p>${esc(d.disclaimer || "")}</p>
|
| ${d.note ? `<div class="note">${sanitizeHtml(d.note)}</div>` : ""}
|
| </div>
|
| ${platforms}
|
| </div>`;
|
| hydrate($("#results"));
|
| $("#results").querySelectorAll(".buy-card .bc-more").forEach(b => b.addEventListener("click", () => {
|
| const det = b.nextElementSibling;
|
| const opening = det.hasAttribute("hidden");
|
| if (opening) det.removeAttribute("hidden"); else det.setAttribute("hidden", "");
|
| b.classList.toggle("open", opening);
|
| }));
|
| $("#cat-version").textContent = d.catalogue_version || "local";
|
| }
|
|
|
|
|
| const VMAP = {
|
| great: { cls: "var(--ok)", soft: "var(--ok-soft)", word: "Runs great", ic: "check" },
|
| tight: { cls: "var(--warn)", soft: "var(--warn-soft)", word: "Tight, but works", ic: "approx" },
|
| no: { cls: "var(--no)", soft: "var(--no-soft)", word: "Won't fit", ic: "x" },
|
| };
|
|
|
| function render(d) {
|
| lastAdvice = d;
|
| const v = VMAP[d.verdict] || VMAP.tight;
|
| const g = d.gauge || {};
|
| $("#cat-version").textContent = d.catalogue_version || "local";
|
|
|
| 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="${esc(o.license_note || o.license)}">${esc(label)}</span>`
|
| + (o.gated ? `<span class="lic gatechip" title="Accept the terms on Hugging Face once before downloading">gated</span>` : "");
|
| };
|
| const current = d.focus || d.headline_model;
|
| const opts = (d.options || []).map(o => {
|
| const ov = VMAP[o.verdict] || VMAP.tight;
|
| const name = o.url
|
| ? `<a href="${safeUrl(o.url)}" target="_blank" rel="noopener">${esc(o.model)}</a>` : esc(o.model);
|
| const cur = o.model === current ? " opt-current" : "";
|
| return `<div class="opt${cur}" data-model="${esc(o.model)}" style="--status:${ov.cls};--status-soft:${ov.soft}">
|
| <div class="vdot"><span class="ic" data-ic="${ov.ic}"></span></div>
|
| <div><div class="name">${name}${licChip(o)}</div><div class="desc">${esc(o.desc)}</div></div>
|
| <div class="meta"><b>${esc(o.memory)}</b><div class="feel">${esc(o.setting)}${o.feel && o.feel !== "-" ? " / " + esc(o.feel) : ""}</div></div>
|
| </div>`;
|
| }).join("");
|
|
|
| const tools = (d.tools || []).map((t, i) => `
|
| <div class="tool">
|
| <div class="tool-head"><span class="tname">${esc(t.name)}</span>
|
| <span class="tag ${i===0?"best":"mid"}">${i===0?"Start here":esc(t.tag)}</span></div>
|
| <div class="twhat">${esc(t.what)}</div>
|
| <div class="tinstall"><span class="ic" data-ic="download"></span>${esc(t.install)}</div>
|
| </div>`).join("");
|
|
|
| const cmds = (d.commands?.items || []).map((c) => `
|
| <div class="cmd-box">
|
| <div class="cmd-label">${esc(c.label)}<button class="copy-btn" data-code="${encodeURIComponent(c.code)}">Copy</button></div>
|
| <pre><code>${esc(c.code)}</code></pre>
|
| </div>`).join("");
|
|
|
| const isFT = d.mode === "finetune";
|
| const cloud = isFT && (d.cloud || []).length ? `
|
| <div class="section-title">Train it in the cloud <span class="sub">when your machine is too small, or you'd rather not tie it up</span></div>
|
| <div class="tool-grid">
|
| ${d.cloud.map(c => `
|
| <div class="tool">
|
| <div class="tool-head"><span class="tname">${esc(c.name)}</span><span class="tag mid">${esc(c.cost)}</span></div>
|
| <div class="twhat">${esc(c.what)}</div>
|
| <div class="tinstall"><a href="${safeUrl(c.link)}" target="_blank" rel="noopener"><span class="ic" data-ic="arrow"></span>Open</a></div>
|
| </div>`).join("")}
|
| </div>` : "";
|
|
|
| $("#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>${esc(d.verdict_word || v.word)}</span>
|
| <h2>${sanitizeHtml(d.headline || "")}</h2>
|
| <p>${sanitizeHtml(d.detail || "")}</p>
|
| ${d.note ? `<div class="note">${sanitizeHtml(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">${esc(g.need_gb)}</span></div>
|
| <div class="gauge-wrap">
|
| <div class="gauge" style="--pct:${g.fill_pct};--gcolor:${v.cls}">
|
| <div class="gauge-fill"></div>
|
| </div>
|
| ${g.has_fast && g.fast_label ? `<div class="gmark gmark-gpu" style="left:${g.mark_pct}%" data-tip="${esc(g.fast_label)}, your graphics card's own memory. A model that fits left of this line runs at full GPU speed."><span class="gmark-lab">${esc(g.fast_label.replace("On the GPU ","").replace("GPU can use","GPU"))} ${esc(g.fast_gb)}</span></div>` : ""}
|
| <div class="gmark gmark-total" style="left:${g.total_pct}%" data-tip="${g.has_fast ? "Everything available: GPU memory plus the system RAM a model can spill into (the spilled part runs slower)." : "Your system RAM, with no graphics card the model runs here on the processor."}"><span class="gmark-lab">${esc(g.total_label)} ${esc(g.total_gb)}</span></div>
|
| </div>
|
| <div class="gauge-legend">
|
| ${(g.breakdown||[]).map(b=>`<span class="item"><span class="sw" style="background:${esc(b.color)}"></span>${esc(b.label)}</span>`).join("")}
|
| <span class="item gauge-hint">The lines mark what you have; the bar is what the model needs.</span>
|
| </div>
|
| ${d.provenance ? `<div class="prov">${sanitizeHtml(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>${esc(d.speed.model)}</b> at your setting;
|
| your machine is the marked dot at about <b>${esc(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">${isFT ? "What you can fine-tune" : "What you can run"} <span class="sub">biggest to smallest, click any model for its full breakdown; names link to Hugging Face</span></div>
|
| <div class="opt-grid">${opts}</div>` : ""}
|
|
|
| ${tools ? `<div class="section-title">${isFT ? "How to fine-tune it" : "How to actually run it"}</div>
|
| <div class="tool-grid">${tools}</div>` : ""}
|
|
|
| ${cloud}
|
|
|
| ${cmds ? `<div class="section-title">Copy-paste to get started</div>
|
| <p class="cmd-intro">${esc(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.innerHTML = '<span class="ic" data-ic="arrow"></span>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);
|
| }));
|
|
|
| $("#results").querySelectorAll(".opt[data-model]").forEach(card =>
|
| card.addEventListener("click", (ev) => {
|
| if (ev.target.closest("a")) return;
|
| focusModel(card.dataset.model);
|
| }));
|
| wireAsk();
|
| }
|
|
|
|
|
| async function focusModel(model) {
|
| const uc = (lastAdvice && lastAdvice.usecase) || state.usecases[0];
|
| try {
|
| const res = await fetch("/api/advise", {
|
| method: "POST", headers: { "Content-Type": "application/json" },
|
| body: JSON.stringify({ ...gather(), usecase: uc, focus: model }),
|
| });
|
| render(await res.json());
|
| $("#results").scrollIntoView({ behavior: "smooth", block: "start" });
|
| } catch (e) {
|
|
|
| }
|
| }
|
|
|
|
|
| let _rooflinePts = null;
|
| async function getRooflinePoints() {
|
| if (_rooflinePts) return _rooflinePts;
|
| try {
|
| const r = await fetch("/static/roofline.json");
|
|
|
|
|
| _rooflinePts = await r.json();
|
| return _rooflinePts;
|
| } catch (e) { return { points: [] }; }
|
| }
|
|
|
| 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">`;
|
|
|
| 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>`;
|
|
|
|
|
| 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)}" data-m="${esc(p.model)}" data-a="${esc(p.accel)}" data-t="${esc(p.tps)}" data-b="${esc(Math.round(p.bw))}" data-p="${esc(p.params_b)}"></circle>`;
|
| }
|
|
|
| if (speed) {
|
|
|
|
|
| const bytes = speed.bytes_gb || 5;
|
| const x1 = xmin, x2 = xmax;
|
| const f = bw => Math.min(Math.max(1 / (bytes / (bw * 0.6) + 0.0032), ymin), ymax);
|
| s += `<line x1="${lx(x1)}" y1="${ly(f(x1))}" x2="${lx(x2)}" y2="${ly(f(x2))}" class="rl-roof"/>`;
|
|
|
| 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"/>`;
|
|
|
| const flip = ux > W * 0.72;
|
| s += `<circle cx="${ux}" cy="${uy}" r="6" class="rl-you" data-you="1" data-m="${esc(speed.model)}" data-t="${esc(speed.tps)}" data-b="${esc(Math.round(speed.eff_bw || speed.bw))}"/>` +
|
| `<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;
|
|
|
|
|
|
|
| host.style.position = "relative";
|
| const tip = document.createElement("div");
|
| tip.className = "rl-tip"; tip.hidden = true;
|
| host.appendChild(tip);
|
| const svg = host.querySelector("svg");
|
| if (svg) {
|
| svg.addEventListener("mousemove", (e) => {
|
| const t = e.target;
|
| const cls = t.getAttribute && t.getAttribute("class");
|
| if (!cls || (!cls.includes("rl-dot") && !cls.includes("rl-you"))) { tip.hidden = true; return; }
|
| const d = t.dataset;
|
| tip.innerHTML = d.you
|
| ? `<b>Your machine</b><br>${esc(d.m || "your pick")} ~ <b>${esc(d.t)} tok/s</b><br><span class="rl-tip-sub">at ~${esc(d.b)} GB/s memory bandwidth</span>`
|
| : `<b>${esc(d.m || "model")}</b>${d.p ? ` / ${esc(d.p)}B` : ""}<br>${esc(d.a || "")}<br><b>${esc(d.t)} tok/s</b> <span class="rl-tip-sub">at ~${esc(d.b)} GB/s</span>`;
|
| tip.hidden = false;
|
| const r = host.getBoundingClientRect();
|
| const x = e.clientX - r.left, y = e.clientY - r.top;
|
| tip.style.left = Math.min(x + 14, host.clientWidth - 150) + "px";
|
| tip.style.top = Math.max(y - 10, 0) + "px";
|
| });
|
| svg.addEventListener("mouseleave", () => { tip.hidden = true; });
|
| }
|
| }
|
|
|
|
|
| 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"; };
|
| }
|
|
|
|
|
| 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;
|
| 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());
|
| } else {
|
|
|
|
|
|
|
|
|
| const allowed = PROVIDER_ALLOWED[state.computer] || ["none", "nvidia", "amd", "intel", "apple", "unsure"];
|
| if (allowed.includes("unsure")) {
|
| state.provider = "unsure";
|
| setActive("#provider-seg", "unsure");
|
| fillGpu();
|
| }
|
| }
|
| 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`);
|
| } else {
|
| $("#vram").value = "";
|
| }
|
| 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();
|
| }
|
|
|
|
|
| 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();
|
|
|
| box.innerHTML = `<div class="ans-card ans-error"><h3>The explainer hit an error</h3><p>${esc(e && e.message ? e.message : e)}</p></div>`;
|
| } finally {
|
| askBusy = false;
|
| }
|
| }
|
|
|
| function renderAnswer(box, a) {
|
| a = a || {};
|
| if (a.error) {
|
| box.innerHTML = `<div class="ans-card ans-error"><h3>The explainer hit an error</h3><p>${esc(a.error)}</p></div>`;
|
| return;
|
| }
|
|
|
|
|
| box.innerHTML = `
|
| <div class="ans-card reveal">
|
| ${a.headline ? `<h3>${esc(a.headline)}</h3>` : ""}
|
| ${a.why ? `<p>${esc(a.why)}</p>` : ""}
|
| ${a.next_step ? `<div class="ans-next"><span class="ic" data-ic="arrow"></span><span>${esc(a.next_step)}</span></div>` : ""}
|
| </div>`;
|
| hydrate(box);
|
| }
|
|
|
|
|
|
|
|
|
| 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)];
|
| if (!lines.length) throw new Error("no data in stream");
|
| const arr = JSON.parse(lines[lines.length - 1][1]);
|
| return Array.isArray(arr) ? arr[0] : arr;
|
| }
|
|
|
|
|
| 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");
|
| wireTaskToggle();
|
| ["#ram","#gpu","#vram","#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();
|
|
|
|
|
| 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();
|
| }
|
|
|
| if (typeof document !== "undefined") init();
|
| if (typeof module !== "undefined" && module.exports) {
|
| module.exports = { esc, safeUrl, sanitizeHtml };
|
| }
|
|
|