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:
Vendored
+172
-46
@@ -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
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user