/* 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/8192/32768/131072) const DEFAULT_CONTEXT_LENGTHS = [512, 2048, 8192, 32768, 131072]; let contextLengths = [...DEFAULT_CONTEXT_LENGTHS]; let contextLengthsActive = new Set(contextLengths); 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 { 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 = '暂无长度,点击下方“添加”自定义'; 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 = ""; } 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; } /* ───────────────────────── 测试历史 ───────────────────────── */ async function loadHistory() { const list = await api("/api/tests"); const tb = $("#history tbody"); tb.innerHTML = ""; if (!list.length) { tb.innerHTML = '暂无测试记录'; return; } for (const t of list) { const s = t.summary || {}; const tr = document.createElement("tr"); tr.innerHTML = ` #${t.id} ${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_ttft_ms)} ${fmt(s.avg_prefill_speed)} ${fmt(s.avg_decode_speed)} ${STATUS_LABEL[t.status] || t.status} `; 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 = `` + 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 = '
无成功采样数据
'; } 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; $("#dt-body").innerHTML = `

📌 汇总指标

状态
${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_ttft_ms)} ms
最佳首字延迟
${fmt(s.best_ttft_ms)} ms
平均预填充速度
${fmt(s.avg_prefill_speed)} tok/s
平均解码速度
${fmt(s.avg_decode_speed)} tok/s
平均提示词
${fmt(s.avg_prompt_tokens)} tok
平均输出
${fmt(s.avg_output_tokens)} tok
平均缓存命中
${fmt(s.avg_cached_tokens, 0)} tok
平均总耗时
${fmt(s.avg_total_ms)} ms

📏 按上下文长度汇总

${byLengthHtml}

⚙️ 测试参数

上下文长度
${(g.context_lengths || []).join(" / ") || "—"} tok
解码输出长度
${g.max_tokens ?? "—"} tok
每个长度采样
${g.samples ?? "—"}
预热(空转)
${g.warmup === false ? "关" : "开"}
避免缓存
${g.avoid_cache ? "开" : "关"}
温度
${fmt(cfg.temperature)}
Base URL
${esc(cfg.base_url || "(默认)")}
校准字符/token
${fmt(s.calibration_chars_per_token)}

📊 每次采样明细

${runsHtml}

📋 完整日志

${logHtml} ${t.error ? `
错误信息:${esc(t.error)}
` : ""} `; $("#detail-mask").hidden = false; $("#dt-body").scrollTop = 0; window.__detail = t; } function closeDetail() { $("#detail-mask").hidden = true; } function exportDetail() { if (!window.__detail) return; download(JSON.stringify(window.__detail, null, 2), `test_${window.__detail.id}.json`, "application/json"); } 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); // 空闲时定期刷新历史 })();