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

638 lines
26 KiB
JavaScript
Raw Normal View History

/* 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);
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,
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 = "";
}
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;
}
/* ───────────────────────── 测试历史 ───────────────────────── */
async function loadHistory() {
const list = await api("/api/tests");
const tb = $("#history tbody");
tb.innerHTML = "";
if (!list.length) {
tb.innerHTML = '<tr><td colspan="11" style="color:var(--muted);text-align:center">暂无测试记录</td></tr>';
return;
}
for (const t of list) {
const s = t.summary || {};
const tr = document.createElement("tr");
tr.innerHTML = `
<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">${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-xlsx="${t.id}">Excel</button>
<button class="btn small danger" data-del="${t.id}">删除</button>
</td>`;
tb.appendChild(tr);
}
}
/* ───────────────────────── 详情弹窗 ───────────────────────── */
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>';
}
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>
<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>⚙️ 测试参数</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">${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();
});
}
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");
});
}
/* ───────────────────────── 提示 ───────────────────────── */
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-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-clear-console").addEventListener("click", clearConsole);
$("#btn-export-log").addEventListener("click", exportCurrentLog);
$("#btn-refresh-history").addEventListener("click", loadHistory);
$("#history tbody").addEventListener("click", (e) => {
const v = e.target.closest("[data-view]");
const x = e.target.closest("[data-xlsx]");
const d = e.target.closest("[data-del]");
if (v) viewDetail(Number(v.dataset.view));
if (x) exportXlsx(Number(x.dataset.xlsx));
if (d) {
const id = Number(d.dataset.del);
if (confirm(`确定删除测试 #${id} 及其全部日志?`)) {
api(`/api/tests/${id}`, "DELETE").then(() => loadHistory());
}
}
});
$("#dt-close").addEventListener("click", closeDetail);
$("#detail-mask").addEventListener("click", (e) => { if (e.target === $("#detail-mask")) closeDetail(); });
$("#dt-export").addEventListener("click", exportDetail);
$("#dt-export-xlsx").addEventListener("click", () => { if (window.__detail) exportXlsx(window.__detail.id); });
document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeDetail(); });
}
/* ───────────────────────── 初始化 ───────────────────────── */
(async function init() {
bind();
renderChips();
updateDefaultUrlHint();
loadConfigs();
loadHistory();
setInterval(() => { if (!currentTestId) loadHistory(); }, 30000); // 空闲时定期刷新历史
})();