// 大模型速度测试助手 - 前端主逻辑 (Ollama 版)
const RECOMMENDED_CASES = ["case_01_generation", "case_02_simple_tool", "case_03_file_analysis", "case_04_parallel_tool"];
let charts = {};
let currentTestData = null;
let pollTimer = null;
let backendCases = {}; // 从 /api/cases 动态加载的用例
let appConfig = {}; // 从 /api/config 动态加载的后端配置
// 初始化
document.addEventListener('DOMContentLoaded', () => {
loadConfig();
renderTestCaseList();
loadModels();
updateUI();
});
// ---------- 后端配置 ----------
async function loadConfig() {
try {
const resp = await fetch('/api/config');
appConfig = await resp.json();
} catch (e) {
appConfig = {};
}
const backend = appConfig.backend || 'ollama';
const label = document.getElementById('versionLabel');
if (label) {
label.textContent = `v2.1 | ${backend === 'openai' ? 'OpenAI 兼容' : 'Ollama'}`;
}
const welcome = document.getElementById('welcomeBackend');
if (welcome) {
welcome.textContent = backend === 'openai'
? `(OpenAI 兼容:${appConfig.openai_base_url || ''})`
: '(本地 Ollama 服务)';
}
}
// ---------- 用例列表 ----------
function renderTestCaseList() {
const container = document.getElementById('testCaseList');
const entries = Object.entries(backendCases);
if (entries.length === 0) {
container.innerHTML = '
无法加载用例列表
';
return;
}
container.innerHTML = entries.map(([id, info]) => `
`).join('');
}
function toggleCase(caseId) {
const cb = document.getElementById(`cb_${caseId}`);
cb.checked = !cb.checked;
updateUI();
}
function selectAll() {
Object.keys(backendCases).forEach(id => { document.getElementById(`cb_${id}`).checked = true; });
updateUI();
}
function clearAll() {
Object.keys(backendCases).forEach(id => { document.getElementById(`cb_${id}`).checked = false; });
updateUI();
}
function selectRecommended() {
Object.keys(backendCases).forEach(id => {
document.getElementById(`cb_${id}`).checked = RECOMMENDED_CASES.includes(id);
});
updateUI();
}
function getSelectedCases() {
const selected = [];
Object.keys(backendCases).forEach(id => {
if (document.getElementById(`cb_${id}`).checked) selected.push(id);
});
return selected;
}
function updateUI() {
const startBtn = document.getElementById('startBtn');
startBtn.disabled = getSelectedCases().length === 0;
}
// ---------- 模型列表 ----------
async function loadModels() {
const sel = document.getElementById('modelSelect');
try {
const resp = await fetch('/api/models');
const data = await resp.json();
if (data.models && data.models.length > 0) {
sel.innerHTML = data.models.map(m => ``).join('');
} else {
sel.innerHTML = ``;
}
} catch (e) {
sel.innerHTML = ``;
}
}
// ---------- 用例列表(从后端加载) ----------
async function loadCases() {
try {
const resp = await fetch('/api/cases');
backendCases = await resp.json();
} catch (e) {
console.warn('加载用例列表失败', e);
backendCases = {};
}
renderTestCaseList();
}
// ---------- 开始 / 停止 ----------
async function startTests() {
const selectedCases = getSelectedCases();
if (selectedCases.length === 0) {
alert('请至少选择一个测试用例');
return;
}
const repeats = parseInt(document.getElementById('repeatsInput').value) || 3;
const skipHeavy = document.getElementById('runMode').value === 'skip_heavy';
const model = document.getElementById('modelSelect').value;
if (!model) {
const backend = appConfig.backend || 'ollama';
alert(backend === 'openai'
? '未选择模型。请检查 OpenAI 兼容服务地址与 API Key 配置,并刷新页面加载模型列表。'
: '未选择模型。请确认 Ollama 已启动(ollama serve)并已拉取模型(ollama pull <模型名>)');
return;
}
// UI 状态
document.getElementById('welcomeCard').style.display = 'none';
document.getElementById('resultsArea').classList.remove('visible');
document.getElementById('statusPanel').classList.add('active');
document.getElementById('stepList').innerHTML = '';
document.getElementById('currentCaseInfo').textContent = `模型: ${model} | 重复: ${repeats} 次`;
document.getElementById('startBtn').disabled = true;
document.getElementById('stopBtn').disabled = false;
updateStatus('running', '正在启动测试...');
updateProgress(0);
try {
const resp = await fetch('/api/run', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
cases: selectedCases,
repeats: repeats,
model: model,
skip_heavy: skipHeavy
})
});
const data = await resp.json();
if (data.error) {
updateStatus('error', data.error);
showError(data.error);
resetButtons();
return;
}
pollStatus();
} catch (e) {
updateStatus('error', '无法连接后端服务,请先运行 python run.py');
showError('无法连接后端服务,请先运行 python run.py');
resetButtons();
}
}
async function stopTests() {
document.getElementById('stopBtn').disabled = true;
updateStatus('running', '正在停止...');
try {
await fetch('/api/stop', { method: 'POST' });
} catch (e) {
// 忽略网络错误
}
}
// ---------- 轮询进度 ----------
async function pollStatus() {
try {
const resp = await fetch('/api/status');
const st = await resp.json();
renderRunStatus(st);
if (st.status === 'running') {
pollTimer = setTimeout(pollStatus, 800);
} else {
finishRun(st);
}
} catch (e) {
// 后端暂时不可达,继续重试
pollTimer = setTimeout(pollStatus, 1500);
}
}
function renderRunStatus(st) {
const total = st.total_cases || 0;
const idx = st.current_case_index || 0;
updateStatus('running', st.message || '运行中...');
updateProgress(total > 0 ? (idx / total) * 100 : 0);
if (st.current_case_name) {
document.getElementById('currentCaseInfo').textContent = `当前: ${st.current_case_name}`;
}
renderSteps(st);
}
function renderSteps(st) {
const cases = st.cases || [];
const list = document.getElementById('stepList');
const currentIdx = st.current_case_index || 0;
list.innerHTML = cases.map((cid, i) => {
const info = backendCases[cid] || { name: cid };
let cls = '', icon = '○';
if (i < currentIdx) { cls = 'completed'; icon = '✓'; }
else if (i === currentIdx) { cls = 'active'; icon = '⟳'; }
return `${icon}${info.name}`;
}).join('');
}
// ---------- 测试完成 ----------
async function finishRun(st) {
resetButtons();
if (st.status === 'failed') {
updateStatus('error', st.message || '测试失败');
showError(st.message || '测试失败');
return;
}
if (st.status === 'stopped') {
updateStatus('error', '测试已停止');
} else {
updateStatus('complete', '测试完成!');
updateProgress(100);
}
try {
const resp = await fetch('/api/results/latest.json');
const report = await resp.json();
if (report.error) throw new Error(report.error);
currentTestData = report;
displayResults(report);
} catch (e) {
console.warn('加载结果失败', e);
showError('测试结束,但加载结果失败:' + e.message);
}
}
function resetButtons() {
document.getElementById('stopBtn').disabled = true;
document.getElementById('startBtn').disabled = false;
}
// ---------- 结果显示 ----------
function showError(msg) {
document.getElementById('resultsArea').classList.add('visible');
document.getElementById('resultsArea').insertAdjacentHTML(
'afterbegin',
`${msg}
`
);
}
function renderInferenceMetrics(data) {
const results = data.results || [];
const ttft = [], prefill = [], decode = [];
results.forEach(r => {
if (r.raw_data && r.raw_data.results) {
r.raw_data.results.forEach(item => {
if (item.ttft_ms > 0) ttft.push(item.ttft_ms);
if (item.prefill_ms > 0) prefill.push(item.prefill_ms);
if (item.decode_speed_tok_s > 0) decode.push(item.decode_speed_tok_s);
});
}
});
const avg = arr => arr.length ? (arr.reduce((a, b) => a + b, 0) / arr.length).toFixed(2) : 'N/A';
const grid = document.getElementById('inferenceMetricsGrid');
grid.innerHTML = `
TTFT (首Token延迟)
${avg(ttft)} ms
均值(越低越好)
Prefill Time
${avg(prefill)} ms
Prompt 处理时间
Decode Speed
${avg(decode)} tok/s
生成速度(越高越好)
`;
document.getElementById('inferenceCard').style.display = 'block';
}
function renderConcurrencySummary(data) {
const card = document.getElementById('concurrencyCard');
const grid = document.getElementById('concurrencyMetricsGrid');
const concurrency = (data.results || []).find(r => r.case_id === 'case_08_concurrency');
if (!concurrency || !concurrency.raw_data) {
card.style.display = 'none';
return;
}
const raw = concurrency.raw_data;
const keep = raw.tps_keep_rate_pct;
const keepColor = keep >= 60 ? '#22c55e' : (keep > 0 ? '#f59e0b' : '#ef4444');
const errColor = raw.error_rate_pct === 0 ? '#22c55e' : '#ef4444';
const metric = (label, val, color) => `
`;
grid.innerHTML =
metric('并发数', raw.concurrency, '#8b5cf6') +
metric('基准 TPS', `${raw.baseline_tps ?? 'N/A'} tok/s`, '#6366f1') +
metric('并发 TPS', `${raw.concurrent_tps ?? 'N/A'} tok/s`, '#6366f1') +
metric('TPS 保持率', `${raw.tps_keep_rate_pct ?? 'N/A'}%`, keepColor) +
metric('P95 延迟', `${raw.e2e_p95_ms ?? 'N/A'} ms`, '#ef4444') +
metric('P99 延迟', `${raw.e2e_p99_ms ?? 'N/A'} ms`, '#ef4444') +
metric('错误率', `${raw.error_rate_pct ?? 'N/A'}%`, errColor) +
metric('成功/失败', `${raw.success_count ?? 0}/${raw.failed_count ?? 0}`, '#64748b');
card.style.display = 'block';
}
function displayResults(data) {
document.getElementById('resultsArea').classList.add('visible');
document.querySelectorAll('#resultsArea .error-banner').forEach(el => el.remove());
renderInferenceMetrics(data);
renderConcurrencySummary(data);
const summary = data.summary || {};
document.getElementById('metricGrid').innerHTML = `
总用例数
${summary.total_cases || 0}
失败
${summary.failed || 0}
总耗时
${(summary.total_elapsed_seconds || 0).toFixed(2)}s
`;
document.getElementById('overviewCard').style.display = 'block';
buildCharts(data);
document.getElementById('chartCard').style.display = 'block';
buildDataTable(data);
document.getElementById('tableCard').style.display = 'block';
document.getElementById('actionButtons').style.display = 'flex';
}
function buildCharts(data) {
Object.values(charts).forEach(c => c.destroy());
charts = {};
const results = data.results || [];
const labels = [], means = [], p95s = [];
results.forEach(r => {
if (r.statistics && Object.keys(r.statistics).length > 0) {
labels.push(r.case_name);
means.push(r.statistics.mean_ms || 0);
p95s.push(r.statistics.p95_ms || 0);
}
});
const barCtx = document.getElementById('barChart').getContext('2d');
charts.bar = new Chart(barCtx, {
type: 'bar',
data: {
labels, datasets: [{
label: '平均延迟 (ms)', data: means,
backgroundColor: 'rgba(99, 102, 241, 0.7)', borderColor: 'rgba(99, 102, 241, 1)', borderWidth: 1
}]
},
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true } } }
});
const p95Ctx = document.getElementById('p95Chart').getContext('2d');
charts.p95 = new Chart(p95Ctx, {
type: 'bar',
data: {
labels, datasets: [{
label: 'P95延迟 (ms)', data: p95s,
backgroundColor: 'rgba(239, 68, 68, 0.7)', borderColor: 'rgba(239, 68, 68, 1)', borderWidth: 1
}]
},
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true } } }
});
}
function buildDataTable(data) {
const tbody = document.getElementById('dataTableBody');
const diffColors = { "简单": "#22c55e", "中等": "#f59e0b", "耗时": "#ef4444", "压力": "#8b5cf6" };
tbody.innerHTML = (data.results || []).map(r => {
const stats = r.statistics || {};
const statusClass = r.status === 'passed' ? 'status-passed' : 'status-failed';
return `
| ${r.case_name} |
${r.difficulty} |
${r.status} |
${stats.mean_ms != null ? stats.mean_ms : 'N/A'} |
${stats.std_ms != null ? stats.std_ms : 'N/A'} |
${stats.min_ms != null ? stats.min_ms : 'N/A'} |
${stats.max_ms != null ? stats.max_ms : 'N/A'} |
${stats.p95_ms != null ? stats.p95_ms : 'N/A'} |
`;
}).join('');
}
// ---------- 操作按钮 ----------
function openReport() {
window.open('/report.html', '_blank');
}
function exportCSV() {
if (!currentTestData) return;
let csv = '用例ID,用例名称,难度,状态,平均延迟(ms),标准差(ms),最小延迟(ms),最大延迟(ms),P95延迟(ms)\n';
currentTestData.results.forEach(r => {
const stats = r.statistics || {};
csv += `${r.case_id},${r.case_name},${r.difficulty},${r.status},${stats.mean_ms || ''},${stats.std_ms || ''},${stats.min_ms || ''},${stats.max_ms || ''},${stats.p95_ms || ''}\n`;
});
const blob = new Blob(['' + csv], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `speed_test_${new Date().toISOString().slice(0, 10)}.csv`;
link.click();
}
function exportJSON() {
if (!currentTestData) return;
const blob = new Blob([JSON.stringify(currentTestData, null, 2)], { type: 'application/json' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `speed_test_${new Date().toISOString().slice(0, 10)}.json`;
link.click();
}
function runNewTest() {
if (pollTimer) clearTimeout(pollTimer);
document.getElementById('resultsArea').classList.remove('visible');
document.getElementById('statusPanel').classList.remove('active');
document.getElementById('welcomeCard').style.display = 'block';
document.getElementById('stepList').innerHTML = '';
Object.values(charts).forEach(c => c.destroy());
charts = {};
currentTestData = null;
updateUI();
}
let historyData = null;
let compareMode = false;
async function loadHistory() {
try {
const resp = await fetch('/api/results/list');
const data = await resp.json();
if (!data.results || data.results.length === 0) {
alert('暂无历史结果');
return;
}
// 构建选择对话框
const listHtml = data.results
.map((r, i) => ``)
.join('\n');
const dialog = document.createElement('div');
dialog.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;z-index:1000;';
dialog.innerHTML = `
历史测试结果对比
选择两次运行结果进行对比(跳过耗时用例):
`;
document.body.appendChild(dialog);
// 默认选择最后两个
if (data.results.length >= 2) {
dialog.querySelector('#historyA').value = data.results.length - 2;
dialog.querySelector('#historyB').value = data.results.length - 1;
}
dialog.querySelector('#compareBtn').onclick = async () => {
const idxA = dialog.querySelector('#historyA').value;
const idxB = dialog.querySelector('#historyB').value;
dialog.remove();
if (idxA === idxB) {
alert('请选择不同的两次运行结果');
return;
}
await showComparison(data.results[parseInt(idxA)], data.results[parseInt(idxB)]);
};
// 点击背景关闭
dialog.onclick = (e) => { if (e.target === dialog) dialog.remove(); };
} catch (e) {
console.warn('加载历史记录失败', e);
alert('无法加载历史记录');
}
}
async function showComparison(runA, runB) {
if (runA.error || runB.error) {
alert('无法读取选定的结果文件');
return;
}
// 加载完整数据
const [dataA, dataB] = await Promise.all([
fetch(`/api/results/${runA.filename}`).then(r => r.json()),
fetch(`/api/results/${runB.filename}`).then(r => r.json()),
]);
if (dataA.error || dataB.error) {
alert('无法加载结果数据');
return;
}
// 显示对比
document.getElementById('welcomeCard').style.display = 'none';
document.getElementById('resultsArea').classList.add('visible');
document.querySelectorAll('#resultsArea .error-banner').forEach(el => el.remove());
const summaryA = dataA.summary || {};
const summaryB = dataB.summary || {};
// 对比卡片
const card = document.createElement('div');
card.className = 'report-card';
card.innerHTML = `
📊 运行对比:${runA.filename} vs ${runB.filename}
| 指标 | ${runA.timestamp ? new Date(runA.timestamp).toLocaleString('zh-CN') : runA.filename} | ${runB.timestamp ? new Date(runB.timestamp).toLocaleString('zh-CN') : runB.filename} | 变化 |
| 模型 | ${dataA.environment?.model || 'N/A'} | ${dataB.environment?.model || 'N/A'} | ${dataA.environment?.model !== dataB.environment?.model ? '⚠️ 不同' : '—'} |
| 总用例数 | ${summaryA.total_cases || 0} | ${summaryB.total_cases || 0} | — |
| 通过 | ${summaryA.passed || 0} | ${summaryB.passed || 0} | ${diffArrow(summaryA.passed, summaryB.passed)} |
| 失败 | ${summaryA.failed || 0} | ${summaryB.failed || 0} | ${diffArrow(summaryA.failed, summaryB.failed)} |
| 总耗时 | ${(summaryA.total_elapsed_seconds || 0).toFixed(1)}s | ${(summaryB.total_elapsed_seconds || 0).toFixed(1)}s | ${diffArrow(summaryA.total_elapsed_seconds, summaryB.total_elapsed_seconds)} |
注:仅对比都通过的用例。跳过耗时用例(长上下文、推理、多轮对话)。
`;
// 插入到 resultsArea 开头
const resultsArea = document.getElementById('resultsArea');
resultsArea.insertBefore(card, resultsArea.firstChild);
// 对比详细用例数据
const compareCard = document.createElement('div');
compareCard.className = 'report-card';
compareCard.innerHTML = `
📋 用例级对比(跳过耗时用例)
| 用例名称 |
运行A 平均延迟 |
运行B 平均延迟 |
变化 |
运行A P95 |
运行B P95 |
变化 |
`;
resultsArea.insertBefore(compareCard, card.nextSibling);
// 填充对比数据
const tbody = document.getElementById('compareTableBody');
const resultsA = (dataA.results || []).filter(r => r.difficulty !== '耗时' && r.status === 'passed');
const resultsB = (dataB.results || []).filter(r => r.difficulty !== '耗时' && r.status === 'passed');
const mapA = {};
resultsA.forEach(r => mapA[r.case_id] = r);
const mapB = {};
resultsB.forEach(r => mapB[r.case_id] = r);
const allIds = [...new Set([...Object.keys(mapA), ...Object.keys(mapB)])];
tbody.innerHTML = allIds.map(id => {
const a = mapA[id], b = mapB[id];
if (!a || !b) return `| ${id} | 仅在一次运行中出现 |
`;
const statsA = a.statistics || {};
const statsB = b.statistics || {};
const meanDiff = diffPercent(statsA.mean_ms, statsB.mean_ms);
const p95Diff = diffPercent(statsA.p95_ms, statsB.p95_ms);
return `
| ${a.case_name} |
${statsA.mean_ms || 'N/A'} |
${statsB.mean_ms || 'N/A'} |
${meanDiff === 0 ? '—' : (meanDiff > 0 ? '↑' : '↓') + Math.abs(meanDiff).toFixed(1) + '%'} |
${statsA.p95_ms || 'N/A'} |
${statsB.p95_ms || 'N/A'} |
${p95Diff === 0 ? '—' : (p95Diff > 0 ? '↑' : '↓') + Math.abs(p95Diff).toFixed(1) + '%'} |
`;
}).join('');
// 滚动到对比区域
card.scrollIntoView({ behavior: 'smooth' });
}
function diffArrow(current, previous) {
if (!current || !previous) return '—';
const diff = current - previous;
if (diff === 0) return '—';
return diff > 0 ? `↑+${diff}` : `↓${diff}`;
}
function diffPercent(current, previous) {
if (!current || !previous || previous === 0) return 0;
return ((current - previous) / previous) * 100;
}