evalstone/webui/static/results.js
sora 7c449d3126 Add local EvalStone web UI for launch and results comparison.
Add a FastAPI panel under webui/ to select suites or benchmarks, configure model endpoint and thinking mode, stream evaluation logs, and visualize multi-model scores from output/.
2026-07-29 06:49:39 +00:00

343 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

(() => {
const state = {
overview: null,
compare: null,
charts: { line: null, bar: null, radar: null },
};
const $ = (id) => document.getElementById(id);
const COLORS = [
'#d6a24a', '#6fbf8a', '#6aa8d6', '#d86a5b',
'#8b7ec8', '#e0c36a', '#5ec4b0', '#c98a6a',
];
function setMsg(text, type = '') {
const el = $('scanMsg');
el.textContent = text || '';
el.className = `msg ${type}`.trim();
}
async function api(path) {
const res = await fetch(path);
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data.detail || res.statusText || 'request failed');
}
return data;
}
function selectedValues(containerId) {
return [...document.querySelectorAll(`#${containerId} input:checked`)].map((el) => el.value);
}
function renderModelList(models) {
const box = $('modelList');
box.innerHTML = '';
if (!models.length) {
box.innerHTML = '<div class="muted">未发现模型结果目录</div>';
return;
}
models.forEach((m, i) => {
const label = document.createElement('label');
label.className = 'pick-item';
label.innerHTML = `
<input type="checkbox" value="${m.folder}" checked />
<span class="pick-main">
<strong>${m.folder}</strong>
<small>${m.model_name} · ${m.n_benchmarks} benches</small>
</span>
<i class="swatch" style="background:${COLORS[i % COLORS.length]}"></i>
`;
box.appendChild(label);
});
}
function renderBenchList(categories, benchmarks) {
const box = $('benchList');
box.innerHTML = '';
if (!benchmarks.length) {
box.innerHTML = '<div class="muted">暂无 benchmark 结果</div>';
return;
}
const cats = categories && categories.length
? categories
: [{ id: 'all', name: '全部', items: benchmarks }];
cats.forEach((cat) => {
const section = document.createElement('div');
section.className = `pick-cat cat-${cat.id}`;
section.innerHTML = `
<div class="pick-cat-head">
<strong>${cat.name}</strong>
<span class="muted">${cat.items.length}</span>
<button type="button" class="ghost cat-all">全选</button>
<button type="button" class="ghost cat-none">清空</button>
</div>
`;
const grid = document.createElement('div');
grid.className = 'pick-grid';
cat.items.forEach((name) => {
const label = document.createElement('label');
label.className = 'pick-chip';
label.innerHTML = `<input type="checkbox" value="${name}" checked /><span>${name}</span>`;
grid.appendChild(label);
});
section.querySelector('.cat-all').addEventListener('click', () => {
grid.querySelectorAll('input').forEach((el) => { el.checked = true; });
});
section.querySelector('.cat-none').addEventListener('click', () => {
grid.querySelectorAll('input').forEach((el) => { el.checked = false; });
});
section.appendChild(grid);
box.appendChild(section);
});
}
function destroyCharts() {
Object.keys(state.charts).forEach((k) => {
if (state.charts[k]) {
state.charts[k].destroy();
state.charts[k] = null;
}
});
}
function chartDatasets(series, type) {
return series.map((s, i) => {
const color = COLORS[i % COLORS.length];
const base = {
label: s.display,
data: s.scores.map((v) => (v == null ? null : Number((v * 100).toFixed(2)))),
borderColor: color,
backgroundColor: type === 'radar' ? color + '33' : color + 'cc',
tension: 0.25,
spanGaps: false,
pointRadius: 4,
pointHoverRadius: 6,
};
if (type === 'bar') {
return { ...base, borderWidth: 0, borderRadius: 4 };
}
if (type === 'radar') {
return { ...base, fill: true, borderWidth: 2 };
}
return { ...base, fill: false, borderWidth: 2 };
});
}
function renderCharts(compare) {
destroyCharts();
const labels = compare.benchmarks;
const common = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
labels: { color: '#c9d4cb', boxWidth: 12, font: { family: 'IBM Plex Sans' } },
},
tooltip: {
callbacks: {
label(ctx) {
const v = ctx.parsed.y ?? ctx.parsed.r;
return `${ctx.dataset.label}: ${v == null ? '—' : v.toFixed(2) + '%'}`;
},
},
},
},
scales: {},
};
state.charts.line = new Chart($('lineChart'), {
type: 'line',
data: { labels, datasets: chartDatasets(compare.series, 'line') },
options: {
...common,
scales: {
x: {
ticks: { color: '#92a197', maxRotation: 45, minRotation: 0 },
grid: { color: 'rgba(51,64,56,0.6)' },
},
y: {
min: 0,
max: 100,
ticks: {
color: '#92a197',
callback: (v) => v + '%',
},
grid: { color: 'rgba(51,64,56,0.6)' },
title: { display: true, text: 'Score (%)', color: '#92a197' },
},
},
},
});
state.charts.bar = new Chart($('barChart'), {
type: 'bar',
data: { labels, datasets: chartDatasets(compare.series, 'bar') },
options: {
...common,
scales: {
x: {
ticks: { color: '#92a197', maxRotation: 45 },
grid: { display: false },
},
y: {
min: 0,
max: 100,
ticks: { color: '#92a197', callback: (v) => v + '%' },
grid: { color: 'rgba(51,64,56,0.6)' },
},
},
},
});
const radarLabels = labels;
const canRadar = radarLabels.length >= 3 && compare.series.length > 0;
const radarCanvas = $('radarChart');
if (!canRadar) {
radarCanvas.parentElement.classList.add('empty');
radarCanvas.parentElement.dataset.empty = '共同 benchmark 不足 3 个,暂不绘制雷达图';
} else {
radarCanvas.parentElement.classList.remove('empty');
delete radarCanvas.parentElement.dataset.empty;
state.charts.radar = new Chart(radarCanvas, {
type: 'radar',
data: { labels: radarLabels, datasets: chartDatasets(compare.series, 'radar') },
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { labels: { color: '#c9d4cb', boxWidth: 12 } },
},
scales: {
r: {
min: 0,
max: 100,
ticks: { color: '#92a197', backdropColor: 'transparent', stepSize: 20 },
grid: { color: 'rgba(51,64,56,0.7)' },
angleLines: { color: 'rgba(51,64,56,0.7)' },
pointLabels: { color: '#c9d4cb', font: { size: 11 } },
},
},
},
});
}
}
function renderTable(compare) {
const thead = $('scoreTable').querySelector('thead');
const tbody = $('scoreTable').querySelector('tbody');
thead.innerHTML = '';
tbody.innerHTML = '';
const head = document.createElement('tr');
head.innerHTML = `<th>Benchmark</th>${compare.series.map((s) => `<th>${s.display}</th>`).join('')}<th>最佳</th>`;
thead.appendChild(head);
compare.benchmarks.forEach((b, i) => {
const tr = document.createElement('tr');
let best = -1;
let bestFolder = '—';
compare.series.forEach((s) => {
const v = s.scores[i];
if (v != null && v > best) {
best = v;
bestFolder = s.display;
}
});
const cells = compare.series.map((s) => {
const v = s.scores[i];
if (v == null) return '<td class="na">—</td>';
const pct = (v * 100).toFixed(2);
const isBest = v === best && best >= 0;
return `<td class="${isBest ? 'best' : ''}">${pct}%</td>`;
}).join('');
tr.innerHTML = `<td class="bench-col">${b}</td>${cells}<td class="best-name">${bestFolder}</td>`;
tbody.appendChild(tr);
});
}
function renderRanking(compare) {
const box = $('rankList');
box.innerHTML = '';
compare.ranking.forEach((item) => {
const card = document.createElement('div');
card.className = 'rank-card';
const rows = item.rows.length
? item.rows.map((r, idx) => `
<div class="rank-row">
<span class="rank-idx">#${idx + 1}</span>
<span class="rank-name">${r.folder}</span>
<span class="rank-score">${(r.score * 100).toFixed(2)}%</span>
</div>`).join('')
: '<div class="muted">无数据</div>';
card.innerHTML = `<strong>${item.benchmark}</strong>${rows}`;
box.appendChild(card);
});
}
async function loadOverview() {
const dir = $('outputDir').value.trim();
const qs = dir ? `?output_dir=${encodeURIComponent(dir)}` : '';
setMsg('扫描中…');
try {
const data = await api(`/api/results/overview${qs}`);
state.overview = data;
if (!$('outputDir').value) $('outputDir').value = data.output_dir || '';
$('scanMeta').textContent = `${data.models.length} 个模型 · ${data.benchmarks.length} 个 benchmark · ${data.output_dir}`;
renderModelList(data.models);
renderBenchList(data.categories, data.benchmarks);
setMsg('扫描完成', 'ok');
await runCompare();
} catch (e) {
setMsg(e.message, 'error');
}
}
async function runCompare() {
const models = selectedValues('modelList');
const benchmarks = selectedValues('benchList');
if (!models.length) {
setMsg('请至少选择一个模型', 'error');
return;
}
if (!benchmarks.length) {
setMsg('请至少选择一个 benchmark', 'error');
return;
}
const dir = $('outputDir').value.trim();
const params = new URLSearchParams({
models: models.join(','),
benchmarks: benchmarks.join(','),
});
if (dir) params.set('output_dir', dir);
const data = await api(`/api/results/compare?${params}`);
state.compare = data;
renderCharts(data);
renderTable(data);
renderRanking(data);
setMsg(`已对比 ${data.series.length} 模型 × ${data.benchmarks.length} benches`, 'ok');
}
$('reloadBtn').addEventListener('click', () => loadOverview());
$('compareBtn').addEventListener('click', () => runCompare().catch((e) => setMsg(e.message, 'error')));
$('modelAllBtn').addEventListener('click', () => {
document.querySelectorAll('#modelList input').forEach((el) => { el.checked = true; });
});
$('modelClearBtn').addEventListener('click', () => {
document.querySelectorAll('#modelList input').forEach((el) => { el.checked = false; });
});
$('benchAllBtn').addEventListener('click', () => {
document.querySelectorAll('#benchList input').forEach((el) => { el.checked = true; });
});
$('benchClearBtn').addEventListener('click', () => {
document.querySelectorAll('#benchList input').forEach((el) => { el.checked = false; });
});
// bootstrap defaults from meta
api('/api/meta').then((meta) => {
$('outputDir').value = meta.defaults?.output_dir || '';
return loadOverview();
}).catch(() => loadOverview());
})();