/* 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 = `` + d.rows.map((r) => ``).join("") + `
测试时间名称模型并发档采样(成功/总数) 首字ms预填充tok/s解码tok/s单流均解码tok/s输出tok总耗时ms
${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)}
`; 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 = `` + lens.map((L) => { const bl = byLength[L] || {}; return ``; }).join("") + `
上下文长度tok采样(成功/总数)首字ms预填充tok/s 解码tok/s提示词tok输出tok总耗时ms
${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)}
`; } else { byLengthHtml = '
无成功采样数据
'; } // 按并发数分组汇总(多并发测试的对比数据) const byConc = s.by_concurrency || {}; let byConcHtml; const concs = Object.keys(byConc).sort((a, b) => a - b); if (concs.length > 1) { byConcHtml = `` + concs.map((C) => { const bl = byConc[C] || {}; return ``; }).join("") + `
并发数采样(成功/总数)首字ms预填充tok/s 解码tok/s单流均解码tok/s输出tok总耗时ms
${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)}
`; } else { byConcHtml = '
本次仅测试了单流(并发 1),无并发对比数据;勾选多个并发档后再测即可对比。
'; } let runsHtml; if (runs.length) { runsHtml = `` + runs.map((r, i) => { const m = r.metrics || {}; return ``; }).join("") + `
采样上下文tok提示词tok缓存tok首字ms预填充tok/s 输出tok解码tok/s总耗时ms备注
${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)) : "✅"}
`; } 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(t.created_at)}
提供商 / 模型
${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); // 空闲时定期刷新历史 })();