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

565 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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) => ({ "&": "&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 {
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="10" 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>${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;
$("#dt-body").innerHTML = `
<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_ttft_ms)} ms</div></div>
<div class="kv-item"><div class="kv-k">最佳首字延迟</div><div class="kv-v">${fmt(s.best_ttft_ms)} ms</div></div>
<div class="kv-item"><div class="kv-k">平均预填充速度</div><div class="kv-v">${fmt(s.avg_prefill_speed)} tok/s</div></div>
<div class="kv-item"><div class="kv-k">平均解码速度</div><div class="kv-v">${fmt(s.avg_decode_speed)} tok/s</div></div>
<div class="kv-item"><div class="kv-k">平均提示词</div><div class="kv-v">${fmt(s.avg_prompt_tokens)} tok</div></div>
<div class="kv-item"><div class="kv-k">平均输出</div><div class="kv-v">${fmt(s.avg_output_tokens)} tok</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">平均总耗时</div><div class="kv-v">${fmt(s.avg_total_ms)} ms</div></div>
</div>
<h3>📏 按上下文长度汇总</h3>
${byLengthHtml}
<h3>⚙️ 测试参数</h3>
<div class="kv">
<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 class="kv-item"><div class="kv-k">校准字符/token</div><div class="kv-v">${fmt(s.calibration_chars_per_token)}</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;
}
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); // 空闲时定期刷新历史
})();