565 lines
23 KiB
JavaScript
565 lines
23 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/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 = '<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); // 空闲时定期刷新历史
|
||
})();
|