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/.
343 lines
11 KiB
JavaScript
343 lines
11 KiB
JavaScript
(() => {
|
||
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());
|
||
})();
|