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

483 lines
22 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));
const toastEl = () => $("#toast");
let toastTimer = null;
function toast(msg) {
const el = toastEl();
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);
const j = await resp.json().catch(() => ({}));
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 || "—";
}
/* 顶部导航高亮 */
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";
async function loadStats() {
try {
const s = await api("/api/stats");
$("#st-models").textContent = s.models;
$("#st-subs").textContent = s.submissions;
$("#st-accounts").textContent = s.accounts;
$("#st-samples").innerHTML = fmtInt(s.samples_ok) + " <small>次成功采样</small>";
} catch (e) { /* ignore */ }
}
function sortClick(key) {
if (sortKey === key) sortOrder = sortOrder === "desc" ? "asc" : "desc";
else { sortKey = key; sortOrder = "desc"; }
loadLeaderboard();
}
async function loadLeaderboard() {
const tb = $("#lb tbody");
if (!tb) return;
tb.innerHTML = '<tr><td colspan="9" class="empty">加载中…</td></tr>';
let d;
try {
d = await api(`/api/leaderboard?sort=${sortKey}&order=${sortOrder}`);
} catch (e) {
tb.innerHTML = `<tr><td colspan="9" 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="9" class="empty">暂无评测数据。可在「提交管理」页面点击「🎲 生成演示数据」,或从 llm-speed-tester 一键发送。</td></tr>';
return;
}
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 class="num">${esc((r.last_tested || "").slice(5, 16))}</td>
</tr>`;
}).join("");
window.__lbRows = d.rows;
window.__lbChart = d.bar_payload;
}
let lbChartUrl = "";
async function genLbChart() {
if (!window.__lbChart) { toast("暂无数据可画图"); return; }
const status = $("#lb-chart-status");
const imgWrap = $("#lb-chart-img");
status.textContent = "⏳ 正在生成...";
try {
const resp = await fetch("/api/chart", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify(window.__lbChart),
});
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 = "✅ 生成完成"; };
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) {
$("#model-body").innerHTML = `<div class="empty">${esc(e.message)}</div>`;
return;
}
document.title = `${d.model} · 模型评测`;
$("#m-title").textContent = `${d.model} 详情`;
$("#m-sub").textContent = `${providerLabel(d.provider)} · 共 ${d.sub_cnt} 条提交`;
$("#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 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 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">${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}
`;
window.__subId = sid;
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 = "";
async function loadSubmissions(resetPage = false) {
const tb = $("#subs tbody");
if (!tb) return;
if (resetPage) subPage = 1;
tb.innerHTML = '<tr><td colspan="8" 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="8" 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="8" 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 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>${s.test_name ? esc(s.test_name.slice(0, 14)) : "—"}</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 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("");
}
/* ───────────────────────── 事件绑定 ───────────────────────── */
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);
}
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 bindSubmissions() {
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 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 tb = $("#subs tbody");
tb.addEventListener("click", (e) => {
const v = e.target.closest("[data-view]");
const d = e.target.closest("[data-del]");
const l = e.target.closest(".s-link");
if (v) { e.preventDefault(); openSubmission(v.dataset.view); }
if (l) { e.preventDefault(); openSubmission(l.dataset.sid); }
if (d) {
const id = d.dataset.del;
if (confirm(`确定删除提交 #${id}`)) {
api(`/api/submissions/${id}`, "DELETE").then(() => { toast("已删除"); loadSubmissions(); });
}
}
});
const close = $("#sub-close");
if (close) {
close.addEventListener("click", () => { $("#sub-mask").hidden = true; });
$("#sub-mask").addEventListener("click", (e) => { if (e.target === $("#sub-mask")) $("#sub-mask").hidden = true; });
}
}
function bindAccounts() {
const add = $("#btn-add-acc");
if (add) add.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 tb = $("#accs tbody");
tb.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(); });
}
}
});
}
/* ───────────────────────── 初始化 ───────────────────────── */
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 === "submissions.html") {
loadSubmissions(true);
bindSubmissions();
} else if (page === "accounts.html") {
loadAccounts();
bindAccounts();
}
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") { const m = $("#sub-mask"); if (m) m.hidden = true; }
});
});