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.
460 lines
17 KiB
JavaScript
Vendored
460 lines
17 KiB
JavaScript
Vendored
// Import a HuggingFace model → convert to a local .cmf (our format) with a
|
|
// live progress view. Search HF, pick quantization + advanced params, watch
|
|
// the conversion (multiple in parallel, resumable across tab switches), then
|
|
// register the result as a local model.
|
|
import { h, mount, toast, opt } from "../ui.js";
|
|
import { t } from "../i18n.js";
|
|
import { api } from "../api.js";
|
|
import {
|
|
loadFeatured,
|
|
featuredCard,
|
|
paintFeaturedHosts,
|
|
startFeaturedDownload,
|
|
jobStatusText,
|
|
} from "./featured.js";
|
|
|
|
const QUANTS = [
|
|
["Q8_2F", "import.q.q8_2f"],
|
|
["Q8_ROW", "import.q.q8_row"],
|
|
["Q4_BLOCK", "import.q.q4"],
|
|
["Q4_TILED", "import.q.q4_tiled"],
|
|
["VBIT", "import.q.vbit"],
|
|
["Q1T", "import.q.q1t"],
|
|
["Q1S", "import.q.q1s"],
|
|
["Q1P", "import.q.q1p"],
|
|
["Q1", "import.q.q1"],
|
|
["F16", "import.q.f16"],
|
|
["F32", "import.q.f32"],
|
|
];
|
|
|
|
|
|
function field(label, control, hint) {
|
|
return h("label", { class: "field" }, h("span", {}, label), control,
|
|
hint ? h("div", { class: "hint" }, hint) : null);
|
|
}
|
|
function fmtNum(n) {
|
|
if (n == null) return "—";
|
|
if (n >= 1e6) return (n / 1e6).toFixed(1) + "M";
|
|
if (n >= 1e3) return (n / 1e3).toFixed(1) + "k";
|
|
return String(n);
|
|
}
|
|
function fmtBytes(n) {
|
|
if (!n) return "";
|
|
const g = n / 1e9;
|
|
return g >= 1 ? g.toFixed(2) + " GB" : (n / 1e6).toFixed(0) + " MB";
|
|
}
|
|
// state → localized label (falls back to the raw state)
|
|
function stateLabel(s) {
|
|
const k = "import.state." + s;
|
|
const v = t(k);
|
|
return v === k ? s : v;
|
|
}
|
|
|
|
export function renderImport() {
|
|
let selected = null;
|
|
let lastModels = []; // cache of the last search, so re-highlighting needs no refetch
|
|
let jobsTimer = null;
|
|
let featured = []; // ready-made .cmf models from the infosave HF account
|
|
let featuredHosts = new Map(); // job id → live progress slot inside a featured card
|
|
let lastJobs = [];
|
|
let jobStates = new Map(); // job id → last seen state, to spot completions
|
|
|
|
const searchIn = h("input", {
|
|
class: "hf-search",
|
|
placeholder: t("import.searchPlaceholder"),
|
|
autofocus: true,
|
|
});
|
|
const results = h("div", { class: "hf-grid" });
|
|
const formHost = h("div", {});
|
|
const jobsHost = h("div", {});
|
|
|
|
// ── live HF search (debounced) ──
|
|
let debounce = null;
|
|
function renderCards(models) {
|
|
if (!models.length) {
|
|
mount(results, h("div", { class: "muted pad" }, t("import.noResults")));
|
|
return;
|
|
}
|
|
mount(results, ...models.map(modelCard));
|
|
}
|
|
async function doSearch() {
|
|
const q = searchIn.value.trim();
|
|
mount(results, h("div", { class: "muted pad" }, t("import.searching")));
|
|
try {
|
|
const r = await api.hfSearch(q, 24);
|
|
lastModels = r.models || [];
|
|
renderCards(lastModels);
|
|
} catch (e) {
|
|
mount(results, h("div", { class: "err pad" }, "HF: " + e.message));
|
|
}
|
|
}
|
|
searchIn.addEventListener("input", () => {
|
|
clearTimeout(debounce);
|
|
debounce = setTimeout(doSearch, 300);
|
|
});
|
|
|
|
function modelCard(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" },
|
|
m.pipeline_tag ? h("span", { class: "badge" }, m.pipeline_tag) : null,
|
|
h("span", { class: "muted" }, "▼ " + fmtNum(m.downloads)),
|
|
h("span", { class: "muted" }, "♥ " + fmtNum(m.likes)),
|
|
(m.tags || []).some((x) => x === "gated") ? h("span", { class: "badge bad" }, "gated") : null
|
|
)
|
|
);
|
|
card.addEventListener("click", () => selectModel(m));
|
|
return card;
|
|
}
|
|
|
|
function selectModel(m) {
|
|
selected = m;
|
|
renderCards(lastModels); // re-highlight from cache — no refetch, no flash
|
|
renderForm();
|
|
formHost.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
|
}
|
|
|
|
// ── conversion form ──
|
|
function renderForm() {
|
|
if (!selected) {
|
|
mount(formHost);
|
|
return;
|
|
}
|
|
const nameIn = h("input", { value: (selected.id.split("/").pop() || "model").toLowerCase() });
|
|
const quantSel = h("select", {}, ...QUANTS.map(([v], i) => opt(v, v, i === 0)));
|
|
const quantHint = h("div", { class: "hint" }, t("import.q.q8_2f"));
|
|
quantSel.addEventListener("change", () => {
|
|
const k = QUANTS.find(([v]) => v === quantSel.value);
|
|
quantHint.textContent = k ? t(k[1]) : "";
|
|
});
|
|
|
|
// advanced (collapsed)
|
|
const linearSel = h("select", {}, opt("", t("import.adv.auto")), opt("gated_delta_net", "gated_delta_net"), opt("vmf_phase", "vmf_phase"));
|
|
const nphaseIn = h("input", { type: "number", min: "1", placeholder: "16" });
|
|
const vbitSel = h("select", {}, opt("", t("import.adv.auto")), opt("log2", "log2"), opt("cubic", "cubic"));
|
|
const meanBitsIn = h("input", { type: "number", step: "0.1", placeholder: "5.0" });
|
|
const shardIn = h("input", { type: "number", step: "0.5", placeholder: t("import.adv.noShard") });
|
|
const skipMtp = h("input", { type: "checkbox" });
|
|
// O(1) attention (cortiq ≥ 0.2.0): constant-memory streaming attention hint,
|
|
// weights byte-identical — the runtime applies it at load.
|
|
const o1Sel = h("select", {},
|
|
opt("", t("import.adv.o1Off")),
|
|
opt("all", t("import.adv.o1All")),
|
|
opt("deep", t("import.adv.o1Deep")),
|
|
opt("custom", t("import.adv.o1Custom")));
|
|
const o1DeepIn = h("input", { type: "number", min: "1", placeholder: "12" });
|
|
const o1CustomIn = h("input", { placeholder: "0,4,8" });
|
|
const o1M = h("input", { type: "number", min: "1", placeholder: "32" });
|
|
const o1Window = h("input", { type: "number", min: "0", placeholder: "128" });
|
|
const o1Sink = h("input", { type: "number", min: "0", placeholder: "4" });
|
|
const o1SpecRow = h("div", { class: "row", style: "display:none" },
|
|
field(t("import.adv.o1DeepN"), o1DeepIn),
|
|
field(t("import.adv.o1Layers"), o1CustomIn));
|
|
const o1Knobs = h("div", { class: "row", style: "display:none" },
|
|
field("--o1-m", o1M, t("import.adv.o1mHint")),
|
|
field("--o1-window", o1Window, t("import.adv.o1windowHint")),
|
|
field("--o1-sink", o1Sink, t("import.adv.o1sinkHint")));
|
|
o1Sel.addEventListener("change", () => {
|
|
const on = !!o1Sel.value;
|
|
o1Knobs.style.display = on ? "" : "none";
|
|
o1SpecRow.style.display = o1Sel.value === "deep" || o1Sel.value === "custom" ? "" : "none";
|
|
o1DeepIn.parentElement.style.display = o1Sel.value === "deep" ? "" : "none";
|
|
o1CustomIn.parentElement.style.display = o1Sel.value === "custom" ? "" : "none";
|
|
});
|
|
const advBody = h(
|
|
"div",
|
|
{ class: "adv-body", style: "display:none" },
|
|
h("div", { class: "row" },
|
|
field(t("import.adv.linearCore"), linearSel, t("import.adv.linearHint")),
|
|
field(t("import.adv.nphase"), nphaseIn)),
|
|
h("div", { class: "row" },
|
|
field(t("import.adv.vbitShape"), vbitSel),
|
|
field(t("import.adv.meanBits"), meanBitsIn)),
|
|
h("div", { class: "row" },
|
|
field(t("import.adv.shard"), shardIn),
|
|
field("MTP", h("label", { class: "check" }, skipMtp, t("import.adv.skipMtp")))),
|
|
field(t("import.adv.o1"), o1Sel, t("import.adv.o1Hint")),
|
|
o1SpecRow,
|
|
o1Knobs,
|
|
);
|
|
const advToggle = h("button", { class: "btn ghost sm" }, "⚙ " + t("import.adv.title"));
|
|
advToggle.addEventListener("click", () => {
|
|
advBody.style.display = advBody.style.display === "none" ? "block" : "none";
|
|
});
|
|
|
|
const goBtn = h("button", { class: "btn primary" }, "⬇ " + t("import.convert"));
|
|
goBtn.addEventListener("click", async () => {
|
|
const params = {
|
|
repo: selected.id,
|
|
quant: quantSel.value,
|
|
name: nameIn.value.trim(),
|
|
skip_mtp: skipMtp.checked,
|
|
};
|
|
if (linearSel.value) params.linear_core = linearSel.value;
|
|
if (nphaseIn.value) params.nphase = parseInt(nphaseIn.value);
|
|
if (vbitSel.value) params.vbit_shape = vbitSel.value;
|
|
if (meanBitsIn.value) params.mean_bits = parseFloat(meanBitsIn.value);
|
|
if (shardIn.value) params.shard_max_gb = parseFloat(shardIn.value);
|
|
if (o1Sel.value) {
|
|
// spec: all | deepN | explicit layer list
|
|
params.o1 =
|
|
o1Sel.value === "deep" ? "deep" + (parseInt(o1DeepIn.value) || 12)
|
|
: o1Sel.value === "custom" ? o1CustomIn.value.trim()
|
|
: "all";
|
|
if (!params.o1) delete params.o1;
|
|
else {
|
|
if (o1M.value) params.o1_m = parseInt(o1M.value);
|
|
if (o1Window.value) params.o1_window = parseInt(o1Window.value);
|
|
if (o1Sink.value) params.o1_sink = parseInt(o1Sink.value);
|
|
}
|
|
}
|
|
goBtn.disabled = true;
|
|
try {
|
|
await api.startImport(params);
|
|
toast(t("import.started"), "ok");
|
|
await refreshJobs(); // picks up the new job + (re)starts polling
|
|
jobsHost.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
|
} catch (e) {
|
|
toast(e.message, "err");
|
|
}
|
|
goBtn.disabled = false;
|
|
});
|
|
|
|
mount(
|
|
formHost,
|
|
h("div", { class: "card import-form" },
|
|
h("div", { class: "card-title" }, t("import.configure") + ": " + selected.id),
|
|
h("div", { class: "row" },
|
|
field(t("import.outName"), nameIn, t("import.outNameHint")),
|
|
field(t("import.quant"), quantSel)),
|
|
quantHint,
|
|
h("div", { class: "adv" }, advToggle, advBody),
|
|
h("div", { class: "actions" }, goBtn),
|
|
)
|
|
);
|
|
}
|
|
|
|
// ── jobs panel: all conversions, live, resumable, cancellable ──
|
|
function ensurePolling() {
|
|
if (!jobsTimer) {
|
|
jobsTimer = setInterval(() => {
|
|
// stop once this view instance was replaced (navigated away)
|
|
if (!root.isConnected) {
|
|
stopPolling();
|
|
return;
|
|
}
|
|
refreshJobs();
|
|
}, 1500);
|
|
}
|
|
}
|
|
function stopPolling() {
|
|
if (jobsTimer) {
|
|
clearInterval(jobsTimer);
|
|
jobsTimer = null;
|
|
}
|
|
}
|
|
// Load + render all jobs. Runs on mount (before `root` is attached — so no
|
|
// isConnected guard here) and on each poll tick.
|
|
async function cancelJob(j) {
|
|
await api.cancelImport(j.id);
|
|
toast(t("import.cancelling"), "warn");
|
|
await refreshJobs();
|
|
}
|
|
|
|
async function refreshJobs() {
|
|
let jobs = [];
|
|
try {
|
|
jobs = (await api.listImports()).jobs || [];
|
|
} catch {
|
|
return;
|
|
}
|
|
jobs = jobs.filter((j) => j.state !== "cancelled"); // hide user-cancelled jobs
|
|
lastJobs = jobs;
|
|
renderJobs(jobs);
|
|
// A settled download changes what the featured cards offer (✓ + delete), so
|
|
// re-read the list on that transition — not on every poll tick.
|
|
const settled = jobs.some((j) => jobStates.get(j.id) === "running" && j.state !== "running");
|
|
jobStates = new Map(jobs.map((j) => [j.id, j.state]));
|
|
if (settled) await renderFeatured();
|
|
else paintFeaturedHosts(featuredHosts, jobs, cancelJob);
|
|
if (jobs.some((j) => j.state === "running")) ensurePolling();
|
|
else stopPolling();
|
|
}
|
|
|
|
function jobCard(j) {
|
|
const running = j.state === "running";
|
|
const pct = j.progress != null ? Math.round(j.progress * 100) : null;
|
|
// det = solid bar at a known %, run = indeterminate slider, ok/err = terminal.
|
|
let bar;
|
|
if (j.state === "done") {
|
|
bar = h("div", { class: "pbar-fill ok", style: "width:100%" });
|
|
} else if (running && pct != null) {
|
|
bar = h("div", { class: "pbar-fill det", style: `width:${pct}%` });
|
|
} else if (running) {
|
|
bar = h("div", { class: "pbar-fill run" }); // CSS drives width + animation
|
|
} else {
|
|
bar = h("div", { class: "pbar-fill err", style: "width:100%" }); // error / cancelled
|
|
}
|
|
const barWrap = h("div", { class: "pbar" }, bar);
|
|
const badge = h(
|
|
"span",
|
|
{ class: "badge " + (j.state === "done" ? "" : running ? "" : "bad") },
|
|
stateLabel(j.state) +
|
|
(running && pct != null ? " · " + pct + "%" : "") +
|
|
(j.size_bytes ? " · " + fmtBytes(j.size_bytes) : "")
|
|
);
|
|
const phase = h("div", { class: "muted small" }, jobStatusText(j));
|
|
const logEl = h("pre", { class: "conv-log" }, (j.log || []).slice(-10).join("\n"));
|
|
|
|
const actions = [];
|
|
if (running) {
|
|
const c = h("button", { class: "btn ghost sm" }, "✕ " + t("import.cancel"));
|
|
c.addEventListener("click", async () => {
|
|
c.disabled = true;
|
|
try {
|
|
await api.cancelImport(j.id);
|
|
toast(t("import.cancelling"), "warn");
|
|
refreshJobs();
|
|
} catch (e) {
|
|
toast(e.message, "err");
|
|
c.disabled = false;
|
|
}
|
|
});
|
|
actions.push(c);
|
|
}
|
|
if (j.state === "done") {
|
|
const r = h("button", { class: "btn primary sm" }, "✓ " + t("import.register"));
|
|
r.addEventListener("click", async () => {
|
|
r.disabled = true;
|
|
try {
|
|
const rr = await api.registerImport(j.id);
|
|
toast(t("import.registered", { id: rr.model_id }), "ok");
|
|
} catch (e) {
|
|
toast(e.message, "err");
|
|
r.disabled = false;
|
|
}
|
|
});
|
|
actions.push(r);
|
|
}
|
|
if (!running) {
|
|
// delete the job + its converted .cmf file(s) from disk
|
|
const d = h("button", { class: "btn ghost sm danger" }, "🗑 " + t("import.delete"));
|
|
d.addEventListener("click", async () => {
|
|
if (!window.confirm(t("import.deleteConfirm"))) return;
|
|
d.disabled = true;
|
|
try {
|
|
await api.deleteImport(j.id);
|
|
toast(t("import.deleted"), "ok");
|
|
refreshJobs();
|
|
} catch (e) {
|
|
toast(e.message, "err");
|
|
d.disabled = false;
|
|
}
|
|
});
|
|
actions.push(d);
|
|
}
|
|
|
|
return h(
|
|
"div",
|
|
{ class: "job" },
|
|
h("div", { class: "job-head" }, h("b", {}, j.repo || j.id), badge),
|
|
barWrap,
|
|
phase,
|
|
logEl,
|
|
actions.length ? h("div", { class: "actions" }, ...actions) : null
|
|
);
|
|
}
|
|
|
|
function renderJobs(jobs) {
|
|
if (!jobs.length) {
|
|
mount(jobsHost);
|
|
return;
|
|
}
|
|
mount(
|
|
jobsHost,
|
|
h(
|
|
"div",
|
|
{ class: "card" },
|
|
h("div", { class: "card-title" }, t("import.jobs")),
|
|
...jobs.map(jobCard)
|
|
)
|
|
);
|
|
}
|
|
|
|
// ── featured: the infosave account, read live so new releases just show up ──
|
|
const featuredGrid = h("div", { class: "hf-grid" }, h("div", { class: "muted pad" }, t("import.searching")));
|
|
const reloadBtn = h("button", { class: "btn ghost sm" }, "↻ " + t("common.refresh"));
|
|
const featuredHost = h(
|
|
"div",
|
|
{ class: "card featured-section" },
|
|
h("div", { class: "card-head", style: "display:flex; align-items:center; gap:10px" },
|
|
h("div", { class: "card-title", style: "margin:0" }, "⚡ " + t("import.featuredTitle")),
|
|
h("div", { style: "flex:1" }),
|
|
reloadBtn),
|
|
h("div", { class: "muted small", style: "margin:2px 0 10px" }, t("import.featuredSub")),
|
|
featuredGrid
|
|
);
|
|
|
|
async function startFeatured(m, v) {
|
|
try {
|
|
const variant = await startFeaturedDownload(m, v);
|
|
toast(t("import.started") + (variant ? ` · ${variant}` : ""), "ok");
|
|
await refreshJobs();
|
|
jobsHost.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
|
} catch (e) { toast(e.message, "err"); }
|
|
}
|
|
|
|
async function deleteFeatured(m, v) {
|
|
const name = (v && v.local_name) || m.local_name || m.id;
|
|
if (!window.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 renderFeatured();
|
|
await refreshJobs();
|
|
} catch (e) { toast(e.message, "err"); }
|
|
}
|
|
|
|
async function renderFeatured(reload = true) {
|
|
if (reload) featured = await loadFeatured();
|
|
let sys = null;
|
|
try { sys = await api.system(); } catch { /* metrics optional */ }
|
|
featuredHosts = new Map();
|
|
const ctx = { hosts: featuredHosts, onDownload: startFeatured, onDelete: deleteFeatured, sys };
|
|
mount(featuredGrid, ...(featured.length
|
|
? featured.map((m) => featuredCard(m, ctx))
|
|
: [h("div", { class: "muted pad" }, t("import.noResults"))]));
|
|
paintFeaturedHosts(featuredHosts, lastJobs, cancelJob);
|
|
}
|
|
reloadBtn.addEventListener("click", async () => {
|
|
reloadBtn.disabled = true;
|
|
mount(featuredGrid, h("div", { class: "muted pad" }, t("import.searching")));
|
|
await renderFeatured();
|
|
reloadBtn.disabled = false;
|
|
});
|
|
|
|
const root = h(
|
|
"div",
|
|
{},
|
|
h("div", { class: "view-head" },
|
|
h("h2", {}, t("nav.import")),
|
|
h("div", { class: "muted" }, t("import.subtitle"))),
|
|
jobsHost, // progress panel on top, above the search
|
|
featuredHost, // featured ready-to-run models
|
|
h("div", { class: "card" }, searchIn, results),
|
|
formHost
|
|
);
|
|
doSearch(); // initial trending list (mounts into `results`)
|
|
renderFeatured(); // ready-made .cmf models, fetched live
|
|
refreshJobs(); // resume: show any running/recent conversions immediately
|
|
return root;
|
|
}
|
|
|