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.
552 lines
18 KiB
JavaScript
552 lines
18 KiB
JavaScript
(() => {
|
||
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, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"');
|
||
}
|
||
|
||
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();
|
||
})();
|