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.
This commit is contained in:
infosave2007
2026-08-07 17:07:48 +03:00
parent a77beeabab
commit 15de36be3b
32 changed files with 1876 additions and 143 deletions
+172 -46
View File
@@ -91,16 +91,33 @@ export async function renderPlayground() {
modeExtra = next;
}
const out = h("div", { class: "answer" }, h("div", { class: "empty" }, t("pg.empty")));
const chatLog = []; // the running conversation: {role, content}
const out = h("div", { class: "answer chat-log" }, h("div", { class: "empty" }, t("pg.empty")));
const decision = h("div", { class: "card routecard" }, h("div", { class: "card-head" }, h("h3", {}, t("pg.decision"))), h("div", { class: "empty" }, "—"));
function bubble(role, text) {
return h("div", { class: "chat-msg " + role }, h("div", { class: "chat-bubble" }, text));
}
function renderChat(liveText) {
const items = chatLog.map((m) => bubble(m.role, m.content));
if (liveText != null) items.push(bubble("assistant", liveText || "…"));
mount(out, ...(items.length ? items : [h("div", { class: "empty" }, t("pg.empty"))]));
out.scrollTop = out.scrollHeight;
}
const clearBtn = h("button", { class: "btn ghost sm" }, "🧹 " + t("pg.clearChat"));
clearBtn.addEventListener("click", () => {
chatLog.length = 0;
renderChat();
mount(decision, h("div", { class: "card-head" }, h("h3", {}, t("pg.decision"))), h("div", { class: "empty" }, "—"));
});
const sendBtn = h("button", { class: "btn primary" }, t("pg.send"));
sendBtn.addEventListener("click", async () => {
const prompt = promptIn.value.trim();
if (!prompt) return;
const messages = [];
if (sysIn.value.trim()) messages.push({ role: "system", content: sysIn.value.trim() });
messages.push({ role: "user", content: prompt });
messages.push(...chatLog, { role: "user", content: prompt });
let model = "cortiq-auto";
if (mode === "auto") {
if (profileSel.value) model = "cortiq-auto:" + profileSel.value;
@@ -109,7 +126,9 @@ export async function renderPlayground() {
}
sendBtn.disabled = true;
sendBtn.textContent = t("pg.sending");
mount(out, h("div", { class: "flex" }, h("span", { class: "spinner" }), " ", t("pg.sending")));
chatLog.push({ role: "user", content: prompt });
promptIn.value = "";
renderChat("");
const body = {
model,
messages,
@@ -120,12 +139,12 @@ export async function renderPlayground() {
if (streamCb.checked) {
const started = performance.now();
let acc = "";
out.textContent = "";
const info = await testStream(body, (d) => {
acc += d;
out.textContent = acc;
renderChat(acc);
});
out.textContent = acc || "—";
chatLog.push({ role: "assistant", content: acc || "—" });
renderChat();
const latency = Math.round(performance.now() - started);
renderDecision({
cortiq: {
@@ -144,19 +163,33 @@ export async function renderPlayground() {
} else {
const r = await api.test(body);
if (r.ok) {
mount(out, r.answer || h("span", { class: "muted" }, "—"));
chatLog.push({ role: "assistant", content: r.answer || "—" });
renderChat();
renderDecision(r);
} else {
mount(out, h("div", { class: "callout" }, h("b", {}, t("pg.error") + ": "), r.error || ""));
chatLog.pop(); // the user turn failed — let them retry it
renderChat();
promptIn.value = prompt;
mount(out, out.firstChild || "", h("div", { class: "callout" }, h("b", {}, t("pg.error") + ": "), r.error || ""));
mount(decision, h("div", { class: "card-head" }, h("h3", {}, t("pg.decision"))), h("div", { class: "empty" }, "—"));
}
}
} catch (e) {
mount(out, h("div", { class: "callout" }, String(e.message || e)));
chatLog.pop();
renderChat();
promptIn.value = prompt;
toast(String(e.message || e), "err");
}
sendBtn.disabled = false;
sendBtn.textContent = t("pg.send");
});
// Cmd/Ctrl+Enter sends
promptIn.addEventListener("keydown", (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault();
sendBtn.click();
}
});
function renderDecision(r) {
const c = r.cortiq || {};
@@ -207,9 +240,9 @@ export async function renderPlayground() {
h("div", { class: "row" }, field(t("pg.temp"), tempIn), field(t("pg.maxTokens"), maxTokIn)),
h(
"div",
{ class: "flex", style: "justify-content:space-between" },
{ class: "flex", style: "justify-content:space-between; gap:8px" },
h("label", { class: "check", style: "margin:0" }, streamCb, t("pg.stream")),
sendBtn
h("div", { class: "flex", style: "gap:8px" }, clearBtn, sendBtn)
)
),
h("div", { class: "card" }, h("div", { class: "card-head" }, h("h3", {}, t("pg.answer"))), out)
@@ -235,8 +268,24 @@ export async function renderPlayground() {
} catch {
return;
}
const secs = ((performance.now() - started) / 1000).toFixed(0);
const elapsed = (performance.now() - started) / 1000;
const pct = j.progress != null ? Math.round(j.progress * 100) : null;
// honest ETA once there is enough signal: total ≈ elapsed / progress
let eta = "";
if (j.state === "running" && j.progress > 0.03) {
const left = elapsed / j.progress - elapsed;
if (left > 1) eta = ` · ${t("pg.eta")} ~${left > 90 ? Math.round(left / 60) + " " + t("pg.min") : Math.round(left) + " s"}`;
}
let statusLine;
if (j.state === "queued") {
const pos = (j.queue_position ?? 0) + 1;
statusLine = t("pg.queued", { n: pos });
} else {
statusLine =
(j.phase || j.state) +
(pct != null && j.state === "running" ? ` · ${pct}%` : "") +
` · ${elapsed.toFixed(0)}s` + eta;
}
mount(
ui.bar,
h("div", { class: "pbar" },
@@ -244,13 +293,14 @@ export async function renderPlayground() {
? (pct != null
? h("div", { class: "pbar-fill det", style: `width:${Math.max(pct, 1)}%` })
: h("div", { class: "pbar-fill run" }))
: h("div", { class: "pbar-fill " + (j.state === "done" ? "ok" : "err"), style: "width:100%" })
: j.state === "queued"
? h("div", { class: "pbar-fill run" })
: h("div", { class: "pbar-fill " + (j.state === "done" ? "ok" : "err"), style: "width:100%" })
),
h("div", { class: "muted small" },
(j.phase || j.state) + (pct != null && j.state === "running" ? ` · ${pct}%` : "") + ` · ${secs}s`),
(j.log || []).length ? h("pre", { class: "conv-log", style: "max-height:90px" }, j.log.slice(-5).join("\n")) : null
h("div", { class: "muted small" }, statusLine),
(j.log || []).length && j.state === "running" ? h("pre", { class: "conv-log", style: "max-height:90px" }, j.log.slice(-5).join("\n")) : null
);
if (j.state !== "running") {
if (j.state !== "running" && j.state !== "queued") {
stopMediaPoll();
ui.goBtn.disabled = false;
ui.cancelBtn.style.display = "none";
@@ -286,6 +336,65 @@ export async function renderPlayground() {
return b;
}
// ── gallery: past generations of one kind, loaded lazily ──
function buildGallery(kind, onOpen) {
const host = h("div", {});
async function refresh() {
let jobs = [];
try {
jobs = ((await api.mediaJobs()).jobs || []).filter((j) => j.kind === kind && j.state === "done");
} catch {
return;
}
if (!jobs.length) {
mount(host);
return;
}
const grid = h("div", { class: "gal-grid" });
for (const j of jobs.slice(0, 12)) {
const del = h("button", { class: "gal-del", title: t("import.delete") }, "×");
del.addEventListener("click", async (e) => {
e.stopPropagation();
if (!window.confirm(t("pg.galDeleteConfirm"))) return;
try {
await api.mediaDelete(j.id);
refresh();
} catch (err) {
toast(err.message, "err");
}
});
const cap = h("div", { class: "gal-cap" }, `${j.width}×${j.height} · seed ${j.seed}`);
const item = h("div", { class: "gal-item", title: j.prompt }, del, cap);
if (kind === "image") {
// lazy thumbnail: decode the PPM only when the item scrolls into view
const thumb = h("canvas", { class: "gal-thumb" });
item.prepend(thumb);
const io = new IntersectionObserver(async (entries) => {
if (!entries.some((x) => x.isIntersecting)) return;
io.disconnect();
try {
const buf = await mediaFileBytes(j.id, "image");
const full = ppmToCanvas(buf);
thumb.width = 128;
thumb.height = Math.round((128 * full.height) / full.width) || 128;
thumb.getContext("2d").drawImage(full, 0, 0, thumb.width, thumb.height);
} catch {
/* file gone */
}
});
io.observe(item);
} else {
item.prepend(h("div", { class: "gal-thumb gal-video" }, "🎬"));
}
item.addEventListener("click", () => onOpen(j));
grid.appendChild(item);
}
mount(host, h("div", { class: "card-title", style: "margin-top:14px" }, t("pg.gallery")), grid);
}
refresh();
return { host, refresh };
}
// ════════════════════════════ image panel ════════════════════════════
let imagePanel;
if (!imageModels.length) {
@@ -328,33 +437,8 @@ export async function renderPlayground() {
watchJob(r.job, { bar: imBar, goBtn: imGo, cancelBtn: imCancel }, async (j) => {
mount(imBar);
const secs = (performance.now() - t0) / 1000;
try {
const buf = await mediaFileBytes(j.id, "image");
const canvas = ppmToCanvas(buf);
canvas.className = "media-canvas";
const dl = h("button", { class: "btn sm" }, "⬇ PNG");
dl.addEventListener("click", () =>
canvas.toBlob((b) => {
const a = h("a", { href: URL.createObjectURL(b), download: `cortiq-${j.id}.png` });
a.click();
}, "image/png")
);
mount(imOut, canvas, h("div", { class: "actions" }, dl));
mount(
imMetrics,
h("div", { class: "card-head" }, h("h3", {}, t("pg.genMetrics"))),
kv(t("pg.model"), h("span", { class: "mono" }, j.model)),
kv(t("pg.resolution"), `${j.width}×${j.height}`),
kv(t("pg.steps"), String(j.steps)),
kv("CFG", String(params.cfg)),
kv(t("pg.seed"), String(j.seed)),
kv(t("pg.genTime"), secs.toFixed(1) + " s"),
kv(t("pg.timePerStep"), (secs / j.steps).toFixed(2) + " s"),
kv(t("pg.fileSize"), fmtBytes(j.out_bytes))
);
} catch (e) {
mount(imOut, h("div", { class: "callout" }, String(e.message || e)));
}
await showImageResult(j, secs);
imGallery.refresh();
});
} catch (e) {
imGo.disabled = false;
@@ -363,6 +447,45 @@ export async function renderPlayground() {
}
});
async function showImageResult(j, secs) {
try {
const buf = await mediaFileBytes(j.id, "image");
const canvas = ppmToCanvas(buf);
canvas.className = "media-canvas";
const dl = h("button", { class: "btn sm" }, "⬇ PNG");
dl.addEventListener("click", () =>
canvas.toBlob((b) => {
const a = h("a", { href: URL.createObjectURL(b), download: `cortiq-${j.id}.png` });
a.click();
}, "image/png")
);
mount(imOut, canvas, h("div", { class: "actions" }, dl));
const rows = [
kv(t("pg.model"), h("span", { class: "mono" }, j.model)),
kv(t("pg.resolution"), `${j.width}×${j.height}`),
kv(t("pg.steps"), String(j.steps)),
kv(t("pg.seed"), String(j.seed)),
kv(t("pg.fileSize"), fmtBytes(j.out_bytes)),
];
if (secs != null) {
rows.push(kv(t("pg.genTime"), secs.toFixed(1) + " s"));
rows.push(kv(t("pg.timePerStep"), (secs / j.steps).toFixed(2) + " s"));
} else if (j.finished && j.started) {
const dur = j.finished - j.started;
rows.push(kv(t("pg.genTime"), dur + " s"));
rows.push(kv(t("pg.timePerStep"), (dur / j.steps).toFixed(2) + " s"));
}
mount(imMetrics, h("div", { class: "card-head" }, h("h3", {}, t("pg.genMetrics"))), ...rows);
} catch (e) {
mount(imOut, h("div", { class: "callout" }, String(e.message || e)));
}
}
const imGallery = buildGallery("image", (j) => {
imSeed.value = j.seed;
imSteps.value = j.steps;
showImageResult(j, null);
});
imagePanel = h(
"div",
{ class: "pg" },
@@ -383,7 +506,7 @@ export async function renderPlayground() {
h("div", { class: "actions" }, imCancel, imGo),
imBar
),
h("div", { class: "card" }, h("div", { class: "card-head" }, h("h3", {}, t("pg.result"))), imOut)
h("div", { class: "card" }, h("div", { class: "card-head" }, h("h3", {}, t("pg.result"))), imOut, imGallery.host)
),
imMetrics
);
@@ -436,6 +559,7 @@ export async function renderPlayground() {
const t0 = performance.now();
watchJob(r.job, { bar: vBar, goBtn: vGo, cancelBtn: vCancel }, async (j) => {
mount(vBar);
vGallery.refresh();
const secs = (performance.now() - t0) / 1000;
try {
await showVideoResult(j, vOut);
@@ -507,6 +631,8 @@ export async function renderPlayground() {
mount(host, player.canvas, h("div", { class: "actions" }, playBtn, ...downloads.childNodes ? [downloads] : []));
}
const vGallery = buildGallery("video", (j) => showVideoResult(j, vOut));
videoPanel = h(
"div",
{ class: "pg" },
@@ -527,7 +653,7 @@ export async function renderPlayground() {
h("div", { class: "actions" }, vCancel, vGo),
vBar
),
h("div", { class: "card" }, h("div", { class: "card-head" }, h("h3", {}, t("pg.result"))), vOut)
h("div", { class: "card" }, h("div", { class: "card-head" }, h("h3", {}, t("pg.result"))), vOut, vGallery.host)
),
vMetrics
);