-
${escapeHtml(quota.label)}
-
${quota.ok ? '已更新' : '未配置'}
+function renderLiveAgents() {
+ const agents = state.liveAgents?.agents || [];
+ $('liveAgentSummary').textContent = agents.length ? `${agents.length} 个运行中` : '无运行中';
+ if (!agents.length) {
+ $('liveAgentBoard').innerHTML = '
tmux 中没有正在运行的 agent
';
+ return;
+ }
+ const groups = new Map();
+ for (const agent of agents) {
+ if (!groups.has(agent.session)) groups.set(agent.session, []);
+ groups.get(agent.session).push(agent);
+ }
+ $('liveAgentBoard').innerHTML = [...groups.entries()].map(([session, members]) => {
+ const rows = members.map((agent) => {
+ const termUrl = `/term/?arg=${encodeURIComponent(agent.session)}&arg=${encodeURIComponent(agent.windowIndex)}`;
+ return `
+ ${escapeHtml(agent.engine)}
+
+ :${agent.windowIndex}
+ ${escapeHtml(agent.windowName)}
+
+ ${escapeHtml(agent.cwd)}
+ 打开终端
+ `;
+ }).join('');
+ return `
+
+ ${escapeHtml(session)}
- ${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
';
+ ${rows}
+ `;
+ }).join('');
}
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();
+ renderLiveAgents();
}
async function refresh() {
@@ -455,10 +201,25 @@ async function refresh() {
button.disabled = true;
button.classList.add('loading');
try {
- const data = await request('/api/dashboard');
- state = { ...state, ...data, errors: data.errors || {} };
+ const [settings, gpus, liveAgents] = await Promise.allSettled([
+ request('/api/settings'),
+ request('/api/gpus'),
+ request('/api/tmux/agents')
+ ]);
+ const errors = {};
+ const value = (result, fallback, key) => {
+ if (result.status === 'fulfilled') return result.value;
+ errors[key] = String(result.reason?.message || result.reason || 'unknown error');
+ return fallback;
+ };
+ state = {
+ ...state,
+ settings: value(settings, state.settings, 'settings'),
+ gpus: value(gpus, [], 'gpus'),
+ liveAgents: value(liveAgents, { ok: false, agents: [] }, 'liveAgents'),
+ errors
+ };
render();
- await loadBranches();
} catch (error) {
toast(`刷新失败: ${error.message}`, 'error');
} finally {
@@ -551,105 +312,35 @@ $('gpuHostsChips').addEventListener('click', async (event) => {
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;
+$('liveAgentForm').addEventListener('submit', async (event) => {
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 : ''
+ engine: $('liveAgentEngine').value,
+ session: $('liveAgentSession').value.trim(),
+ cwd: $('liveAgentCwd').value.trim(),
+ windowName: $('liveAgentWindowName').value.trim()
};
- if (resume && !payload.resumeTaskId) {
- toast('没有可继续的 Session', 'warn');
+ if (!payload.session || !payload.cwd) {
+ toast('请填写 session 和工作目录', 'warn');
return;
}
- const submitButtons = [$('startTaskButton'), $('resumeTaskButton')];
- submitButtons.forEach((btn) => { if (btn) btn.disabled = true; });
+ const button = $('liveAgentLaunchButton');
+ button.disabled = true;
try {
- await request('/api/tasks', {
+ const result = await request('/api/tmux/agents', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload)
});
- $('promptInput').value = '';
- toast(resume ? 'Session 已继续' : '任务已启动', 'success');
+ toast(`已在 ${result.session} 启动 ${result.engine}`, 'success');
+ $('liveAgentWindowName').value = '';
await refresh();
} catch (error) {
- toast(`启动任务失败: ${error.message}`, 'error');
+ toast(`启动 agent 失败: ${error.message}`, 'error');
} finally {
- submitButtons.forEach((btn) => { if (btn) btn.disabled = false; });
+ button.disabled = false;
}
-}
-
-$('taskForm').addEventListener('submit', async (event) => {
- event.preventDefault();
- await submitTask(false);
});
-$('resumeTaskButton').addEventListener('click', () => submitTask(true));
-
refresh();
setInterval(refresh, 60000);
diff --git a/public/index.html b/public/index.html
index 48c55fa..d36f012 100644
--- a/public/index.html
+++ b/public/index.html
@@ -4,7 +4,7 @@
Local Kanban
-
+
@@ -14,7 +14,7 @@
LK
Local Kanban
-
GPU · 额度 · 项目 · Agent
+
GPU · Live Agents