// Ready-to-run .cmf models, read live from the infosave HuggingFace account via // /setup/featured — so a newly published model shows up without a code change. // Shared by the first-run wizard and the Import view: same cards, same download // / progress / delete behaviour in both places. import { h, mount, toast } from "../ui.js"; import { t } from "../i18n.js"; import { api } from "../api.js"; export async function loadFeatured() { try { const r = await api.setupFeatured(); return r.models || []; } catch { return []; } } export function quantFromVariant(name) { const s = (name || "").toLowerCase(); if (s.includes("q1t")) return "Q1T"; if (s.includes("q2tp")) return "Q2TP"; if (s.includes("q4tp")) return "Q4TP"; if (s.includes("q4t")) return "Q4T"; if (s.includes("q1")) return "Q1"; if (s.includes("q4")) return "Q4"; if (s.includes("q8")) return "Q8"; return ""; } // Same thresholds as the backend's format_bytes, so card and job agree. export function fmtBytes(n) { if (!n && n !== 0) return ""; if (n >= 1e9) return (n / 1e9).toFixed(1) + " GB"; if (n >= 1e6) return Math.round(n / 1e6) + " MB"; if (n >= 1e3) return Math.round(n / 1e3) + " KB"; return n + " B"; } export function jobPct(j) { if (j.progress == null) return null; return Math.max(0, Math.min(100, Math.round(j.progress * 100))); } /// "downloading · 42% · 1.4 / 3.2 GB" — whatever of it the job actually knows. /// A settled job shows its result, not the phase it stopped in. export function jobStatusText(j) { const parts = []; if (j.state === "running") { if (j.phase) parts.push(j.phase); const pct = jobPct(j); if (pct != null) parts.push(pct + "%"); if (j.total_bytes) parts.push(`${fmtBytes(j.done_bytes || 0)} / ${fmtBytes(j.total_bytes)}`); } else if (j.state === "done" && j.size_bytes) { parts.push(fmtBytes(j.size_bytes)); } return parts.join(" · "); } export function jobBar(j) { const pct = jobPct(j); if (j.state === "done") return h("div", { class: "pbar" }, h("div", { class: "pbar-fill ok", style: "width:100%" })); if (j.state !== "running") return h("div", { class: "pbar" }, h("div", { class: "pbar-fill err", style: "width:100%" })); // known percentage → determinate fill; unknown (listing files) → animated slider return h("div", { class: "pbar" }, pct != null ? h("div", { class: "pbar-fill det", style: `width:${Math.max(pct, 1)}%` }) : h("div", { class: "pbar-fill run" }) ); } /// HF gives `desc` as a bare pipeline tag ("text-generation") for live entries and /// a real sentence for curated ones. A one-word tag belongs in the badge row, not /// on a line of its own under every card. function descBadge(desc) { return desc && !desc.includes(" ") ? h("span", { class: "badge" }, desc) : null; } function descLine(desc) { return desc && desc.includes(" ") ? h("div", { class: "muted small", style: "margin:6px 0" }, desc) : null; } /// One model. `ctx` = { hosts: Map(jobId → element), onDownload(m, v), onDelete(m, v) }. /// Live progress lands in the per-variant host so a poll can repaint it in place, /// without re-rendering the grid under the user's cursor. export function featuredCard(m, ctx) { const badge = m.badge || "CMF"; const label = m.label || m.id; const desc = m.desc || ""; const variants = m.variants && m.variants.length ? m.variants : null; const size = m.size || (m.size_bytes ? fmtBytes(m.size_bytes) : ""); const isDownloaded = !!m.downloaded; const progressHost = (v) => { const host = h("div", { class: "card-prog" }); const jid = (v && v.job) || m.job; if (jid) ctx.hosts.set(jid, host); return host; }; // multi-variant (e.g. DeepSeek with parts-q2tp / parts-q4tp) → separate buttons per variant if (variants && variants.length > 1) { return h("div", { class: "hf-card featured-card" + (isDownloaded ? " sel" : "") }, h("div", { class: "hf-id" }, label, isDownloaded ? h("span", { class: "badge ok", style: "margin-left:6px" }, "✓") : null), h("div", { class: "hf-meta" }, h("span", { class: "badge" }, badge), descBadge(desc), isDownloaded ? h("span", { class: "badge ok" }, "✓ " + t("onboarding.downloaded")) : null), descLine(desc), h("div", { class: "muted small" }, m.id), h("div", { style: "display:flex; flex-direction:column; gap:6px; margin-top:8px" }, ...variants.map((v) => { const q = quantFromVariant(v.name) || badge; const done = !!v.downloaded; const upd = !!v.update_available; const lab = done ? (upd ? `↻ ${t("onboarding.update")} ${q} · ${v.size}` : `✓ ${q} · ${v.size}`) : `⬇ ${q} · ${v.size}`; const btn = h("button", { class: "btn sm " + (done && !upd ? "ghost" : "primary"), disabled: done && !upd ? true : null, onClick: () => ctx.onDownload(m, v), title: v.name + (done ? " — " + t("onboarding.downloaded") : ""), }, lab); const row = h("div", { style: "display:flex; gap:6px; align-items:center; flex-wrap:wrap" }, btn); if (done) { const del = h("button", { class: "btn sm ghost danger", title: t("onboarding.deleteFile") }, "🗑"); del.addEventListener("click", () => ctx.onDelete(m, v)); row.appendChild(del); } return h("div", {}, row, progressHost(v)); }) ) ); } const v0 = variants && variants[0]; const done0 = !!(v0 && v0.downloaded) || isDownloaded; const upd0 = !!(v0 && v0.update_available) || !!m.update_available; const q0 = quantFromVariant((v0 && v0.name) || "") || badge; const size0 = (v0 && v0.size) || size; const btn0 = h("button", { class: "btn sm " + (done0 && !upd0 ? "ghost" : "primary"), disabled: done0 && !upd0 ? true : null, onClick: () => ctx.onDownload(m, v0), title: done0 && !upd0 ? t("onboarding.downloaded") : "" }, done0 ? (upd0 ? `↻ ${t("onboarding.update")} ${q0} · ${size0}` : `✓ ${q0} · ${size0}`) : `⬇ ${q0}${size0 ? " · " + size0 : ""}`); const del0 = done0 ? h("button", { class: "btn sm ghost danger", title: t("onboarding.deleteFile") }, "🗑 " + t("import.delete")) : null; if (del0) del0.addEventListener("click", () => ctx.onDelete(m, v0)); return h("div", { class: "hf-card featured-card" + (done0 ? " sel" : "") }, h("div", { class: "hf-id" }, label, done0 ? h("span", { class: "badge ok", style: "margin-left:6px" }, "✓") : null), h("div", { class: "hf-meta" }, h("span", { class: "badge" }, badge), size0 ? h("span", { class: "badge", style: "background:var(--bg); border-style:dashed" + (done0 ? "; opacity:.7" : "") }, (done0 ? "✓ " : "⬇ ") + size0) : null, descBadge(desc) ), descLine(desc), h("div", { class: "muted small" }, m.id), h("div", { style: "display:flex; gap:6px; margin-top:8px; align-items:center; flex-wrap:wrap" }, btn0, del0), progressHost(v0) ); } /// Repaint the per-variant progress hosts from the current job list. export function paintFeaturedHosts(hosts, jobs, onCancel) { const byId = new Map(jobs.map((j) => [j.id, j])); for (const [id, host] of hosts) { const j = byId.get(id); if (!j || j.state !== "running") { mount(host); continue; } const cancel = h("button", { class: "btn sm ghost", style: "margin-left:auto", title: t("import.cancel") }, "✕"); cancel.addEventListener("click", async () => { cancel.disabled = true; try { await onCancel(j); } catch (e) { toast(e.message, "err"); cancel.disabled = false; } }); mount(host, jobBar(j), h("div", { style: "display:flex; align-items:center; gap:8px" }, h("span", { class: "muted small" }, jobStatusText(j)), cancel ) ); } } /// Start the download for a featured variant. `local_name` is the stem the /// gateway itself derived, sent back verbatim so download / progress / delete /// can never disagree about which file this is. export async function startFeaturedDownload(m, v) { const fallbackBase = m.id.split("/").pop().toLowerCase().replace(/[^a-z0-9_-]/g, "-"); const name = (v && v.local_name) || m.local_name || fallbackBase; const variant = v && v.name ? v.name : undefined; await api.startImport({ repo: m.id, quant: "Q1", name, variant }); return variant; }