/* 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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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 = '暂无长度,点击下方“添加”自定义';
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 = `${len}✕`;
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 = '暂无并发档,点击下方“添加”自定义';
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 = `${c}✕`;
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 = '';
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 = `${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 += `
[${l.rel.toFixed(3)}s] ${esc(l.msg)}
`;
}
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 = '| 暂无测试记录 |
';
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 = `
|
#${t.id} |
${esc(t.created_at)} |
${esc(t.name || "—")} |
${esc(PROVIDER_LABEL[t.provider] || t.provider)} |
${esc(t.model)} |
${esc(cls.join("/"))} |
${fmt(s.samples_ok)}/${fmt(s.samples_total)} |
${fmt(s.avg_ttft_ms)} |
${fmt(s.avg_prefill_speed)} |
${fmt(s.avg_decode_speed)} |
${STATUS_LABEL[t.status] || t.status} |
| `;
tb.appendChild(tr);
}
updateHistSelectedLabel();
}
/* ───────────────────────── 多测试对比 ───────────────────────── */
function cmpChartBlock(id, csv, payload, title) {
return `
点击“生成/刷新图表”绘制:${esc(title)}
📋 画图数据(CSV,可一键复制)
`;
}
async function openCompare() {
const ids = [...histSelected];
if (!ids.length) { toast("请先在测试历史中勾选要对比的测试"); return; }
const mask = $("#cmp-mask");
const body = $("#cmp-body");
mask.hidden = false;
body.innerHTML = '⏳ 正在汇总对比数据...
';
let d;
try {
d = await api("/api/compare", "POST", { ids });
} catch (e) {
body.innerHTML = '对比失败:' + esc(e.message) + "
"; return;
}
if (!d.ok) {
body.innerHTML = '' + esc(d.error || "对比失败") + "
"; return;
}
window.__cmpPayloads = {
bar: d.bar_payload,
line: d.line ? d.line.payload : null,
};
const table = `
| 测试 | 时间 | 名称 | 模型 | 并发档 | 采样(成功/总数) |
首字ms | 预填充tok/s | 解码tok/s | 单流均解码tok/s | 输出tok | 总耗时ms |
` +
d.rows.map((r) => `
|
${esc(r.created_at)} |
${esc(r.name || "—")} |
${esc(r.model)} |
${(r.concurrency_levels || []).join("/") || "1"} |
${fmt(r.samples_ok)}/${fmt(r.samples_total)} |
${fmt(r.avg_ttft_ms)} |
${fmt(r.avg_prefill_speed)} |
${fmt(r.avg_decode_speed)} |
${fmt(r.avg_stream_decode)} |
${fmt(r.avg_output_tokens)} |
${fmt(r.avg_total_ms)} |
`).join("") + `
`;
const lineBlock = d.line
? cmpChartBlock("line", d.line.csv, d.line.payload, "解码速度随并发变化(各测试对比)")
: '未生成并发折线图:所选测试需至少有 2 个共同并发档(同一批并发数都测过)。可改用上方柱状图对比。
';
body.innerHTML = `
📊 指标对比表(点击测试名可查看详情)
${table}
📈 柱状图:速度对比
${cmpChartBlock("bar", d.bar_csv, d.bar_payload, "预填充空心柱 / 解码实心柱")}
📈 折线图:解码速度随并发变化
${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 = `
| 上下文长度tok | 采样(成功/总数) | 首字ms | 预填充tok/s |
解码tok/s | 提示词tok | 输出tok | 总耗时ms |
` +
lens.map((L) => {
const bl = byLength[L] || {};
return `
| ${L} |
${fmt(bl.samples_ok)}/${fmt(bl.samples_total)} |
${fmt(bl.avg_ttft_ms)} |
${fmt(bl.avg_prefill_speed)} |
${fmt(bl.avg_decode_speed)} |
${fmt(bl.avg_prompt_tokens)} |
${fmt(bl.avg_output_tokens)} |
${fmt(bl.avg_total_ms)} |
`;
}).join("") + `
`;
} else {
byLengthHtml = '无成功采样数据
';
}
// 按并发数分组汇总(多并发测试的对比数据)
const byConc = s.by_concurrency || {};
let byConcHtml;
const concs = Object.keys(byConc).sort((a, b) => a - b);
if (concs.length > 1) {
byConcHtml = `
| 并发数 | 采样(成功/总数) | 首字ms | 预填充tok/s |
解码tok/s | 单流均解码tok/s | 输出tok | 总耗时ms |
` +
concs.map((C) => {
const bl = byConc[C] || {};
return `
| ${C} |
${fmt(bl.samples_ok)}/${fmt(bl.samples_total)} |
${fmt(bl.avg_ttft_ms)} |
${fmt(bl.avg_prefill_speed)} |
${fmt(bl.avg_decode_speed)} |
${fmt(bl.avg_stream_decode)} |
${fmt(bl.avg_output_tokens)} |
${fmt(bl.avg_total_ms)} |
`;
}).join("") + `
`;
} else {
byConcHtml = '本次仅测试了单流(并发 1),无并发对比数据;勾选多个并发档后再测即可对比。
';
}
let runsHtml;
if (runs.length) {
runsHtml = `
| 采样 | 上下文tok | 提示词tok | 缓存tok | 首字ms | 预填充tok/s |
输出tok | 解码tok/s | 总耗时ms | 备注 |
` +
runs.map((r, i) => {
const m = r.metrics || {};
return `
| ${i + 1} |
${r.context_length || m.context_length || "—"} |
${fmt(m.prompt_tokens)} |
${fmt(m.cached_tokens, 0)} |
${fmt(m.ttft_ms)} |
${fmt(m.prefill_speed)} |
${fmt(m.output_tokens)} |
${fmt(m.decode_speed)} |
${fmt(m.total_ms)} |
${r.error ? "❌ " + esc(r.error.slice(0, 60)) : "✅"} |
`;
}).join("") + `
`;
} else {
runsHtml = '无采样数据
';
}
const logHtml = `` + logs.map((l) =>
`
[${l.rel.toFixed(3)}s] ${esc(l.msg)}
`
).join("") + `
`;
$("#dt-id").textContent = id;
const overallRow = (label, avgV, minV, maxV) =>
`| ${label} | ${fmt(avgV)} | ${fmt(minV)} | ${fmt(maxV)} |
`;
$("#dt-body").innerHTML = `
${t.name ? `🏷️ ${esc(t.name)}
` : ""}
📌 汇总指标
状态
${STATUS_LABEL[t.status] || t.status}
提供商 / 模型
${esc(PROVIDER_LABEL[t.provider] || t.provider)} / ${esc(t.model)}
采样(成功/总数)
${fmt(s.samples_ok)} / ${fmt(s.samples_total)}
平均缓存命中
${fmt(s.avg_cached_tokens, 0)} tok
校准字符/token
${fmt(s.calibration_chars_per_token)}
📊 整体统计(平均 / 最小 / 最大)
| 指标 | 平均 | 最小 | 最大 |
${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)}
📏 按上下文长度汇总
${byLengthHtml}
🔀 按并发数汇总(整批吞吐)
${byConcHtml}
📈 折线图(预填充左轴虚线 / 解码右轴实线)
点击上方按钮,用 data-chart-tool 生成双Y轴折线图(左轴=预填充虚线,右轴=解码实线)
📋 画图数据(CSV,可一键复制)
🔀 并发对比折线图(X=并发数)
X 轴=并发数,左轴=预填充(虚线),右轴=解码(实线),直观看出吞吐随并发的变化
📋 画图数据(CSV,可一键复制)
⚙️ 测试参数
测试名称
${esc(t.name || "—")}
上下文长度
${(g.context_lengths || []).join(" / ") || "—"} tok
并发数
${(s.concurrency_levels || g.concurrency_levels || [1]).join(" / ")}
解码输出长度
${g.max_tokens ?? "—"} tok
每个组合采样
${g.samples ?? "—"}
预热(空转)
${g.warmup === false ? "关" : "开"}
避免缓存
${g.avoid_cache ? "开" : "关"}
温度
${fmt(cfg.temperature)}
Base URL
${esc(cfg.base_url || "(默认)")}
📊 每次采样明细
${runsHtml}
📋 完整日志
${logHtml}
${t.error ? `错误信息:${esc(t.error)}
` : ""}
`;
$("#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 = "未探测到 GPU(nvidia-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); // 空闲时定期刷新历史
})();