/* 模型评测网站 前端逻辑 */ "use strict"; const $ = (s) => document.querySelector(s); const $$ = (s) => Array.from(document.querySelectorAll(s)); const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); const fmt = (v, d = "—") => (v === null || v === undefined || isNaN(v) ? d : Number(v).toFixed(2).replace(/\.?0+$/, "")); const fmtInt = (v, d = "—") => (v === null || v === undefined || isNaN(v) ? d : Math.round(v)); let toastTimer = null; function toast(msg) { const el = $("#toast"); el.textContent = msg; el.style.display = "block"; clearTimeout(toastTimer); toastTimer = setTimeout(() => { el.style.display = "none"; }, 3200); } async function api(path, method = "GET", body) { const opt = { method, headers: {} }; if (body !== undefined) { opt.headers["Content-Type"] = "application/json"; opt.body = JSON.stringify(body); } const resp = await fetch(path, opt); let j = {}; try { j = await resp.json(); } catch (e) { j = {}; } if (!resp.ok && !j.ok) throw new Error(j.error || `请求失败(${resp.status})`); return j; } function providerLabel(p) { const m = { autodl: "Autodl", siliconflow: "SiliconFlow", "local-qwen": "本地Qwen", openai: "OpenAI", anthropic: "Anthropic", google: "Gemini", local: "本地" }; return m[p] || p || "—"; } const hwTag = (hw) => (hw ? `🖥 ${esc(hw)}` : "—"); /* ───────────────────────── 对比车(localStorage 持久化) ───────────────────────── */ const CART_KEY = "evalCompareCart"; function getCart() { try { const c = JSON.parse(localStorage.getItem(CART_KEY) || "[]"); return Array.isArray(c) ? c : []; } catch (e) { return []; } } function saveCart(c) { localStorage.setItem(CART_KEY, JSON.stringify(c)); } function cartAdd(key) { const c = getCart(); if (!c.includes(key)) { c.push(key); saveCart(c); toast("+ 已加入对比"); } else { toast("已在对比中"); } renderCartBar(); } function cartRemove(key) { saveCart(getCart().filter((k) => k !== key)); renderCartBar(); } function cartClear() { saveCart([]); renderCartBar(); } function cartModelName(key) { const i = String(key).indexOf("|"); return i >= 0 ? String(key).slice(i + 1) : String(key); } function renderCartBar() { const bar = $("#cart-bar"); if (!bar) return; const c = getCart(); if (!c.length) { bar.hidden = true; return; } bar.hidden = false; const cnt = $("#cart-count"); if (cnt) cnt.textContent = c.length; const items = $("#cart-items"); if (items) items.innerHTML = c.map((k) => `${esc(cartModelName(k))}`).join("、"); } function bindCartBar() { const bar = $("#cart-bar"); if (!bar) return; const clear = $("#cart-clear"); if (clear) clear.addEventListener("click", cartClear); const go = $("#cart-go"); if (go) go.addEventListener("click", () => { location.href = "/compare.html"; }); renderCartBar(); } /* 运行速度对比:3 个系列(颜色深浅三色),前端可配置类型/轴 */ const RUN_SERIES = [ { key: "avg_prefill_speed", label: "预填充速度", unit: "tok/s", color: "#5b94ff", depth: "浅" }, { key: "avg_decode_speed", label: "解码速度", unit: "tok/s", color: "#2ee67a", depth: "深" }, { key: "avg_ttft_ms", label: "首字延迟", unit: "ms", color: "#ffb43c", depth: "中" }, ]; function buildRunPayload(opts) { const series = (opts.series || RUN_SERIES).filter((s) => s.show); const payload = { data: opts.csv, chartType: "bar", title: opts.title || "模型运行速度对比", theme: "default", showLegend: series.length > 1, showGrid: true, showLabel: opts.showLabel ?? false, smoothLine: true, dualYAxis: series.some((s) => s.axis === 1), leftAxisName: opts.leftAxisName || "预填充速度(tok/s)", rightAxisName: opts.rightAxisName || "解码速度(tok/s)", seriesTypes: series.map((s) => s.type), seriesStyles: series.map((s) => s.style), seriesAxis: series.map((s) => s.axis), seriesColors: series.map((s) => s.color), width: 1100, height: 560, pixelRatio: 2, }; if (opts.xRotate !== undefined && opts.xRotate !== "" && opts.xRotate !== null) payload.xLabelRotate = Number(opts.xRotate); if (opts.xFont) payload.xLabelFontSize = Number(opts.xFont); if (opts.xInterval !== undefined && opts.xInterval !== "" && opts.xInterval !== null) payload.xLabelInterval = Number(opts.xInterval); if (!payload.dualYAxis) payload.seriesAxis = null; return payload; } function navActive() { const path = location.pathname.split("/").pop() || "index.html"; $$(".nav a").forEach((a) => { a.classList.toggle("active", a.getAttribute("href") === path); }); } /* ───────────────────────── 首页:排行(热度前10 + 搜索) ───────────────────────── */ let sortKey = "heat"; let sortOrder = "desc"; let lbModels = []; // 全部模型(含 heat/intro/count_override),已按热度排序 let lbAll = []; let lbSelected = new Set(); // selected keys(对比图用) let lbTopN = 10; let lbQuery = ""; const modelKey = (p, m) => `${p}|${m}`; const LB_SORT_GETTER = { heat: (r) => r.heat || 0, cnt: (r) => r.cnt, accounts: (r) => r.accounts, avg_decode_speed: (r) => r.avg_decode_speed || 0, avg_prefill_speed: (r) => r.avg_prefill_speed || 0, avg_ttft_ms: (r) => r.avg_ttft_ms || 0, best_decode: (r) => r.best_decode || 0, last_tested: (r) => r.last_tested || "", }; async function loadStats() { try { const s = await api("/api/stats"); if ($("#st-models")) $("#st-models").textContent = s.models; if ($("#st-subs")) $("#st-subs").textContent = s.submissions; if ($("#st-accounts")) $("#st-accounts").textContent = s.accounts; if ($("#st-samples")) $("#st-samples").innerHTML = fmtInt(s.samples_ok) + " 次成功采样"; if ($("#st-caps")) $("#st-caps").textContent = s.capabilities; } catch (e) { /* ignore */ } } function sortClick(key) { if (sortKey === key) sortOrder = sortOrder === "desc" ? "asc" : "desc"; else { sortKey = key; sortOrder = "desc"; } applyView(); } function cmpLb(a, b) { const g = LB_SORT_GETTER[sortKey] || LB_SORT_GETTER.heat; const va = g(a), vb = g(b); let c = 0; if (typeof va === "number" && typeof vb === "number") c = va - vb; else c = String(va).localeCompare(String(vb)); return sortOrder === "desc" ? -c : c; } function renderLbChips() { const box = $("#lb-chart-models"); if (!box) return; box.innerHTML = ""; if (!lbModels.length) { box.innerHTML = '暂无模型'; return; } for (const m of lbModels) { const on = lbSelected.has(m.key); const c = document.createElement("span"); c.className = "chip" + (on ? " on" : ""); c.title = "点击选择/取消该模型"; c.innerHTML = `${esc(m.model)}`; c.addEventListener("click", () => { if (lbSelected.has(m.key)) lbSelected.delete(m.key); else lbSelected.add(m.key); c.classList.toggle("on"); }); box.appendChild(c); } } async function loadLeaderboard() { const tb = $("#lb tbody"); if (!tb) return; tb.innerHTML = '加载中…'; let d; try { d = await api(`/api/leaderboard?sort=heat&order=desc&limit=500`); } catch (e) { tb.innerHTML = `加载失败:${esc(e.message)}`; return; } if (d.top_n) lbTopN = d.top_n; lbAll = d.rows; lbModels = lbAll.map((r) => ({ provider: r.provider, model: r.model, key: modelKey(r.provider, r.model), intro: r.intro || "", heat: r.heat || 0 })); if (!lbSelected.size) lbModels.forEach((m) => lbSelected.add(m.key)); else lbModels.forEach((m) => { if (!lbSelected.has(m.key)) lbSelected.add(m.key); }); renderLbChips(); applyView(); } function applyView() { const tb = $("#lb tbody"); if (!tb) return; let rows = lbAll; const q = lbQuery.trim().toLowerCase(); if (q) { rows = lbAll.filter((r) => (r.model || "").toLowerCase().includes(q) || (r.provider || "").toLowerCase().includes(q) || ((r.intro || "") || "").toLowerCase().includes(q)); } rows = [...rows].sort(cmpLb); if (!q) rows = rows.slice(0, lbTopN); // 默认显示热度前 N 个 $$("#lb th[data-sort]").forEach((th) => { th.querySelector(".arrow").textContent = (th.dataset.sort === sortKey) ? (sortOrder === "desc" ? " ▼" : " ▲") : ""; }); if (!rows.length) { tb.innerHTML = '' + (q ? `未找到匹配「${esc(lbQuery)}」的模型` : "暂无评测数据。可在后台管理「提交管理」里点击「🎲 生成演示数据」或从 llm-speed-tester 一键发送。") + ''; return; } const maxDecode = Math.max(...rows.map((r) => r.avg_decode_speed || 0)); tb.innerHTML = rows.map((r, i) => { const meter = r.avg_decode_speed ? `
` : ""; const intro = r.intro ? `
${esc(r.intro)}
` : ""; return ` ${i + 1} ${esc(providerLabel(r.provider))} ${esc(r.model)}${intro} 🔥 ${fmtInt(r.heat || 0)} ${fmtInt(r.count_override > 0 ? r.count_override : r.cnt)} ${fmtInt(r.accounts)} ${fmt(r.avg_decode_speed)}${meter} ${fmt(r.avg_prefill_speed)} ${fmt(r.avg_ttft_ms)} ${fmt(r.best_decode)} ${hwTag(r.hardware)} ${esc((r.last_tested || "").slice(5, 16))} 详情 `; }).join(""); } /* 搜索框:点击显示热门5个,输入过滤 */ function renderSearchDrop(items) { const drop = $("#lb-search-drop"); if (!drop) return; if (!items || !items.length) { drop.hidden = true; return; } drop.hidden = false; drop.innerHTML = items.map((r) => { const key = modelKey(r.provider, r.model); const intro = r.intro ? `${esc(r.intro)}` : `${esc(providerLabel(r.provider))}`; return `
${esc(r.model)} 🔥${fmtInt(r.heat || 0)}
${intro} · 提交 ${fmtInt(r.cnt)} · 解码 ${fmt(r.avg_decode_speed)} tok/s
`; }).join(""); } function bindSearch() { const input = $("#lb-search"); const drop = $("#lb-search-drop"); if (!input || !drop) return; input.addEventListener("focus", () => { // 点击输入框 → 显示最常见的5个速度项(按热度) renderSearchDrop(lbAll.slice(0, 5)); }); input.addEventListener("input", () => { lbQuery = input.value; applyView(); const q = lbQuery.trim().toLowerCase(); if (q) renderSearchDrop(lbAll.filter((r) => (r.model || "").toLowerCase().includes(q) || (r.provider || "").toLowerCase().includes(q) || ((r.intro || "") || "").toLowerCase().includes(q)).slice(0, 5)); else renderSearchDrop(lbAll.slice(0, 5)); }); input.addEventListener("blur", () => { setTimeout(() => { drop.hidden = true; }, 150); }); drop.addEventListener("mousedown", (e) => { const item = e.target.closest(".search-item"); if (!item) return; e.preventDefault(); const key = item.dataset.key; const m = lbModels.find((x) => x.key === key); if (m) location.href = `/model.html?provider=${encodeURIComponent(m.provider)}&model=${encodeURIComponent(m.model)}`; }); } let lbChartUrl = ""; async function genLbChart() { const status = $("#lb-chart-status"); const imgWrap = $("#lb-chart-img"); if (!lbSelected.size) { status.textContent = "请至少选择一个模型"; return; } status.textContent = "⏳ 正在生成..."; try { // 重新请求指定模型的排行数据(对比图只包含所选模型) const models = [...lbSelected].join(","); const d = await api(`/api/leaderboard?sort=heat&order=desc&models=${encodeURIComponent(models)}`); if (!d.rows.length) { status.textContent = "所选模型暂无数据"; return; } const n = d.rows.length; // 运行速度对比:预填充(左轴·空心柱) + 解码(右轴·实心柱) + 首字(虚线),三色 const series = RUN_SERIES.map((s, i) => ({ ...s, show: true, type: ["bar", "bar", "line"][i], style: ["hollow", "solid", "dashed"][i], axis: [0, 1, 1][i], })); const payload = buildRunPayload({ csv: d.chart_csv, title: `模型运行速度对比(${n} 个模型)`, series, xRotate: n > 14 ? 45 : (n > 8 ? 30 : 0), xFont: n > 14 ? 11 : 12, }); const resp = await fetch("/api/chart", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); if (!resp.ok) { const j = await resp.json().catch(() => ({})); status.textContent = "❌ " + (j.error || "生成失败"); return; } const blob = await resp.blob(); if (lbChartUrl) URL.revokeObjectURL(lbChartUrl); lbChartUrl = URL.createObjectURL(blob); imgWrap.innerHTML = ""; const img = document.createElement("img"); img.src = lbChartUrl; img.alt = "模型运行速度对比图"; img.onload = () => { status.textContent = `✅ 已生成(${n} 个模型)`; }; imgWrap.appendChild(img); } catch (e) { status.textContent = "❌ " + e.message; } } function dlChartUrl() { if (!lbChartUrl) { toast("请先生成图表"); return null; } const a = document.createElement("a"); a.href = lbChartUrl; a.download = "运行速度对比图.png"; document.body.appendChild(a); a.click(); a.remove(); } /* ───────────────────────── 模型详情页 ───────────────────────── */ async function loadModel() { const params = new URLSearchParams(location.search); const provider = params.get("provider") || ""; const model = params.get("model") || ""; if (!model) { $("#model-body").innerHTML = '
缺少模型参数
'; return; } let d; try { d = await api(`/api/model?provider=${encodeURIComponent(provider)}&model=${encodeURIComponent(model)}`); } catch (e) { $("#m-sub").textContent = e.message; return; } document.title = `${d.model} · 模型评测`; $("#m-title").textContent = `${d.model} 详情`; $("#m-sub").textContent = `${providerLabel(d.provider)} · 共 ${d.sub_cnt} 条提交`; // 硬件汇总 const hws = {}; d.submissions.forEach((s) => { if (s.hardware) hws[s.hardware] = (hws[s.hardware] || 0) + 1; }); const hwList = Object.entries(hws).map(([hw, n]) => `${esc(hw)} ×${n}`).join(";"); $("#m-hw").textContent = hwList ? `🖥 硬件:${hwList}` : ""; $("#md-submissions").innerHTML = d.submissions.map((s) => { const conc = (s.concurrency_levels || [1]).join("/"); return ` #${s.id} ${esc(s.account)} ${esc(s.created_at)} ${hwTag(s.hardware)} ${fmt(s.samples_ok)}/${fmt(s.samples_total)} ${fmt(s.avg_ttft_ms)} ${fmt(s.avg_prefill_speed)} ${fmt(s.avg_decode_speed)} ${fmt(s.avg_stream_decode)} ${conc} ${fmt(s.avg_output_tokens)} ${s.test_name ? `${esc(s.test_name.slice(0, 16))}` : "—"} `; }).join(""); window.__modelChart = d.line_payload; window.__modelLineCsv = d.line_csv; $("#m-chart-csv").value = d.line_csv; $("#m-chart-img").innerHTML = '
点击「🎨 生成折线图」绘制
'; $("#m-chart-status").textContent = ""; $("#md-lengths").innerHTML = d.by_length.map((r) => ` ${fmtInt(r.length)} ${fmtInt(r.count)} ${fmt(r.avg_prefill_speed)} ${fmt(r.avg_decode_speed)} ${fmt(r.avg_ttft_ms)} `).join(""); } let modelChartUrl = ""; async function genModelChart() { if (!window.__modelChart) { toast("暂无数据可画图"); return; } const status = $("#m-chart-status"); const imgWrap = $("#m-chart-img"); status.textContent = "⏳ 正在生成..."; try { const resp = await fetch("/api/chart", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(window.__modelChart), }); if (!resp.ok) { const j = await resp.json().catch(() => ({})); status.textContent = "❌ " + (j.error || "生成失败"); return; } const blob = await resp.blob(); if (modelChartUrl) URL.revokeObjectURL(modelChartUrl); modelChartUrl = URL.createObjectURL(blob); imgWrap.innerHTML = ""; const img = document.createElement("img"); img.src = modelChartUrl; img.alt = "速度随上下文长度"; img.onload = () => { status.textContent = "✅ 生成完成"; }; imgWrap.appendChild(img); } catch (e) { status.textContent = "❌ " + e.message; } } /* ───────────────────────── 能力测试页 ───────────────────────── */ async function loadCapabilities() { const grid = $("#cap-grid"); if (!grid) return; grid.innerHTML = '
加载中…
'; let list; try { list = await api("/api/capabilities"); } catch (e) { grid.innerHTML = `
加载失败:${esc(e.message)}
`; return; } if ($("#cap-sub")) $("#cap-sub").textContent = `共 ${list.length} 项能力测试`; if (!list.length) { grid.innerHTML = '
暂无能力测试内容,请到「⚙️ 后台管理 → 能力测试管理」添加。
'; return; } grid.innerHTML = list.map((c) => `
${esc(c.icon || "🧪")} ${esc(c.name)} ${esc(c.category || "未分类")}
${esc(c.description || "暂无描述")}
📝 含 ${(c.items || []).length} 个测试项,点击查看详情
`).join(""); grid.querySelectorAll(".cap-card").forEach((card) => { card.addEventListener("click", () => openCapDetail(list.find((c) => c.id === Number(card.dataset.cap)))); }); } function openCapDetail(c) { const mask = $("#cap-mask"); if (!mask) return; mask.hidden = false; $("#cap-modal-title").textContent = `${c.icon || "🧪"} ${c.name}`; const items = (c.items || []).map((it, i) => `
${i + 1}. ${esc(it.title || "未命名")}
📝 ${esc(it.prompt || "")}
${it.expect ? `
🎯 期望:${esc(it.expect)}
` : ""}
`).join(""); $("#cap-modal-body").innerHTML = `
分类
${esc(c.category || "未分类")}
测试项数
${(c.items || []).length}
更新时间
${esc(c.updated_at || c.created_at || "")}
${esc(c.description || "")}

📝 测试项

${items || '
暂无测试项
'} `; } /* ───────────────────────── 模型对比页(对比车驱动) ───────────────────────── */ let cmpModels = []; let cmpSelected = new Set(); let cmpOrder = []; // 对比顺序:对比车加入顺序 或 默认前5 let cmpCols = new Set(["model", "avg_prefill_speed", "avg_decode_speed"]); // 默认只对比预填充+解码 let cmpRows = []; let cmpCsv = ""; let cmpChartUrl = ""; const CMP_COL_DEFS = [ { key: "provider", label: "提供商", type: "text" }, { key: "model", label: "模型", type: "text" }, { key: "hardware", label: "硬件", type: "hw" }, { key: "cnt", label: "提交数", type: "num" }, { key: "accounts", label: "账号数", type: "num" }, { key: "samples_ok", label: "采样数", type: "num" }, { key: "avg_decode_speed", label: "平均解码 tok/s", type: "num" }, { key: "avg_prefill_speed", label: "平均预填充 tok/s", type: "num" }, { key: "avg_stream_decode", label: "单流均解码 tok/s", type: "num" }, { key: "avg_ttft_ms", label: "首字 ms", type: "num" }, { key: "best_decode", label: "最佳解码 tok/s", type: "num" }, { key: "min_decode_speed", label: "最低解码 tok/s", type: "num" }, { key: "avg_output_tokens", label: "输出 tok", type: "num" }, { key: "avg_total_ms", label: "总耗时 ms", type: "num" }, { key: "last_tested", label: "最后测试", type: "time" }, ]; function renderCmpColToggles() { const box = $("#cmp-cols"); if (!box) return; box.innerHTML = CMP_COL_DEFS.map((c) => ` `).join(""); box.querySelectorAll("input[data-cmpcol]").forEach((inp) => { inp.addEventListener("change", () => { if (inp.checked) cmpCols.add(inp.value); else cmpCols.delete(inp.value); renderCmpTable(); }); }); } async function initCompare() { const box = $("#cmp-models"); if (!box) return; let d; try { d = await api("/api/leaderboard?limit=500"); } catch (e) { box.innerHTML = `加载失败`; return; } cmpModels = d.rows.map((r) => ({ provider: r.provider, model: r.model, key: modelKey(r.provider, r.model) })); // 对比车优先(按加入顺序);否则默认前5 const cart = getCart(); const cartValid = cart.filter((k) => cmpModels.some((m) => m.key === k)); if (cartValid.length) { cmpOrder = cartValid; cmpSelected = new Set(cartValid); } else { cmpOrder = cmpModels.slice(0, 5).map((m) => m.key); cmpSelected = new Set(cmpOrder); } renderCmpChips(); renderCmpColToggles(); renderSeriesCfg(); runCompare(); } function renderCmpChips() { const box = $("#cmp-models"); box.innerHTML = ""; if (!cmpModels.length) { box.innerHTML = '暂无模型'; return; } for (const m of cmpModels) { const on = cmpSelected.has(m.key); const c = document.createElement("span"); c.className = "chip" + (on ? " on" : ""); c.innerHTML = `${esc(m.model)}`; c.addEventListener("click", () => { if (cmpSelected.has(m.key)) { cmpSelected.delete(m.key); cmpOrder = cmpOrder.filter((k) => k !== m.key); } else { cmpSelected.add(m.key); if (!cmpOrder.includes(m.key)) cmpOrder.push(m.key); } c.classList.toggle("on"); }); box.appendChild(c); } } async function runCompare() { const status = $("#cmp-status"); const tbody = $("#cmp-table tbody"); if (!cmpOrder.length) { status.textContent = "请至少选择一个模型"; return; } status.textContent = "⏳ 正在对比..."; const models = cmpOrder.join(","); try { const d = await api(`/api/compare?models=${encodeURIComponent(models)}`); cmpRows = d.rows; cmpCsv = d.chart_csv; renderCmpTable(); status.textContent = `✅ 已对比 ${cmpRows.length} 个模型(${cmpOrder.length} 个选中)`; } catch (e) { status.textContent = "❌ " + e.message; } } function renderCmpTable() { const table = $("#cmp-table"); const thead = table.querySelector("thead"); const tbody = table.querySelector("tbody"); const cols = CMP_COL_DEFS.filter((c) => cmpCols.has(c.key)); thead.innerHTML = "" + cols.map((c) => `${esc(c.label)}`).join("") + ""; if (!cmpRows.length) { tbody.innerHTML = '请先选择模型并点击「🔄 生成对比」'; return; } tbody.innerHTML = cmpRows.map((r) => "" + cols.map((c) => { const v = r[c.key]; if (c.type === "hw") return `${hwTag(v)}`; if (c.type === "num") return `${fmt(v)}`; if (c.type === "time") return `${esc((v || "").slice(5, 16))}`; if (c.key === "model") return `${esc(v)}`; if (c.key === "provider") return `${esc(providerLabel(v))}`; return `${esc(v)}`; }).join("") + "").join(""); } /* 对比图系列配置:柱状(实心/空心) / 折线(实线/虚线) / 左右双轴 / 三色深度 */ function renderSeriesCfg() { const tbody = $("#cmp-series-cfg tbody"); if (!tbody) return; tbody.innerHTML = RUN_SERIES.map((s, i) => { const show = i < 2; // 默认预填充+解码 const type = ["bar", "bar", "line"][i]; const style = ["hollow", "solid", "dashed"][i]; const axis = [0, 1, 1][i]; return ` ${esc(s.label)} ${s.unit}·${s.depth}色 ${s.color} `; }).join(""); } function collectSeriesCfg() { return RUN_SERIES.map((s) => { const tr = $(`#cmp-series-cfg tr[data-skey="${s.key}"]`); if (!tr) return { ...s, show: false }; const show = tr.querySelector(".sf-show").checked; const [type, style] = tr.querySelector(".sf-type").value.split("|"); const axis = Number(tr.querySelector(".sf-axis").value); return { ...s, show, type, style, axis }; }); } async function genCmpChart() { const status = $("#cmp-chart-status"); const imgWrap = $("#cmp-chart-img"); if (!cmpOrder.length) { status.textContent = "请至少选择一个模型"; return; } const series = collectSeriesCfg(); if (!series.some((s) => s.show)) { status.textContent = "请至少勾选一个系列"; return; } status.textContent = "⏳ 正在生成..."; const xRotate = $("#cmp-xrotate").value; const xFont = parseInt($("#cmp-xfont").value) || 12; const xInterval = $("#cmp-xinterval").value; try { if (!cmpCsv) { const models = cmpOrder.join(","); const d = await api(`/api/compare?models=${encodeURIComponent(models)}`); cmpCsv = d.chart_csv; } const payload = buildRunPayload({ csv: cmpCsv, title: `模型运行速度对比(${cmpOrder.length} 个模型)`, series, xRotate: xRotate === "auto" ? "" : xRotate, xFont, xInterval: xInterval === "auto" ? "" : xInterval, }); const resp = await fetch("/api/chart", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); if (!resp.ok) { const j = await resp.json().catch(() => ({})); status.textContent = "❌ " + (j.error || "生成失败"); return; } const blob = await resp.blob(); if (cmpChartUrl) URL.revokeObjectURL(cmpChartUrl); cmpChartUrl = URL.createObjectURL(blob); imgWrap.innerHTML = ""; const img = document.createElement("img"); img.src = cmpChartUrl; img.onload = () => { status.textContent = "✅ 已生成"; }; imgWrap.appendChild(img); } catch (e) { status.textContent = "❌ " + e.message; } } /* ───────────────────────── 提交详情弹窗 ───────────────────────── */ async function openSubmission(sid) { const mask = $("#sub-mask"); if (!mask) return; mask.hidden = false; $("#sub-body").innerHTML = '
加载中…
'; let s; try { s = await api(`/api/submissions/${sid}`); } catch (e) { $("#sub-body").innerHTML = `
${esc(e.message)}
`; return; } $("#sub-id").textContent = `#${sid}`; const byLength = s.by_length || {}; const byConc = s.by_concurrency || {}; const lengthRows = Object.keys(byLength).sort((a, b) => a - b).map((L) => { const bl = byLength[L]; return `${L}${fmt(bl.avg_ttft_ms)} ${fmt(bl.avg_prefill_speed)}${fmt(bl.avg_decode_speed)} ${fmt(bl.avg_prompt_tokens)}${fmt(bl.avg_output_tokens)}`; }).join(""); const concRows = Object.keys(byConc).sort((a, b) => a - b).map((C) => { const bl = byConc[C]; return `${C}${fmt(bl.avg_ttft_ms)} ${fmt(bl.avg_prefill_speed)}${fmt(bl.avg_decode_speed)} ${fmt(bl.avg_stream_decode)}${fmt(bl.avg_total_ms)}`; }).join(""); const runs = s.runs || []; const runsHtml = runs.length ? `` + runs.map((r, i) => { const m = r.metrics || {}; return ``; }).join("") + "
#上下文tok提示词tok 首字ms预填充tok/s输出tok解码tok/s总耗时ms备注
${i + 1}${r.context_length || m.context_length || "—"} ${fmt(m.prompt_tokens)}${fmt(m.ttft_ms)} ${fmt(m.prefill_speed)}${fmt(m.output_tokens)} ${fmt(m.decode_speed)}${fmt(m.total_ms)} ${r.error ? "❌" : "✅"}
" : '
无采样明细(演示数据)
'; $("#sub-body").innerHTML = `
账号
${esc(s.account)}
模型
${esc(s.model)}
提供商
${esc(providerLabel(s.provider))}
硬件
${hwTag(s.hardware)}
测试名称
${esc(s.test_name || "—")}
提交时间
${esc(s.created_at)}
来源
${esc(s.source_site)} #${s.source_test_id}

📊 整体指标

采样(成功/总数)
${fmtInt(s.samples_ok)} / ${fmtInt(s.samples_total)}
首字延迟
${fmt(s.avg_ttft_ms)} ms
预填充速度
${fmt(s.avg_prefill_speed)} tok/s
解码速度
${fmt(s.avg_decode_speed)} tok/s
单流均解码
${fmt(s.avg_stream_decode)} tok/s
平均输出
${fmtInt(s.avg_output_tokens)} tok

📏 按上下文长度

${lengthRows || ''}
长度tok首字ms预填充tok/s解码tok/s提示词tok输出tok
无数据

🔀 按并发数

${concRows || ''}
并发首字ms预填充tok/s解码tok/s单流均解码tok/s总耗时ms
仅单流

📈 图表

点击「🎨 生成图表」

📋 采样明细

${runsHtml} `; let chartUrl = ""; $("#sub-chart-gen").addEventListener("click", async () => { const status = $("#sub-chart-status"); status.textContent = "⏳ 正在生成..."; try { const resp = await fetch(`/api/submissions/${sid}/chart`); if (!resp.ok) { const j = await resp.json().catch(() => ({})); status.textContent = "❌ " + (j.error || "生成失败"); return; } const blob = await resp.blob(); if (chartUrl) URL.revokeObjectURL(chartUrl); chartUrl = URL.createObjectURL(blob); const wrap = $("#sub-chart-img"); wrap.innerHTML = ""; const img = document.createElement("img"); img.src = chartUrl; img.onload = () => { status.textContent = "✅ 生成完成"; }; wrap.appendChild(img); } catch (e) { status.textContent = "❌ " + e.message; } }); } /* ───────────────────────── 后台管理 ───────────────────────── */ let subPage = 1; let subQuery = ""; let capEditId = null; async function checkAdmin() { try { const d = await api("/api/admin/session"); if (d.admin) { $("#login-box").hidden = true; $("#admin-box").hidden = false; $("#btn-logout").hidden = false; loadSubmissions(true); loadAccounts(); loadCaps(); loadSpeedItems(); loadSettings(); return true; } } catch (e) { /* not logged in */ } $("#login-box").hidden = false; $("#admin-box").hidden = true; return false; } function switchTab(tab) { $$("#admin-tabs button").forEach((b) => b.classList.toggle("active", b.dataset.tab === tab)); $("#tab-subs").hidden = tab !== "subs"; $("#tab-accs").hidden = tab !== "accs"; $("#tab-caps").hidden = tab !== "caps"; $("#tab-speed").hidden = tab !== "speed"; $("#tab-settings").hidden = tab !== "settings"; } async function loadSubmissions(resetPage = false) { const tb = $("#subs tbody"); if (!tb) return; if (resetPage) subPage = 1; tb.innerHTML = '加载中…'; let d; try { d = await api(`/api/submissions?page=${subPage}&page_size=20&q=${encodeURIComponent(subQuery)}`); } catch (e) { tb.innerHTML = `加载失败:${esc(e.message)}`; return; } $("#sub-total").textContent = `共 ${d.total} 条`; $("#sub-pg").textContent = `${d.page} / ${d.pages}`; $("#sub-prev").disabled = d.page <= 1; $("#sub-next").disabled = d.page >= d.pages; if (!d.items.length) { tb.innerHTML = '暂无提交记录。可点击「🎲 生成演示数据」或从 llm-speed-tester 发送。'; return; } tb.innerHTML = d.items.map((s) => ` #${s.id} ${esc(s.account)} ${esc(s.created_at)} ${esc(providerLabel(s.provider))} ${esc(s.model)} ${esc(s.hardware || "未标注 ✎")} ${fmt(s.samples_ok)}/${fmt(s.samples_total)} ${fmt(s.avg_prefill_speed)} ${fmt(s.avg_decode_speed)} ${fmt(s.avg_ttft_ms)} `).join(""); } async function editHardware(sid) { const v = prompt(`为提交 #${sid} 设置硬件/GPU(留空清除):`, ""); if (v === null) return; try { await api(`/api/submissions/${sid}/hardware`, "PUT", { hardware: v }); toast("硬件已更新"); loadSubmissions(); } catch (e) { toast("更新失败:" + e.message); } } async function loadAccounts() { const tb = $("#accs tbody"); if (!tb) return; let list; try { list = await api("/api/accounts"); } catch (e) { tb.innerHTML = `加载失败`; return; } if (!list.length) { tb.innerHTML = '暂无账号。llm-speed-tester 一键发送时账号会自动创建。'; return; } tb.innerHTML = list.map((a) => ` ${esc(a.name)} ${esc(a.remark || "—")} ${esc(a.created_at)} ${fmtInt(a.cnt)} 条 `).join(""); } async function loadCaps() { const tb = $("#caps tbody"); if (!tb) return; let list; try { list = await api("/api/admin/capabilities"); } catch (e) { tb.innerHTML = `加载失败:${esc(e.message)}`; return; } if (!list.length) { tb.innerHTML = '暂无能力测试,点击「➕ 新增能力测试」或「🎲 生成演示能力测试」。'; return; } tb.innerHTML = list.map((c) => ` ${c.id} ${esc(c.icon || "🧪")} ${esc(c.name)} ${esc(c.category || "未分类")} ${(c.items || []).length} ${esc((c.description || "").slice(0, 20))} ${c.sort} ${c.status ? '启用' : '停用'} `).join(""); } async function loadSpeedItems() { const tb = $("#si tbody"); if (!tb) return; let list; try { list = await api("/api/admin/speed-items"); } catch (e) { tb.innerHTML = `加载失败:${esc(e.message)}`; return; } if (!list.length) { tb.innerHTML = '暂无速度项,点击「🔄 从提交同步」自动生成。'; return; } tb.innerHTML = list.map((s) => ` ${s.id} ${esc(providerLabel(s.provider))} ${esc(s.model)} 🔥 ${fmtInt(s.heat)} ${fmtInt(s.count)} ${esc((s.intro || "—").slice(0, 22))} ${esc(s.remark || "—")} ${fmtInt(s.real_cnt)} `).join(""); } async function loadSettings() { const s = await api("/api/admin/settings"); if ($("#set-watermark")) $("#set-watermark").value = s.watermark || ""; if ($("#set-topn")) $("#set-topn").value = s.top_n || 10; } function editSpeedItem(btn) { const provider = prompt("提供商:", btn.dataset.provider || "") ?? btn.dataset.provider; const model = prompt("模型名称:", btn.dataset.model || ""); if (model === null) return; const heat = prompt("热度(数字):", btn.dataset.heat || "0"); const count = prompt("个数(展示提交数,0=自动):", btn.dataset.count || "0"); const intro = prompt("配置简介:", btn.dataset.intro || ""); const remark = prompt("备注:", btn.dataset.remark || ""); const body = { provider: provider || "", model: model || "", heat: parseInt(heat) || 0, count: parseInt(count) || 0, intro: intro || "", remark: remark || "", }; api(`/api/admin/speed-items/${btn.dataset.editSi}`, "PUT", body).then(() => { toast("已保存"); loadSpeedItems(); }); } function openCapEditor(c) { capEditId = c ? c.id : null; $("#cap-edit-title").textContent = c ? `编辑能力测试 #${c.id}` : "新增能力测试"; $("#ce-name").value = c ? c.name : ""; $("#ce-category").value = c ? c.category : ""; $("#ce-icon").value = c ? c.icon : "🧪"; $("#ce-sort").value = c ? c.sort : 0; $("#ce-desc").value = c ? c.description : ""; $("#ce-items").value = c ? JSON.stringify(c.items || [], null, 2) : '[{"title":"题目1","prompt":"提示词内容","expect":"期望结果"}]'; $("#ce-status").checked = c ? !!c.status : true; $("#cap-mask").hidden = false; } function parseItems(text) { const t = (text || "").trim(); if (!t) return []; let arr; try { arr = JSON.parse(t); } catch (e) { throw new Error("测试项 JSON 格式错误:" + e.message); } if (!Array.isArray(arr)) throw new Error("测试项必须是 JSON 数组"); return arr; } async function saveCapEditor() { const name = $("#ce-name").value.trim(); if (!name) { toast("请填写名称"); return; } let items; try { items = parseItems($("#ce-items").value); } catch (e) { toast(e.message); return; } const body = { name, category: $("#ce-category").value.trim(), icon: $("#ce-icon").value.trim() || "🧪", sort: parseInt($("#ce-sort").value) || 0, description: $("#ce-desc").value.trim(), items, status: $("#ce-status").checked ? 1 : 0, }; try { if (capEditId) await api(`/api/admin/capabilities/${capEditId}`, "PUT", body); else await api("/api/admin/capabilities", "POST", body); toast("已保存"); $("#cap-mask").hidden = true; loadCaps(); } catch (e) { toast("保存失败:" + e.message); } } /* ───────────────────────── 事件绑定 ───────────────────────── */ function bindIndex() { $$("#lb th[data-sort]").forEach((th) => th.addEventListener("click", () => sortClick(th.dataset.sort))); const gen = $("#lb-chart-gen"); if (gen) gen.addEventListener("click", genLbChart); const dl = $("#lb-chart-dl"); if (dl) dl.addEventListener("click", () => { if (!lbChartUrl) { toast("请先生成图表"); return; } dlChartUrl(); }); const all = $("#lb-chart-all"); if (all) all.addEventListener("click", () => { lbModels.forEach((m) => lbSelected.add(m.key)); renderLbChips(); }); const none = $("#lb-chart-none"); if (none) none.addEventListener("click", () => { lbSelected.clear(); renderLbChips(); }); // +对比按钮 / 详情链接 const tb = $("#lb tbody"); if (tb) tb.addEventListener("click", (e) => { const btn = e.target.closest("[data-cart]"); if (btn) { e.preventDefault(); cartAdd(btn.dataset.cart); } }); bindSearch(); bindCartBar(); } function bindModel() { const gen = $("#m-chart-gen"); if (gen) gen.addEventListener("click", genModelChart); const copy = $("#m-chart-copy"); if (copy) copy.addEventListener("click", () => { const ta = $("#m-chart-csv"); ta.select(); if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(ta.value).then(() => toast("已复制")); else { document.execCommand("copy"); toast("已复制"); } }); const dl = $("#m-chart-dl"); if (dl) dl.addEventListener("click", () => { if (!modelChartUrl) { toast("请先生成图表"); return; } const a = document.createElement("a"); a.href = modelChartUrl; a.download = "model_chart.png"; document.body.appendChild(a); a.click(); a.remove(); }); const body = $("#md-submissions"); if (body) body.addEventListener("click", (e) => { const link = e.target.closest(".s-link"); if (link) { e.preventDefault(); openSubmission(link.dataset.sid); } }); } function bindCapabilities() { const close = $("#cap-close"); if (close) { close.addEventListener("click", () => { $("#cap-mask").hidden = true; }); $("#cap-mask").addEventListener("click", (e) => { if (e.target === $("#cap-mask")) $("#cap-mask").hidden = true; }); } } function bindCompare() { const all = $("#cmp-all"); if (all) all.addEventListener("click", () => { cmpModels.forEach((m) => { cmpSelected.add(m.key); if (!cmpOrder.includes(m.key)) cmpOrder.push(m.key); }); renderCmpChips(); }); const none = $("#cmp-none"); if (none) none.addEventListener("click", () => { cmpSelected.clear(); cmpOrder = []; renderCmpChips(); }); const clearCart = $("#cmp-clear-cart"); if (clearCart) clearCart.addEventListener("click", () => { cartClear(); initCompare(); }); const run = $("#cmp-run"); if (run) run.addEventListener("click", runCompare); const gen = $("#cmp-chart-gen"); if (gen) gen.addEventListener("click", genCmpChart); const dl = $("#cmp-chart-dl"); if (dl) dl.addEventListener("click", () => { if (!cmpChartUrl) { toast("请先生成图表"); return; } const a = document.createElement("a"); a.href = cmpChartUrl; a.download = "模型运行速度对比图.png"; document.body.appendChild(a); a.click(); a.remove(); }); renderCmpColToggles(); renderSeriesCfg(); bindCartBar(); } function bindAdmin() { const login = $("#btn-login"); if (login) login.addEventListener("click", async () => { const u = $("#login-user").value.trim(); const p = $("#login-pass").value; try { await api("/api/admin/login", "POST", { username: u, password: p }); $("#login-err").hidden = true; checkAdmin(); } catch (e) { $("#login-err").hidden = false; $("#login-err").textContent = e.message; } }); const logout = $("#btn-logout"); if (logout) logout.addEventListener("click", async () => { await api("/api/admin/logout", "POST"); location.reload(); }); $$("#admin-tabs button").forEach((b) => b.addEventListener("click", () => switchTab(b.dataset.tab))); // 提交管理 const search = $("#sub-search"); if (search) { search.addEventListener("keydown", (e) => { if (e.key === "Enter") { subQuery = search.value.trim(); loadSubmissions(true); } }); $("#btn-search").addEventListener("click", () => { subQuery = search.value.trim(); loadSubmissions(true); }); } $("#sub-prev").addEventListener("click", () => { if (subPage > 1) { subPage--; loadSubmissions(); } }); $("#sub-next").addEventListener("click", () => { subPage++; loadSubmissions(); }); const seed = $("#btn-seed"); if (seed) seed.addEventListener("click", async () => { seed.disabled = true; seed.textContent = "生成中…"; try { const r = await api("/api/seed-demo", "POST", {}); toast(`已生成 ${r.seeded} 条演示数据`); loadSubmissions(true); } catch (e) { toast("生成失败:" + e.message); } seed.disabled = false; seed.textContent = "🎲 生成演示数据"; }); const subsTb = $("#subs tbody"); subsTb.addEventListener("click", (e) => { const v = e.target.closest("[data-view]"); const d = e.target.closest("[data-del]"); const l = e.target.closest(".s-link"); const h = e.target.closest("[data-hw]"); if (v) { e.preventDefault(); openSubmission(v.dataset.view); } if (l) { e.preventDefault(); openSubmission(l.dataset.sid); } if (h) { editHardware(Number(h.dataset.hw)); } if (d) { const id = d.dataset.del; if (confirm(`确定删除提交 #${id}?`)) { api(`/api/submissions/${id}`, "DELETE").then(() => { toast("已删除"); loadSubmissions(); }); } } }); // 账号管理 const addAcc = $("#btn-add-acc"); if (addAcc) addAcc.addEventListener("click", async () => { const name = $("#acc-name").value.trim(); const remark = $("#acc-remark").value.trim(); if (!name) { toast("请输入账号名称"); return; } try { await api("/api/accounts", "POST", { name, remark }); toast("账号已创建"); $("#acc-name").value = ""; $("#acc-remark").value = ""; loadAccounts(); } catch (e) { toast(e.message); } }); const accsTb = $("#accs tbody"); accsTb.addEventListener("click", (e) => { const ed = e.target.closest("[data-edit]"); const dl = e.target.closest("[data-del]"); if (ed) { const name = prompt("账号名称:", ed.dataset.name); if (name === null) return; const remark = prompt("备注:", ed.dataset.remark) || ""; api(`/api/accounts/${ed.dataset.edit}`, "PUT", { name, remark }).then(() => { toast("已保存"); loadAccounts(); }); } if (dl) { const id = dl.dataset.del; if (confirm("删除该账号会同时删除其下所有提交,确定?")) { api(`/api/accounts/${id}`, "DELETE").then(() => { toast("已删除"); loadAccounts(); }); } } }); // 能力测试管理 const capAdd = $("#btn-cap-add"); if (capAdd) capAdd.addEventListener("click", () => openCapEditor(null)); const capSeed = $("#btn-cap-seed"); if (capSeed) capSeed.addEventListener("click", async () => { capSeed.disabled = true; capSeed.textContent = "生成中…"; try { const r = await api("/api/seed-capabilities", "POST", {}); toast(`已生成 ${r.seeded} 项能力测试`); loadCaps(); } catch (e) { toast("生成失败:" + e.message); } capSeed.disabled = false; capSeed.textContent = "🎲 生成演示能力测试"; }); const capsTb = $("#caps tbody"); capsTb.addEventListener("click", async (e) => { const ed = e.target.closest("[data-edit-cap]"); const dl = e.target.closest("[data-del-cap]"); if (ed) { const list = await api("/api/admin/capabilities"); const c = list.find((x) => x.id === Number(ed.dataset.editCap)); if (c) openCapEditor(c); } if (dl) { const id = dl.dataset.delCap; if (confirm(`确定删除能力测试 #${id}?`)) { api(`/api/admin/capabilities/${id}`, "DELETE").then(() => { toast("已删除"); loadCaps(); }); } } }); const ceSave = $("#ce-save"); if (ceSave) ceSave.addEventListener("click", saveCapEditor); const ceCancel = $("#ce-cancel"); if (ceCancel) ceCancel.addEventListener("click", () => { $("#cap-mask").hidden = true; }); const ceClose = $("#cap-edit-close"); if (ceClose) ceClose.addEventListener("click", () => { $("#cap-mask").hidden = true; }); // 提交详情弹窗关闭 const subClose = $("#sub-close"); if (subClose) { subClose.addEventListener("click", () => { $("#sub-mask").hidden = true; }); $("#sub-mask").addEventListener("click", (e) => { if (e.target === $("#sub-mask")) $("#sub-mask").hidden = true; }); } // 速度项管理 const siAdd = $("#btn-si-add"); if (siAdd) siAdd.addEventListener("click", () => { const provider = prompt("提供商:", "") || ""; const model = prompt("模型名称:", ""); if (!model) { toast("请填写模型名称"); return; } const heat = prompt("热度:", "0") || 0; const count = prompt("个数(展示提交数,0=自动):", "0") || 0; const intro = prompt("配置简介:", "") || ""; api("/api/admin/speed-items", "POST", { provider, model, heat: parseInt(heat) || 0, count: parseInt(count) || 0, intro }) .then((r) => { if (!r.ok) throw new Error(r.error || "添加失败"); toast("已添加"); loadSpeedItems(); }) .catch((e) => toast(e.message)); }); const siSync = $("#btn-si-sync"); if (siSync) siSync.addEventListener("click", async () => { siSync.disabled = true; siSync.textContent = "同步中…"; try { const r = await api("/api/admin/speed-items/sync", "POST", {}); toast(`已同步 ${r.added} 个速度项`); loadSpeedItems(); } catch (e) { toast("同步失败:" + e.message); } siSync.disabled = false; siSync.textContent = "🔄 从提交同步"; }); const siTb = $("#si tbody"); if (siTb) siTb.addEventListener("click", (e) => { const ed = e.target.closest("[data-edit-si]"); const dl = e.target.closest("[data-del-si]"); if (ed) editSpeedItem(ed); if (dl) { if (confirm(`确定删除速度项 #${dl.dataset.delSi}?`)) { api(`/api/admin/speed-items/${dl.dataset.delSi}`, "DELETE").then(() => { toast("已删除"); loadSpeedItems(); }); } } }); // 设置 const save = $("#btn-settings-save"); if (save) save.addEventListener("click", async () => { const body = { watermark_text: $("#set-watermark").value.trim(), top_n: parseInt($("#set-topn").value) || 10, }; try { await api("/api/admin/settings", "PUT", body); const st = $("#set-status"); st.textContent = "✅ 已保存,刷新页面生效"; setTimeout(() => { st.textContent = ""; }, 3000); toast("设置已保存"); } catch (e) { toast("保存失败:" + e.message); } }); } /* ───────────────────────── 初始化 ───────────────────────── */ document.addEventListener("DOMContentLoaded", () => { navActive(); const page = location.pathname.split("/").pop() || "index.html"; if (page === "index.html" || page === "") { loadStats(); loadLeaderboard(); bindIndex(); } else if (page === "model.html") { loadModel(); bindModel(); } else if (page === "capabilities.html") { loadCapabilities(); bindCapabilities(); } else if (page === "compare.html") { initCompare(); bindCompare(); } else if (page === "admin.html") { checkAdmin(); bindAdmin(); } document.addEventListener("keydown", (e) => { if (e.key === "Escape") { $("#sub-mask").hidden = true; $("#cap-mask").hidden = true; } }); });