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.
336 lines
15 KiB
JavaScript
Vendored
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;
|
|
}
|