438 lines
15 KiB
JavaScript
438 lines
15 KiB
JavaScript
|
|
/* CorrectBench Web UI Logic */
|
|||
|
|
|
|||
|
|
const App = {
|
|||
|
|
state: {
|
|||
|
|
running: false,
|
|||
|
|
stopping: false,
|
|||
|
|
autoScroll: true,
|
|||
|
|
logLines: [],
|
|||
|
|
eventSource: null,
|
|||
|
|
statusInterval: null,
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
// ===== 初始化 =====
|
|||
|
|
init() {
|
|||
|
|
this.bindEvents();
|
|||
|
|
this.loadConfigs();
|
|||
|
|
this.startStatusPolling();
|
|||
|
|
ResultsApp.init();
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
bindEvents() {
|
|||
|
|
document.getElementById('config-select').addEventListener('change', (e) => {
|
|||
|
|
this.onConfigChange(e.target.value);
|
|||
|
|
});
|
|||
|
|
document.getElementById('btn-start').addEventListener('click', () => this.startTask());
|
|||
|
|
document.getElementById('btn-stop').addEventListener('click', () => this.stopTask());
|
|||
|
|
document.getElementById('btn-clear').addEventListener('click', () => this.clearLogs());
|
|||
|
|
document.getElementById('auto-scroll').addEventListener('change', (e) => {
|
|||
|
|
this.state.autoScroll = e.target.checked;
|
|||
|
|
});
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
// ===== 配置管理 =====
|
|||
|
|
async loadConfigs() {
|
|||
|
|
try {
|
|||
|
|
const resp = await fetch('/api/configs');
|
|||
|
|
const configs = await resp.json();
|
|||
|
|
|
|||
|
|
const select = document.getElementById('config-select');
|
|||
|
|
select.innerHTML = '';
|
|||
|
|
|
|||
|
|
// 分组:根目录文件 vs configs/ 子目录
|
|||
|
|
const rootConfigs = configs.filter(c => !c.name.startsWith('configs/'));
|
|||
|
|
const subConfigs = configs.filter(c => c.name.startsWith('configs/'));
|
|||
|
|
|
|||
|
|
if (rootConfigs.length > 0) {
|
|||
|
|
const group = document.createElement('optgroup');
|
|||
|
|
group.label = '📝 用户配置';
|
|||
|
|
rootConfigs.forEach(c => {
|
|||
|
|
const opt = document.createElement('option');
|
|||
|
|
opt.value = c.name;
|
|||
|
|
opt.textContent = c.name;
|
|||
|
|
group.appendChild(opt);
|
|||
|
|
});
|
|||
|
|
select.appendChild(group);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (subConfigs.length > 0) {
|
|||
|
|
const group = document.createElement('optgroup');
|
|||
|
|
group.label = '📁 预设配置';
|
|||
|
|
subConfigs.forEach(c => {
|
|||
|
|
const opt = document.createElement('option');
|
|||
|
|
opt.value = c.name;
|
|||
|
|
opt.textContent = c.name.replace('configs/', '');
|
|||
|
|
group.appendChild(opt);
|
|||
|
|
});
|
|||
|
|
select.appendChild(group);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 默认选中 custom.yaml
|
|||
|
|
const opts = Array.from(select.options);
|
|||
|
|
const customIdx = opts.findIndex(o => o.value === 'custom.yaml');
|
|||
|
|
if (customIdx >= 0) {
|
|||
|
|
select.selectedIndex = customIdx;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
this.onConfigChange(select.value);
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error('加载配置失败:', e);
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
async onConfigChange(configName) {
|
|||
|
|
if (!configName) return;
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const resp = await fetch(`/api/config/${encodeURIComponent(configName)}`);
|
|||
|
|
const data = await resp.json();
|
|||
|
|
|
|||
|
|
if (data.error) {
|
|||
|
|
this.setConfigInfo({ error: data.error });
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
this.setConfigInfo(data.info);
|
|||
|
|
document.getElementById('config-path').textContent = data.path;
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error('加载配置详情失败:', e);
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
setConfigInfo(info) {
|
|||
|
|
const container = document.getElementById('config-info');
|
|||
|
|
const tasksContainer = document.getElementById('task-list');
|
|||
|
|
|
|||
|
|
if (info.error) {
|
|||
|
|
container.innerHTML = `<div class="config-info-row"><span class="label" style="color:var(--danger)">错误: ${info.error}</span></div>`;
|
|||
|
|
tasksContainer.innerHTML = '';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
container.innerHTML = `
|
|||
|
|
<div class="config-info-row">
|
|||
|
|
<span class="label">模型</span>
|
|||
|
|
<span class="value model">${info.model || '未指定'}</span>
|
|||
|
|
</div>
|
|||
|
|
<div class="config-info-row">
|
|||
|
|
<span class="label">RTL生成模型</span>
|
|||
|
|
<span class="value model">${info.rtlgen_model || '未指定'}</span>
|
|||
|
|
</div>
|
|||
|
|
<div class="config-info-row">
|
|||
|
|
<span class="label">运行模式</span>
|
|||
|
|
<span class="value">${info.mode || '未指定'}</span>
|
|||
|
|
</div>
|
|||
|
|
<div class="config-info-row">
|
|||
|
|
<span class="label">最大迭代</span>
|
|||
|
|
<span class="value">${info.itermax || '未指定'}</span>
|
|||
|
|
</div>
|
|||
|
|
`;
|
|||
|
|
|
|||
|
|
// 题目列表
|
|||
|
|
const tasks = info.tasks || [];
|
|||
|
|
if (tasks.length > 0) {
|
|||
|
|
tasksContainer.innerHTML = tasks.map(t => `<div class="task-item">${t}</div>`).join('');
|
|||
|
|
} else if (info.dataset) {
|
|||
|
|
tasksContainer.innerHTML = `<div class="task-item" style="color:var(--text-muted)">数据集: ${info.dataset}</div>`;
|
|||
|
|
} else {
|
|||
|
|
tasksContainer.innerHTML = `<div class="task-item" style="color:var(--text-muted)">未指定题目</div>`;
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
// ===== 任务控制 =====
|
|||
|
|
async startTask() {
|
|||
|
|
const configName = document.getElementById('config-select').value;
|
|||
|
|
if (!configName) return;
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const resp = await fetch('/api/start', {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify({ config: configName })
|
|||
|
|
});
|
|||
|
|
const data = await resp.json();
|
|||
|
|
|
|||
|
|
if (data.error) {
|
|||
|
|
alert(data.error);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
this.state.running = true;
|
|||
|
|
this.state.stopping = false;
|
|||
|
|
this.state.logLines = [];
|
|||
|
|
this.clearLogDisplay();
|
|||
|
|
this.appendLogLine('正在启动...', 'system');
|
|||
|
|
this.updateUI();
|
|||
|
|
this.startLogStream();
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error('启动失败:', e);
|
|||
|
|
alert('启动失败: ' + e.message);
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
async stopTask() {
|
|||
|
|
if (!this.state.running) return;
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const resp = await fetch('/api/stop', { method: 'POST' });
|
|||
|
|
const data = await resp.json();
|
|||
|
|
|
|||
|
|
if (data.error) {
|
|||
|
|
alert(data.error);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
this.state.stopping = true;
|
|||
|
|
this.appendLogLine('[正在停止...]', 'warning');
|
|||
|
|
this.updateUI();
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error('停止失败:', e);
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
clearLogs() {
|
|||
|
|
this.state.logLines = [];
|
|||
|
|
this.clearLogDisplay();
|
|||
|
|
document.getElementById('log-line-count').textContent = '0 行';
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
// ===== 日志流 =====
|
|||
|
|
startLogStream() {
|
|||
|
|
// 关闭旧连接
|
|||
|
|
if (this.state.eventSource) {
|
|||
|
|
this.state.eventSource.close();
|
|||
|
|
this.state.eventSource = null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
this._connectSSE();
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
_connectSSE() {
|
|||
|
|
const es = new EventSource('/api/logs');
|
|||
|
|
this.state.eventSource = es;
|
|||
|
|
|
|||
|
|
es.onmessage = (event) => {
|
|||
|
|
try {
|
|||
|
|
const data = JSON.parse(event.data);
|
|||
|
|
const line = data.line;
|
|||
|
|
const cls = this.classifyLine(line);
|
|||
|
|
this.appendLogLine(line, cls);
|
|||
|
|
|
|||
|
|
// 检测结束标记
|
|||
|
|
if (line === '[完成]' || line === '[已停止]') {
|
|||
|
|
this.state.running = false;
|
|||
|
|
this.state.stopping = false;
|
|||
|
|
this.updateUI();
|
|||
|
|
es.close();
|
|||
|
|
this.state.eventSource = null;
|
|||
|
|
}
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error('解析日志失败:', e);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
es.onerror = () => {
|
|||
|
|
es.close();
|
|||
|
|
this.state.eventSource = null;
|
|||
|
|
|
|||
|
|
if (this.state.running) {
|
|||
|
|
// SSE 断开但任务仍在运行,2秒后重连
|
|||
|
|
// 先用快照补齐可能丢失的日志
|
|||
|
|
this._reconnectWithSnapshot();
|
|||
|
|
} else {
|
|||
|
|
// 任务已结束,确认最终状态
|
|||
|
|
setTimeout(() => this.checkStatus(), 500);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
async _reconnectWithSnapshot() {
|
|||
|
|
// 获取快照,补齐断开期间丢失的日志
|
|||
|
|
try {
|
|||
|
|
const resp = await fetch('/api/logs/snapshot');
|
|||
|
|
const data = await resp.json();
|
|||
|
|
const snapshotSet = new Set(data.lines);
|
|||
|
|
|
|||
|
|
// 找出快照中有但本地没有的行
|
|||
|
|
let newLines = [];
|
|||
|
|
for (const line of data.lines) {
|
|||
|
|
if (!this.state.logLines.includes(line)) {
|
|||
|
|
newLines.push(line);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 追加缺失的日志
|
|||
|
|
for (const line of newLines) {
|
|||
|
|
const cls = this.classifyLine(line);
|
|||
|
|
this.appendLogLine(line, cls, true); // true = 不重复检测
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 检查快照中是否包含结束标记
|
|||
|
|
const lastLine = data.lines[data.lines.length - 1];
|
|||
|
|
if (lastLine === '[完成]' || lastLine === '[已停止]') {
|
|||
|
|
this.state.running = false;
|
|||
|
|
this.state.stopping = false;
|
|||
|
|
this.updateUI();
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
} catch (e) {
|
|||
|
|
console.warn('获取日志快照失败:', e);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 任务仍在运行,重连 SSE
|
|||
|
|
if (this.state.running) {
|
|||
|
|
setTimeout(() => this._connectSSE(), 2000);
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
classifyLine(line) {
|
|||
|
|
if (line.includes('[完成]')) return 'success system';
|
|||
|
|
if (line.includes('[已停止]') || line.includes('[正在停止')) return 'warning system';
|
|||
|
|
if (line.includes('[错误]') || line.includes('❌') || line.includes('ERROR')) return 'error';
|
|||
|
|
if (line.includes('⚠️') || line.includes('WARNING')) return 'warning';
|
|||
|
|
if (line.includes('INFO')) return 'info';
|
|||
|
|
return '';
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
appendLogLine(text, cls, skipDuplicate) {
|
|||
|
|
// 跳过重复行(重连补齐时使用)
|
|||
|
|
if (skipDuplicate && this.state.logLines.includes(text)) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
this.state.logLines.push(text);
|
|||
|
|
|
|||
|
|
const container = document.getElementById('log-content');
|
|||
|
|
// 移除占位符
|
|||
|
|
const placeholder = container.querySelector('.log-placeholder');
|
|||
|
|
if (placeholder) placeholder.remove();
|
|||
|
|
|
|||
|
|
const div = document.createElement('div');
|
|||
|
|
div.className = 'log-line' + (cls ? ' ' + cls : '');
|
|||
|
|
div.textContent = text;
|
|||
|
|
container.appendChild(div);
|
|||
|
|
|
|||
|
|
// 限制显示行数
|
|||
|
|
while (container.children.length > 2000) {
|
|||
|
|
container.removeChild(container.firstChild);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 更新行数
|
|||
|
|
document.getElementById('log-line-count').textContent = `${this.state.logLines.length} 行`;
|
|||
|
|
|
|||
|
|
// 自动滚动
|
|||
|
|
if (this.state.autoScroll) {
|
|||
|
|
container.scrollTop = container.scrollHeight;
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
clearLogDisplay() {
|
|||
|
|
const container = document.getElementById('log-content');
|
|||
|
|
container.innerHTML = `
|
|||
|
|
<div class="log-placeholder">
|
|||
|
|
<div class="log-placeholder-content">
|
|||
|
|
<div class="icon">⌨️</div>
|
|||
|
|
<div>等待开始生成...</div>
|
|||
|
|
</div>
|
|||
|
|
</div>`;
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
// ===== 状态管理 =====
|
|||
|
|
startStatusPolling() {
|
|||
|
|
this.state.statusInterval = setInterval(() => this.checkStatus(), 3000);
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
async checkStatus() {
|
|||
|
|
try {
|
|||
|
|
const resp = await fetch('/api/status');
|
|||
|
|
const data = await resp.json();
|
|||
|
|
|
|||
|
|
const wasRunning = this.state.running;
|
|||
|
|
this.state.running = data.running;
|
|||
|
|
|
|||
|
|
// 如果状态从 running 变为非 running,且没有通过 SSE 检测到
|
|||
|
|
if (wasRunning && !data.running && this.state.eventSource) {
|
|||
|
|
this.state.eventSource.close();
|
|||
|
|
this.state.eventSource = null;
|
|||
|
|
this.state.stopping = false;
|
|||
|
|
// 获取最终日志快照
|
|||
|
|
await this.fetchLogSnapshot();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
this.updateUI(data);
|
|||
|
|
} catch (e) {
|
|||
|
|
// 忽略轮询错误
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
async fetchLogSnapshot() {
|
|||
|
|
try {
|
|||
|
|
const resp = await fetch('/api/logs/snapshot');
|
|||
|
|
const data = await resp.json();
|
|||
|
|
// 只在日志为空时加载快照
|
|||
|
|
if (this.state.logLines.length === 0 && data.lines.length > 0) {
|
|||
|
|
this.clearLogDisplay();
|
|||
|
|
data.lines.forEach(line => {
|
|||
|
|
const cls = this.classifyLine(line);
|
|||
|
|
this.appendLogLine(line, cls);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
} catch (e) {
|
|||
|
|
// 忽略
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
updateUI(statusData) {
|
|||
|
|
const startBtn = document.getElementById('btn-start');
|
|||
|
|
const stopBtn = document.getElementById('btn-stop');
|
|||
|
|
const select = document.getElementById('config-select');
|
|||
|
|
const badge = document.getElementById('status-badge');
|
|||
|
|
const elapsed = document.getElementById('elapsed');
|
|||
|
|
|
|||
|
|
startBtn.disabled = this.state.running;
|
|||
|
|
stopBtn.disabled = !this.state.running;
|
|||
|
|
select.disabled = this.state.running;
|
|||
|
|
|
|||
|
|
// 状态徽章
|
|||
|
|
if (this.state.running && this.state.stopping) {
|
|||
|
|
badge.className = 'status-badge stopping';
|
|||
|
|
badge.innerHTML = '<span class="status-dot"></span>正在停止';
|
|||
|
|
} else if (this.state.running) {
|
|||
|
|
badge.className = 'status-badge running';
|
|||
|
|
badge.innerHTML = '<span class="status-dot"></span>运行中';
|
|||
|
|
} else if (this.state.logLines.length > 0) {
|
|||
|
|
const lastLine = this.state.logLines[this.state.logLines.length - 1];
|
|||
|
|
if (lastLine === '[已停止]') {
|
|||
|
|
badge.className = 'status-badge stopped';
|
|||
|
|
badge.innerHTML = '<span class="status-dot"></span>已停止';
|
|||
|
|
} else if (lastLine === '[完成]') {
|
|||
|
|
badge.className = 'status-badge completed';
|
|||
|
|
badge.innerHTML = '<span class="status-dot"></span>已完成';
|
|||
|
|
} else {
|
|||
|
|
badge.className = 'status-badge idle';
|
|||
|
|
badge.innerHTML = '就绪';
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
badge.className = 'status-badge idle';
|
|||
|
|
badge.innerHTML = '就绪';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 计时
|
|||
|
|
if (statusData && statusData.elapsed > 0) {
|
|||
|
|
elapsed.textContent = this.formatTime(statusData.elapsed);
|
|||
|
|
} else if (!this.state.running) {
|
|||
|
|
elapsed.textContent = '';
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
formatTime(seconds) {
|
|||
|
|
const m = Math.floor(seconds / 60);
|
|||
|
|
const s = Math.floor(seconds % 60);
|
|||
|
|
if (m > 0) return `${m}m ${s}s`;
|
|||
|
|
return `${s}s`;
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 页面加载完成后初始化
|
|||
|
|
document.addEventListener('DOMContentLoaded', () => App.init());
|