let state = { gpus: [], quotas: [], repos: [], branches: [], agentProfiles: [], tasks: [], selectedRepoId: null, selectedBranch: null, expandedGpuHosts: new Set(), expandedTasks: new Set(), expandedTerminals: new Set(), settings: { gpuHosts: [] }, errors: {} }; const $ = (id) => document.getElementById(id); function percent(value, total) { if (!Number.isFinite(value) || !Number.isFinite(total) || total <= 0) return 0; return Math.max(0, Math.min(100, Math.round((value / total) * 100))); } function escapeHtml(value) { return String(value ?? '').replace(/[&<>"']/g, (char) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char])); } function formatDate(value) { if (!value) return 'N/A'; const date = new Date(value); if (Number.isNaN(date.getTime())) return 'N/A'; return date.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); } function formatDuration(start, end) { const from = new Date(start).getTime(); const to = new Date(end).getTime(); if (!Number.isFinite(from) || !Number.isFinite(to) || to < from) return ''; const seconds = Math.max(1, Math.round((to - from) / 1000)); if (seconds < 60) return `${seconds}s`; const minutes = Math.round(seconds / 60); if (minutes < 60) return `${minutes}m`; const hours = Math.floor(minutes / 60); const rest = minutes % 60; return rest ? `${hours}h ${rest}m` : `${hours}h`; } function statusLabel(status) { return { running: '运行中', completed: '已完成', failed: '失败' }[status] || status || '未知'; } function canResumeTask(task) { return Boolean(task.sessionId && task.status !== 'running'); } function shortSession(sessionId) { return sessionId ? sessionId.slice(0, 8) : '无 Session'; } function isGpuIdle(gpu) { const memoryPercent = percent(gpu.memoryUsedMiB, gpu.memoryTotalMiB); return memoryPercent < 5 || gpu.gpuUtilizationPercent < 5; } function isGpuAbnormal(gpu) { const memoryPercent = percent(gpu.memoryUsedMiB, gpu.memoryTotalMiB); return memoryPercent >= 5 && gpu.gpuUtilizationPercent < 5; } function toast(message, tone = '') { if (!message) return; const host = $('toastHost'); if (!host) return; const node = document.createElement('div'); node.className = `toast ${tone}`.trim(); node.textContent = message; host.appendChild(node); setTimeout(() => { node.style.transition = 'opacity 200ms ease'; node.style.opacity = '0'; setTimeout(() => node.remove(), 220); }, 4500); } async function request(path, options) { const response = await fetch(path, options); if (response.status === 401) { window.location.href = '/login'; throw new Error('未登录'); } if (!response.ok) { const text = await response.text().catch(() => ''); throw new Error(text || `${response.status} ${response.statusText}`); } return response.json(); } const ERROR_LABELS = { settings: '设置', gpus: 'GPU', quotas: '额度', repos: '项目列表', agentProfiles: 'Agent 配置', tasks: '任务历史' }; function renderErrorBanner() { const banner = $('errorBanner'); if (!banner) return; const entries = Object.entries(state.errors || {}); if (!entries.length) { banner.hidden = true; banner.innerHTML = ''; return; } banner.hidden = false; banner.innerHTML = `
部分数据加载失败
`; } function renderGpuHostsChips() { const chips = $('gpuHostsChips'); if (!chips) return; const hosts = state.settings?.gpuHosts || []; if (!hosts.length) { chips.innerHTML = '未配置任何机器'; return; } const statusByHost = new Map(state.gpus.map((host) => [host.host, host.ok])); chips.innerHTML = hosts.map((host) => { const ok = statusByHost.get(host); const cls = ok === true ? 'ok' : ok === false ? 'bad' : ''; const safe = escapeHtml(host); return ` ${safe} `; }).join(''); } function renderGpus() { const okHosts = state.gpus.filter((host) => host.ok).length; $('gpuSummary').textContent = state.gpus.length ? `${okHosts}/${state.gpus.length} 在线` : '尚未配置'; renderGpuHostsChips(); $('gpuBoard').innerHTML = state.gpus.length ? state.gpus.map((host) => { if (!host.ok) { return `
${escapeHtml(host.host)}离线
${escapeHtml(host.error || '无法获取 GPU 状态')}
`; } const idleCount = host.gpus.filter(isGpuIdle).length; const totalCount = host.gpus.length; const expanded = state.expandedGpuHosts.has(host.host); const thumbRows = host.gpus.map((gpu) => { const memoryPercent = percent(gpu.memoryUsedMiB, gpu.memoryTotalMiB); const thumbClass = isGpuAbnormal(gpu) ? 'abnormal' : isGpuIdle(gpu) ? 'idle' : 'busy'; return ``; }).join(''); const gpuRows = host.gpus.map((gpu) => { const memoryPercent = percent(gpu.memoryUsedMiB, gpu.memoryTotalMiB); return `
#${gpu.index} ${escapeHtml(gpu.name)} ${gpu.gpuUtilizationPercent}% GPU
${gpu.memoryUsedMiB} / ${gpu.memoryTotalMiB} MiB ${memoryPercent}% 显存
`; }).join(''); return `
${expanded ? `
${gpuRows || '未发现 GPU'}
` : ''}
`; }).join('') : '
还没有配置 GPU 机器
'; } function renderQuotas() { const okSources = state.quotas.filter((quota) => quota.ok).length; $('quotaSummary').textContent = state.quotas.length ? `${okSources}/${state.quotas.length} 可用` : '未配置'; $('quotaBoard').innerHTML = state.quotas.length ? state.quotas.map((quota) => { const summary = quota.summary || {}; const limit = summary.limit ?? null; const used = summary.used ?? null; const remaining = summary.remaining ?? null; const usedPercent = limit && used !== null ? percent(used, limit) : null; return `
${escapeHtml(quota.label)} ${quota.ok ? '已更新' : '未配置'}
${quota.ok ? `
剩余${remaining ?? 'N/A'}
已用${used ?? 'N/A'}
${usedPercent === null ? '' : ``} ` : `
${escapeHtml(quota.error || '尚未配置')}
`}
`; }).join('') : '
还没有配置额度数据源
'; } function renderRepos() { const selectedRepo = state.repos.find((repo) => repo.id === state.selectedRepoId); $('repoSummary').textContent = state.repos.length ? (selectedRepo ? `${state.repos.length} 个 · 当前 ${selectedRepo.fullName}` : `${state.repos.length} 个`) : '暂无'; $('repoBoard').innerHTML = state.repos.length ? state.repos.map((repo) => { const selected = repo.id === state.selectedRepoId ? ' selected' : ''; return `
${escapeHtml(repo.fullName)} ${escapeHtml(repo.defaultBranch)}
${repo.description ? `
${escapeHtml(repo.description)}
` : ''}
${escapeHtml(repo.cloneUrl)}
`; }).join('') : '
无法读取项目,请检查 Gitea 配置
'; renderAgentContext(); } function renderAgentContext() { const repo = state.repos.find((item) => item.id === state.selectedRepoId); const repoEl = $('agentContextRepo'); const branchEl = $('agentContextBranch'); if (!repoEl || !branchEl) return; if (repo) { repoEl.textContent = repo.fullName; repoEl.classList.remove('empty'); } else { repoEl.textContent = '从左侧选择一个项目'; repoEl.classList.add('empty'); } if (state.selectedBranch) { branchEl.textContent = state.selectedBranch; branchEl.hidden = false; } else { branchEl.hidden = true; branchEl.textContent = ''; } } function renderBranches() { $('branchSelect').innerHTML = state.branches.length ? state.branches.map((branch) => { const selected = branch.name === state.selectedBranch ? 'selected' : ''; return ``; }).join('') : ''; renderAgentContext(); } function renderProfiles() { $('profileSelect').innerHTML = state.agentProfiles.map((profile) => ( `` )).join(''); } function taskMatchesSelection(task) { return task.repoFullName === state.selectedRepoId && task.branch === state.selectedBranch && canResumeTask(task); } function renderResumeTasks() { const options = state.tasks.filter(taskMatchesSelection); const prev = $('resumeTaskSelect').value; $('resumeTaskSelect').innerHTML = [ '', ...options.map((task) => { const prompt = task.prompt ? ` · ${task.prompt.slice(0, 40)}` : ''; const label = `${formatDate(task.finishedAt || task.createdAt)} · ${statusLabel(task.status)} · ${shortSession(task.sessionId)}${prompt}`; return ``; }) ].join(''); if (prev && options.some((task) => task.id === prev)) { $('resumeTaskSelect').value = prev; } $('resumeTaskButton').disabled = options.length === 0; } function renderTerminal(label, content, tone = '', expanded = true, terminalKey = '') { if (!content) return ''; const lineCount = content.split('\n').length; const body = `
${escapeHtml(content)}
`; if (!expanded) { const openAttr = terminalKey && state.expandedTerminals.has(terminalKey) ? ' open' : ''; return `
${label} ${lineCount} lines ${body}
`; } return `
${label} ${lineCount} lines
${body}
`; } function taskFullBody(task, { expandedTerminals }) { const duration = task.finishedAt ? formatDuration(task.createdAt, task.finishedAt) : ''; return `
${escapeHtml(task.profileLabel)}
${escapeHtml(task.repoFullName)} · ${escapeHtml(task.branch)}
${escapeHtml(statusLabel(task.status))}
${formatDate(task.createdAt)} ${task.finishedAt ? `完成 ${formatDate(task.finishedAt)}` : ''} ${duration ? `耗时 ${escapeHtml(duration)}` : ''} ${Number.isInteger(task.exitCode) ? `Exit ${task.exitCode}` : ''} ${task.sessionId ? `Session ${escapeHtml(shortSession(task.sessionId))}` : ''} ${task.parentTaskId ? `续自 ${escapeHtml(task.parentTaskId.slice(0, 8))}` : ''}
${escapeHtml(task.workspace)}
${escapeHtml(task.prompt)}
${renderTerminal('stderr', task.stderr, 'stderr', expandedTerminals, `${task.id}:stderr`)} ${renderTerminal('stdout', task.stdout, '', expandedTerminals, `${task.id}:stdout`)} `; } function renderActiveTaskCard(task) { return `
${taskFullBody(task, { expandedTerminals: true })}
`; } function renderHistoryTaskCard(task) { const duration = task.finishedAt ? formatDuration(task.createdAt, task.finishedAt) : ''; const promptPreview = (task.prompt || '').replace(/\s+/g, ' ').trim().slice(0, 100); const resumeButton = canResumeTask(task) ? `` : ''; const openAttr = state.expandedTasks.has(task.id) ? ' open' : ''; return `
${escapeHtml(statusLabel(task.status))} ${escapeHtml(task.repoFullName)} · ${escapeHtml(task.branch)} ${promptPreview ? `${escapeHtml(promptPreview)}` : ''} ${resumeButton} ${formatDate(task.finishedAt || task.createdAt)} ${duration ? escapeHtml(duration) : ''}
${taskFullBody(task, { expandedTerminals: false })}
`; } function renderTaskCard(task) { return task.status === 'running' ? renderActiveTaskCard(task) : renderHistoryTaskCard(task); } function renderTasks() { const running = state.tasks.filter((task) => task.status === 'running').length; const completed = state.tasks.filter((task) => task.status === 'completed').length; const failed = state.tasks.filter((task) => task.status === 'failed').length; $('taskSummary').textContent = `${running} 运行中 · ${completed} 已完成 · ${failed} 失败`; const activeTasks = state.tasks.filter((task) => task.status === 'running'); const historyTasks = state.tasks.filter((task) => task.status !== 'running'); const sections = []; if (activeTasks.length) { sections.push(`

运行中

${activeTasks.length} 个
${activeTasks.map(renderTaskCard).join('')}
`); } if (historyTasks.length) { sections.push(`

历史 Session

${historyTasks.length} 个
${historyTasks.map(renderTaskCard).join('')}
`); } $('taskBoard').innerHTML = sections.join('') || '
暂无 Session
'; } function render() { if (!state.selectedRepoId && state.repos[0]) state.selectedRepoId = state.repos[0].id; renderErrorBanner(); renderGpus(); renderQuotas(); renderRepos(); renderBranches(); renderProfiles(); renderResumeTasks(); renderTasks(); } async function loadBranches() { if (!state.selectedRepoId) { state.branches = []; state.selectedBranch = null; renderBranches(); return; } try { state.branches = await request(`/api/branches?repo=${encodeURIComponent(state.selectedRepoId)}`); } catch (error) { state.branches = []; toast(`读取 branches 失败: ${error.message}`, 'error'); } const repo = state.repos.find((item) => item.id === state.selectedRepoId); if (!state.branches.some((branch) => branch.name === state.selectedBranch)) { state.selectedBranch = state.branches.find((branch) => branch.name === repo?.defaultBranch)?.name || state.branches[0]?.name || null; } renderBranches(); renderResumeTasks(); } async function refresh() { const button = $('refreshButton'); button.disabled = true; button.classList.add('loading'); try { const data = await request('/api/dashboard'); state = { ...state, ...data, errors: data.errors || {} }; render(); await loadBranches(); } catch (error) { toast(`刷新失败: ${error.message}`, 'error'); } finally { button.disabled = false; button.classList.remove('loading'); } } $('refreshButton').addEventListener('click', refresh); $('logoutButton').addEventListener('click', async () => { try { await fetch('/api/auth/logout', { method: 'POST' }); } finally { window.location.href = '/login'; } }); $('gpuBoard').addEventListener('click', (event) => { const button = event.target.closest('.host-summary'); if (!button) return; const host = button.dataset.host; if (state.expandedGpuHosts.has(host)) state.expandedGpuHosts.delete(host); else state.expandedGpuHosts.add(host); renderGpus(); }); async function saveGpuHosts(gpuHosts, successMessage) { try { const settings = await request('/api/settings/gpu-hosts', { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ gpuHosts }) }); state.settings = settings; renderGpuHostsChips(); if (successMessage) toast(successMessage, 'success'); await refresh(); return true; } catch (error) { toast(`保存失败: ${error.message}`, 'error'); return false; } } function setHostAdding(adding) { const trigger = $('hostAddTrigger'); const form = $('hostAddForm'); const input = $('hostAddInput'); if (!trigger || !form) return; form.hidden = !adding; trigger.hidden = adding; if (adding && input) { input.value = ''; input.focus(); } } $('hostAddTrigger').addEventListener('click', () => setHostAdding(true)); $('hostAddCancel').addEventListener('click', () => setHostAdding(false)); $('hostAddInput').addEventListener('keydown', (event) => { if (event.key === 'Escape') { event.preventDefault(); setHostAdding(false); } }); $('hostAddForm').addEventListener('submit', async (event) => { event.preventDefault(); const name = $('hostAddInput').value.trim(); if (!name) { toast('请输入主机名', 'warn'); return; } const current = state.settings?.gpuHosts || []; if (current.includes(name)) { toast(`${name} 已存在`, 'warn'); return; } const ok = await saveGpuHosts([...current, name], `已添加 ${name}`); if (ok) setHostAdding(false); }); $('gpuHostsChips').addEventListener('click', async (event) => { const button = event.target.closest('[data-remove-host]'); if (!button) return; const host = button.dataset.removeHost; const current = state.settings?.gpuHosts || []; if (!current.includes(host)) return; await saveGpuHosts(current.filter((item) => item !== host), `已移除 ${host}`); }); $('repoBoard').addEventListener('click', (event) => { const card = event.target.closest('.repo-card'); if (!card) return; state.selectedRepoId = card.dataset.id; state.selectedBranch = null; renderRepos(); loadBranches(); }); $('branchSelect').addEventListener('change', (event) => { state.selectedBranch = event.target.value; renderResumeTasks(); renderAgentContext(); }); async function selectResumeTask(taskId) { const task = state.tasks.find((item) => item.id === taskId); if (!task) return; state.selectedRepoId = task.repoFullName; state.selectedBranch = task.branch; renderRepos(); await loadBranches(); $('resumeTaskSelect').value = task.id; $('taskForm').scrollIntoView({ behavior: 'smooth', block: 'start' }); $('promptInput').focus(); } $('taskBoard').addEventListener('click', (event) => { const button = event.target.closest('[data-resume-task]'); if (!button) return; event.preventDefault(); event.stopPropagation(); selectResumeTask(button.dataset.resumeTask); }); $('taskBoard').addEventListener('toggle', (event) => { const target = event.target; if (!(target instanceof HTMLDetailsElement)) return; if (target.matches('details.history-task')) { const id = target.dataset.taskId; if (!id) return; if (target.open) state.expandedTasks.add(id); else state.expandedTasks.delete(id); return; } if (target.matches('details.terminal')) { const key = target.dataset.terminalKey; if (!key) return; if (target.open) state.expandedTerminals.add(key); else state.expandedTerminals.delete(key); } }, true); async function submitTask(resume) { const repo = state.repos.find((item) => item.id === state.selectedRepoId); const prompt = $('promptInput').value.trim(); if (!repo) { toast('请先从左侧选择一个项目', 'warn'); return; } if (!prompt) { toast('请输入命令', 'warn'); return; } const payload = { repo, branch: $('branchSelect').value, profileId: $('profileSelect').value, prompt, resumeTaskId: resume ? $('resumeTaskSelect').value : '' }; if (resume && !payload.resumeTaskId) { toast('没有可继续的 Session', 'warn'); return; } const submitButtons = [$('startTaskButton'), $('resumeTaskButton')]; submitButtons.forEach((btn) => { if (btn) btn.disabled = true; }); try { await request('/api/tasks', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload) }); $('promptInput').value = ''; toast(resume ? 'Session 已继续' : '任务已启动', 'success'); await refresh(); } catch (error) { toast(`启动任务失败: ${error.message}`, 'error'); } finally { submitButtons.forEach((btn) => { if (btn) btn.disabled = false; }); } } $('taskForm').addEventListener('submit', async (event) => { event.preventDefault(); await submitTask(false); }); $('resumeTaskButton').addEventListener('click', () => submitTask(true)); refresh(); setInterval(refresh, 60000);