From 1120d8f11ae90f3420844b0e95bb3af42dccd821 Mon Sep 17 00:00:00 2001 From: infosave2007 Date: Fri, 7 Aug 2026 18:56:47 +0300 Subject: [PATCH] feat(benchmark): the built-in benchmark, redesigned into the console MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- web/css/styles.css | 6 + web/js/app.js | 2 + web/js/i18n/de.js | 24 +++ web/js/i18n/en.js | 24 +++ web/js/i18n/es.js | 24 +++ web/js/i18n/fr.js | 24 +++ web/js/i18n/ru.js | 24 +++ web/js/i18n/tr.js | 24 +++ web/js/i18n/zh.js | 24 +++ web/js/views/benchmark.js | 305 ++++++++++++++++++++++++++++++++++++++ 10 files changed, 481 insertions(+) create mode 100644 web/js/views/benchmark.js diff --git a/web/css/styles.css b/web/css/styles.css index 508b0b2..93a74c6 100644 --- a/web/css/styles.css +++ b/web/css/styles.css @@ -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; } diff --git a/web/js/app.js b/web/js/app.js index fec3659..cb4e088 100644 --- a/web/js/app.js +++ b/web/js/app.js @@ -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 }, diff --git a/web/js/i18n/de.js b/web/js/i18n/de.js index 214d0ae..be9e935 100644 --- a/web/js/i18n/de.js +++ b/web/js/i18n/de.js @@ -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", }; diff --git a/web/js/i18n/en.js b/web/js/i18n/en.js index 50dbc46..9d85153 100644 --- a/web/js/i18n/en.js +++ b/web/js/i18n/en.js @@ -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", }; diff --git a/web/js/i18n/es.js b/web/js/i18n/es.js index 374c6df..86d4024 100644 --- a/web/js/i18n/es.js +++ b/web/js/i18n/es.js @@ -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", }; diff --git a/web/js/i18n/fr.js b/web/js/i18n/fr.js index 6e5be47..70405b2 100644 --- a/web/js/i18n/fr.js +++ b/web/js/i18n/fr.js @@ -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", }; diff --git a/web/js/i18n/ru.js b/web/js/i18n/ru.js index 29d9889..a3184fe 100644 --- a/web/js/i18n/ru.js +++ b/web/js/i18n/ru.js @@ -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": "зацикливание", }; diff --git a/web/js/i18n/tr.js b/web/js/i18n/tr.js index 8f76ba1..ab1db12 100644 --- a/web/js/i18n/tr.js +++ b/web/js/i18n/tr.js @@ -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", }; diff --git a/web/js/i18n/zh.js b/web/js/i18n/zh.js index 0a47ecf..9082b91 100644 --- a/web/js/i18n/zh.js +++ b/web/js/i18n/zh.js @@ -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": "检测到循环", }; diff --git a/web/js/views/benchmark.js b/web/js/views/benchmark.js new file mode 100644 index 0000000..156dfab --- /dev/null +++ b/web/js/views/benchmark.js @@ -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 — 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; +}