Initial commit of LLM speed test app

This commit is contained in:
2026-09-06 11:45:52 +08:00
commit 5544b5f9f9
38 changed files with 8587 additions and 0 deletions
+440
View File
@@ -0,0 +1,440 @@
/* 速度测试助手 UI 样式 */
:root {
--primary: #6366f1;
--primary-dark: #4f46e5;
--success: #22c55e;
--warning: #f59e0b;
--danger: #ef4444;
--gray-50: #f8fafc;
--gray-100: #f1f5f9;
--gray-200: #e2e8f0;
--gray-300: #cbd5e1;
--gray-600: #475569;
--gray-700: #334155;
--gray-800: #1e293b;
--gray-900: #0f172a;
--radius: 8px;
--shadow: 0 1px 3px rgba(0,0,0,0.1), 0 1px 2px rgba(0,0,0,0.06);
--shadow-lg: 0 10px 15px -3px rgba(0,0,0,0.1);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
background: var(--gray-50);
color: var(--gray-800);
line-height: 1.6;
}
.app-container {
display: grid;
grid-template-columns: 320px 1fr;
min-height: 100vh;
}
/* 左侧面板 */
.sidebar {
background: white;
border-right: 1px solid var(--gray-200);
padding: 20px;
overflow-y: auto;
height: 100vh;
position: sticky;
top: 0;
}
.sidebar-header {
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 2px solid var(--gray-200);
}
.sidebar-header h1 {
font-size: 18px;
color: var(--primary);
margin-bottom: 5px;
}
.sidebar-header .version {
font-size: 12px;
color: var(--gray-600);
}
.panel {
margin-bottom: 20px;
}
.panel-title {
font-size: 14px;
font-weight: 600;
color: var(--gray-700);
margin-bottom: 10px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.test-case-item {
display: flex;
align-items: center;
padding: 10px;
margin-bottom: 6px;
border-radius: var(--radius);
cursor: pointer;
transition: background 0.2s;
border: 1px solid transparent;
}
.test-case-item:hover {
background: var(--gray-100);
}
.test-case-item input[type="checkbox"] {
margin-right: 10px;
cursor: pointer;
}
.test-case-info {
flex: 1;
}
.test-case-name {
font-size: 13px;
font-weight: 500;
}
.test-case-diff {
font-size: 11px;
color: var(--gray-600);
margin-top: 2px;
}
.diff-简单 { color: var(--success); }
.diff-中等 { color: var(--warning); }
.diff-耗时 { color: var(--danger); }
.quick-actions {
display: flex;
gap: 8px;
margin-bottom: 15px;
}
.quick-actions button {
flex: 1;
padding: 6px 10px;
font-size: 12px;
border: 1px solid var(--gray-300);
border-radius: var(--radius);
background: white;
cursor: pointer;
transition: all 0.2s;
}
.quick-actions button:hover {
background: var(--gray-100);
}
.config-group {
margin-bottom: 15px;
}
.config-group label {
display: block;
font-size: 13px;
font-weight: 500;
margin-bottom: 5px;
color: var(--gray-700);
}
.config-group input, .config-group select {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--gray-300);
border-radius: var(--radius);
font-size: 13px;
}
.config-group input:focus, .config-group select:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
}
.btn {
display: block;
width: 100%;
padding: 12px;
border: none;
border-radius: var(--radius);
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
margin-bottom: 10px;
}
.btn-primary {
background: var(--primary);
color: white;
}
.btn-primary:hover { background: var(--primary-dark); }
.btn-secondary {
background: var(--gray-100);
color: var(--gray-700);
}
.btn-secondary:hover { background: var(--gray-200); }
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* 右侧主区域 */
.main-content {
padding: 20px 30px;
overflow-y: auto;
}
.status-panel {
background: white;
border-radius: var(--radius);
padding: 20px;
margin-bottom: 20px;
box-shadow: var(--shadow);
display: none;
}
.status-panel.active {
display: block;
}
.status-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 15px;
}
.status-text {
font-size: 16px;
font-weight: 600;
}
.status-text.running { color: var(--primary); }
.status-text.complete { color: var(--success); }
.status-text.error { color: var(--danger); }
.progress-bar-container {
width: 100%;
height: 8px;
background: var(--gray-200);
border-radius: 4px;
overflow: hidden;
margin-bottom: 15px;
}
.progress-bar {
height: 100%;
background: linear-gradient(90deg, var(--primary), #8b5cf6);
border-radius: 4px;
transition: width 0.3s ease;
width: 0%;
}
.step-list {
list-style: none;
padding: 0;
}
.step-item {
display: flex;
align-items: center;
padding: 8px 0;
font-size: 13px;
color: var(--gray-600);
}
.step-item.completed { color: var(--success); }
.step-item.active { color: var(--primary); font-weight: 500; }
.step-icon {
width: 20px;
margin-right: 10px;
text-align: center;
}
/* 结果区域 */
.results-area {
display: none;
}
.results-area.visible {
display: block;
}
.report-card {
background: white;
border-radius: var(--radius);
padding: 25px;
margin-bottom: 20px;
box-shadow: var(--shadow);
}
.report-card h2 {
font-size: 18px;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 2px solid var(--gray-200);
}
.metric-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 15px;
margin-bottom: 20px;
}
.metric-card {
background: var(--gray-50);
padding: 15px;
border-radius: var(--radius);
border-left: 4px solid var(--primary);
}
.metric-card .label {
font-size: 12px;
color: var(--gray-600);
text-transform: uppercase;
}
.metric-card .value {
font-size: 24px;
font-weight: bold;
margin-top: 5px;
color: var(--gray-800);
}
.metric-card-value-desc {
font-size: 11px;
color: var(--gray-600);
margin-top: 4px;
}
.metric-description {
background: var(--gray-50);
padding: 15px;
border-radius: var(--radius);
margin-top: 15px;
font-size: 13px;
line-height: 1.8;
color: var(--gray-700);
}
.metric-description p {
margin-bottom: 8px;
}
.metric-description p:last-child {
margin-bottom: 0;
}
.metric-description strong {
color: var(--gray-900);
}
.metric-card.success { border-left-color: var(--success); }
.metric-card.warning { border-left-color: var(--warning); }
.metric-card.danger { border-left-color: var(--danger); }
table.data-table {
width: 100%;
border-collapse: collapse;
}
table.data-table th,
table.data-table td {
padding: 10px 12px;
text-align: left;
border-bottom: 1px solid var(--gray-200);
font-size: 13px;
}
table.data-table th {
background: var(--gray-100);
font-weight: 600;
font-size: 12px;
text-transform: uppercase;
color: var(--gray-600);
}
table.data-table tr:hover {
background: var(--gray-50);
}
.mono {
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
font-size: 12px;
}
.chart-wrapper {
position: relative;
height: 350px;
margin: 20px 0;
}
.action-buttons {
display: flex;
gap: 10px;
margin-top: 20px;
}
.action-buttons button {
padding: 10px 20px;
border: 1px solid var(--gray-300);
border-radius: var(--radius);
background: white;
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: all 0.2s;
}
.action-buttons button:hover {
background: var(--gray-100);
}
.action-buttons button.primary {
background: var(--primary);
color: white;
border-color: var(--primary);
}
.action-buttons button.primary:hover {
background: var(--primary-dark);
}
/* 错误提示 */
.error-banner {
background: #fef2f2;
border: 1px solid #fecaca;
border-left: 4px solid var(--danger);
color: #b91c1c;
padding: 12px 15px;
border-radius: var(--radius);
margin-bottom: 15px;
font-size: 13px;
}
/* 响应式 */
@media (max-width: 1024px) {
.app-container {
grid-template-columns: 1fr;
}
.sidebar {
position: static;
height: auto;
}
}
+173
View File
@@ -0,0 +1,173 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>大模型速度测试助手</title>
<link rel="stylesheet" href="css/style.css">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
</head>
<body>
<div class="app-container">
<!-- 左侧控制面板 -->
<aside class="sidebar">
<div class="sidebar-header">
<h1>大模型速度测试助手</h1>
<div class="version" id="versionLabel">v2.1</div>
</div>
<!-- 测试用例选择 -->
<div class="panel">
<div class="panel-title">测试用例选择</div>
<div class="quick-actions">
<button onclick="selectRecommended()">推荐组合</button>
<button onclick="selectAll()">全选</button>
<button onclick="clearAll()">清空</button>
</div>
<div id="testCaseList">
<!-- 从 /api/cases 动态加载 -->
</div>
</div>
<!-- 参数配置 -->
<div class="panel">
<div class="panel-title">参数配置</div>
<div class="config-group">
<label>模型</label>
<select id="modelSelect">
<option value="">加载中...</option>
</select>
</div>
<div class="config-group">
<label>重复次数</label>
<input type="number" id="repeatsInput" value="3" min="1" max="20">
</div>
<div class="config-group">
<label>运行模式</label>
<select id="runMode">
<option value="all">全部用例</option>
<option value="skip_heavy">跳过耗时用例</option>
</select>
</div>
</div>
<!-- 操作按钮 -->
<div class="panel">
<button class="btn btn-primary" id="startBtn" onclick="startTests()" disabled>
开始测试
</button>
<button class="btn btn-secondary" id="stopBtn" onclick="stopTests()" disabled>
停止测试
</button>
<button class="btn btn-secondary" id="loadHistoryBtn" onclick="loadHistory()">
加载历史对比
</button>
</div>
</aside>
<!-- 右侧主内容区 -->
<main class="main-content">
<!-- 运行状态面板 -->
<div class="status-panel" id="statusPanel">
<div class="status-header">
<div class="status-text running" id="statusText">准备中...</div>
<div id="progressPercent">0%</div>
</div>
<div class="progress-bar-container">
<div class="progress-bar" id="progressBar"></div>
</div>
<div id="currentCaseInfo" style="font-size:13px;margin-bottom:10px;color:#64748b;"></div>
<ul class="step-list" id="stepList"></ul>
</div>
<!-- 结果展示区 -->
<div class="results-area" id="resultsArea">
<!-- 推理指标概览 -->
<div class="report-card" id="inferenceCard" style="display:none;">
<h2>推理核心指标</h2>
<div class="metric-grid" id="inferenceMetricsGrid"></div>
<div class="metric-description">
<p><strong>TTFT(首 Token 延迟)</strong>:从请求到生成第一个 token 的时间,影响用户感知响应速度</p>
<p><strong>Prefill Time(预填充时间)</strong>:处理 prompt 的时间,与上下文长度正相关</p>
<p><strong>Decode Speed(解码速度)</strong>:tokens/s,衡量模型生成效率</p>
</div>
</div>
<!-- 并发压力摘要 -->
<div class="report-card" id="concurrencyCard" style="display:none;">
<h2>⚡ 并发压力测试摘要</h2>
<div class="metric-grid" id="concurrencyMetricsGrid"></div>
</div>
<!-- 概览卡片 -->
<div class="report-card" id="overviewCard" style="display:none;">
<h2>测试概览</h2>
<div class="metric-grid" id="metricGrid"></div>
</div>
<!-- 图表区 -->
<div class="report-card" id="chartCard" style="display:none;">
<h2>性能对比图表</h2>
<div class="chart-wrapper">
<canvas id="barChart"></canvas>
</div>
<div class="chart-wrapper">
<canvas id="p95Chart"></canvas>
</div>
</div>
<!-- 数据表 -->
<div class="report-card" id="tableCard" style="display:none;">
<h2>详细数据</h2>
<div style="overflow-x:auto;">
<table class="data-table" id="dataTable">
<thead>
<tr>
<th>用例名称</th>
<th>难度</th>
<th>状态</th>
<th>平均延迟 (ms)</th>
<th>标准差</th>
<th>最小</th>
<th>最大</th>
<th>P95</th>
</tr>
</thead>
<tbody id="dataTableBody"></tbody>
</table>
</div>
</div>
<!-- 操作按钮 -->
<div class="action-buttons" id="actionButtons" style="display:none;">
<button onclick="openReport()">打开完整报告</button>
<button onclick="exportCSV()">导出 CSV</button>
<button onclick="exportJSON()">导出 JSON</button>
<button class="primary" onclick="runNewTest()">新测试</button>
</div>
</div>
<!-- 初始欢迎状态 -->
<div class="report-card" id="welcomeCard">
<h2>欢迎使用大模型速度测试助手</h2>
<p style="color:#64748b;margin:15px 0;">
本工具用于测试大模型在不同场景下的响应速度
<span id="welcomeBackend">(Ollama 本地 / OpenAI 兼容服务)</span>。
<br>请在左侧面板选择测试用例和模型,然后点击"开始测试"。
</p>
<div style="margin-top:20px;">
<h3 style="font-size:14px;margin-bottom:10px;">快速开始:</h3>
<ol style="padding-left:20px;color:#64748b;font-size:13px;line-height:2;">
<li>左侧选择测试用例("推荐组合"快速勾选)</li>
<li>选择模型和重复次数</li>
<li>点击"开始测试",观察实时进度</li>
<li>完成后导出 CSV / JSON 或打开完整报告</li>
</ol>
</div>
</div>
</main>
</div>
<script src="js/app.js"></script>
</body>
</html>
+664
View File
@@ -0,0 +1,664 @@
// 大模型速度测试助手 - 前端主逻辑 (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;
}