Files
cortiq-gateway/web/js/views/playground.js
T
infosave2007 a77beeabab feat(playground): image & video generation tabs + real chat metrics
The infosave collection now ships media models (Lumina-Image-2.0, MiniMax-H3
Turbo) that cortiq runs via `imagine`/`animate` CLI, not `cortiq serve`. The
gateway grows a media API: local .cmf files are classified by `cortiq info`
(chat/image/video, cached), generations run as tracked jobs with step progress
parsed from stdout, one at a time, cancellable, outputs under <data>/media.

The playground becomes three tabs. Image: width/height/steps/CFG/seed, live
progress, result rendered from P6 PPM to a canvas in the browser (PNG export
via toBlob). Video: frames/steps/quality/seed plus optional first/last
keyframes (rasterized to PPM client-side); the MJPEG+PCM AVI is decoded in JS
and played on a canvas synced to the wav — no ffmpeg on either side (mp4 remux
happens opportunistically when ffmpeg exists). A metrics card shows resolution,
steps, seed, total time, time per step/frame and file size.

Chat metrics: the local cortiq server sends no usage chunk in streams, so
token counts showed 0/0. tap_stream now injects one OpenAI-shaped usage event
ahead of [DONE] with the gateway's ~4-chars/token estimate, flagged
`cortiq_estimated`; the UI renders it as "≈ N / M" and adds tok/s and
time-to-first-token. Registration skips media packs (a `cortiq serve` entry
for a diffusion model would only fail) in both Register and the wizard's
Finish.

36 new UI strings translated across all 7 languages.
2026-08-07 15:55:06 +03:00

547 lines
23 KiB
JavaScript
Vendored
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Playground — three tabs over the live gateway:
// chat → the routing pipeline (with streaming, token counts, tok/s, TTFT)
// image → `cortiq imagine` over a local text-to-image .cmf (Lumina)
// video → `cortiq animate` over a local text-to-video .cmf (MiniMax-H3)
// Media results are decoded in the browser (PPM → canvas, MJPEG AVI → canvas
// player synced to the wav) — the same no-ffmpeg stance as the engine itself.
import { h, mount, toast, money, ms } from "../ui.js";
import { t } from "../i18n.js";
import { api, testStream, mediaFileUrl, mediaFileBytes } from "../api.js";
import { appState } from "../app.js";
import { ppmToCanvas, fileToPpmB64, aviJpegFrames, mjpegPlayer } from "../media-utils.js";
function fmtBytes(n) {
if (!n && n !== 0) return "—";
if (n >= 1e9) return (n / 1e9).toFixed(2) + " GB";
if (n >= 1e6) return (n / 1e6).toFixed(1) + " MB";
if (n >= 1e3) return Math.round(n / 1e3) + " KB";
return n + " B";
}
function kv(k, v) {
return h("div", { class: "kv" }, h("span", { class: "k" }, k), h("span", { class: "v" }, v));
}
function field(label, control, hint) {
return h("label", { class: "field" }, h("span", {}, label), control, hint ? h("div", { class: "hint" }, hint) : null);
}
export async function renderPlayground() {
const meta = appState.meta || (await api.meta());
const modelsData = await api.listModels();
const ids = (modelsData.models || []).map((m) => m.id);
let mediaModels = [];
try {
mediaModels = (await api.mediaModels()).models || [];
} catch {
/* media API unavailable — tabs will show the hint */
}
const imageModels = mediaModels.filter((m) => m.modality === "image");
const videoModels = mediaModels.filter((m) => m.modality === "video");
const root = h("div");
let tab = "chat";
// ── tabs ──
const tabsSeg = h(
"div",
{ class: "seg" },
h("button", { class: "active", "data-tab": "chat" }, "💬 " + t("pg.tab.chat")),
h("button", { "data-tab": "image" }, "🖼 " + t("pg.tab.image")),
h("button", { "data-tab": "video" }, "🎬 " + t("pg.tab.video"))
);
const panelHost = h("div", {});
tabsSeg.querySelectorAll("button").forEach((b) =>
b.addEventListener("click", () => {
tab = b.dataset.tab;
tabsSeg.querySelectorAll("button").forEach((x) => x.classList.toggle("active", x === b));
mount(panelHost, panels[tab]);
})
);
// ════════════════════════════ chat panel ════════════════════════════
let mode = "auto";
const sysIn = h("textarea", { placeholder: t("pg.systemPlaceholder"), rows: 2 });
const promptIn = h("textarea", { placeholder: t("pg.promptPlaceholder"), rows: 5 });
const tempIn = h("input", { type: "number", step: "0.1", value: 0.7 });
const maxTokIn = h("input", { type: "number", value: 512 });
const streamCb = h("input", { type: "checkbox", checked: true });
const profileSel = h("select", {}, ...(meta.profiles || []).map((p) => h("option", { value: p }, p)));
const modelSel = h("select", {}, ...ids.map((id) => h("option", { value: id }, id)));
const modeSeg = h(
"div",
{ class: "seg" },
h("button", { class: "active", onClick: (e) => setMode("auto", e) }, t("pg.mode.auto")),
h("button", { onClick: (e) => setMode("pinned", e) }, t("pg.mode.pinned"))
);
function buildModeExtra() {
return h(
"label",
{ class: "field" },
h("span", {}, mode === "auto" ? t("pg.profile") : t("pg.model")),
mode === "auto" ? profileSel : modelSel
);
}
let modeExtra = buildModeExtra();
function setMode(m, e) {
mode = m;
modeSeg.querySelectorAll("button").forEach((b) => b.classList.remove("active"));
e.target.classList.add("active");
const next = buildModeExtra();
modeExtra.replaceWith(next);
modeExtra = next;
}
const out = h("div", { class: "answer" }, 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" }, "—"));
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 });
let model = "cortiq-auto";
if (mode === "auto") {
if (profileSel.value) model = "cortiq-auto:" + profileSel.value;
} else {
model = modelSel.value;
}
sendBtn.disabled = true;
sendBtn.textContent = t("pg.sending");
mount(out, h("div", { class: "flex" }, h("span", { class: "spinner" }), " ", t("pg.sending")));
const body = {
model,
messages,
temperature: parseFloat(tempIn.value),
max_tokens: parseInt(maxTokIn.value) || null,
};
try {
if (streamCb.checked) {
const started = performance.now();
let acc = "";
out.textContent = "";
const info = await testStream(body, (d) => {
acc += d;
out.textContent = acc;
});
out.textContent = acc || "—";
const latency = Math.round(performance.now() - started);
renderDecision({
cortiq: {
task_label: info.task_label,
complexity: { score: info.score, tier: info.tier },
selected_model: info.selected_model,
route_source: info.route_source,
cost_usd: info.cost_usd,
failover: false,
},
usage: info.usage || {},
usage_estimated: info.usage_estimated,
latency_ms: latency,
ttft_ms: info.ttft_ms,
});
} else {
const r = await api.test(body);
if (r.ok) {
mount(out, r.answer || h("span", { class: "muted" }, "—"));
renderDecision(r);
} else {
mount(out, 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)));
}
sendBtn.disabled = false;
sendBtn.textContent = t("pg.send");
});
function renderDecision(r) {
const c = r.cortiq || {};
const score = c.complexity?.score ?? 0;
const u = r.usage || {};
const haveUsage = u.completion_tokens != null;
// generation speed over the produce phase (after first token when known)
let tokS = null;
if (haveUsage && r.latency_ms) {
const genMs = r.ttft_ms != null ? Math.max(1, r.latency_ms - r.ttft_ms) : r.latency_ms;
tokS = (u.completion_tokens / (genMs / 1000)).toFixed(1);
}
mount(
decision,
h("div", { class: "card-head" }, h("h3", {}, t("pg.decision"))),
kv(t("pg.task"), c.task_label || "—"),
kv(t("pg.tier"), c.complexity?.tier ? h("span", { class: "badge tier-" + c.complexity.tier }, c.complexity.tier) : "—"),
h(
"div",
{ class: "kv", style: "display:block" },
h("div", { class: "flex" }, h("span", { class: "k grow" }, t("pg.score")), h("span", { class: "v" }, score.toFixed(2))),
h("div", { class: "scorebar" }, h("i", { style: `width:${Math.min(100, score * 100)}%` }))
),
kv(t("pg.selected"), h("span", { class: "mono" }, c.selected_model || "—")),
kv(t("pg.source"), c.route_source || "—"),
kv(t("pg.tokens"), haveUsage ? `${r.usage_estimated ? "≈ " : ""}${u.prompt_tokens ?? 0} / ${u.completion_tokens ?? 0}` : "—"),
kv(t("pg.tokSpeed"), tokS != null ? tokS + " tok/s" : "—"),
kv(t("pg.ttft"), r.ttft_ms != null ? ms(r.ttft_ms) : "—"),
kv(t("pg.cost"), money(c.cost_usd)),
kv(t("pg.latency"), ms(r.latency_ms)),
kv(t("pg.failover"), c.failover ? "yes" : "no")
);
}
const chatPanel = h(
"div",
{ class: "pg" },
h(
"div",
{},
h(
"div",
{ class: "card" },
field(t("pg.system"), sysIn),
field(t("pg.prompt"), promptIn),
field(t("pg.mode"), modeSeg),
modeExtra,
h("div", { class: "row" }, field(t("pg.temp"), tempIn), field(t("pg.maxTokens"), maxTokIn)),
h(
"div",
{ class: "flex", style: "justify-content:space-between" },
h("label", { class: "check", style: "margin:0" }, streamCb, t("pg.stream")),
sendBtn
)
),
h("div", { class: "card" }, h("div", { class: "card-head" }, h("h3", {}, t("pg.answer"))), out)
),
decision
);
// ═══════════════════════ shared media machinery ═══════════════════════
let mediaPoll = null;
function stopMediaPoll() {
if (mediaPoll) clearInterval(mediaPoll);
mediaPoll = null;
}
// one generation at a time server-side → one active job client-side
function watchJob(id, ui, onDone) {
stopMediaPoll();
const started = performance.now();
mediaPoll = setInterval(async () => {
if (!root.isConnected) return stopMediaPoll();
let j;
try {
j = await api.mediaJob(id);
} catch {
return;
}
const secs = ((performance.now() - started) / 1000).toFixed(0);
const pct = j.progress != null ? Math.round(j.progress * 100) : null;
mount(
ui.bar,
h("div", { class: "pbar" },
j.state === "running"
? (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%" })
),
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
);
if (j.state !== "running") {
stopMediaPoll();
ui.goBtn.disabled = false;
ui.cancelBtn.style.display = "none";
if (j.state === "done") onDone(j);
else if (j.state === "error") toast(t("pg.genFailed"), "err");
}
}, 1000);
}
function mediaControls(goBtn, onCancelId) {
const cancelBtn = h("button", { class: "btn ghost", style: "display:none" }, "✕ " + t("import.cancel"));
cancelBtn.addEventListener("click", async () => {
try {
await api.mediaCancel(onCancelId.id);
} catch (e) {
toast(e.message, "err");
}
});
return cancelBtn;
}
function noModelsHint(kindLabel) {
return h(
"div",
{ class: "card" },
h("div", { class: "callout" },
t("pg.noMediaModels", { kind: kindLabel }),
" ",
h("a", { href: "#/import" }, t("settings.cmf.importLink") + " ↗"))
);
}
function dice(seedIn) {
const b = h("button", { class: "btn ghost sm", title: t("pg.randomSeed") }, "🎲");
b.addEventListener("click", () => (seedIn.value = Math.floor(Math.random() * 1e9)));
return b;
}
// ════════════════════════════ image panel ════════════════════════════
let imagePanel;
if (!imageModels.length) {
imagePanel = noModelsHint(t("pg.tab.image"));
} else {
const imModel = h("select", {}, ...imageModels.map((m) => h("option", { value: m.name }, `${m.name} · ${fmtBytes(m.size_bytes)}`)));
const imPrompt = h("textarea", { placeholder: t("pg.imagePromptPlaceholder"), rows: 3 });
const imW = h("select", {}, ...[256, 384, 512, 640, 768, 1024].map((v) => h("option", { value: v, selected: v === 512 ? true : null }, v)));
const imH = h("select", {}, ...[256, 384, 512, 640, 768, 1024].map((v) => h("option", { value: v, selected: v === 512 ? true : null }, v)));
const imSteps = h("input", { type: "number", min: 1, max: 100, value: 30 });
const imCfg = h("input", { type: "number", step: "0.5", value: 4 });
const imSeed = h("input", { type: "number", value: 42 });
const imBar = h("div", {});
const imOut = h("div", { class: "media-out" }, h("div", { class: "empty" }, t("pg.imageEmpty")));
const imMetrics = h("div", { class: "card routecard" }, h("div", { class: "card-head" }, h("h3", {}, t("pg.genMetrics"))), h("div", { class: "empty" }, "—"));
const imGo = h("button", { class: "btn primary" }, "🖼 " + t("pg.generate"));
const imCancelRef = { id: null };
const imCancel = mediaControls(imGo, imCancelRef);
imGo.addEventListener("click", async () => {
const prompt = imPrompt.value.trim();
if (!prompt) return;
imGo.disabled = true;
imCancel.style.display = "";
mount(imOut, h("div", { class: "flex" }, h("span", { class: "spinner" }), " ", t("pg.generating")));
const params = {
kind: "image",
model: imModel.value,
prompt,
width: +imW.value,
height: +imH.value,
steps: +imSteps.value || 30,
cfg: parseFloat(imCfg.value) || 4,
seed: +imSeed.value || 42,
};
try {
const r = await api.mediaGenerate(params);
imCancelRef.id = r.job;
const t0 = performance.now();
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)));
}
});
} catch (e) {
imGo.disabled = false;
imCancel.style.display = "none";
mount(imOut, h("div", { class: "callout" }, String(e.message || e)));
}
});
imagePanel = h(
"div",
{ class: "pg" },
h(
"div",
{},
h(
"div",
{ class: "card" },
field(t("pg.model"), imModel),
field(t("pg.prompt"), imPrompt),
h("div", { class: "row" }, field(t("pg.width"), imW), field(t("pg.height"), imH)),
h("div", { class: "row" },
field(t("pg.steps"), imSteps),
field("CFG", imCfg, t("pg.cfgHint"))),
h("div", { class: "row" },
field(t("pg.seed"), h("div", { class: "flex", style: "gap:6px" }, imSeed, dice(imSeed)))),
h("div", { class: "actions" }, imCancel, imGo),
imBar
),
h("div", { class: "card" }, h("div", { class: "card-head" }, h("h3", {}, t("pg.result"))), imOut)
),
imMetrics
);
}
// ════════════════════════════ video panel ════════════════════════════
let videoPanel;
if (!videoModels.length) {
videoPanel = noModelsHint(t("pg.tab.video"));
} else {
const vModel = h("select", {}, ...videoModels.map((m) => h("option", { value: m.name }, `${m.name} · ${fmtBytes(m.size_bytes)}`)));
const vPrompt = h("textarea", { placeholder: t("pg.videoPromptPlaceholder"), rows: 3 });
const vW = h("input", { type: "number", step: 32, value: 512 });
const vH = h("input", { type: "number", step: 32, value: 288 });
const vFrames = h("input", { type: "number", value: 39 });
const vSteps = h("input", { type: "number", value: 4 });
const vSeed = h("input", { type: "number", value: 42 });
const vQuality = h("input", { type: "number", min: 10, max: 100, value: 92 });
const vFirst = h("input", { type: "file", accept: "image/*" });
const vLast = h("input", { type: "file", accept: "image/*" });
const vBar = h("div", {});
const vOut = h("div", { class: "media-out" }, h("div", { class: "empty" }, t("pg.videoEmpty")));
const vMetrics = h("div", { class: "card routecard" }, h("div", { class: "card-head" }, h("h3", {}, t("pg.genMetrics"))), h("div", { class: "empty" }, "—"));
const vGo = h("button", { class: "btn primary" }, "🎬 " + t("pg.generate"));
const vCancelRef = { id: null };
const vCancel = mediaControls(vGo, vCancelRef);
vGo.addEventListener("click", async () => {
const prompt = vPrompt.value.trim();
if (!prompt) return;
vGo.disabled = true;
vCancel.style.display = "";
mount(vOut, h("div", { class: "flex" }, h("span", { class: "spinner" }), " ", t("pg.generating")));
const params = {
kind: "video",
model: vModel.value,
prompt,
width: +vW.value || 512,
height: +vH.value || 288,
frames: +vFrames.value || 39,
steps: +vSteps.value || 4,
seed: +vSeed.value || 42,
quality: +vQuality.value || 92,
};
try {
if (vFirst.files[0]) params.first_frame_b64 = await fileToPpmB64(vFirst.files[0], params.width, params.height);
if (vLast.files[0]) params.last_frame_b64 = await fileToPpmB64(vLast.files[0], params.width, params.height);
const r = await api.mediaGenerate(params);
vCancelRef.id = r.job;
const t0 = performance.now();
watchJob(r.job, { bar: vBar, goBtn: vGo, cancelBtn: vCancel }, async (j) => {
mount(vBar);
const secs = (performance.now() - t0) / 1000;
try {
await showVideoResult(j, vOut);
const durS = (j.frames || 0) / 24;
mount(
vMetrics,
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.frames"), `${j.frames} @ 24 fps`),
kv(t("pg.duration"), durS.toFixed(1) + " s"),
kv(t("pg.steps"), String(j.steps)),
kv(t("pg.seed"), String(j.seed)),
kv(t("pg.genTime"), secs.toFixed(1) + " s"),
kv(t("pg.timePerFrame"), j.frames ? (secs / j.frames).toFixed(2) + " s" : "—"),
kv(t("pg.fileSize"), fmtBytes(j.out_bytes))
);
} catch (e) {
mount(vOut, h("div", { class: "callout" }, String(e.message || e)));
}
});
} catch (e) {
vGo.disabled = false;
vCancel.style.display = "none";
mount(vOut, h("div", { class: "callout" }, String(e.message || e)));
}
});
async function showVideoResult(j, host) {
const downloads = h("div", { class: "actions" });
for (const kind of j.outputs || []) {
const label = { video: "⬇ AVI", audio: "⬇ WAV", mp4: "⬇ MP4" }[kind];
if (!label) continue;
const b = h("button", { class: "btn sm" }, label);
b.addEventListener("click", async () => {
const { url } = await mediaFileUrl(j.id, kind);
const ext = kind === "video" ? "avi" : kind === "audio" ? "wav" : "mp4";
h("a", { href: url, download: `cortiq-${j.id}.${ext}` }).click();
});
downloads.appendChild(b);
}
if ((j.outputs || []).includes("mp4")) {
// ffmpeg was present server-side → native playback
const { url } = await mediaFileUrl(j.id, "mp4");
mount(host, h("video", { src: url, controls: true, class: "media-canvas" }), downloads);
return;
}
// no ffmpeg anywhere: decode the MJPEG AVI ourselves, sync to the wav
const buf = await mediaFileBytes(j.id, "video");
const frames = aviJpegFrames(buf);
let audio = null;
if ((j.outputs || []).includes("audio")) {
const { url } = await mediaFileUrl(j.id, "audio");
audio = h("audio", { src: url });
}
const player = mjpegPlayer(frames, 24, audio);
player.canvas.className = "media-canvas";
await player.seek0();
const playBtn = h("button", { class: "btn primary sm" }, "▶ " + t("pg.play"));
playBtn.addEventListener("click", async () => {
if (player.state.playing) {
player.pause();
playBtn.textContent = "▶ " + t("pg.play");
} else {
playBtn.textContent = "⏸";
await player.play();
}
});
mount(host, player.canvas, h("div", { class: "actions" }, playBtn, ...downloads.childNodes ? [downloads] : []));
}
videoPanel = h(
"div",
{ class: "pg" },
h(
"div",
{},
h(
"div",
{ class: "card" },
field(t("pg.model"), vModel),
field(t("pg.prompt"), vPrompt),
h("div", { class: "row" }, field(t("pg.width"), vW, t("pg.videoSizeHint")), field(t("pg.height"), vH)),
h("div", { class: "row" }, field(t("pg.frames"), vFrames, t("pg.framesHint")), field(t("pg.steps"), vSteps, t("pg.videoStepsHint"))),
h("div", { class: "row" },
field(t("pg.seed"), h("div", { class: "flex", style: "gap:6px" }, vSeed, dice(vSeed))),
field(t("pg.quality"), vQuality)),
h("div", { class: "row" }, field(t("pg.firstFrame"), vFirst, t("pg.keyframeHint")), field(t("pg.lastFrame"), vLast)),
h("div", { class: "actions" }, vCancel, vGo),
vBar
),
h("div", { class: "card" }, h("div", { class: "card-head" }, h("h3", {}, t("pg.result"))), vOut)
),
vMetrics
);
}
const panels = { chat: chatPanel, image: imagePanel, video: videoPanel };
mount(
root,
h("div", { class: "page-head" }, h("h2", {}, t("pg.title")), h("p", {}, t("pg.subtitle"))),
tabsSeg,
panelHost
);
mount(panelHost, panels.chat);
return root;
}