Files
model-eval-site/static/js/app.js
T

889 lines
40 KiB
JavaScript
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.
/* 模型评测网站 前端逻辑 */
"use strict";
const $ = (s) => document.querySelector(s);
const $$ = (s) => Array.from(document.querySelectorAll(s));
const esc = (s) => String(s ?? "").replace(/[&<>"']/g,
(c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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 ? `<span class="hw-tag" title="${esc(hw)}">🖥 ${esc(hw)}</span>` : "—");
function navActive() {
const path = location.pathname.split("/").pop() || "index.html";
$$(".nav a").forEach((a) => {
a.classList.toggle("active", a.getAttribute("href") === path);
});
}
/* ───────────────────────── 首页:排行 ───────────────────────── */
let sortKey = "avg_decode_speed";
let sortOrder = "desc";
let lbModels = []; // [{provider, model, key}]
let lbSelected = new Set(); // selected keys
const modelKey = (p, m) => `${p}|${m}`;
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) + " <small>次成功采样</small>";
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"; }
loadLeaderboard();
}
function renderLbChips() {
const box = $("#lb-chart-models");
if (!box) return;
box.innerHTML = "";
if (!lbModels.length) { box.innerHTML = '<span class="hint">暂无模型</span>'; 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 = `<span>${esc(m.model)}</span>`;
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 = '<tr><td colspan="11" class="empty">加载中…</td></tr>';
let d;
try {
d = await api(`/api/leaderboard?sort=${sortKey}&order=${sortOrder}`);
} catch (e) {
tb.innerHTML = `<tr><td colspan="11" class="empty">加载失败:${esc(e.message)}</td></tr>`;
return;
}
$$("#lb th[data-sort]").forEach((th) => {
th.querySelector(".arrow").textContent = (th.dataset.sort === sortKey) ? (sortOrder === "desc" ? " ▼" : " ▲") : "";
});
if (!d.rows.length) {
tb.innerHTML = '<tr><td colspan="11" class="empty">暂无评测数据。可在后台管理「提交管理」里点击「🎲 生成演示数据」或从 llm-speed-tester 一键发送。</td></tr>';
return;
}
// 记录模型列表,用于对比图选择
lbModels = d.rows.map((r) => ({ provider: r.provider, model: r.model, key: modelKey(r.provider, r.model) }));
if (!lbSelected.size) lbModels.forEach((m) => lbSelected.add(m.key));
else lbModels.forEach((m) => { if (!lbSelected.has(m.key)) lbSelected.add(m.key); }); // 新模型默认选中
renderLbChips();
const maxDecode = Math.max(...d.rows.map((r) => r.avg_decode_speed || 0));
tb.innerHTML = d.rows.map((r, i) => {
const meter = r.avg_decode_speed ? `<div class="meter"><div style="width:${(r.avg_decode_speed / maxDecode * 100).toFixed(1)}%"></div></div>` : "";
return `<tr>
<td class="num">${i + 1}</td>
<td><span class="pill">${esc(providerLabel(r.provider))}</span></td>
<td><a href="/model.html?provider=${encodeURIComponent(r.provider)}&model=${encodeURIComponent(r.model)}" title="查看详情">${esc(r.model)}</a></td>
<td class="num">${fmtInt(r.cnt)}</td>
<td class="num">${fmtInt(r.accounts)}</td>
<td class="num"><span class="badge-decode">${fmt(r.avg_decode_speed)}</span>${meter}</td>
<td class="num">${fmt(r.avg_prefill_speed)}</td>
<td class="num">${fmt(r.avg_ttft_ms)}</td>
<td class="num">${fmt(r.best_decode)}</td>
<td>${hwTag(r.hardware)}</td>
<td class="num">${esc((r.last_tested || "").slice(5, 16))}</td>
</tr>`;
}).join("");
}
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=avg_decode_speed&order=desc&models=${encodeURIComponent(models)}`);
if (!d.rows.length) { status.textContent = "所选模型暂无数据"; return; }
const resp = await fetch("/api/chart", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify(d.bar_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 = `✅ 已生成(${d.rows.length} 个模型)`; };
imgWrap.appendChild(img);
} catch (e) { status.textContent = "❌ " + e.message; }
}
/* ───────────────────────── 模型详情页 ───────────────────────── */
async function loadModel() {
const params = new URLSearchParams(location.search);
const provider = params.get("provider") || "";
const model = params.get("model") || "";
if (!model) { $("#model-body").innerHTML = '<div class="empty">缺少模型参数</div>'; 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 `<tr>
<td><a href="#" data-sid="${s.id}" class="s-link">#${s.id}</a></td>
<td>${esc(s.account)}</td>
<td>${esc(s.created_at)}</td>
<td>${hwTag(s.hardware)}</td>
<td class="num">${fmt(s.samples_ok)}/${fmt(s.samples_total)}</td>
<td class="num">${fmt(s.avg_ttft_ms)}</td>
<td class="num">${fmt(s.avg_prefill_speed)}</td>
<td class="num"><span class="badge-decode">${fmt(s.avg_decode_speed)}</span></td>
<td class="num">${fmt(s.avg_stream_decode)}</td>
<td class="num">${conc}</td>
<td class="num">${fmt(s.avg_output_tokens)}</td>
<td>${s.test_name ? `<span class="pill gray" title="${esc(s.test_name)}">${esc(s.test_name.slice(0, 16))}</span>` : "—"}</td>
</tr>`;
}).join("");
window.__modelChart = d.line_payload;
window.__modelLineCsv = d.line_csv;
$("#m-chart-csv").value = d.line_csv;
$("#m-chart-img").innerHTML = '<div class="hint">点击「🎨 生成折线图」绘制</div>';
$("#m-chart-status").textContent = "";
$("#md-lengths").innerHTML = d.by_length.map((r) => `<tr>
<td class="num">${fmtInt(r.length)}</td>
<td class="num">${fmtInt(r.count)}</td>
<td class="num">${fmt(r.avg_prefill_speed)}</td>
<td class="num"><span class="badge-decode">${fmt(r.avg_decode_speed)}</span></td>
<td class="num">${fmt(r.avg_ttft_ms)}</td>
</tr>`).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 = '<div class="hint">加载中…</div>';
let list;
try { list = await api("/api/capabilities"); }
catch (e) { grid.innerHTML = `<div class="hint">加载失败:${esc(e.message)}</div>`; return; }
if ($("#cap-sub")) $("#cap-sub").textContent = `共 ${list.length} 项能力测试`;
if (!list.length) {
grid.innerHTML = '<div class="hint">暂无能力测试内容,请到「⚙️ 后台管理 → 能力测试管理」添加。</div>';
return;
}
grid.innerHTML = list.map((c) => `
<div class="cap-card" data-cap="${c.id}">
<div class="cap-head">
<span class="cap-icon">${esc(c.icon || "🧪")}</span>
<span class="cap-name">${esc(c.name)}</span>
<span class="cap-cat"><span class="pill green">${esc(c.category || "未分类")}</span></span>
</div>
<div class="cap-desc">${esc(c.description || "暂无描述")}</div>
<div class="cap-count">📝 含 ${(c.items || []).length} 个测试项,点击查看详情</div>
</div>`).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) => `
<div class="cap-item">
<div class="it-title">${i + 1}. ${esc(it.title || "未命名")}</div>
<div class="it-prompt">📝 ${esc(it.prompt || "")}</div>
${it.expect ? `<div class="it-expect">🎯 期望:${esc(it.expect)}</div>` : ""}
</div>`).join("");
$("#cap-modal-body").innerHTML = `
<div class="kv" style="margin-bottom:14px">
<div class="kv-item"><div class="kv-k">分类</div><div class="kv-v">${esc(c.category || "未分类")}</div></div>
<div class="kv-item"><div class="kv-k">测试项数</div><div class="kv-v">${(c.items || []).length}</div></div>
<div class="kv-item"><div class="kv-k">更新时间</div><div class="kv-v">${esc(c.updated_at || c.created_at || "")}</div></div>
</div>
<div class="hint" style="margin-bottom:8px">${esc(c.description || "")}</div>
<h3>📝 测试项</h3>
${items || '<div class="hint">暂无测试项</div>'}
`;
}
/* ───────────────────────── 模型对比页 ───────────────────────── */
let cmpModels = [];
let cmpSelected = new Set();
let cmpCols = new Set(["provider", "model", "hardware", "avg_decode_speed", "avg_prefill_speed", "avg_ttft_ms"]);
let cmpRows = [];
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) => `
<label><input type="checkbox" value="${c.key}" data-cmpcol="${c.key}" ${cmpCols.has(c.key) ? "checked" : ""}> ${esc(c.label)}</label>`).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 loadCmpModels() {
const box = $("#cmp-models");
if (!box) return;
let d;
try { d = await api("/api/leaderboard?limit=500"); }
catch (e) { box.innerHTML = `<span class="hint">加载失败</span>`; return; }
cmpModels = d.rows.map((r) => ({ provider: r.provider, model: r.model, key: modelKey(r.provider, r.model) }));
if (!cmpSelected.size) cmpModels.forEach((m) => cmpSelected.add(m.key));
renderCmpChips();
}
function renderCmpChips() {
const box = $("#cmp-models");
box.innerHTML = "";
if (!cmpModels.length) { box.innerHTML = '<span class="hint">暂无模型</span>'; return; }
for (const m of cmpModels) {
const on = cmpSelected.has(m.key);
const c = document.createElement("span");
c.className = "chip" + (on ? " on" : "");
c.innerHTML = `<span>${esc(m.model)}</span>`;
c.addEventListener("click", () => {
if (cmpSelected.has(m.key)) cmpSelected.delete(m.key); else cmpSelected.add(m.key);
c.classList.toggle("on");
});
box.appendChild(c);
}
}
async function runCompare() {
const status = $("#cmp-status");
const tbody = $("#cmp-table tbody");
if (!cmpSelected.size) { status.textContent = "请至少选择一个模型"; return; }
status.textContent = "⏳ 正在对比...";
const models = [...cmpSelected].join(",");
try {
const d = await api(`/api/compare?models=${encodeURIComponent(models)}`);
cmpRows = d.rows;
renderCmpTable();
status.textContent = `✅ 已对比 ${cmpRows.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 = "<tr>" + cols.map((c) => `<th class="${c.type === "num" ? "num" : ""}">${esc(c.label)}</th>`).join("") + "</tr>";
if (!cmpRows.length) { tbody.innerHTML = '<tr><td class="empty" colspan="' + cols.length + '">请先选择模型并点击「🔄 生成对比」</td></tr>'; return; }
tbody.innerHTML = cmpRows.map((r) => "<tr>" + cols.map((c) => {
const v = r[c.key];
if (c.type === "hw") return `<td>${hwTag(v)}</td>`;
if (c.type === "num") return `<td class="num">${fmt(v)}</td>`;
if (c.type === "time") return `<td class="num">${esc((v || "").slice(5, 16))}</td>`;
if (c.key === "model") return `<td><a href="/model.html?provider=${encodeURIComponent(r.provider)}&model=${encodeURIComponent(r.model)}">${esc(v)}</a></td>`;
if (c.key === "provider") return `<td><span class="pill">${esc(providerLabel(v))}</span></td>`;
return `<td>${esc(v)}</td>`;
}).join("") + "</tr>").join("");
}
async function genCmpChart() {
const status = $("#cmp-chart-status");
const imgWrap = $("#cmp-chart-img");
if (!cmpSelected.size) { status.textContent = "请至少选择一个模型"; return; }
const metric = $("#cmp-metric").value;
const ctype = $("#cmp-chart-type").value;
status.textContent = "⏳ 正在生成...";
try {
const models = [...cmpSelected].join(",");
const d = await api(`/api/compare?models=${encodeURIComponent(models)}&chart_metric=${metric}&chart_type=${ctype}`);
if (!d.rows.length) { status.textContent = "所选模型暂无数据"; return; }
const resp = await fetch("/api/chart", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify(d.chart_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 = `✅ 已生成(${d.rows.length} 个模型)`; };
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 = '<div class="empty">加载中…</div>';
let s;
try { s = await api(`/api/submissions/${sid}`); }
catch (e) { $("#sub-body").innerHTML = `<div class="empty">${esc(e.message)}</div>`; 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 `<tr><td class="num">${L}</td><td class="num">${fmt(bl.avg_ttft_ms)}</td>
<td class="num">${fmt(bl.avg_prefill_speed)}</td><td class="num">${fmt(bl.avg_decode_speed)}</td>
<td class="num">${fmt(bl.avg_prompt_tokens)}</td><td class="num">${fmt(bl.avg_output_tokens)}</td></tr>`;
}).join("");
const concRows = Object.keys(byConc).sort((a, b) => a - b).map((C) => {
const bl = byConc[C];
return `<tr><td class="num">${C}</td><td class="num">${fmt(bl.avg_ttft_ms)}</td>
<td class="num">${fmt(bl.avg_prefill_speed)}</td><td class="num">${fmt(bl.avg_decode_speed)}</td>
<td class="num">${fmt(bl.avg_stream_decode)}</td><td class="num">${fmt(bl.avg_total_ms)}</td></tr>`;
}).join("");
const runs = s.runs || [];
const runsHtml = runs.length ? `<table class="mini"><thead><tr><th>#</th><th>上下文tok</th><th>提示词tok</th>
<th>首字ms</th><th>预填充tok/s</th><th>输出tok</th><th>解码tok/s</th><th>总耗时ms</th><th>备注</th></tr></thead><tbody>` +
runs.map((r, i) => {
const m = r.metrics || {};
return `<tr><td>${i + 1}</td><td class="num">${r.context_length || m.context_length || "—"}</td>
<td class="num">${fmt(m.prompt_tokens)}</td><td class="num">${fmt(m.ttft_ms)}</td>
<td class="num">${fmt(m.prefill_speed)}</td><td class="num">${fmt(m.output_tokens)}</td>
<td class="num">${fmt(m.decode_speed)}</td><td class="num">${fmt(m.total_ms)}</td>
<td>${r.error ? "❌" : "✅"}</td></tr>`;
}).join("") + "</tbody></table>"
: '<div class="hint">无采样明细(演示数据)</div>';
$("#sub-body").innerHTML = `
<div class="kv" style="margin-bottom:14px">
<div class="kv-item"><div class="kv-k">账号</div><div class="kv-v">${esc(s.account)}</div></div>
<div class="kv-item"><div class="kv-k">模型</div><div class="kv-v">${esc(s.model)}</div></div>
<div class="kv-item"><div class="kv-k">提供商</div><div class="kv-v">${esc(providerLabel(s.provider))}</div></div>
<div class="kv-item"><div class="kv-k">硬件</div><div class="kv-v">${hwTag(s.hardware)}</div></div>
<div class="kv-item"><div class="kv-k">测试名称</div><div class="kv-v">${esc(s.test_name || "—")}</div></div>
<div class="kv-item"><div class="kv-k">提交时间</div><div class="kv-v">${esc(s.created_at)}</div></div>
<div class="kv-item"><div class="kv-k">来源</div><div class="kv-v">${esc(s.source_site)} #${s.source_test_id}</div></div>
</div>
<h3>📊 整体指标</h3>
<div class="kv" style="margin-bottom:14px">
<div class="kv-item"><div class="kv-k">采样(成功/总数)</div><div class="kv-v">${fmtInt(s.samples_ok)} / ${fmtInt(s.samples_total)}</div></div>
<div class="kv-item"><div class="kv-k">首字延迟</div><div class="kv-v">${fmt(s.avg_ttft_ms)} ms</div></div>
<div class="kv-item"><div class="kv-k">预填充速度</div><div class="kv-v">${fmt(s.avg_prefill_speed)} tok/s</div></div>
<div class="kv-item"><div class="kv-k">解码速度</div><div class="kv-v" style="color:var(--accent2)">${fmt(s.avg_decode_speed)} tok/s</div></div>
<div class="kv-item"><div class="kv-k">单流均解码</div><div class="kv-v">${fmt(s.avg_stream_decode)} tok/s</div></div>
<div class="kv-item"><div class="kv-k">平均输出</div><div class="kv-v">${fmtInt(s.avg_output_tokens)} tok</div></div>
</div>
<h3>📏 按上下文长度</h3>
<div class="tbl-wrap" style="margin-bottom:12px"><table class="mini"><thead><tr>
<th>长度tok</th><th>首字ms</th><th>预填充tok/s</th><th>解码tok/s</th><th>提示词tok</th><th>输出tok</th>
</tr></thead><tbody>${lengthRows || '<tr><td colspan="6" class="empty">无数据</td></tr>'}</tbody></table></div>
<h3>🔀 按并发数</h3>
<div class="tbl-wrap" style="margin-bottom:12px"><table class="mini"><thead><tr>
<th>并发</th><th>首字ms</th><th>预填充tok/s</th><th>解码tok/s</th><th>单流均解码tok/s</th><th>总耗时ms</th>
</tr></thead><tbody>${concRows || '<tr><td colspan="6" class="empty">仅单流</td></tr>'}</tbody></table></div>
<h3>📈 图表</h3>
<div class="chart-img-wrap" id="sub-chart-img"><div class="hint">点击「🎨 生成图表」</div></div>
<div style="margin-top:6px"><button class="btn small primary" id="sub-chart-gen">🎨 生成图表</button>
<span class="hint" id="sub-chart-status"></span></div>
<h3>📋 采样明细</h3>
${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();
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";
}
async function loadSubmissions(resetPage = false) {
const tb = $("#subs tbody");
if (!tb) return;
if (resetPage) subPage = 1;
tb.innerHTML = '<tr><td colspan="11" class="empty">加载中…</td></tr>';
let d;
try {
d = await api(`/api/submissions?page=${subPage}&page_size=20&q=${encodeURIComponent(subQuery)}`);
} catch (e) {
tb.innerHTML = `<tr><td colspan="11" class="empty">加载失败:${esc(e.message)}</td></tr>`;
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 = '<tr><td colspan="11" class="empty">暂无提交记录。可点击「🎲 生成演示数据」或从 llm-speed-tester 发送。</td></tr>';
return;
}
tb.innerHTML = d.items.map((s) => `<tr>
<td><a href="#" class="s-link" data-sid="${s.id}">#${s.id}</a></td>
<td>${esc(s.account)}</td>
<td>${esc(s.created_at)}</td>
<td><span class="pill">${esc(providerLabel(s.provider))}</span></td>
<td><a href="/model.html?provider=${encodeURIComponent(s.provider)}&model=${encodeURIComponent(s.model)}">${esc(s.model)}</a></td>
<td><span class="hw-tag" title="点击修改硬件" data-hw="${s.id}" style="cursor:pointer">${esc(s.hardware || "未标注 ✎")}</span></td>
<td class="num">${fmt(s.samples_ok)}/${fmt(s.samples_total)}</td>
<td class="num">${fmt(s.avg_prefill_speed)}</td>
<td class="num"><span class="badge-decode">${fmt(s.avg_decode_speed)}</span></td>
<td class="num">${fmt(s.avg_ttft_ms)}</td>
<td>
<button class="btn small" data-view="${s.id}">查看</button>
<button class="btn small danger" data-del="${s.id}">删除</button>
</td>
</tr>`).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 = `<tr><td colspan="5" class="empty">加载失败</td></tr>`; return; }
if (!list.length) {
tb.innerHTML = '<tr><td colspan="5" class="empty">暂无账号。llm-speed-tester 一键发送时账号会自动创建。</td></tr>';
return;
}
tb.innerHTML = list.map((a) => `<tr>
<td>${esc(a.name)}</td>
<td>${esc(a.remark || "—")}</td>
<td>${esc(a.created_at)}</td>
<td class="num"><span class="pill green">${fmtInt(a.cnt)} 条</span></td>
<td>
<button class="btn small" data-edit="${a.id}" data-name="${esc(a.name)}" data-remark="${esc(a.remark || "")}">编辑</button>
<button class="btn small danger" data-del="${a.id}">删除</button>
</td>
</tr>`).join("");
}
async function loadCaps() {
const tb = $("#caps tbody");
if (!tb) return;
let list;
try { list = await api("/api/admin/capabilities"); }
catch (e) { tb.innerHTML = `<tr><td colspan="9" class="empty">加载失败:${esc(e.message)}</td></tr>`; return; }
if (!list.length) {
tb.innerHTML = '<tr><td colspan="9" class="empty">暂无能力测试,点击「➕ 新增能力测试」或「🎲 生成演示能力测试」。</td></tr>';
return;
}
tb.innerHTML = list.map((c) => `<tr>
<td>${c.id}</td>
<td style="font-size:18px">${esc(c.icon || "🧪")}</td>
<td>${esc(c.name)}</td>
<td><span class="pill green">${esc(c.category || "未分类")}</span></td>
<td class="num">${(c.items || []).length}</td>
<td title="${esc(c.description || "")}">${esc((c.description || "").slice(0, 20))}</td>
<td class="num">${c.sort}</td>
<td>${c.status ? '<span class="pill green">启用</span>' : '<span class="pill gray">停用</span>'}</td>
<td>
<button class="btn small" data-edit-cap="${c.id}">编辑</button>
<button class="btn small danger" data-del-cap="${c.id}">删除</button>
</td>
</tr>`).join("");
}
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 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(); });
}
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)); renderCmpChips(); });
const none = $("#cmp-none");
if (none) none.addEventListener("click", () => { cmpSelected.clear(); renderCmpChips(); });
const run = $("#cmp-run");
if (run) run.addEventListener("click", runCompare);
const gen = $("#cmp-chart-gen");
if (gen) gen.addEventListener("click", genCmpChart);
renderCmpColToggles();
}
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; });
}
}
/* ───────────────────────── 初始化 ───────────────────────── */
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") {
loadCmpModels();
bindCompare();
} else if (page === "admin.html") {
checkAdmin();
bindAdmin();
}
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") { $("#sub-mask").hidden = true; $("#cap-mask").hidden = true; }
});
});