Files
cortiq-gateway/web/js/views/onboarding.js
T
infosave2007 9ec8861464 release: v0.2.37 — zero-argument startup, first-run wizard, real download progress
Startup no longer needs arguments: the config is created on first launch, a busy
port is swapped for a free one, the console is open by default, and the browser
lands on a setup wizard that downloads a ready-made .cmf model and wires it into
routing (managed server + routing.default) when you press Finish.

Download progress was reported from curl's own meter, which prints a full
0→100% bar for the -L redirect hop before the transfer starts — so every job
showed 100% and then hung. Progress now comes from bytes on disk against the
size from the HuggingFace tree (or a HEAD request).

The UI and the gateway also disagreed about the output file name, so a finished
download never marked its card as downloaded; the gateway now returns the exact
stem and the UI sends it back verbatim for download, progress and delete.

Featured models are read live from the infosave HF account in both the wizard
and Import, and all 432 UI strings now exist in every one of the 7 languages.
2026-08-06 17:56:42 +03:00

333 lines
14 KiB
JavaScript
Vendored

// 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;
}