// First-run onboarding wizard: language → featured .cmf models (infosave) → HF search+convert → done. // No manual TOML edits: every action goes through the admin API. import { h, mount, toast } from "../ui.js"; import { t, getLang, setLang, LANGS } from "../i18n.js"; import { api } from "../api.js"; import { loadFeatured as fetchFeatured, featuredCard, paintFeaturedHosts, startFeaturedDownload, jobBar, jobStatusText, fmtBytes, } from "./featured.js"; let step = 0; // 0 lang, 1 featured, 2 hf convert, 3 done let featured = []; let featuredLoaded = false; function langStep(onNext) { const cards = LANGS.map((l) => h("button", { class: "onb-lang" + (getLang() === l.code ? " sel" : ""), onClick: () => { setLang(l.code); cards.forEach((c) => c.classList.toggle("sel", c.dataset.code === l.code)); toast(l.flag + " " + l.label, "ok"); }, "data-code": l.code, title: l.label, "aria-label": l.label, }, h("span", { class: "flag" }, l.flag), h("b", {}, l.label)) ); return h("div", { class: "onb-step" }, h("h2", {}, t("onboarding.welcome")), h("p", { class: "muted" }, t("onboarding.welcomeSub")), h("div", { class: "onb-langs" }, ...cards), h("div", { class: "actions" }, h("button", { class: "btn primary", onClick: onNext }, t("onboarding.next") + " →") ) ); } export function renderOnboarding() { const root = h("div", { class: "onb-wrap" }); let jobsPoll = null; let jobs = []; let jobStates = new Map(); // job id → last seen state, to spot completions let hosts = new Map(); // job id → card progress host (rebuilt on every render) let jobsHost = h("div", {}); // Job rows: bar, live status, and the actions that finish or undo a download. function jobsMini(list) { if (!list.length) return h("div", { class: "muted small" }, t("onboarding.noJobs")); return h("div", {}, ...list.map((j) => { const act = []; if (j.state === "done") { const btn = h("button", { class: "btn primary sm" }, "✓ " + t("import.register")); btn.addEventListener("click", async () => { btn.disabled = true; try { const r = await api.registerImport(j.id); toast(t("import.registered", { id: r.model_id }), "ok"); } catch (e) { toast(e.message, "err"); btn.disabled = false; } }); act.push(btn); } if (j.state === "running") { const cancel = h("button", { class: "btn ghost sm" }, "✕ " + t("import.cancel")); cancel.addEventListener("click", async () => { cancel.disabled = true; try { await api.cancelImport(j.id); toast(t("import.cancelling"), "ok"); await refreshJobs(); } catch (e) { toast(e.message, "err"); cancel.disabled = false; } }); act.push(cancel); } else { // done / error / cancelled — remove the .cmf from disk and redo the step const del = h("button", { class: "btn ghost sm danger", title: t("onboarding.deleteFile") }, "🗑 " + t("import.delete")); del.addEventListener("click", () => deleteJob(j)); act.push(del); } const known = ["running", "done", "error", "cancelled"].includes(j.state); return h("div", { class: "job", style: "margin:8px 0" }, h("div", { class: "job-head" }, h("b", {}, j.name || j.repo || j.id), h("span", { class: "badge" + (j.state === "done" ? " ok" : j.state === "running" ? "" : " bad") }, known ? t("import.state." + j.state) : j.state) ), jobBar(j), h("div", { class: "muted small" }, jobStatusText(j)), h("pre", { class: "conv-log", style: "max-height:80px" }, (j.log || []).slice(-6).join("\n")), act.length ? h("div", { class: "actions" }, ...act) : null ); }) ); } // Repaint only the live bits (card hosts + job list) — cheap enough for 1.5s. function paintJobs() { mount(jobsHost, jobs.length ? h("div", { class: "card" }, h("div", { class: "card-title" }, t("import.jobs")), jobsMini(jobs)) : h("div", {})); paintFeaturedHosts(hosts, jobs, async (j) => { await api.cancelImport(j.id); toast(t("import.cancelling"), "ok"); await refreshJobs(); }); } async function refreshJobs() { try { jobs = (await api.listImports()).jobs || []; } catch { jobs = []; } // A job that just settled changes what the cards offer (✓ + delete), so the // featured list has to be re-read — but only on the transition, not per poll. const settled = jobs.some((j) => { const prev = jobStates.get(j.id); return prev === "running" && j.state !== "running"; }); jobStates = new Map(jobs.map((j) => [j.id, j.state])); if (settled) { await loadFeatured(); render(); // rebuilds the cards (✓ + delete) and repaints the job rows } else { paintJobs(); } if (jobs.some((j) => j.state === "running")) { if (!jobsPoll) jobsPoll = setInterval(refreshJobs, 1500); } else { if (jobsPoll) { clearInterval(jobsPoll); jobsPoll = null; } } } async function loadFeatured() { featured = await fetchFeatured(); featuredLoaded = true; } function topNav(prev, next, extra) { return h("div", { class: "actions", style: "margin-bottom:12px; position:sticky; top:0; background:var(--bg); z-index:2; padding:8px 0; border-bottom:1px solid var(--border-soft); backdrop-filter:blur(4px)" }, h("button", { class: "btn ghost", onClick: prev }, "← " + t("onboarding.back")), extra ? extra : h("div", { style: "flex:1" }), h("button", { class: "btn primary", onClick: next }, t("onboarding.next") + " →") ); } async function startDownload(m, v) { try { const variant = await startFeaturedDownload(m, v); toast(t("import.started") + (variant ? ` · ${variant}` : ""), "ok"); await refreshJobs(); } catch (e) { toast(e.message, "err"); } } async function handleDelete(m, v) { const name = (v && v.local_name) || m.local_name || m.id; if (!confirm(t("onboarding.confirmDelete", { id: name }))) return; try { const r = await api.deleteSetupFile(m.id, v && v.name, (v && v.local_name) || m.local_name); toast(t("import.deleted"), "ok"); if (r && r.unregistered && r.unregistered.length) { toast(t("onboarding.unregistered", { ids: r.unregistered.join(", ") }), "ok"); } await loadFeatured(); render(); await refreshJobs(); } catch (e) { toast(e.message, "err"); } } // Delete straight from a job row: the gateway knows that job's output path. async function deleteJob(j) { if (!confirm(t("onboarding.confirmDelete", { id: j.name || j.repo }))) return; try { await api.deleteImport(j.id); toast(t("import.deleted"), "ok"); await loadFeatured(); render(); await refreshJobs(); } catch (e) { toast(e.message, "err"); } } function render() { const dots = [0, 1, 2, 3].map((i) => h("span", { class: "dot" + (i === step ? " on" : i < step ? " done" : "") })); const header = h("div", { class: "onb-header" }, h("div", { class: "onb-dots" }, ...dots), h("div", { class: "onb-title" }, t("onboarding.title") + ` — ${step + 1}/4`) ); let body; const next = () => { step = Math.min(3, step + 1); render(); }; const prev = () => { step = Math.max(0, step - 1); render(); }; hosts = new Map(); // rebuilt by featuredCard below if (step === 0) { const top = h("div", { class: "actions", style: "margin-bottom:12px; justify-content:flex-end" }, h("button", { class: "btn primary", onClick: next }, t("onboarding.next") + " →") ); body = h("div", {}, top, langStep(next) ); } else if (step === 1) { const top = topNav(prev, next, h("button", { class: "btn ghost", onClick: () => { step = 3; render(); } }, t("onboarding.skip"))); const ctx = { hosts, onDownload: startDownload, onDelete: handleDelete }; // the list is fetched from HuggingFace — say so instead of showing a blank grid const grid = featured.length ? h("div", { class: "hf-grid" }, ...featured.map((m) => featuredCard(m, ctx))) : h("div", { class: "muted pad" }, featuredLoaded ? t("import.noResults") : t("import.searching")); body = h("div", { class: "onb-step" }, top, h("h2", {}, "📦 " + t("onboarding.featuredTitle")), h("p", { class: "muted" }, t("onboarding.featuredSub")), grid, jobsHost, h("div", { class: "actions" }, h("button", { class: "btn ghost", onClick: prev }, "← " + t("onboarding.back")), h("button", { class: "btn", onClick: async () => { await loadFeatured(); render(); await refreshJobs(); } }, t("common.refresh")), h("button", { class: "btn primary", onClick: next }, t("onboarding.next") + " →"), h("button", { class: "btn ghost", onClick: () => { step = 3; render(); } }, t("onboarding.skip")) ) ); } else if (step === 2) { const top2 = topNav(prev, next); // lightweight HF search inline const inp = h("input", { placeholder: t("import.searchPlaceholder"), style: "width:100%" }); const res = h("div", { class: "hf-grid" }); const quantSel = h("select", {}, h("option", { value: "Q8_2F" }, "Q8_2F — " + t("import.q.q8_2f")), h("option", { value: "Q4_BLOCK" }, "Q4 — " + t("import.q.q4")), h("option", { value: "Q1T" }, "Q1T — " + t("import.q.q1t")), h("option", { value: "Q1" }, "Q1 — " + t("import.q.q1")), ); let selected = null; let debounce = null; async function doSearch() { const q = inp.value.trim(); mount(res, h("div", { class: "muted pad" }, t("import.searching"))); try { const r = await api.hfSearch(q, 16); const models = r.models || []; if (!models.length) { mount(res, h("div", { class: "muted pad" }, t("import.noResults"))); return; } mount(res, ...models.map((m) => { const card = h("div", { class: "hf-card" + (selected && selected.id === m.id ? " sel" : "") }, h("div", { class: "hf-id" }, m.id), h("div", { class: "hf-meta" }, h("span", { class: "badge" }, m.pipeline_tag || "model"), h("span", { class: "muted" }, "♥ " + (m.likes ?? 0))) ); card.addEventListener("click", () => { selected = m; doSearch(); }); return card; })); } catch (e) { mount(res, h("div", { class: "err" }, String(e.message))); } } inp.addEventListener("input", () => { clearTimeout(debounce); debounce = setTimeout(doSearch, 300); }); const go = h("button", { class: "btn primary" }, "⬇ " + t("import.convert")); go.addEventListener("click", async () => { if (!selected) { toast(t("import.noResults"), "warn"); return; } go.disabled = true; try { await api.startImport({ repo: selected.id, quant: quantSel.value, name: selected.id.split("/").pop().toLowerCase() }); toast(t("import.started"), "ok"); await refreshJobs(); } catch (e) { toast(e.message, "err"); } go.disabled = false; }); // auto initial search setTimeout(doSearch, 100); body = h("div", { class: "onb-step" }, top2, h("h2", {}, "🔍 " + t("onboarding.hfTitle")), h("p", { class: "muted" }, t("onboarding.hfSub")), h("div", { class: "card" }, inp, res), h("div", { class: "card", style: "margin-top:12px" }, h("div", { class: "row" }, h("label", { class: "field" }, h("span", {}, t("import.quant")), quantSel)), h("div", { class: "actions" }, go) ), jobsHost, h("div", { class: "actions" }, h("button", { class: "btn ghost", onClick: prev }, "← " + t("onboarding.back")), h("button", { class: "btn primary", onClick: next }, t("onboarding.next") + " →") ) ); } else { // done — finishing connects every completed download as a local model const ready = jobs.filter((j) => j.state === "done"); const doneBtn = h("button", { class: "btn primary" }, t("onboarding.finish")); doneBtn.addEventListener("click", async () => { doneBtn.disabled = true; let r = null; try { r = await api.setupComplete(getLang(), true); } catch (e) { toast(e.message, "err"); } if (r && r.registered && r.registered.length) { toast(t("onboarding.registeredN", { n: r.registered.length, ids: r.registered.join(", ") }), "ok"); if (!r.runtime_installed) toast(t("onboarding.runtimeMissing"), "warn"); // let the toast be readable before the reload swaps the page await new Promise((res) => setTimeout(res, 900)); } location.hash = "#/models"; location.reload(); }); const skipBtn = h("button", { class: "btn ghost", onClick: async () => { try { await api.setupComplete(getLang(), false); } catch {} location.hash = "#/dashboard"; } }, t("onboarding.skipSetup")); body = h("div", { class: "onb-step" }, h("h2", {}, "✅ " + t("onboarding.doneTitle")), h("p", { class: "muted" }, t("onboarding.doneSub")), ready.length ? h("div", { class: "card" }, h("div", { class: "card-title" }, t("onboarding.willRegister")), ...ready.map((j) => h("div", { class: "muted small" }, "• cmf-" + (j.name || j.repo) + (j.size_bytes ? ` · ${fmtBytes(j.size_bytes)}` : ""))) ) : h("div", { class: "muted small" }, t("onboarding.nothingToRegister")), jobsHost, h("div", { class: "callout" }, t("onboarding.doneHint")), h("div", { class: "actions" }, h("button", { class: "btn ghost", onClick: prev }, "← " + t("onboarding.back")), skipBtn, doneBtn ) ); } mount(root, h("div", { class: "onb" }, header, body)); paintJobs(); // fill the freshly built hosts with whatever we already know if (jobs.some((j) => j.state === "running") && !jobsPoll) { jobsPoll = setInterval(refreshJobs, 1500); } } loadFeatured().then(() => { render(); refreshJobs(); }); render(); // cleanup poll on detach const obs = new MutationObserver(() => { if (!root.isConnected && jobsPoll) { clearInterval(jobsPoll); jobsPoll = null; obs.disconnect(); } }); obs.observe(document.body, { childList: true, subtree: true }); return root; }