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

373 lines
12 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 = {
meta: null,
selectionMode: 'suite',
suite: 'official',
activeJobId: null,
pollTimer: null,
logOffset: 0,
};
const $ = (id) => document.getElementById(id);
const form = $('launchForm');
const formMsg = $('formMsg');
const logView = $('logView');
const jobList = $('jobList');
function setMsg(text, type = '') {
formMsg.textContent = text || '';
formMsg.className = `msg ${type}`.trim();
}
async function api(path, options) {
const res = await fetch(path, {
headers: { 'Content-Type': 'application/json' },
...options,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const detail = data.detail || res.statusText || 'request failed';
throw new Error(typeof detail === 'string' ? detail : JSON.stringify(detail));
}
return data;
}
function fillDefaults(meta) {
const d = meta.defaults;
$('model').value = d.model || '';
$('api_url').value = d.api_url || '';
$('api_key').value = d.api_key || 'EMPTY';
$('seed').value = d.seed;
$('batch_size').value = d.batch_size;
$('thinking').checked = !!d.thinking;
$('dataset_dir').value = d.dataset_dir || '';
$('output_dir').value = d.output_dir || '';
$('config').value = d.config || '';
$('tokenizer_path').value = d.tokenizer_path || '';
$('judge_model').value = d.judge_model || '';
$('judge_api_url').value = d.judge_api_url || '';
$('judge_max_tokens').value = d.judge_max_tokens || '';
state.suite = d.suite || 'official';
}
function renderSuites() {
const box = $('suiteList');
box.innerHTML = '';
const suites = state.meta.suites;
Object.keys(suites).forEach((name) => {
const info = suites[name];
const btn = document.createElement('button');
btn.type = 'button';
btn.className = `suite-card${state.suite === name ? ' active' : ''}`;
btn.innerHTML = `<strong>${name}</strong><small>${info.all.length} benchmarks<br>${info.all.slice(0, 6).join(', ')}${info.all.length > 6 ? '…' : ''}</small>`;
btn.addEventListener('click', () => {
state.suite = name;
renderSuites();
});
box.appendChild(btn);
});
}
function renderBenchmarks() {
const box = $('benchmarkList');
box.innerHTML = '';
const multi = new Set(Object.keys(state.meta.multi_run || {}));
const categories = state.meta.categories || [
{ id: 'all', name: '全部', items: state.meta.benchmarks || [] },
];
categories.forEach((cat) => {
const section = document.createElement('section');
section.className = `bench-category cat-${cat.id}`;
section.dataset.category = cat.id;
const head = document.createElement('div');
head.className = 'bench-cat-head';
head.innerHTML = `
<div class="bench-cat-title">
<strong>${cat.name}</strong>
<span class="muted">${cat.items.length}</span>
</div>
<div class="bench-cat-actions">
<button type="button" class="ghost cat-select">全选本组</button>
<button type="button" class="ghost cat-clear">清空</button>
</div>
`;
head.querySelector('.cat-select').addEventListener('click', () => {
section.querySelectorAll('input[type="checkbox"]').forEach((el) => { el.checked = true; });
updateSelectedCount();
});
head.querySelector('.cat-clear').addEventListener('click', () => {
section.querySelectorAll('input[type="checkbox"]').forEach((el) => { el.checked = false; });
updateSelectedCount();
});
section.appendChild(head);
const grid = document.createElement('div');
grid.className = 'bench-cat-grid';
cat.items.forEach((name) => {
const label = document.createElement('label');
const isMulti = multi.has(name);
label.className = `bench-item${isMulti ? ' multi' : ''}`;
label.title = isMulti ? `multi-run x${state.meta.multi_run[name]}` : cat.name;
label.innerHTML = `<input type="checkbox" value="${name}" /><span>${name}</span>`;
if (isMulti) {
const tag = document.createElement('em');
tag.className = 'run-tag';
tag.textContent = `×${state.meta.multi_run[name]}`;
label.appendChild(tag);
}
label.querySelector('input').addEventListener('change', updateSelectedCount);
grid.appendChild(label);
});
section.appendChild(grid);
box.appendChild(section);
});
updateSelectedCount();
}
function updateSelectedCount() {
const n = [...document.querySelectorAll('#benchmarkList input:checked')].length;
$('selectedCount').textContent = `已选 ${n}`;
}
function setMode(mode) {
state.selectionMode = mode;
document.querySelectorAll('.mode-tabs .tab').forEach((el) => {
el.classList.toggle('active', el.dataset.mode === mode);
});
$('suitePane').classList.toggle('hidden', mode !== 'suite');
$('datasetsPane').classList.toggle('hidden', mode !== 'datasets');
}
function collectPayload() {
const datasets = [...document.querySelectorAll('#benchmarkList input:checked')].map((el) => el.value);
const excludeRaw = $('exclude').value.trim();
const exclude = excludeRaw
? excludeRaw.split(',').map((s) => s.trim()).filter(Boolean)
: [];
const payload = {
model: $('model').value.trim(),
api_url: $('api_url').value.trim(),
api_key: $('api_key').value.trim() || 'EMPTY',
thinking: $('thinking').checked,
selection_mode: state.selectionMode,
suite: state.suite,
datasets,
exclude,
folder_name: $('folder_name').value.trim() || null,
limit: $('limit').value.trim() || null,
seed: Number($('seed').value || 42),
batch_size: Number($('batch_size').value || 4),
thinking_max_tokens_scale: Number($('thinking_max_tokens_scale').value || 1),
max_tokens_add: Number($('max_tokens_add').value || 0),
dataset_dir: $('dataset_dir').value.trim() || null,
output_dir: $('output_dir').value.trim() || null,
config: $('config').value.trim() || null,
tokenizer_path: $('tokenizer_path').value.trim() || null,
judge_model: $('judge_model').value.trim() || null,
judge_api_url: $('judge_api_url').value.trim() || null,
judge_api_key: $('judge_api_key').value.trim() || null,
judge_max_tokens: $('judge_max_tokens').value
? Number($('judge_max_tokens').value)
: null,
write_summary: $('write_summary').value === 'true',
};
return payload;
}
function statusClass(status) {
return status || 'idle';
}
function renderActive(job) {
const card = $('activeCard');
if (!job) {
card.className = 'active-card idle';
$('activeStatus').className = 'badge';
$('activeStatus').textContent = 'idle';
$('activeJobId').textContent = '—';
$('activeCmd').textContent = '尚未启动任务';
$('stopBtn').disabled = true;
return;
}
card.className = `active-card ${statusClass(job.status)}`;
$('activeStatus').className = `badge ${statusClass(job.status)}`;
$('activeStatus').textContent = job.status;
$('activeJobId').textContent = job.id;
$('activeCmd').textContent = (job.command || []).join(' ');
$('stopBtn').disabled = job.status !== 'running';
state.activeJobId = job.id;
}
function renderJobs(jobs, activeId) {
jobList.innerHTML = '';
if (!jobs.length) {
jobList.innerHTML = '<div class="muted">暂无历史任务</div>';
return;
}
jobs.forEach((job) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'job-item';
const model = job.payload?.model || '-';
const thinking = job.payload?.thinking ? 'thinking' : 'no-thinking';
btn.innerHTML = `
<div class="row">
<strong>${job.id}</strong>
<span class="badge ${statusClass(job.status)}">${job.status}</span>
</div>
<small>${model} · ${thinking}</small>
`;
btn.addEventListener('click', () => followJob(job.id, true));
if (job.id === activeId) btn.style.borderColor = 'rgba(214,162,74,0.75)';
jobList.appendChild(btn);
});
}
async function refreshJobs() {
const data = await api('/api/jobs');
renderJobs(data.jobs || [], data.active_job_id);
if (data.active_job_id) {
const active = (data.jobs || []).find((j) => j.id === data.active_job_id);
if (active) renderActive(active);
} else if (state.activeJobId) {
const current = (data.jobs || []).find((j) => j.id === state.activeJobId);
if (current) renderActive(current);
}
return data;
}
async function pullLogs(reset = false) {
if (!state.activeJobId) return;
if (reset) {
state.logOffset = 0;
logView.textContent = '';
}
const data = await api(`/api/jobs/${state.activeJobId}/logs?offset=${state.logOffset}`);
if (data.content) {
logView.textContent += data.content;
state.logOffset = data.next_offset;
if ($('autoScroll').checked) logView.scrollTop = logView.scrollHeight;
}
if (data.status) {
$('activeStatus').className = `badge ${statusClass(data.status)}`;
$('activeStatus').textContent = data.status;
$('activeCard').className = `active-card ${statusClass(data.status)}`;
$('stopBtn').disabled = data.status !== 'running';
}
if (data.done) stopPolling();
}
function stopPolling() {
if (state.pollTimer) {
clearInterval(state.pollTimer);
state.pollTimer = null;
}
}
function startPolling() {
stopPolling();
state.pollTimer = setInterval(async () => {
try {
await pullLogs(false);
await refreshJobs();
} catch (e) {
console.error(e);
}
}, 1200);
}
async function followJob(jobId, resetLog = false) {
const job = await api(`/api/jobs/${jobId}`);
renderActive(job);
await pullLogs(resetLog);
if (job.status === 'running' || job.status === 'queued') startPolling();
else stopPolling();
}
async function init() {
try {
await api('/api/health');
$('healthDot').className = 'dot ok';
$('healthText').textContent = 'server online';
} catch (e) {
$('healthDot').className = 'dot bad';
$('healthText').textContent = 'server offline';
setMsg(e.message, 'error');
return;
}
state.meta = await api('/api/meta');
fillDefaults(state.meta);
renderSuites();
renderBenchmarks();
setMode('suite');
const jobs = await refreshJobs();
if (jobs.active_job_id) {
await followJob(jobs.active_job_id, true);
} else if (jobs.jobs?.[0]) {
await followJob(jobs.jobs[0].id, true);
}
}
document.querySelectorAll('.mode-tabs .tab').forEach((el) => {
el.addEventListener('click', () => setMode(el.dataset.mode));
});
$('selectAllBtn').addEventListener('click', () => {
document.querySelectorAll('#benchmarkList input').forEach((el) => { el.checked = true; });
updateSelectedCount();
});
$('clearAllBtn').addEventListener('click', () => {
document.querySelectorAll('#benchmarkList input').forEach((el) => { el.checked = false; });
updateSelectedCount();
});
$('refreshJobsBtn').addEventListener('click', () => refreshJobs().catch((e) => setMsg(e.message, 'error')));
form.addEventListener('submit', async (e) => {
e.preventDefault();
setMsg('');
const payload = collectPayload();
if (payload.selection_mode === 'datasets' && !payload.datasets.length) {
setMsg('请至少选择一个 benchmark', 'error');
return;
}
$('launchBtn').disabled = true;
try {
const job = await api('/api/jobs', {
method: 'POST',
body: JSON.stringify(payload),
});
setMsg(`任务已启动: ${job.id}`, 'ok');
renderActive(job);
state.logOffset = 0;
logView.textContent = '';
await refreshJobs();
startPolling();
await pullLogs(true);
} catch (err) {
setMsg(err.message, 'error');
} finally {
$('launchBtn').disabled = false;
}
});
$('stopBtn').addEventListener('click', async () => {
if (!state.activeJobId) return;
if (!confirm(`确认停止任务 ${state.activeJobId}`)) return;
try {
const job = await api(`/api/jobs/${state.activeJobId}/stop`, { method: 'POST' });
renderActive(job);
setMsg('任务已停止', 'ok');
await refreshJobs();
await pullLogs(false);
} catch (err) {
setMsg(err.message, 'error');
}
});
init();
})();