1045 lines
44 KiB
JavaScript
1045 lines
44 KiB
JavaScript
/* 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 = '<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 = "未探测到 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); // 空闲时定期刷新历史
|
||
})();
|