Initial commit of LLM speed test app
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user