// 大模型速度测试助手 - 前端主逻辑 (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]) => `
${info.name}
${info.difficulty} · 预计 ${info.estimated_seconds}s
`).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) => `
    ${label}
    ${val}
    `; 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.passed || 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; }