Files
cortiq-gateway/web/js/views/featured.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

206 lines
8.9 KiB
JavaScript
Vendored

// Ready-to-run .cmf models, read live from the infosave HuggingFace account via
// /setup/featured — so a newly published model shows up without a code change.
// Shared by the first-run wizard and the Import view: same cards, same download
// / progress / delete behaviour in both places.
import { h, mount, toast } from "../ui.js";
import { t } from "../i18n.js";
import { api } from "../api.js";
export async function loadFeatured() {
try {
const r = await api.setupFeatured();
return r.models || [];
} catch {
return [];
}
}
export function quantFromVariant(name) {
const s = (name || "").toLowerCase();
if (s.includes("q1t")) return "Q1T";
if (s.includes("q2tp")) return "Q2TP";
if (s.includes("q4tp")) return "Q4TP";
if (s.includes("q4t")) return "Q4T";
if (s.includes("q1")) return "Q1";
if (s.includes("q4")) return "Q4";
if (s.includes("q8")) return "Q8";
return "";
}
// Same thresholds as the backend's format_bytes, so card and job agree.
export function fmtBytes(n) {
if (!n && n !== 0) return "";
if (n >= 1e9) return (n / 1e9).toFixed(1) + " GB";
if (n >= 1e6) return Math.round(n / 1e6) + " MB";
if (n >= 1e3) return Math.round(n / 1e3) + " KB";
return n + " B";
}
export function jobPct(j) {
if (j.progress == null) return null;
return Math.max(0, Math.min(100, Math.round(j.progress * 100)));
}
/// "downloading · 42% · 1.4 / 3.2 GB" — whatever of it the job actually knows.
/// A settled job shows its result, not the phase it stopped in.
export function jobStatusText(j) {
const parts = [];
if (j.state === "running") {
if (j.phase) parts.push(j.phase);
const pct = jobPct(j);
if (pct != null) parts.push(pct + "%");
if (j.total_bytes) parts.push(`${fmtBytes(j.done_bytes || 0)} / ${fmtBytes(j.total_bytes)}`);
} else if (j.state === "done" && j.size_bytes) {
parts.push(fmtBytes(j.size_bytes));
}
return parts.join(" · ");
}
export function jobBar(j) {
const pct = jobPct(j);
if (j.state === "done") return h("div", { class: "pbar" }, h("div", { class: "pbar-fill ok", style: "width:100%" }));
if (j.state !== "running") return h("div", { class: "pbar" }, h("div", { class: "pbar-fill err", style: "width:100%" }));
// known percentage → determinate fill; unknown (listing files) → animated slider
return h("div", { class: "pbar" },
pct != null
? h("div", { class: "pbar-fill det", style: `width:${Math.max(pct, 1)}%` })
: h("div", { class: "pbar-fill run" })
);
}
/// HF gives `desc` as a bare pipeline tag ("text-generation") for live entries and
/// a real sentence for curated ones. A one-word tag belongs in the badge row, not
/// on a line of its own under every card.
function descBadge(desc) {
return desc && !desc.includes(" ") ? h("span", { class: "badge" }, desc) : null;
}
function descLine(desc) {
return desc && desc.includes(" ") ? h("div", { class: "muted small", style: "margin:6px 0" }, desc) : null;
}
/// Disk/RAM sanity for a model of `bytes` size against the live system
/// snapshot (ctx.sys): running a model needs it in RAM; downloading needs disk.
function fitWarnings(bytes, sys) {
const out = [];
if (!bytes || !sys) return out;
if (sys.disk_free && bytes > sys.disk_free)
out.push(h("span", { class: "badge bad", title: t("fit.diskTitle") }, "⚠ " + t("fit.disk")));
else if (sys.ram_total && bytes > sys.ram_total * 0.75)
out.push(h("span", { class: "badge warn", title: t("fit.ramTitle") }, "⚠ " + t("fit.ram")));
return out;
}
/// One model. `ctx` = { hosts: Map(jobId → element), onDownload(m, v), onDelete(m, v), sys? }.
/// Live progress lands in the per-variant host so a poll can repaint it in place,
/// without re-rendering the grid under the user's cursor.
export function featuredCard(m, ctx) {
const badge = m.badge || "CMF";
const label = m.label || m.id;
const desc = m.desc || "";
const variants = m.variants && m.variants.length ? m.variants : null;
const size = m.size || (m.size_bytes ? fmtBytes(m.size_bytes) : "");
const isDownloaded = !!m.downloaded;
const progressHost = (v) => {
const host = h("div", { class: "card-prog" });
const jid = (v && v.job) || m.job;
if (jid) ctx.hosts.set(jid, host);
return host;
};
// multi-variant (e.g. DeepSeek with parts-q2tp / parts-q4tp) → separate buttons per variant
if (variants && variants.length > 1) {
return h("div", { class: "hf-card featured-card" + (isDownloaded ? " sel" : "") },
h("div", { class: "hf-id" }, label, isDownloaded ? h("span", { class: "badge ok", style: "margin-left:6px" }, "✓") : null),
h("div", { class: "hf-meta" },
h("span", { class: "badge" }, badge),
descBadge(desc),
...fitWarnings(m.size_bytes, ctx.sys),
isDownloaded ? h("span", { class: "badge ok" }, "✓ " + t("onboarding.downloaded")) : null),
descLine(desc),
h("div", { class: "muted small" }, m.id),
h("div", { style: "display:flex; flex-direction:column; gap:6px; margin-top:8px" },
...variants.map((v) => {
const q = quantFromVariant(v.name) || badge;
const done = !!v.downloaded;
const upd = !!v.update_available;
const lab = done
? (upd ? `↻ ${t("onboarding.update")} ${q} · ${v.size}` : `✓ ${q} · ${v.size}`)
: `⬇ ${q} · ${v.size}`;
const btn = h("button", {
class: "btn sm " + (done && !upd ? "ghost" : "primary"),
disabled: done && !upd ? true : null,
onClick: () => ctx.onDownload(m, v),
title: v.name + (done ? " — " + t("onboarding.downloaded") : ""),
}, lab);
const row = h("div", { style: "display:flex; gap:6px; align-items:center; flex-wrap:wrap" }, btn);
if (done) {
const del = h("button", { class: "btn sm ghost danger", title: t("onboarding.deleteFile") }, "🗑");
del.addEventListener("click", () => ctx.onDelete(m, v));
row.appendChild(del);
}
return h("div", {}, row, progressHost(v));
})
)
);
}
const v0 = variants && variants[0];
const done0 = !!(v0 && v0.downloaded) || isDownloaded;
const upd0 = !!(v0 && v0.update_available) || !!m.update_available;
const q0 = quantFromVariant((v0 && v0.name) || "") || badge;
const size0 = (v0 && v0.size) || size;
const btn0 = h("button", {
class: "btn sm " + (done0 && !upd0 ? "ghost" : "primary"),
disabled: done0 && !upd0 ? true : null,
onClick: () => ctx.onDownload(m, v0),
title: done0 && !upd0 ? t("onboarding.downloaded") : ""
}, done0
? (upd0 ? `↻ ${t("onboarding.update")} ${q0} · ${size0}` : `✓ ${q0} · ${size0}`)
: `⬇ ${q0}${size0 ? " · " + size0 : ""}`);
const del0 = done0 ? h("button", { class: "btn sm ghost danger", title: t("onboarding.deleteFile") }, "🗑 " + t("import.delete")) : null;
if (del0) del0.addEventListener("click", () => ctx.onDelete(m, v0));
return h("div", { class: "hf-card featured-card" + (done0 ? " sel" : "") },
h("div", { class: "hf-id" }, label, done0 ? h("span", { class: "badge ok", style: "margin-left:6px" }, "✓") : null),
h("div", { class: "hf-meta" },
h("span", { class: "badge" }, badge),
size0 ? h("span", { class: "badge", style: "background:var(--bg); border-style:dashed" + (done0 ? "; opacity:.7" : "") }, (done0 ? "✓ " : "⬇ ") + size0) : null,
descBadge(desc),
...(done0 ? [] : fitWarnings((v0 && v0.size_bytes) || m.size_bytes, ctx.sys))
),
descLine(desc),
h("div", { class: "muted small" }, m.id),
h("div", { style: "display:flex; gap:6px; margin-top:8px; align-items:center; flex-wrap:wrap" }, btn0, del0),
progressHost(v0)
);
}
/// Repaint the per-variant progress hosts from the current job list.
export function paintFeaturedHosts(hosts, jobs, onCancel) {
const byId = new Map(jobs.map((j) => [j.id, j]));
for (const [id, host] of hosts) {
const j = byId.get(id);
if (!j || j.state !== "running") { mount(host); continue; }
const cancel = h("button", { class: "btn sm ghost", style: "margin-left:auto", title: t("import.cancel") }, "✕");
cancel.addEventListener("click", async () => {
cancel.disabled = true;
try { await onCancel(j); } catch (e) { toast(e.message, "err"); cancel.disabled = false; }
});
mount(host,
jobBar(j),
h("div", { style: "display:flex; align-items:center; gap:8px" },
h("span", { class: "muted small" }, jobStatusText(j)),
cancel
)
);
}
}
/// Start the download for a featured variant. `local_name` is the stem the
/// gateway itself derived, sent back verbatim so download / progress / delete
/// can never disagree about which file this is.
export async function startFeaturedDownload(m, v) {
const fallbackBase = m.id.split("/").pop().toLowerCase().replace(/[^a-z0-9_-]/g, "-");
const name = (v && v.local_name) || m.local_name || fallbackBase;
const variant = v && v.name ? v.name : undefined;
await api.startImport({ repo: m.id, quant: "Q1", name, variant });
return variant;
}