665 lines
27 KiB
JavaScript
665 lines
27 KiB
JavaScript
// 大模型速度测试助手 - 前端主逻辑 (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 = '<p style="padding:10px;color:#64748b;font-size:13px;">无法加载用例列表</p>';
|
||
return;
|
||
}
|
||
container.innerHTML = entries.map(([id, info]) => `
|
||
<div class="test-case-item" onclick="toggleCase('${id}')">
|
||
<input type="checkbox" id="cb_${id}" data-case-id="${id}" onclick="event.stopPropagation()">
|
||
<div class="test-case-info">
|
||
<div class="test-case-name">${info.name}</div>
|
||
<div class="test-case-diff diff-${info.difficulty}">${info.difficulty} · 预计 ${info.estimated_seconds}s</div>
|
||
</div>
|
||
</div>
|
||
`).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 => `<option value="${m}">${m}</option>`).join('');
|
||
} else {
|
||
sel.innerHTML = `<option value="">${data.error || '未找到已拉取的模型'}</option>`;
|
||
}
|
||
} catch (e) {
|
||
sel.innerHTML = `<option value="">后端未启动(请运行 python run.py)</option>`;
|
||
}
|
||
}
|
||
|
||
// ---------- 用例列表(从后端加载) ----------
|
||
|
||
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 `<li class="step-item ${cls}"><span class="step-icon">${icon}</span>${info.name}</li>`;
|
||
}).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',
|
||
`<div class="error-banner">${msg}</div>`
|
||
);
|
||
}
|
||
|
||
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 = `
|
||
<div class="metric-card" style="border-left-color:#6366f1;">
|
||
<div class="label">TTFT (首Token延迟)</div>
|
||
<div class="value" style="color:#6366f1;">${avg(ttft)} ms</div>
|
||
<div class="metric-card-value-desc">均值(越低越好)</div>
|
||
</div>
|
||
<div class="metric-card" style="border-left-color:#8b5cf6;">
|
||
<div class="label">Prefill Time</div>
|
||
<div class="value" style="color:#8b5cf6;">${avg(prefill)} ms</div>
|
||
<div class="metric-card-value-desc">Prompt 处理时间</div>
|
||
</div>
|
||
<div class="metric-card" style="border-left-color:#22c55e;">
|
||
<div class="label">Decode Speed</div>
|
||
<div class="value" style="color:#22c55e;">${avg(decode)} tok/s</div>
|
||
<div class="metric-card-value-desc">生成速度(越高越好)</div>
|
||
</div>
|
||
`;
|
||
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) => `
|
||
<div class="metric-card" style="border-left-color:${color};">
|
||
<div class="label">${label}</div>
|
||
<div class="value" style="color:${color};">${val}</div>
|
||
</div>`;
|
||
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 = `
|
||
<div class="metric-card"><div class="label">总用例数</div><div class="value">${summary.total_cases || 0}</div></div>
|
||
<div class="metric-card success"><div class="label">通过</div><div class="value" style="color:#22c55e">${summary.passed || 0}</div></div>
|
||
<div class="metric-card ${summary.failed > 0 ? 'danger' : 'success'}">
|
||
<div class="label">失败</div>
|
||
<div class="value" style="color:${summary.failed > 0 ? '#ef4444' : '#22c55e'}">${summary.failed || 0}</div>
|
||
</div>
|
||
<div class="metric-card warning"><div class="label">总耗时</div><div class="value">${(summary.total_elapsed_seconds || 0).toFixed(2)}s</div></div>
|
||
`;
|
||
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 `
|
||
<tr>
|
||
<td><strong>${r.case_name}</strong></td>
|
||
<td><span class="diff-badge" style="background:${diffColors[r.difficulty] || '#6b7280'};padding:4px 10px;border-radius:12px;color:white;font-size:12px;">${r.difficulty}</span></td>
|
||
<td class="${statusClass}" style="font-weight:bold;">${r.status}</td>
|
||
<td class="mono">${stats.mean_ms != null ? stats.mean_ms : 'N/A'}</td>
|
||
<td class="mono">${stats.std_ms != null ? stats.std_ms : 'N/A'}</td>
|
||
<td class="mono">${stats.min_ms != null ? stats.min_ms : 'N/A'}</td>
|
||
<td class="mono">${stats.max_ms != null ? stats.max_ms : 'N/A'}</td>
|
||
<td class="mono">${stats.p95_ms != null ? stats.p95_ms : 'N/A'}</td>
|
||
</tr>
|
||
`;
|
||
}).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) => `<option value="${i}">${r.filename} — ${r.model} (${r.total_cases}用例, ${r.passed}/${r.total_cases}通过)</option>`)
|
||
.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 = `
|
||
<div style="background:white;border-radius:12px;padding:24px;max-width:600px;width:90%;max-height:80vh;overflow:auto;">
|
||
<h3 style="margin-bottom:16px;">历史测试结果对比</h3>
|
||
<p style="color:#64748b;font-size:13px;margin-bottom:12px;">选择两次运行结果进行对比(跳过耗时用例):</p>
|
||
<div style="display:flex;gap:12px;margin-bottom:16px;">
|
||
<div style="flex:1;">
|
||
<label style="font-size:12px;color:#64748b;">第一次运行</label>
|
||
<select id="historyA" style="width:100%;padding:8px;border:1px solid #e2e8f0;border-radius:8px;">${listHtml}</select>
|
||
</div>
|
||
<div style="flex:1;">
|
||
<label style="font-size:12px;color:#64748b;">第二次运行</label>
|
||
<select id="historyB" style="width:100%;padding:8px;border:1px solid #e2e8f0;border-radius:8px;">${listHtml}</select>
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;gap:8px;justify-content:flex-end;">
|
||
<button onclick="this.closest('div[style]').parentElement.remove()" style="padding:8px 16px;border:1px solid #e2e8f0;border-radius:8px;cursor:pointer;">取消</button>
|
||
<button id="compareBtn" style="padding:8px 16px;background:#6366f1;color:white;border:none;border-radius:8px;cursor:pointer;">对比</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
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 = `
|
||
<h2>📊 运行对比:${runA.filename} vs ${runB.filename}</h2>
|
||
<table class="data-table" style="margin-top:16px;">
|
||
<thead>
|
||
<tr><th>指标</th><th>${runA.timestamp ? new Date(runA.timestamp).toLocaleString('zh-CN') : runA.filename}</th><th>${runB.timestamp ? new Date(runB.timestamp).toLocaleString('zh-CN') : runB.filename}</th><th>变化</th></tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr><td>模型</td><td>${dataA.environment?.model || 'N/A'}</td><td>${dataB.environment?.model || 'N/A'}</td><td>${dataA.environment?.model !== dataB.environment?.model ? '⚠️ 不同' : '—'}</td></tr>
|
||
<tr><td>总用例数</td><td>${summaryA.total_cases || 0}</td><td>${summaryB.total_cases || 0}</td><td>—</td></tr>
|
||
<tr><td>通过</td><td>${summaryA.passed || 0}</td><td>${summaryB.passed || 0}</td><td>${diffArrow(summaryA.passed, summaryB.passed)}</td></tr>
|
||
<tr><td>失败</td><td>${summaryA.failed || 0}</td><td>${summaryB.failed || 0}</td><td>${diffArrow(summaryA.failed, summaryB.failed)}</td></tr>
|
||
<tr><td>总耗时</td><td>${(summaryA.total_elapsed_seconds || 0).toFixed(1)}s</td><td>${(summaryB.total_elapsed_seconds || 0).toFixed(1)}s</td><td>${diffArrow(summaryA.total_elapsed_seconds, summaryB.total_elapsed_seconds)}</td></tr>
|
||
</tbody>
|
||
</table>
|
||
<div style="margin-top:16px;font-size:12px;color:#64748b;">
|
||
<p>注:仅对比都通过的用例。跳过耗时用例(长上下文、推理、多轮对话)。</p>
|
||
</div>
|
||
`;
|
||
|
||
// 插入到 resultsArea 开头
|
||
const resultsArea = document.getElementById('resultsArea');
|
||
resultsArea.insertBefore(card, resultsArea.firstChild);
|
||
|
||
// 对比详细用例数据
|
||
const compareCard = document.createElement('div');
|
||
compareCard.className = 'report-card';
|
||
compareCard.innerHTML = `
|
||
<h2>📋 用例级对比(跳过耗时用例)</h2>
|
||
<div style="overflow-x:auto;margin-top:16px;">
|
||
<table class="data-table">
|
||
<thead>
|
||
<tr>
|
||
<th>用例名称</th>
|
||
<th>运行A 平均延迟</th>
|
||
<th>运行B 平均延迟</th>
|
||
<th>变化</th>
|
||
<th>运行A P95</th>
|
||
<th>运行B P95</th>
|
||
<th>变化</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="compareTableBody"></tbody>
|
||
</table>
|
||
</div>
|
||
`;
|
||
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 `<tr><td>${id}</td><td colspan="6" style="color:#64748b;">仅在一次运行中出现</td></tr>`;
|
||
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 `
|
||
<tr>
|
||
<td><strong>${a.case_name}</strong></td>
|
||
<td class="mono">${statsA.mean_ms || 'N/A'}</td>
|
||
<td class="mono">${statsB.mean_ms || 'N/A'}</td>
|
||
<td style="color:${meanDiff > 0 ? '#ef4444' : meanDiff < 0 ? '#22c55e' : '#64748b'}">${meanDiff === 0 ? '—' : (meanDiff > 0 ? '↑' : '↓') + Math.abs(meanDiff).toFixed(1) + '%'}</td>
|
||
<td class="mono">${statsA.p95_ms || 'N/A'}</td>
|
||
<td class="mono">${statsB.p95_ms || 'N/A'}</td>
|
||
<td style="color:${p95Diff > 0 ? '#ef4444' : p95Diff < 0 ? '#22c55e' : '#64748b'}">${p95Diff === 0 ? '—' : (p95Diff > 0 ? '↑' : '↓') + Math.abs(p95Diff).toFixed(1) + '%'}</td>
|
||
</tr>
|
||
`;
|
||
}).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;
|
||
}
|