Files
cortiq-gateway/web/js/views/onboarding.js
T
infosave2007 15de36be3b feat: ops & UX batch — jobs survive restarts, logs/system in the console, media queue, gallery, chat, exact stream tokens
Fifteen changes aimed at the first day of real use:

- Import and media jobs persist to <data>/jobs-*.json and reload on start
  (running ones settle as errors); the Register button no longer vanishes
  because the gateway restarted.
- Docker ships the cortiq binary (built in the builder stage) — local .cmf
  chat models and image/video generation now work in containers, where
  auto-install never could (no cargo in the runtime image). libvulkan1 added
  for optional GPU offload.
- Open admin console on 0.0.0.0 without a token now warns in the log and
  shows a dismissible banner — the VPS footgun is at least labeled.
- "Restart gateway" button in Settings: stops managed model servers, spawns
  a fresh copy (bind retried while the port frees), Docker relies on the
  container restart policy instead.
- Dashboard "System" panel: CPU sparkline + load, RAM (with the gateway's
  own RSS), free disk at the models dir — sampled by sysinfo every 2 s.
- Featured model cards warn "not enough disk" / "may not fit in RAM" before
  the download, from the same live snapshot.
- Log viewer in the sidebar: a 500-line in-memory ring fed by a second
  ANSI-free tracing layer; filter, level, pause, copy.
- Update check: crates.io polled twice a day, the console shows an
  "update available" notice.
- Media generations queue up (with position shown) instead of being
  rejected; progress gained an honest ETA from measured throughput.
- Generation gallery under the image/video tabs: lazy PPM thumbnails,
  click to reopen with seed/steps, delete.
- Playground chat is a real dialogue now: history is resent, bubbles,
  Clear, Cmd/Ctrl+Enter.
- "Connect your app" snippets (curl / Python / JS) on the keys page, with
  the real key inlined right after creation.
- One-click benchmark on the Models page via `cortiq bench --json`.
- Config export now downloads the actual gateway.toml (comments intact);
  import accepts TOML (validated, hot-reloaded) and the legacy JSON.
- Streaming token counts are exact when the model server provides usage
  (cortiq-server patch, shipped separately); the gateway fallback now
  counts content chunks (1/token on cortiq) instead of chars/4.

Wizard's Finish lands on the Playground. 37 new strings in all 7 languages.
2026-08-07 17:07:48 +03:00

336 lines
15 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 sysSnapshot = null; // disk/RAM для плашек "не влезет" на карточках
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();
try { sysSnapshot = await api.system(); } catch { /* optional */ }
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, sys: sysSnapshot };
// 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));
}
// land where the reward is: try the model right away
location.hash = "#/playground";
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;
}