feat(benchmark): the built-in benchmark, redesigned into the console

The old standalone benchmark.html (Russian-only, Tailwind+Chart.js from CDNs,
never shipped in the crate) becomes a proper sidebar view. Same scoring ideas,
reimplemented: keyword checks with |-synonyms, think-tag stripping, tail-loop
detection, pass/partial/fail — plus what the old page never had: runs go
through the LIVE pipeline (pinned per model, streamed), so every test also
measures tok/s and time-to-first-token with the gateway's real token counts.

A built-in suite of 12 language-neutral probes (math, logic, code, QA,
instruction-following, extraction, translation, JSON formatting); the ⚡ quick
mode runs the 6 short ones — about a minute on a local model. Results: score
bars per model sorted best-first, a per-test grid (click a cell for the full
answer), previous runs and custom tests kept in the browser.

22 new strings in all 7 languages.
This commit is contained in:
infosave2007
2026-08-07 18:56:47 +03:00
parent 914ff42fc5
commit 1120d8f11a
10 changed files with 481 additions and 0 deletions
+6
View File
@@ -585,6 +585,12 @@ body.modal-open { overflow: hidden; }
/* Featured gallery: the list grows as new models are published, so give it more
room than the search grid before it starts scrolling. */
.featured-section .hf-grid { max-height: min(62vh, 760px); }
/* Benchmark view */
.bm-sum { margin-bottom: 10px; }
.bm-cell { cursor: pointer; white-space: nowrap; }
.bm-cell:hover { background: var(--hover); }
.badge.ok { background: rgba(52,211,153,.15); color: var(--good); }
/* Routing: per-task rule rows */
.taskrow { align-items: flex-start; }
.taskname { min-width: 190px; line-height: 1.35; }
+2
View File
@@ -12,6 +12,7 @@ import { renderKeys } from "./views/keys.js";
import { renderPlayground } from "./views/playground.js";
import { renderSettings } from "./views/settings.js";
import { renderLogs } from "./views/logs.js";
import { renderBenchmark } from "./views/benchmark.js";
import { renderOnboarding } from "./views/onboarding.js";
const ROUTES = [
@@ -22,6 +23,7 @@ const ROUTES = [
{ id: "protocols", icon: "⇆", view: renderProtocols },
{ id: "keys", icon: "🔑", view: renderKeys },
{ id: "playground", icon: "✦", view: renderPlayground },
{ id: "benchmark", icon: "⏱", view: renderBenchmark },
{ id: "logs", icon: "📜", view: renderLogs },
{ id: "settings", icon: "⚙", view: renderSettings },
{ id: "onboarding", icon: "✦", view: renderOnboarding, hidden: true },
+24
View File
@@ -563,4 +563,28 @@ export default {
"routing.tasks.builtin": "Router nicht erreichbar — eingebauter Labelsatz",
"routing.tasks.addCustom": "Aufgabe hinzufügen",
"routing.tasks.addPlaceholder": "eigenes Label…",
// benchmark view
"nav.benchmark": "Benchmark",
"bm.title": "Benchmark",
"bm.subtitle": "Qualitäts- und Tempo-Proben durch die Live-Pipeline: Antworten per Schlüsselwort geprüft, Schleifen erkannt, tok/s und Zeit bis zum ersten Token je Modell.",
"bm.models": "Zu testende Modelle",
"bm.quickMode": "Schnelltest",
"bm.quickHint": "Die schnelle Teilmenge mit kurzen Antworten — etwa eine Minute pro lokalem Modell.",
"bm.suiteInfo": "Eingebaute Suite: {q} schnelle / {f} volle Tests, plus Ihre eigenen.",
"bm.run": "Starten",
"bm.pickModel": "Wählen Sie mindestens ein Modell.",
"bm.testOf": "Test {i} von {n}",
"bm.results": "Ergebnisse",
"bm.quick": "schnell",
"bm.test": "Test",
"bm.cellHint": "Klick auf eine Zelle zeigt die vollständige Antwort.",
"bm.history": "Frühere Läufe (in diesem Browser gespeichert)",
"bm.custom": "Eigene Tests",
"bm.customHint": "Prompt + kommagetrennte Schlüsselwörter, die die Antwort enthalten muss (| für Synonyme). Im Browser gespeichert.",
"bm.promptPh": "Prompt…",
"bm.checkPh": "Schlüsselwörter, kommagetrennt",
"bm.addTest": "Test hinzufügen",
"bm.emptyAnswer": "leere Antwort",
"bm.loopDetected": "Schleife erkannt",
};
+24
View File
@@ -556,4 +556,28 @@ export default {
"routing.tasks.builtin": "router unreachable — built-in label set",
"routing.tasks.addCustom": "Add task",
"routing.tasks.addPlaceholder": "custom label…",
// benchmark view
"nav.benchmark": "Benchmark",
"bm.title": "Benchmark",
"bm.subtitle": "Quality and speed probes through the live pipeline: keyword-checked answers, anti-loop detection, tok/s and time-to-first-token per model.",
"bm.models": "Models to test",
"bm.quickMode": "Quick test",
"bm.quickHint": "The fast subset with short answers — about a minute per local model.",
"bm.suiteInfo": "Built-in suite: {q} quick / {f} full tests, plus your custom ones.",
"bm.run": "Run",
"bm.pickModel": "Pick at least one model.",
"bm.testOf": "test {i} of {n}",
"bm.results": "Results",
"bm.quick": "quick",
"bm.test": "Test",
"bm.cellHint": "Click a cell to see the model's full answer.",
"bm.history": "Previous runs (stored in this browser)",
"bm.custom": "Custom tests",
"bm.customHint": "Prompt + comma-separated keywords the answer must contain (use | for synonyms). Stored in this browser.",
"bm.promptPh": "prompt…",
"bm.checkPh": "keywords, comma-separated",
"bm.addTest": "Add test",
"bm.emptyAnswer": "empty answer",
"bm.loopDetected": "loop detected",
};
+24
View File
@@ -563,4 +563,28 @@ export default {
"routing.tasks.builtin": "router inaccesible — conjunto integrado",
"routing.tasks.addCustom": "Añadir tarea",
"routing.tasks.addPlaceholder": "etiqueta propia…",
// benchmark view
"nav.benchmark": "Benchmark",
"bm.title": "Benchmark",
"bm.subtitle": "Pruebas de calidad y velocidad a través del pipeline en vivo: respuestas verificadas por palabras clave, detección de bucles, tok/s y tiempo del primer token por modelo.",
"bm.models": "Modelos a probar",
"bm.quickMode": "Prueba rápida",
"bm.quickHint": "El subconjunto rápido con respuestas cortas — alrededor de un minuto por modelo local.",
"bm.suiteInfo": "Suite integrada: {q} pruebas rápidas / {f} completas, más las tuyas.",
"bm.run": "Ejecutar",
"bm.pickModel": "Elige al menos un modelo.",
"bm.testOf": "prueba {i} de {n}",
"bm.results": "Resultados",
"bm.quick": "rápida",
"bm.test": "Prueba",
"bm.cellHint": "Haz clic en una celda para ver la respuesta completa.",
"bm.history": "Ejecuciones anteriores (guardadas en este navegador)",
"bm.custom": "Pruebas propias",
"bm.customHint": "Prompt + palabras clave separadas por comas que la respuesta debe contener (| para sinónimos). Se guardan en este navegador.",
"bm.promptPh": "prompt…",
"bm.checkPh": "palabras clave, separadas por comas",
"bm.addTest": "Añadir prueba",
"bm.emptyAnswer": "respuesta vacía",
"bm.loopDetected": "bucle detectado",
};
+24
View File
@@ -563,4 +563,28 @@ export default {
"routing.tasks.builtin": "routeur injoignable — jeu d'étiquettes intégré",
"routing.tasks.addCustom": "Ajouter une tâche",
"routing.tasks.addPlaceholder": "étiquette personnalisée…",
// benchmark view
"nav.benchmark": "Benchmark",
"bm.title": "Benchmark",
"bm.subtitle": "Sondes de qualité et de vitesse via le pipeline en direct : réponses vérifiées par mots-clés, boucles détectées, tok/s et temps du premier token par modèle.",
"bm.models": "Modèles à tester",
"bm.quickMode": "Test rapide",
"bm.quickHint": "Le sous-ensemble rapide aux réponses courtes — environ une minute par modèle local.",
"bm.suiteInfo": "Suite intégrée : {q} tests rapides / {f} complets, plus les vôtres.",
"bm.run": "Lancer",
"bm.pickModel": "Choisissez au moins un modèle.",
"bm.testOf": "test {i} sur {n}",
"bm.results": "Résultats",
"bm.quick": "rapide",
"bm.test": "Test",
"bm.cellHint": "Cliquez sur une cellule pour voir la réponse complète.",
"bm.history": "Exécutions précédentes (stockées dans ce navigateur)",
"bm.custom": "Tests personnalisés",
"bm.customHint": "Prompt + mots-clés séparés par des virgules que la réponse doit contenir (| pour les synonymes). Stockés dans ce navigateur.",
"bm.promptPh": "prompt…",
"bm.checkPh": "mots-clés, séparés par des virgules",
"bm.addTest": "Ajouter un test",
"bm.emptyAnswer": "réponse vide",
"bm.loopDetected": "boucle détectée",
};
+24
View File
@@ -555,4 +555,28 @@ export default {
"routing.tasks.builtin": "роутер недоступен — встроенный набор меток",
"routing.tasks.addCustom": "Добавить задачу",
"routing.tasks.addPlaceholder": "своя метка…",
// benchmark view
"nav.benchmark": "Бенчмарк",
"bm.title": "Бенчмарк",
"bm.subtitle": "Проверки качества и скорости через живой пайплайн: ответы сверяются по ключевым словам, ловится зацикливание, замеряются tok/s и время первого токена.",
"bm.models": "Какие модели тестировать",
"bm.quickMode": "Быстрый тест",
"bm.quickHint": "Короткое подмножество с краткими ответами — около минуты на локальную модель.",
"bm.suiteInfo": "Встроенный набор: {q} быстрых / {f} полных тестов, плюс ваши собственные.",
"bm.run": "Запустить",
"bm.pickModel": "Выберите хотя бы одну модель.",
"bm.testOf": "тест {i} из {n}",
"bm.results": "Результаты",
"bm.quick": "быстрый",
"bm.test": "Тест",
"bm.cellHint": "Клик по ячейке — полный ответ модели.",
"bm.history": "Прошлые прогоны (хранятся в этом браузере)",
"bm.custom": "Свои тесты",
"bm.customHint": "Промпт + ключевые слова через запятую, которые должны быть в ответе (| — синонимы). Хранятся в этом браузере.",
"bm.promptPh": "промпт…",
"bm.checkPh": "ключевые слова через запятую",
"bm.addTest": "Добавить тест",
"bm.emptyAnswer": "пустой ответ",
"bm.loopDetected": "зацикливание",
};
+24
View File
@@ -563,4 +563,28 @@ export default {
"routing.tasks.builtin": "yönlendiriciye ulaşılamıyor — yerleşik etiket seti",
"routing.tasks.addCustom": "Görev ekle",
"routing.tasks.addPlaceholder": "özel etiket…",
// benchmark view
"nav.benchmark": "Kıyaslama",
"bm.title": "Kıyaslama",
"bm.subtitle": "Canlı hat üzerinden kalite ve hız probları: yanıtlar anahtar kelimeyle doğrulanır, döngü tespit edilir, model başına tok/s ve ilk token süresi ölçülür.",
"bm.models": "Test edilecek modeller",
"bm.quickMode": "Hızlı test",
"bm.quickHint": "Kısa yanıtlı hızlı alt küme — yerel model başına yaklaşık bir dakika.",
"bm.suiteInfo": "Yerleşik küme: {q} hızlı / {f} tam test, artı kendi testleriniz.",
"bm.run": "Çalıştır",
"bm.pickModel": "En az bir model seçin.",
"bm.testOf": "{n} testten {i}.",
"bm.results": "Sonuçlar",
"bm.quick": "hızlı",
"bm.test": "Test",
"bm.cellHint": "Modelin tam yanıtı için hücreye tıklayın.",
"bm.history": "Önceki çalıştırmalar (bu tarayıcıda saklanır)",
"bm.custom": "Özel testler",
"bm.customHint": "İstem + yanıtın içermesi gereken virgülle ayrılmış anahtar kelimeler (| eşanlamlılar için). Bu tarayıcıda saklanır.",
"bm.promptPh": "istem…",
"bm.checkPh": "anahtar kelimeler, virgülle",
"bm.addTest": "Test ekle",
"bm.emptyAnswer": "boş yanıt",
"bm.loopDetected": "döngü tespit edildi",
};
+24
View File
@@ -563,4 +563,28 @@ export default {
"routing.tasks.builtin": "路由器不可达 — 使用内置标签集",
"routing.tasks.addCustom": "添加任务",
"routing.tasks.addPlaceholder": "自定义标签……",
// benchmark view
"nav.benchmark": "基准测试",
"bm.title": "基准测试",
"bm.subtitle": "通过实时管线做质量与速度探测:按关键词核对答案、检测循环输出,并按模型测量 tok/s 与首个 token 时间。",
"bm.models": "要测试的模型",
"bm.quickMode": "快速测试",
"bm.quickHint": "答案简短的快速子集 — 每个本地模型约一分钟。",
"bm.suiteInfo": "内置套件:{q} 个快速 / {f} 个完整测试,另加你自己的。",
"bm.run": "运行",
"bm.pickModel": "请至少选择一个模型。",
"bm.testOf": "第 {i}/{n} 个测试",
"bm.results": "结果",
"bm.quick": "快速",
"bm.test": "测试",
"bm.cellHint": "点击单元格查看模型的完整回答。",
"bm.history": "历史运行(保存在本浏览器)",
"bm.custom": "自定义测试",
"bm.customHint": "提示词 + 逗号分隔的关键词,回答必须包含它们(| 表示同义词)。保存在本浏览器。",
"bm.promptPh": "提示词……",
"bm.checkPh": "关键词,逗号分隔",
"bm.addTest": "添加测试",
"bm.emptyAnswer": "回答为空",
"bm.loopDetected": "检测到循环",
};
+305
View File
@@ -0,0 +1,305 @@
// Benchmark — quality + speed probes for pool models, run through the live
// pipeline (pinned per model, streaming → TTFT and tok/s are real).
// A redesign of the old standalone benchmark.html: same scoring ideas
// (keyword checks with | synonyms, think-tag stripping, anti-loop detection,
// pass/partial/fail), now embedded, localized and CDN-free.
import { h, mount, toast, ms } from "../ui.js";
import { t } from "../i18n.js";
import { api, testStream } from "../api.js";
// ── built-in suite ──────────────────────────────────────────────────────────
// Checks are language-neutral where possible (digits, identifiers, emails);
// `check` entries may hold |-separated synonyms — one hit per entry counts.
// `quick: true` marks the fast subset.
const SUITE = [
{ id: "math-mult", cat: "math", quick: true, max: 96, prompt: "What is 17 * 23? Answer with the number only.", check: ["391"] },
{ id: "math-eq", cat: "math", max: 128, prompt: "Solve 2x + 6 = 14. Answer with the value of x only.", check: ["4"] },
{ id: "logic-syll", cat: "logic", max: 96, prompt: "If all bloops are razzies and all razzies are lazzies, are all bloops lazzies? Answer yes or no.", check: ["yes|да|oui|ja|sí|si|evet|是"] },
{ id: "count-letters", cat: "logic", quick: true, max: 64, prompt: "How many letters are in the word 'cat'? Answer with a digit.", check: ["3"] },
{ id: "apples", cat: "logic", quick: true, max: 96, prompt: "John has 3 apples, buys 2 more, then eats 1. How many apples does he have? Answer with a digit.", check: ["4"] },
{ id: "code-fact", cat: "code", max: 256, prompt: "Write a Python function factorial(n) that returns n!.", check: ["def", "factorial", "return"] },
{ id: "qa-capital", cat: "qa", quick: true, max: 48, prompt: "What is the capital of France? One word.", check: ["paris|париж"] },
{ id: "primes", cat: "qa", max: 96, prompt: "List the first five prime numbers, comma-separated.", check: ["2", "3", "5", "7", "11"] },
{ id: "instr-ok", cat: "instruction", quick: true, max: 32, prompt: "Reply with exactly the word OK and nothing else.", check: ["ok"] },
{ id: "extract-email", cat: "extraction", quick: true, max: 64, prompt: "Extract the email address from: 'Contact us at info@example.com today.' Reply with the address only.", check: ["info@example.com"] },
{ id: "translate", cat: "translation", max: 64, prompt: "Translate to English: «Доброе утро». Reply with the translation only.", check: ["good morning"] },
{ id: "format-json", cat: "instruction", max: 96, prompt: 'Return a JSON object with a single key "n" whose value is 7. JSON only.', check: ['"n"', "7"] },
];
const LS_CUSTOM = "allaigate_bench_custom";
const LS_HISTORY = "allaigate_bench_history";
function customTests() {
try {
return JSON.parse(localStorage.getItem(LS_CUSTOM) || "[]");
} catch {
return [];
}
}
function saveCustom(list) {
localStorage.setItem(LS_CUSTOM, JSON.stringify(list));
}
function history() {
try {
return JSON.parse(localStorage.getItem(LS_HISTORY) || "[]");
} catch {
return [];
}
}
function pushHistory(run) {
const l = history();
l.unshift(run);
localStorage.setItem(LS_HISTORY, JSON.stringify(l.slice(0, 12)));
}
// ── evaluation (ported from the old benchmark, trimmed) ─────────────────────
function stripThink(text) {
return text.replace(/<(?:\|)?(?:think|thought)(?:\|)?>[\s\S]*?(?:<\/(?:\|)?(?:think|thought)(?:\|)?>|$)/gi, "").trim();
}
// tail loop: the same 12+ char chunk repeated 3+ times back to back
function looksLooped(text) {
const tail = text.slice(-360);
for (let len = 12; len <= 60; len += 8) {
const probe = tail.slice(-len);
if (probe && tail.split(probe).length - 1 >= 3) return true;
}
return false;
}
function evaluate(raw, test) {
let text = stripThink(raw);
if (!text && raw) text = raw; // max_tokens cut inside <think> — judge the raw
const resp = text.toLowerCase();
if (!resp.trim()) return { status: "fail", score: 0, note: t("bm.emptyAnswer") };
if (looksLooped(resp)) return { status: "fail", score: 0, note: t("bm.loopDetected") };
let hit = 0;
for (const cond of test.check) {
const opts = cond.toLowerCase().split("|").map((s) => s.trim());
if (opts.some((o) => o && resp.includes(o))) hit++;
}
const frac = hit / test.check.length;
const status = frac >= 1 ? "pass" : frac >= 0.5 ? "partial" : "fail";
return { status, score: frac, note: `${hit}/${test.check.length}` };
}
export async function renderBenchmark() {
const modelsData = await api.listModels();
const ids = (modelsData.models || []).map((m) => m.id);
const root = h("div");
let running = false;
let stopFlag = false;
const modelCbs = ids.map((id) => ({ id, cb: h("input", { type: "checkbox", checked: ids.length === 1 ? true : null }) }));
const quickCb = h("input", { type: "checkbox", checked: true });
const resultsHost = h("div", {});
const progressHost = h("div", {});
const historyHost = h("div", {});
function suite() {
const base = SUITE.concat(customTests());
return quickCb.checked ? base.filter((x) => x.quick || x.custom) : base;
}
const runBtn = h("button", { class: "btn primary" }, "▶ " + t("bm.run"));
const stopBtn = h("button", { class: "btn ghost", style: "display:none" }, "✕ " + t("import.cancel"));
stopBtn.addEventListener("click", () => {
stopFlag = true;
stopBtn.disabled = true;
});
runBtn.addEventListener("click", async () => {
const models = modelCbs.filter((m) => m.cb.checked).map((m) => m.id);
if (!models.length) {
toast(t("bm.pickModel"), "warn");
return;
}
running = true;
stopFlag = false;
runBtn.disabled = true;
stopBtn.style.display = "";
stopBtn.disabled = false;
const tests = suite();
const run = { ts: Date.now(), quick: quickCb.checked, models: {}, nTests: tests.length };
for (const model of models) {
const rec = { results: [], score: 0, tokS: [], ttft: [], failed: false };
run.models[model] = rec;
for (let i = 0; i < tests.length; i++) {
if (stopFlag) break;
const test = tests[i];
mount(
progressHost,
h("div", { class: "card" },
h("div", { class: "flex" },
h("span", { class: "spinner" }), " ",
h("b", { class: "mono" }, model), " · ",
t("bm.testOf", { i: i + 1, n: tests.length }), " · ",
h("span", { class: "muted" }, test.id)),
h("div", { class: "pbar", style: "margin-top:8px" },
h("div", { class: "pbar-fill det", style: `width:${Math.round((i / tests.length) * 100)}%` })))
);
const started = performance.now();
let text = "";
try {
const info = await testStream(
{ model, messages: [{ role: "user", content: test.prompt }], temperature: 0, max_tokens: test.max || 128 },
(d) => (text += d)
);
const latency = performance.now() - started;
const ev = evaluate(text, test);
const compl = info.usage?.completion_tokens;
if (compl && latency > 0) {
const gen = info.ttft_ms != null ? Math.max(1, latency - info.ttft_ms) : latency;
rec.tokS.push(compl / (gen / 1000));
}
if (info.ttft_ms != null) rec.ttft.push(info.ttft_ms);
rec.results.push({ id: test.id, cat: test.cat, ...ev, latency: Math.round(latency), answer: stripThink(text).slice(0, 300) });
} catch (e) {
rec.results.push({ id: test.id, cat: test.cat, status: "fail", score: 0, note: String(e.message || e).slice(0, 80), latency: 0, answer: "" });
rec.failed = true;
}
}
const n = rec.results.length || 1;
rec.score = rec.results.reduce((a, r) => a + r.score, 0) / n;
rec.avgTokS = rec.tokS.length ? rec.tokS.reduce((a, b) => a + b, 0) / rec.tokS.length : null;
rec.avgTtft = rec.ttft.length ? rec.ttft.reduce((a, b) => a + b, 0) / rec.ttft.length : null;
if (stopFlag) break;
}
mount(progressHost);
running = false;
runBtn.disabled = false;
stopBtn.style.display = "none";
if (!stopFlag) pushHistory(run);
renderResults(run);
renderHistory();
});
function statusIcon(s) {
return s === "pass" ? "✅" : s === "partial" ? "◐" : "❌";
}
function renderResults(run) {
const models = Object.keys(run.models);
if (!models.length) {
mount(resultsHost);
return;
}
// summary bars, best score first
const sorted = models.slice().sort((a, b) => run.models[b].score - run.models[a].score);
const summary = h("div", {},
...sorted.map((m) => {
const r = run.models[m];
const pct = Math.round(r.score * 100);
return h("div", { class: "bm-sum" },
h("div", { class: "flex" },
h("b", { class: "mono grow" }, m),
h("span", { class: "badge " + (pct >= 80 ? "ok" : pct >= 50 ? "" : "bad") }, pct + "%"),
r.avgTokS != null ? h("span", { class: "muted small", style: "margin-left:8px" }, r.avgTokS.toFixed(1) + " tok/s") : null,
r.avgTtft != null ? h("span", { class: "muted small", style: "margin-left:8px" }, "TTFT " + ms(Math.round(r.avgTtft))) : null),
h("div", { class: "sysbar" }, h("i", { class: pct < 50 ? "bad" : "", style: `width:${pct}%` })));
}));
// detail table: rows = tests, one column per model
const header = h("tr", {}, h("th", {}, t("bm.test")), ...models.map((m) => h("th", { class: "mono" }, m)));
const rows = (run.models[models[0]].results || []).map((_, i) =>
h("tr", {},
h("td", {},
h("div", {}, run.models[models[0]].results[i].id),
h("div", { class: "muted small" }, run.models[models[0]].results[i].cat)),
...models.map((m) => {
const r = run.models[m].results[i];
if (!r) return h("td", {}, "—");
const cell = h("td", { class: "bm-cell", title: r.answer || r.note },
statusIcon(r.status) + " ",
h("span", { class: "muted small" }, r.note + (r.latency ? ` · ${(r.latency / 1000).toFixed(1)}s` : "")));
cell.addEventListener("click", () => {
mount(detail, h("div", { class: "card" },
h("div", { class: "card-title" }, `${r.id} · ${m} · ${statusIcon(r.status)}`),
h("pre", { class: "conv-log", style: "max-height:200px" }, r.answer || "—")));
});
return cell;
})));
const detail = h("div", {});
mount(resultsHost,
h("div", { class: "card" },
h("div", { class: "card-head" },
h("h3", {}, t("bm.results")),
h("span", { class: "card-head sub" }, new Date(run.ts).toLocaleString() + (run.quick ? " · " + t("bm.quick") : ""))),
summary,
h("div", { class: "table-wrap", style: "margin-top:14px" }, h("table", {}, h("thead", {}, header), h("tbody", {}, ...rows))),
h("div", { class: "muted small", style: "margin-top:6px" }, t("bm.cellHint"))),
detail);
}
function renderHistory() {
const l = history();
if (!l.length) {
mount(historyHost);
return;
}
mount(historyHost, h("div", { class: "card" },
h("div", { class: "card-title" }, t("bm.history")),
...l.map((run) => {
const models = Object.keys(run.models);
const best = models.slice().sort((a, b) => run.models[b].score - run.models[a].score)[0];
const row = h("div", { class: "flex bench-row", style: "cursor:pointer" },
h("span", { class: "muted small" }, new Date(run.ts).toLocaleString()),
h("span", { class: "grow mono small" }, models.join(", ")),
run.quick ? h("span", { class: "badge" }, t("bm.quick")) : null,
best ? h("b", {}, Math.round(run.models[best].score * 100) + "%") : null);
row.addEventListener("click", () => renderResults(run));
return row;
})));
}
// ── custom tests (stored in the browser) ──
function customCard() {
const host = h("div", { class: "card" });
function paint() {
const list = customTests();
const promptIn = h("input", { placeholder: t("bm.promptPh") });
const checkIn = h("input", { placeholder: t("bm.checkPh") });
const addBtn = h("button", { class: "btn ghost sm" }, "+ " + t("bm.addTest"));
addBtn.addEventListener("click", () => {
const prompt = promptIn.value.trim();
const check = checkIn.value.split(",").map((s) => s.trim()).filter(Boolean);
if (!prompt || !check.length) return;
list.push({ id: "custom-" + (list.length + 1), cat: "custom", custom: true, max: 128, prompt, check });
saveCustom(list);
paint();
});
mount(host,
h("div", { class: "card-title" }, t("bm.custom")),
h("div", { class: "muted small", style: "margin-bottom:8px" }, t("bm.customHint")),
...list.map((tst, i) =>
h("div", { class: "flex bench-row" },
h("span", { class: "grow small" }, tst.prompt.slice(0, 90)),
h("span", { class: "muted small mono" }, tst.check.join(", ").slice(0, 40)),
h("button", { class: "btn sm ghost danger", onClick: () => { list.splice(i, 1); saveCustom(list); paint(); } }, "×"))),
h("div", { class: "flex", style: "gap:8px; margin-top:8px" }, promptIn, checkIn, addBtn));
}
paint();
return host;
}
mount(root,
h("div", { class: "page-head" },
h("h2", {}, t("bm.title")),
h("p", {}, t("bm.subtitle"))),
ids.length === 0 ? h("div", { class: "callout" }, t("routing.empty")) : null,
h("div", { class: "card" },
h("div", { class: "card-title" }, t("bm.models")),
h("div", { class: "flex wrap", style: "gap:10px" },
...modelCbs.map(({ id, cb }) => h("label", { class: "check" }, cb, h("span", { class: "mono" }, id)))),
h("div", { class: "flex", style: "justify-content:space-between; margin-top:14px" },
h("label", { class: "check", style: "margin:0", title: t("bm.quickHint") }, quickCb, "⚡ " + t("bm.quickMode")),
h("div", { class: "flex", style: "gap:8px" }, stopBtn, runBtn)),
h("div", { class: "muted small", style: "margin-top:6px" },
t("bm.suiteInfo", { q: SUITE.filter((x) => x.quick).length, f: SUITE.length }))),
progressHost,
resultsHost,
customCard(),
historyHost);
renderHistory();
return root;
}