Files
llm-speed-tester/static/js/app.js
T

1045 lines
44 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.
/* LLM 速度测试台 前端逻辑 */
"use strict";
const $ = (s) => document.querySelector(s);
const $$ = (s) => Array.from(document.querySelectorAll(s));
const PROVIDER_DEFAULT_URL = {
openai: "https://api.openai.com/v1",
anthropic: "https://api.anthropic.com",
google: "https://generativelanguage.googleapis.com",
};
const PROVIDER_LABEL = {
openai: "OpenAI 兼容", anthropic: "Anthropic", google: "Google Gemini",
};
const STATUS_LABEL = {
running: "测试中", done: "完成", error: "出错", canceled: "已取消",
};
let currentTestId = null; // 正在跑的测试 id
let pollTimer = null;
let lastLogId = 0;
let consoleLogs = []; // 当前测试已加载日志 [{id,level,msg,rel}]
// 上下文长度:chips 列表 + 启用集合(默认 512/2048/4096/8192/16384/32768/65536/131072
const DEFAULT_CONTEXT_LENGTHS = [512, 2048, 4096, 8192, 16384, 32768, 65536, 131072];
let contextLengths = [...DEFAULT_CONTEXT_LENGTHS];
let contextLengthsActive = new Set(contextLengths);
// 并发数:chips 列表 + 启用集合(默认单流 [1],预设 2/4,可丝滑添加自定义并发数)
const DEFAULT_CONCURRENCY_LEVELS = [1, 2, 4];
let concurrencyLevels = [...DEFAULT_CONCURRENCY_LEVELS];
let concurrencyLevelsActive = new Set([1]); // 默认单流
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 : v);
const pad = (n) => String(n).padStart(2, "0");
const clock = (ts) => `${pad(ts.getHours())}:${pad(ts.getMinutes())}:${pad(ts.getSeconds())}`;
/* ───────────────────────── 通用请求 ───────────────────────── */
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);
return resp.json();
}
/* ───────────────────────── 配置管理 ───────────────────────── */
function currentConfig() {
return {
name: $("#cfg-name").value.trim(),
provider: $("#cfg-provider").value,
base_url: $("#cfg-baseurl").value.trim(),
api_key: $("#cfg-apikey").value.trim(),
model: $("#cfg-model").value.trim(),
temperature: parseFloat($("#cfg-temp").value) || 0.7,
};
}
function currentGen() {
const lens = contextLengths.filter((l) => contextLengthsActive.has(l));
return {
name: $("#gen-name").value.trim(),
context_lengths: lens.length ? lens : [2048],
max_tokens: parseInt($("#gen-max-tokens").value) || 128,
samples: parseInt($("#gen-samples").value) || 2,
concurrency_levels: [...concurrencyLevelsActive].sort((a, b) => a - b),
avoid_cache: $("#gen-avoid-cache").checked,
warmup: $("#gen-warmup").checked,
};
}
/* ───────────────────────── 上下文长度 chips ───────────────────────── */
function renderChips() {
const box = $("#gen-contexts");
box.innerHTML = "";
if (!contextLengths.length) {
box.innerHTML = '<span class="chips-empty">暂无长度,点击下方“添加”自定义</span>';
return;
}
for (const len of contextLengths) {
const active = contextLengthsActive.has(len);
const c = document.createElement("span");
c.className = "chip" + (active ? "" : " off");
c.title = "点击启用/禁用";
c.innerHTML = `<span class="chip-v">${len}</span><span class="chip-x">✕</span>`;
c.addEventListener("click", (e) => {
if (e.target.closest(".chip-x")) {
contextLengths = contextLengths.filter((x) => x !== len);
contextLengthsActive.delete(len);
renderChips();
} else {
if (contextLengthsActive.has(len)) contextLengthsActive.delete(len);
else contextLengthsActive.add(len);
renderChips();
}
});
box.appendChild(c);
}
}
function addContextLength() {
const v = parseInt($("#gen-context-add").value);
if (!v || v < 16) { toast("请输入有效长度(≥16"); return; }
if (!contextLengths.includes(v)) {
contextLengths.push(v);
contextLengthsActive.add(v);
contextLengths.sort((a, b) => a - b);
renderChips();
}
$("#gen-context-add").value = "";
}
/* ───────────────────────── 并发数 chips ───────────────────────── */
function renderConcurrencyChips() {
const box = $("#gen-concurrency");
if (!box) return;
box.innerHTML = "";
if (!concurrencyLevels.length) {
box.innerHTML = '<span class="chips-empty">暂无并发档,点击下方“添加”自定义</span>';
return;
}
for (const c of concurrencyLevels) {
const active = concurrencyLevelsActive.has(c);
const el = document.createElement("span");
el.className = "chip" + (active ? "" : " off");
el.title = "点击启用/禁用并发档";
el.innerHTML = `<span class="chip-v">${c}</span><span class="chip-x">✕</span>`;
el.addEventListener("click", (e) => {
if (e.target.closest(".chip-x")) {
concurrencyLevels = concurrencyLevels.filter((x) => x !== c);
concurrencyLevelsActive.delete(c);
} else {
if (concurrencyLevelsActive.has(c)) concurrencyLevelsActive.delete(c);
else concurrencyLevelsActive.add(c);
}
if (!concurrencyLevelsActive.size) concurrencyLevelsActive.add(1); // 至少保留一档
renderConcurrencyChips();
});
box.appendChild(el);
}
}
function addConcurrency() {
const v = parseInt($("#gen-conc-add").value);
if (!v || v < 1) { toast("请输入有效并发数(≥1)"); return; }
if (!concurrencyLevels.includes(v)) {
concurrencyLevels.push(v);
concurrencyLevelsActive.add(v);
concurrencyLevels.sort((a, b) => a - b);
renderConcurrencyChips();
}
$("#gen-conc-add").value = "";
}
function updateDefaultUrlHint() {
const p = $("#cfg-provider").value;
$("#cfg-default-url").textContent = "默认地址:" + PROVIDER_DEFAULT_URL[p];
if (!$("#cfg-baseurl").value.trim()) {
$("#cfg-baseurl").placeholder = "留空使用默认:" + PROVIDER_DEFAULT_URL[p];
}
}
async function loadConfigs() {
const list = await api("/api/configs");
const sel = $("#cfg-list");
const keep = sel.value;
sel.innerHTML = '<option value="">— 选择配置加载 —</option>';
for (const c of list) {
const o = document.createElement("option");
o.value = c.id;
o.textContent = `${c.name}${PROVIDER_LABEL[c.provider] || c.provider} / ${c.model}${c.has_key ? "" : " · 无Key"}`;
sel.appendChild(o);
}
if (keep && [...sel.options].some((o) => o.value === keep)) sel.value = keep;
}
function loadSelectedConfig() {
const id = $("#cfg-list").value;
if (!id) return;
api(`/api/configs/${id}`).then((c) => {
$("#cfg-name").value = c.name || "";
$("#cfg-provider").value = c.provider || "openai";
$("#cfg-baseurl").value = c.base_url || "";
$("#cfg-apikey").value = c.api_key || "";
$("#cfg-model").value = c.model || "";
$("#cfg-temp").value = c.temperature ?? 0.7;
$("#tmp-val").textContent = (c.temperature ?? 0.7).toFixed(1);
updateDefaultUrlHint();
flash("已加载配置:" + c.name);
}).catch(() => toast("加载失败"));
}
/* ───────────────────────── 连接测试 ───────────────────────── */
async function testConnection() {
const cfg = currentConfig();
if (!cfg.api_key) { showConn(false, "请先填写 API Key"); return; }
const box = $("#conn-result");
box.hidden = false; box.className = "conn-result";
box.textContent = "⏳ 正在测试连接...";
try {
const r = await api("/api/configs/test", "POST", cfg);
if (r.ok) {
const m = r.metrics || {};
showConn(true,
`✅ 连接成功(${r.total_ms}ms| 首字 ${fmt(m.ttft_ms)}ms | 提示词 ${fmt(m.prompt_tokens)} tok | 输出 ${fmt(m.output_tokens)} tok${r.note ? " " + r.note : ""}`);
} else {
showConn(false, "❌ " + (r.error || "连接失败"));
}
} catch (e) {
showConn(false, "❌ " + e.message);
}
}
function showConn(ok, text) {
const box = $("#conn-result");
box.hidden = false;
box.className = "conn-result " + (ok ? "ok" : "fail");
box.textContent = text;
}
/* ───────────────────────── 测试运行 ───────────────────────── */
function setStatus(state, label) {
const badge = $("#run-status");
badge.className = "status-badge " + state;
badge.innerHTML = `<span class="dot"></span>${label}`;
}
function clearConsole() {
consoleLogs = [];
$("#console").innerHTML = "";
$("#log-count").textContent = "";
resetMetrics();
}
function appendLogs(logs) {
const box = $("#console");
const atBottom = box.scrollHeight - box.scrollTop - box.clientHeight < 40;
let html = "";
for (const l of logs) {
html += `<div class="ln ${esc(l.level)}"><span class="ts">[${l.rel.toFixed(3)}s]</span> ${esc(l.msg)}</div>`;
}
box.insertAdjacentHTML("beforeend", html);
$("#log-count").textContent = `${consoleLogs.length} 条`;
if (atBottom) box.scrollTop = box.scrollHeight;
}
function resetMetrics() {
["m-ttft", "m-prefill", "m-decode", "m-prompts", "m-outputs", "m-total"].forEach((id) => {
const el = $(`#${id}`);
el.textContent = "—";
el.className = "m-value";
});
}
function updateMetrics(m, isSummary = false) {
if (!m) return;
const val = (k, ak) => (m[k] !== null && m[k] !== undefined && !isNaN(m[k])) ? m[k] : m[ak];
const set = (id, v, accent = false, good = false) => {
const el = $(`#${id}`);
el.textContent = fmt(v);
el.className = "m-value" + (accent ? " accent" : "") + (good ? " good" : "");
};
set("m-ttft", val("ttft_ms", "avg_ttft_ms"), false, true);
set("m-prefill", val("prefill_speed", "avg_prefill_speed"), true, false);
set("m-decode", val("decode_speed", "avg_decode_speed"), false, true);
set("m-prompts", val("prompt_tokens", "avg_prompt_tokens"));
set("m-outputs", val("output_tokens", "avg_output_tokens"));
set("m-total", val("total_ms", "avg_total_ms"));
const u = isSummary ? "均 ms" : "ms";
$("#m-ttft-u").textContent = u;
}
function startTest() {
const cfg = currentConfig();
const gen = currentGen();
if (!cfg.api_key) { toast("请填写 API Key"); return; }
if (!cfg.model) { toast("请填写模型名称"); return; }
clearConsole();
currentTestId = null;
lastLogId = 0;
setStatus("running", "正在测试…");
$("#btn-start").disabled = true;
$("#btn-cancel").disabled = false;
api("/api/tests", "POST", { config: cfg, gen }).then((r) => {
if (!r.ok) {
throw new Error(r.error || "启动失败");
}
currentTestId = r.id;
appendLogs([{ id: 0, level: "sys", msg: `测试 #${r.id} 已启动(${cfg.name || cfg.model}`, rel: 0 }]);
pollTimer = setInterval(pollLogs, 800);
pollLogs();
}).catch((e) => {
setStatus("error", "启动失败");
toast(e.message);
$("#btn-start").disabled = false;
$("#btn-cancel").disabled = true;
});
}
async function pollLogs() {
if (currentTestId == null) return;
try {
const d = await api(`/api/tests/${currentTestId}/logs?after=${lastLogId}`);
if (d.logs && d.logs.length) {
appendLogs(d.logs);
lastLogId = d.after || lastLogId;
}
if (d.last_run) updateMetrics(d.last_run);
if (d.status === "done" || d.status === "error" || d.status === "canceled") {
finishTest(d);
}
} catch (e) {
/* 网络抖动忽略 */
}
}
function finishTest(d) {
clearInterval(pollTimer);
pollTimer = null;
$("#btn-start").disabled = false;
$("#btn-cancel").disabled = true;
if (d.summary && d.summary.samples_ok > 0) {
updateMetrics(d.summary, true);
}
const map = { done: ["done", "✅ 测试完成"], error: ["error", "⚠️ 测试出错"], canceled: ["canceled", "⏹ 已取消"] };
const [state, label] = map[d.status] || ["done", "完成"];
setStatus(state, label);
if (d.status === "error" && d.error) toast("测试出错:" + d.error);
loadHistory();
}
function stopTest() {
if (currentTestId == null) return;
api(`/api/tests/${currentTestId}/cancel`, "POST").then(() => toast("正在停止…"));
$("#btn-cancel").disabled = true;
}
/* ───────────────────────── 测试历史 ───────────────────────── */
let histSelected = new Set(); // 勾选用于对比的测试 id
function updateHistSelectedLabel() {
const el = $("#hist-selected");
if (el) el.textContent = histSelected.size ? `已选 ${histSelected.size} 个` : "";
}
async function loadHistory() {
const list = await api("/api/tests");
const tb = $("#history tbody");
tb.innerHTML = "";
const selAll = $("#hist-check-all");
if (selAll) selAll.checked = false;
if (!list.length) {
tb.innerHTML = '<tr><td colspan="13" style="color:var(--muted);text-align:center">暂无测试记录</td></tr>';
updateHistSelectedLabel();
return;
}
for (const t of list) {
const s = t.summary || {};
const g = t.gen || {};
const cls = s.concurrency_levels || g.concurrency_levels || [1];
const tr = document.createElement("tr");
tr.innerHTML = `
<td class="chk-col"><input type="checkbox" class="hist-chk" data-id="${t.id}" ${histSelected.has(t.id) ? "checked" : ""}></td>
<td>#${t.id}</td>
<td>${esc(t.created_at)}</td>
<td title="${esc(t.name || "")}">${esc(t.name || "—")}</td>
<td>${esc(PROVIDER_LABEL[t.provider] || t.provider)}</td>
<td>${esc(t.model)}</td>
<td class="num">${esc(cls.join("/"))}</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">${fmt(s.avg_decode_speed)}</td>
<td><span class="status-pill ${esc(t.status)}">${STATUS_LABEL[t.status] || t.status}</span></td>
<td>
<button class="btn small" data-view="${t.id}">查看</button>
<button class="btn small" data-send="${t.id}" title="发送到评测站对应账号下">📤 发送</button>
<button class="btn small" data-xlsx="${t.id}">Excel</button>
<button class="btn small danger" data-del="${t.id}">删除</button>
</td>`;
tb.appendChild(tr);
}
updateHistSelectedLabel();
}
/* ───────────────────────── 多测试对比 ───────────────────────── */
function cmpChartBlock(id, csv, payload, title) {
return `<div class="chart-block">
<div class="chart-toolbar">
<button class="btn small primary" data-cmp-gen="${id}">🎨 生成/刷新图表</button>
<span class="hint" data-cmp-status="${id}"></span>
</div>
<div class="chart-img-wrap" data-cmp-img="${id}"><div class="hint">点击“生成/刷新图表”绘制:${esc(title)}</div></div>
<div class="chart-data-head">📋 画图数据(CSV,可一键复制)</div>
<textarea class="chart-csv" data-cmp-csv="${id}" readonly spellcheck="false">${esc(csv)}</textarea>
<div class="chart-toolbar" style="margin-top:6px">
<button class="btn small" data-cmp-copy="${id}">📋 复制画图数据</button>
<button class="btn small" data-cmp-dl="${id}">⬇ 下载 PNG</button>
</div>
</div>`;
}
async function openCompare() {
const ids = [...histSelected];
if (!ids.length) { toast("请先在测试历史中勾选要对比的测试"); return; }
const mask = $("#cmp-mask");
const body = $("#cmp-body");
mask.hidden = false;
body.innerHTML = '<div class="hint">⏳ 正在汇总对比数据...</div>';
let d;
try {
d = await api("/api/compare", "POST", { ids });
} catch (e) {
body.innerHTML = '<div class="conn-result fail">对比失败:' + esc(e.message) + "</div>"; return;
}
if (!d.ok) {
body.innerHTML = '<div class="conn-result fail">' + esc(d.error || "对比失败") + "</div>"; return;
}
window.__cmpPayloads = {
bar: d.bar_payload,
line: d.line ? d.line.payload : null,
};
const table = `<table class="mini compare"><thead><tr>
<th>测试</th><th>时间</th><th>名称</th><th>模型</th><th>并发档</th><th>采样(成功/总数)</th>
<th>首字ms</th><th>预填充tok/s</th><th>解码tok/s</th><th>单流均解码tok/s</th><th>输出tok</th><th>总耗时ms</th>
</tr></thead><tbody>` +
d.rows.map((r) => `<tr>
<td><button class="btn link" data-vd="${r.id}">${esc(r.label)}</button></td>
<td>${esc(r.created_at)}</td>
<td title="${esc(r.name || "")}">${esc(r.name || "—")}</td>
<td>${esc(r.model)}</td>
<td>${(r.concurrency_levels || []).join("/") || "1"}</td>
<td class="num">${fmt(r.samples_ok)}/${fmt(r.samples_total)}</td>
<td class="num">${fmt(r.avg_ttft_ms)}</td>
<td class="num">${fmt(r.avg_prefill_speed)}</td>
<td class="num">${fmt(r.avg_decode_speed)}</td>
<td class="num">${fmt(r.avg_stream_decode)}</td>
<td class="num">${fmt(r.avg_output_tokens)}</td>
<td class="num">${fmt(r.avg_total_ms)}</td>
</tr>`).join("") + `</tbody></table>`;
const lineBlock = d.line
? cmpChartBlock("line", d.line.csv, d.line.payload, "解码速度随并发变化(各测试对比)")
: '<div class="hint">未生成并发折线图:所选测试需至少有 2 个共同并发档(同一批并发数都测过)。可改用上方柱状图对比。</div>';
body.innerHTML = `
<h3>📊 指标对比表(点击测试名可查看详情)</h3>
${table}
<h3>📈 柱状图:速度对比</h3>
${cmpChartBlock("bar", d.bar_csv, d.bar_payload, "预填充空心柱 / 解码实心柱")}
<h3>📈 折线图:解码速度随并发变化</h3>
${lineBlock}
`;
// 绑定图表生成/复制/下载
body.querySelectorAll("[data-cmp-gen]").forEach((btn) => {
const id = btn.dataset.cmpGen;
const payload = window.__cmpPayloads[id];
const imgWrap = body.querySelector(`[data-cmp-img="${id}"]`);
const statusEl = body.querySelector(`[data-cmp-status="${id}"]`);
const csvTa = body.querySelector(`[data-cmp-csv="${id}"]`);
let url = "";
btn.addEventListener("click", async () => {
statusEl.textContent = "⏳ 正在生成...";
try {
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(() => ({}));
statusEl.textContent = "❌ " + (j.error || "生成失败"); return;
}
const blob = await resp.blob();
if (url) URL.revokeObjectURL(url);
url = URL.createObjectURL(blob);
imgWrap.innerHTML = "";
const img = document.createElement("img");
img.src = url;
img.style.maxWidth = "100%";
img.onload = () => { statusEl.textContent = "✅ 生成完成"; };
imgWrap.appendChild(img);
} catch (e) { statusEl.textContent = "❌ " + e.message; }
});
body.querySelector(`[data-cmp-copy="${id}"]`).addEventListener("click", () => {
csvTa.select();
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(csvTa.value).then(() => toast("画图数据已复制"));
else document.execCommand("copy");
});
body.querySelector(`[data-cmp-dl="${id}"]`).addEventListener("click", () => {
if (!url) { toast("请先生成图表"); return; }
const a = document.createElement("a");
a.href = url; a.download = `compare_${id}.png`;
document.body.appendChild(a); a.click(); a.remove();
});
});
// 对比表里的“查看”按钮
body.querySelectorAll("[data-vd]").forEach((b) => {
b.addEventListener("click", () => viewDetail(Number(b.dataset.vd)));
});
}
function closeCompare() { $("#cmp-mask").hidden = true; }
/* ───────────────────────── 详情弹窗 ───────────────────────── */
async function viewDetail(id) {
const t = await api(`/api/tests/${id}`);
const s = t.summary || {};
const g = t.gen || {};
const cfg = t.config || {};
const runs = t.runs || [];
const logs = t.logs || [];
const byLength = s.by_length || {};
let byLengthHtml;
const lens = Object.keys(byLength).sort((a, b) => a - b);
if (lens.length) {
byLengthHtml = `<table class="mini"><thead><tr>
<th>上下文长度tok</th><th>采样(成功/总数)</th><th>首字ms</th><th>预填充tok/s</th>
<th>解码tok/s</th><th>提示词tok</th><th>输出tok</th><th>总耗时ms</th>
</tr></thead><tbody>` +
lens.map((L) => {
const bl = byLength[L] || {};
return `<tr>
<td class="num">${L}</td>
<td class="num">${fmt(bl.samples_ok)}/${fmt(bl.samples_total)}</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>
<td class="num">${fmt(bl.avg_total_ms)}</td>
</tr>`;
}).join("") + `</tbody></table>`;
} else {
byLengthHtml = '<div class="hint">无成功采样数据</div>';
}
// 按并发数分组汇总(多并发测试的对比数据)
const byConc = s.by_concurrency || {};
let byConcHtml;
const concs = Object.keys(byConc).sort((a, b) => a - b);
if (concs.length > 1) {
byConcHtml = `<table class="mini"><thead><tr>
<th>并发数</th><th>采样(成功/总数)</th><th>首字ms</th><th>预填充tok/s</th>
<th>解码tok/s</th><th>单流均解码tok/s</th><th>输出tok</th><th>总耗时ms</th>
</tr></thead><tbody>` +
concs.map((C) => {
const bl = byConc[C] || {};
return `<tr>
<td class="num">${C}</td>
<td class="num">${fmt(bl.samples_ok)}/${fmt(bl.samples_total)}</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_output_tokens)}</td>
<td class="num">${fmt(bl.avg_total_ms)}</td>
</tr>`;
}).join("") + `</tbody></table>`;
} else {
byConcHtml = '<div class="hint">本次仅测试了单流(并发 1),无并发对比数据;勾选多个并发档后再测即可对比。</div>';
}
let runsHtml;
if (runs.length) {
runsHtml = `<table class="mini"><thead><tr>
<th>采样</th><th>上下文tok</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 class="${r.error ? "err" : ""}">
<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.cached_tokens, 0)}</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 ? "❌ " + esc(r.error.slice(0, 60)) : "✅"}</td>
</tr>`;
}).join("") + `</tbody></table>`;
} else {
runsHtml = '<div class="hint">无采样数据</div>';
}
const logHtml = `<div class="detail-log">` + logs.map((l) =>
`<div class="ln ${esc(l.level)}"><span style="color:#5b6b8c">[${l.rel.toFixed(3)}s]</span> ${esc(l.msg)}</div>`
).join("") + `</div>`;
$("#dt-id").textContent = id;
const overallRow = (label, avgV, minV, maxV) =>
`<tr><td>${label}</td><td class="num">${fmt(avgV)}</td><td class="num">${fmt(minV)}</td><td class="num">${fmt(maxV)}</td></tr>`;
$("#dt-body").innerHTML = `
${t.name ? `<div class="detail-name">🏷️ ${esc(t.name)}</div>` : ""}
<h3>📌 汇总指标</h3>
<div class="kv">
<div class="kv-item"><div class="kv-k">状态</div><div class="kv-v">${STATUS_LABEL[t.status] || t.status}</div></div>
<div class="kv-item"><div class="kv-k">创建时间</div><div class="kv-v">${esc(t.created_at)}</div></div>
<div class="kv-item"><div class="kv-k">提供商 / 模型</div><div class="kv-v">${esc(PROVIDER_LABEL[t.provider] || t.provider)} / ${esc(t.model)}</div></div>
<div class="kv-item"><div class="kv-k">采样(成功/总数)</div><div class="kv-v">${fmt(s.samples_ok)} / ${fmt(s.samples_total)}</div></div>
<div class="kv-item"><div class="kv-k">平均缓存命中</div><div class="kv-v">${fmt(s.avg_cached_tokens, 0)} tok</div></div>
<div class="kv-item"><div class="kv-k">校准字符/token</div><div class="kv-v">${fmt(s.calibration_chars_per_token)}</div></div>
</div>
<h3>📊 整体统计(平均 / 最小 / 最大)</h3>
<table class="mini"><thead><tr>
<th>指标</th><th>平均</th><th>最小</th><th>最大</th>
</tr></thead><tbody>
${overallRow("首字延迟(ms)", s.avg_ttft_ms, s.min_ttft_ms, s.max_ttft_ms)}
${overallRow("预填充速度(tok/s)", s.avg_prefill_speed, s.min_prefill_speed, s.max_prefill_speed)}
${overallRow("解码速度(tok/s)", s.avg_decode_speed, s.min_decode_speed, s.max_decode_speed)}
${overallRow("提示词(tok)", s.avg_prompt_tokens, null, null)}
${overallRow("输出(tok)", s.avg_output_tokens, null, null)}
${overallRow("总耗时(ms)", s.avg_total_ms, s.min_total_ms, s.max_total_ms)}
</tbody></table>
<h3>📏 按上下文长度汇总</h3>
${byLengthHtml}
<h3>🔀 按并发数汇总(整批吞吐)</h3>
${byConcHtml}
<h3>📈 折线图(预填充左轴虚线 / 解码右轴实线)</h3>
<div class="chart-block">
<div class="chart-toolbar">
<button class="btn small primary" id="dt-chart-gen">🎨 生成/刷新折线图</button>
<span class="hint" id="dt-chart-status"></span>
</div>
<div class="chart-img-wrap" id="dt-chart-img">
<div class="hint">点击上方按钮,用 data-chart-tool 生成双Y轴折线图(左轴=预填充虚线,右轴=解码实线)</div>
</div>
<div class="chart-data-head">📋 画图数据(CSV,可一键复制)</div>
<textarea class="chart-csv" id="dt-chart-csv" readonly spellcheck="false" placeholder="(生成图表后自动填充,或手动复制)"></textarea>
<div class="chart-toolbar" style="margin-top:6px">
<button class="btn small" id="dt-chart-copy">📋 复制画图数据</button>
<button class="btn small" id="dt-chart-download">⬇ 下载图表 PNG</button>
</div>
</div>
<h3>🔀 并发对比折线图(X=并发数)</h3>
<div class="chart-block">
<div class="chart-toolbar">
<button class="btn small primary" id="dt-cc-gen">🎨 生成/刷新图表</button>
<span class="hint" id="dt-cc-status"></span>
</div>
<div class="chart-img-wrap" id="dt-cc-img">
<div class="hint">X 轴=并发数,左轴=预填充(虚线),右轴=解码(实线),直观看出吞吐随并发的变化</div>
</div>
<div class="chart-data-head">📋 画图数据(CSV,可一键复制)</div>
<textarea class="chart-csv" id="dt-cc-csv" readonly spellcheck="false" placeholder="(生成图表后自动填充,或手动复制)"></textarea>
<div class="chart-toolbar" style="margin-top:6px">
<button class="btn small" id="dt-cc-copy">📋 复制画图数据</button>
<button class="btn small" id="dt-cc-download">⬇ 下载图表 PNG</button>
</div>
</div>
<h3>⚙️ 测试参数</h3>
<div class="kv">
<div class="kv-item"><div class="kv-k">测试名称</div><div class="kv-v">${esc(t.name || "—")}</div></div>
<div class="kv-item"><div class="kv-k">上下文长度</div><div class="kv-v">${(g.context_lengths || []).join(" / ") || "—"} tok</div></div>
<div class="kv-item"><div class="kv-k">并发数</div><div class="kv-v">${(s.concurrency_levels || g.concurrency_levels || [1]).join(" / ")}</div></div>
<div class="kv-item"><div class="kv-k">解码输出长度</div><div class="kv-v">${g.max_tokens ?? "—"} tok</div></div>
<div class="kv-item"><div class="kv-k">每个组合采样</div><div class="kv-v">${g.samples ?? "—"}</div></div>
<div class="kv-item"><div class="kv-k">预热(空转)</div><div class="kv-v">${g.warmup === false ? "关" : "开"}</div></div>
<div class="kv-item"><div class="kv-k">避免缓存</div><div class="kv-v">${g.avoid_cache ? "开" : "关"}</div></div>
<div class="kv-item"><div class="kv-k">温度</div><div class="kv-v">${fmt(cfg.temperature)}</div></div>
<div class="kv-item"><div class="kv-k">Base URL</div><div class="kv-v">${esc(cfg.base_url || "(默认)")}</div></div>
</div>
<h3>📊 每次采样明细</h3>
${runsHtml}
<h3>📋 完整日志</h3>
${logHtml}
${t.error ? `<div class="conn-result fail" style="margin-top:10px">错误信息:${esc(t.error)}</div>` : ""}
`;
$("#detail-mask").hidden = false;
$("#dt-body").scrollTop = 0;
window.__detail = t;
// 图表:生成/刷新 + 复制数据 + 下载
const genBtn = $("#dt-chart-gen");
const csvTa = $("#dt-chart-csv");
const imgWrap = $("#dt-chart-img");
const statusEl = $("#dt-chart-status");
let chartUrl = "";
const genChart = () => {
statusEl.textContent = "⏳ 正在生成...";
fetch(`/api/tests/${id}/chart-data`).then((r) => r.json()).then((d) => {
if (!d.ok) { statusEl.textContent = "❌ " + (d.error || "生成失败"); return; }
csvTa.value = d.csv;
chartUrl = `/api/tests/${id}/chart?t=${Date.now()}`;
const img = document.createElement("img");
img.src = chartUrl;
img.alt = "预填充/解码 折线图";
img.style.maxWidth = "100%";
img.onload = () => { imgWrap.innerHTML = ""; imgWrap.appendChild(img); statusEl.textContent = "✅ 生成完成"; };
img.onerror = () => { statusEl.textContent = "❌ 图表生成失败"; };
}).catch((e) => { statusEl.textContent = "❌ " + e.message; });
};
genBtn.addEventListener("click", genChart);
$("#dt-chart-copy").addEventListener("click", () => {
if (!csvTa.value) { toast("暂无画图数据,请先生成图表"); return; }
csvTa.select();
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(csvTa.value).then(() => toast("画图数据已复制"));
} else {
document.execCommand("copy");
toast("画图数据已复制");
}
});
$("#dt-chart-download").addEventListener("click", () => {
if (!chartUrl) { toast("请先生成图表"); return; }
const a = document.createElement("a");
a.href = chartUrl;
a.download = `llm_speed_chart_${id}.png`;
document.body.appendChild(a); a.click(); a.remove();
});
// 并发对比折线图:生成/刷新 + 复制数据 + 下载
const ccGen = $("#dt-cc-gen");
const ccCsv = $("#dt-cc-csv");
const ccImg = $("#dt-cc-img");
const ccStatus = $("#dt-cc-status");
let ccUrl = "";
const genCcChart = () => {
ccStatus.textContent = "⏳ 正在生成...";
fetch(`/api/tests/${id}/concurrency-chart-data`).then((r) => r.json()).then((d) => {
if (!d.ok) { ccStatus.textContent = "❌ " + (d.error || "生成失败"); return; }
ccCsv.value = d.csv;
ccUrl = `/api/tests/${id}/concurrency-chart?t=${Date.now()}`;
const img = document.createElement("img");
img.src = ccUrl;
img.alt = "并发对比折线图";
img.style.maxWidth = "100%";
img.onload = () => { ccImg.innerHTML = ""; ccImg.appendChild(img); ccStatus.textContent = "✅ 生成完成"; };
img.onerror = () => { ccStatus.textContent = "❌ 图表生成失败"; };
}).catch((e) => { ccStatus.textContent = "❌ " + e.message; });
};
ccGen.addEventListener("click", genCcChart);
$("#dt-cc-copy").addEventListener("click", () => {
if (!ccCsv.value) { toast("暂无画图数据,请先生成图表"); return; }
ccCsv.select();
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(ccCsv.value).then(() => toast("画图数据已复制"));
else { document.execCommand("copy"); toast("画图数据已复制"); }
});
$("#dt-cc-download").addEventListener("click", () => {
if (!ccUrl) { toast("请先生成图表"); return; }
const a = document.createElement("a");
a.href = ccUrl;
a.download = `llm_concurrency_chart_${id}.png`;
document.body.appendChild(a); a.click(); a.remove();
});
}
function closeDetail() { $("#detail-mask").hidden = true; }
async function exportDetail() {
if (!window.__detail) return;
try {
const resp = await fetch(`/api/tests/${window.__detail.id}/export.json`);
if (!resp.ok) throw new Error("服务端导出失败");
const j = await resp.json();
download(JSON.stringify(j, null, 2), `test_${j.id}.json`, "application/json");
} catch (e) {
toast("导出失败:" + e.message);
}
}
async function exportXlsx(id) {
try {
const resp = await fetch(`/api/tests/${id}/export.xlsx`);
if (!resp.ok) {
const j = await resp.json().catch(() => ({}));
throw new Error(j.error || "导出失败");
}
const blob = await resp.blob();
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `llm_speed_test_${id}.xlsx`;
document.body.appendChild(a);
a.click();
setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 100);
} catch (e) {
toast("导出失败:" + e.message);
}
}
/* ───────────────────────── 导出 / 下载 ───────────────────────── */
function download(text, filename, type = "text/plain") {
const blob = new Blob([text], { type });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = filename;
document.body.appendChild(a);
a.click();
setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 100);
}
function exportCurrentLog() {
if (!currentTestId) { toast("当前没有进行中的测试"); return; }
api(`/api/tests/${currentTestId}`).then((t) => {
const lines = t.logs.map((l) => `[${l.rel.toFixed(3)}s] [${l.level}] ${l.msg}`).join("\n");
const head = `# LLM 速度测试日志 test#${t.id} ${t.created_at}\n# 状态: ${t.status} 提供商: ${t.provider} 模型: ${t.model}\n`;
download(head + lines + "\n", `llm_test_${t.id}.log`, "text/plain");
});
}
/* ───────────────────────── 评测站同步(一键发送) ───────────────────────── */
const EVAL_TOKEN = "meval16066"; // 与评测站 config.SUBMIT_TOKEN 一致
function evalSettings() {
return {
url: (localStorage.getItem("evalUrl") || "http://127.0.0.1:16066").replace(/\/+$/, ""),
account: localStorage.getItem("evalAccount") || "默认账号",
hardware: localStorage.getItem("evalHardware") || "",
};
}
function loadEvalSettings() {
const s = evalSettings();
const u = $("#eval-url"), a = $("#eval-account"), h = $("#eval-hardware");
if (u) u.value = s.url;
if (a) a.value = s.account;
if (h) h.value = s.hardware;
}
function saveEvalSettings() {
localStorage.setItem("evalUrl", $("#eval-url").value.trim() || "http://127.0.0.1:16066");
localStorage.setItem("evalAccount", $("#eval-account").value.trim() || "默认账号");
localStorage.setItem("evalHardware", $("#eval-hardware").value.trim() || "");
}
async function detectGpu() {
const box = $("#eval-result");
try {
const r = await api("/api/gpu");
if (r.label) {
$("#eval-hardware").value = r.label;
saveEvalSettings();
box.hidden = false; box.className = "conn-result ok";
box.textContent = `✅ 已探测到 GPU${r.label}`;
} else {
box.hidden = false; box.className = "conn-result fail";
box.textContent = "未探测到 GPUnvidia-smi 不可用),可手动填写硬件描述";
}
} catch (e) {
box.hidden = false; box.className = "conn-result fail";
box.textContent = "❌ 探测失败:" + e.message;
}
}
async function testEvalConn() {
saveEvalSettings();
const s = evalSettings();
const box = $("#eval-result");
box.hidden = false; box.className = "conn-result";
box.textContent = "⏳ 正在连接评测站...";
try {
const resp = await fetch(s.url + "/api/health");
if (!resp.ok) throw new Error("HTTP " + resp.status);
const j = await resp.json();
const st = j.stats || {};
box.className = "conn-result ok";
box.textContent = `✅ 连接成功(端口 ${j.port}| 模型 ${st.models} · 提交 ${st.submissions} · 账号 ${st.accounts}`;
} catch (e) {
box.className = "conn-result fail";
box.textContent = "❌ 连接失败:" + e.message;
}
}
async function sendToEval(id) {
saveEvalSettings();
const s = evalSettings();
if (!confirm(`将测试 #${id} 发送到评测站(${s.url})的账号「${s.account}」下?`)) return;
let t;
try {
const resp = await fetch(`/api/tests/${id}/export.json`);
if (!resp.ok) throw new Error("读取测试数据失败");
t = await resp.json();
} catch (e) {
toast("读取失败:" + e.message); return;
}
if (!(t.summary && t.summary.samples_ok > 0)) {
toast("该测试没有成功采样数据,无法发送"); return;
}
const payload = {
account: s.account,
source_test_id: id,
source_site: "llm-speed-tester",
provider: t.provider || "",
model: t.model || "",
hardware: s.hardware,
test_name: t.name || "",
summary: t.summary || {},
gen: t.gen || {},
config: t.config || {},
runs: t.runs || [],
};
try {
const resp = await fetch(s.url + "/api/submit", {
method: "POST",
headers: { "Content-Type": "application/json", "X-Token": EVAL_TOKEN },
body: JSON.stringify(payload),
});
const j = await resp.json().catch(() => ({}));
if (!j.ok) throw new Error(j.error || "发送失败(HTTP " + resp.status + "");
toast(`✅ 已发送!评测站提交 #${j.id}(账号 ${j.account} / ${j.model}`);
setTimeout(() => {
toast(`📤 查看详情:${s.url}${j.url}`);
}, 3600);
} catch (e) {
toast("❌ 发送失败:" + e.message);
}
}
/* ───────────────────────── 提示 ───────────────────────── */
function toast(msg) {
const el = document.createElement("div");
el.textContent = msg;
el.style.cssText = "position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:#1d2538;border:1px solid var(--accent);color:#fff;padding:10px 18px;border-radius:10px;z-index:999;font-size:13px;box-shadow:0 6px 20px rgba(0,0,0,.4)";
document.body.appendChild(el);
setTimeout(() => el.remove(), 3200);
}
function flash(msg) {
toast(msg);
}
/* ───────────────────────── 事件绑定 ───────────────────────── */
function bind() {
$("#cfg-provider").addEventListener("change", updateDefaultUrlHint);
$("#cfg-temp").addEventListener("input", () => $("#tmp-val").textContent = parseFloat($("#cfg-temp").value).toFixed(1));
$("#btn-toggle-key").addEventListener("click", () => {
const inp = $("#cfg-apikey");
inp.type = inp.type === "password" ? "text" : "password";
});
$("#btn-save-config").addEventListener("click", async () => {
const r = await api("/api/configs", "POST", currentConfig());
if (r.ok) { toast("配置已保存"); loadConfigs(); }
else toast(r.error || "保存失败");
});
$("#btn-load-config").addEventListener("click", loadSelectedConfig);
$("#btn-del-config").addEventListener("click", async () => {
const id = $("#cfg-list").value;
if (!id) { toast("请先选择要删除的配置"); return; }
if (!confirm("确定删除该配置?")) return;
await api(`/api/configs/${id}`, "DELETE");
toast("已删除"); loadConfigs();
});
$("#btn-test-conn").addEventListener("click", testConnection);
$("#btn-eval-test").addEventListener("click", testEvalConn);
$("#btn-detect-gpu").addEventListener("click", detectGpu);
$("#btn-start").addEventListener("click", startTest);
$("#btn-cancel").addEventListener("click", stopTest);
$("#btn-context-add").addEventListener("click", addContextLength);
$("#gen-context-add").addEventListener("keydown", (e) => { if (e.key === "Enter") addContextLength(); });
$("#btn-conc-add").addEventListener("click", addConcurrency);
$("#gen-conc-add").addEventListener("keydown", (e) => { if (e.key === "Enter") addConcurrency(); });
$("#btn-clear-console").addEventListener("click", clearConsole);
$("#btn-export-log").addEventListener("click", exportCurrentLog);
$("#btn-refresh-history").addEventListener("click", loadHistory);
$("#btn-compare").addEventListener("click", openCompare);
$("#history tbody").addEventListener("click", (e) => {
const v = e.target.closest("[data-view]");
const s = e.target.closest("[data-send]");
const x = e.target.closest("[data-xlsx]");
const d = e.target.closest("[data-del]");
if (v) viewDetail(Number(v.dataset.view));
if (s) sendToEval(Number(s.dataset.send));
if (x) exportXlsx(Number(x.dataset.xlsx));
if (d) {
const id = Number(d.dataset.del);
if (confirm(`确定删除测试 #${id} 及其全部日志?`)) {
histSelected.delete(id);
api(`/api/tests/${id}`, "DELETE").then(() => { loadHistory(); updateHistSelectedLabel(); });
}
}
});
// 历史勾选(用于多测试对比)
$("#history tbody").addEventListener("change", (e) => {
const chk = e.target.closest(".hist-chk");
if (!chk) return;
const id = Number(chk.dataset.id);
if (chk.checked) histSelected.add(id);
else histSelected.delete(id);
updateHistSelectedLabel();
});
$("#hist-check-all").addEventListener("change", (e) => {
$$(".hist-chk").forEach((c) => { c.checked = e.target.checked; });
histSelected.clear();
if (e.target.checked) $$(".hist-chk").forEach((c) => histSelected.add(Number(c.dataset.id)));
updateHistSelectedLabel();
});
$("#dt-close").addEventListener("click", closeDetail);
$("#detail-mask").addEventListener("click", (e) => { if (e.target === $("#detail-mask")) closeDetail(); });
$("#cmp-close").addEventListener("click", closeCompare);
$("#cmp-mask").addEventListener("click", (e) => { if (e.target === $("#cmp-mask")) closeCompare(); });
$("#dt-export").addEventListener("click", exportDetail);
$("#dt-export-xlsx").addEventListener("click", () => { if (window.__detail) exportXlsx(window.__detail.id); });
$("#dt-send-eval").addEventListener("click", () => { if (window.__detail) sendToEval(window.__detail.id); });
document.addEventListener("keydown", (e) => { if (e.key === "Escape") { closeDetail(); closeCompare(); } });
}
/* ───────────────────────── 初始化 ───────────────────────── */
(async function init() {
bind();
renderChips();
renderConcurrencyChips();
updateDefaultUrlHint();
loadConfigs();
loadHistory();
loadEvalSettings();
setInterval(() => { if (!currentTestId) loadHistory(); }, 30000); // 空闲时定期刷新历史
})();