sora 541b0b477d Improve EvalStone web UI paths, custom suites, and results charts.
Move required path fields up front, persist custom benchmark suites, enlarge readable typography, show full suite benchmark names, add capability-domain trend charts with local Chart.js, and polish ranking name display.
2026-07-29 07:38:05 +00:00

552 lines
18 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',
suiteKind: 'builtin', // builtin | custom
suite: 'official',
customSuites: {},
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';
state.suiteKind = 'builtin';
state.customSuites = meta.custom_suites || {};
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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.suiteKind === 'builtin' && state.suite === name ? ' active' : ''}`;
const names = info.all.map(escapeHtml).join(', ');
btn.innerHTML = `
<strong>${escapeHtml(name)}</strong>
<span class="suite-count">${info.all.length} benchmarks</span>
<div class="suite-names">${names}</div>
`;
btn.addEventListener('click', () => {
state.suiteKind = 'builtin';
state.suite = name;
renderSuites();
renderCustomSuites();
});
box.appendChild(btn);
});
}
function renderCustomSuites() {
const box = $('customSuiteList');
const empty = $('customSuiteEmpty');
box.innerHTML = '';
const names = Object.keys(state.customSuites || {});
empty.classList.toggle('hidden', names.length > 0);
names.forEach((name) => {
const items = state.customSuites[name] || [];
const wrap = document.createElement('div');
wrap.className = `suite-card custom${state.suiteKind === 'custom' && state.suite === name ? ' active' : ''}`;
const main = document.createElement('button');
main.type = 'button';
main.className = 'suite-card-main';
main.innerHTML = `
<strong>${escapeHtml(name)}</strong>
<span class="suite-count">${items.length} benchmarks · 自定义</span>
<div class="suite-names">${items.map(escapeHtml).join(', ')}</div>
`;
main.addEventListener('click', () => {
state.suiteKind = 'custom';
state.suite = name;
renderSuites();
renderCustomSuites();
// sync editor checkboxes
const set = new Set(items);
document.querySelectorAll('#customPickList input').forEach((el) => {
el.checked = set.has(el.value);
});
$('customSuiteName').value = name;
updateCustomPickCount();
});
const del = document.createElement('button');
del.type = 'button';
del.className = 'ghost danger-text suite-del';
del.textContent = '删除';
del.addEventListener('click', async (e) => {
e.stopPropagation();
if (!confirm(`删除自定义组合「${name}」?`)) return;
try {
const data = await api(`/api/custom-suites/${encodeURIComponent(name)}`, { method: 'DELETE' });
state.customSuites = data.suites || {};
if (state.suiteKind === 'custom' && state.suite === name) {
state.suiteKind = 'builtin';
state.suite = state.meta.defaults?.suite || 'official';
}
renderSuites();
renderCustomSuites();
setMsg(`已删除组合: ${name}`, 'ok');
} catch (err) {
setMsg(err.message, 'error');
}
});
wrap.appendChild(main);
wrap.appendChild(del);
box.appendChild(wrap);
});
}
function renderBenchmarkPicker(containerId, withCountCb) {
const box = $(containerId);
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}`;
const head = document.createElement('div');
head.className = 'bench-cat-head';
head.innerHTML = `
<div class="bench-cat-title">
<strong>${escapeHtml(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; });
withCountCb();
});
head.querySelector('.cat-clear').addEventListener('click', () => {
section.querySelectorAll('input[type="checkbox"]').forEach((el) => { el.checked = false; });
withCountCb();
});
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="${escapeHtml(name)}" /><span>${escapeHtml(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', withCountCb);
grid.appendChild(label);
});
section.appendChild(grid);
box.appendChild(section);
});
withCountCb();
}
function renderBenchmarks() {
renderBenchmarkPicker('benchmarkList', updateSelectedCount);
}
function renderCustomPicker() {
renderBenchmarkPicker('customPickList', updateCustomPickCount);
}
function updateSelectedCount() {
const n = [...document.querySelectorAll('#benchmarkList input:checked')].length;
$('selectedCount').textContent = `已选 ${n}`;
}
function updateCustomPickCount() {
const n = [...document.querySelectorAll('#customPickList input:checked')].length;
$('customPickCount').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)
: [];
let selectionMode = state.selectionMode;
let suite = state.suite;
let finalDatasets = datasets;
if (selectionMode === 'suite' && state.suiteKind === 'custom') {
selectionMode = 'datasets';
finalDatasets = [...(state.customSuites[state.suite] || [])];
suite = state.suite;
}
const payload = {
model: $('model').value.trim(),
api_url: $('api_url').value.trim(),
api_key: $('api_key').value.trim() || 'EMPTY',
thinking: $('thinking').checked,
selection_mode: selectionMode,
suite,
datasets: finalDatasets,
exclude: selectionMode === 'suite' ? 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;
}
async function saveCustomSuite(name, datasets) {
const data = await api('/api/custom-suites', {
method: 'POST',
body: JSON.stringify({ name, datasets }),
});
state.customSuites = data.suites || {};
state.suiteKind = 'custom';
state.suite = name;
renderSuites();
renderCustomSuites();
setMsg(`已永久保存组合: ${name}${datasets.length} 个)`, 'ok');
}
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>${escapeHtml(job.id)}</strong>
<span class="badge ${statusClass(job.status)}">${escapeHtml(job.status)}</span>
</div>
<small>${escapeHtml(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();
renderCustomSuites();
renderBenchmarks();
renderCustomPicker();
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')));
$('saveCustomSuiteBtn').addEventListener('click', async () => {
const name = $('customSuiteName').value.trim();
const datasets = [...document.querySelectorAll('#customPickList input:checked')].map((el) => el.value);
if (!name) {
setMsg('请填写组合名称', 'error');
return;
}
if (!datasets.length) {
setMsg('请至少勾选一个 benchmark', 'error');
return;
}
try {
await saveCustomSuite(name, datasets);
} catch (err) {
setMsg(err.message, 'error');
}
});
$('loadCheckedToCustomBtn').addEventListener('click', () => {
// no-op helper text: already editing in place; just focus name
$('customSuiteName').focus();
setMsg('请在下方勾选数据集并填写组合名称后保存', 'ok');
});
$('saveFromDatasetsBtn').addEventListener('click', async () => {
const datasets = [...document.querySelectorAll('#benchmarkList input:checked')].map((el) => el.value);
if (!datasets.length) {
setMsg('请先勾选 benchmark', 'error');
return;
}
const name = prompt('输入要永久保存的组合名称:');
if (!name || !name.trim()) return;
try {
// sync into custom picker too
const set = new Set(datasets);
document.querySelectorAll('#customPickList input').forEach((el) => {
el.checked = set.has(el.value);
});
$('customSuiteName').value = name.trim();
updateCustomPickCount();
await saveCustomSuite(name.trim(), datasets);
setMode('suite');
} catch (err) {
setMsg(err.message, 'error');
}
});
form.addEventListener('submit', async (e) => {
e.preventDefault();
setMsg('');
const requiredPaths = [
['dataset_dir', 'Dataset Dir'],
['output_dir', 'Output Dir'],
['config', 'Config YAML'],
['tokenizer_path', 'Tokenizer Path'],
];
for (const [id, label] of requiredPaths) {
if (!$(id).value.trim()) {
setMsg(`请填写必填路径: ${label}`, 'error');
$(id).focus();
return;
}
}
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();
})();