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.
206 lines
8.9 KiB
JavaScript
Vendored
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;
|
|
}
|