const state = { sessions: [], messages: [], tasks: [], approvals: [], workers: [], events: [], selectedSessionId: null, connection: "connecting", banner: null, }; const TASK_GROUPS = [ { title: "进行中", statuses: ["assigned", "running"] }, { title: "等待处理", statuses: ["planning", "queued"] }, { title: "等待审批或阻塞", statuses: ["waiting_approval", "blocked", "paused"] }, { title: "已完成", statuses: ["completed"] }, { title: "已结束", statuses: ["failed", "cancelled"] }, ]; const elements = { sessionList: document.querySelector("#session-list"), workerList: document.querySelector("#worker-list"), messageList: document.querySelector("#message-list"), taskList: document.querySelector("#task-list"), approvalList: document.querySelector("#approval-list"), eventList: document.querySelector("#event-list"), planHint: document.querySelector("#plan-hint"), sessionTitleDisplay: document.querySelector("#session-title-display"), sessionSummary: document.querySelector("#session-summary"), createSessionForm: document.querySelector("#create-session-form"), sessionTitleInput: document.querySelector("#session-title"), createWorkerForm: document.querySelector("#create-worker-form"), workerName: document.querySelector("#worker-name"), workerOs: document.querySelector("#worker-os"), workerCapabilities: document.querySelector("#worker-capabilities"), messageForm: document.querySelector("#message-form"), messageInput: document.querySelector("#message-input"), resetDemo: document.querySelector("#reset-demo"), archiveSession: document.querySelector("#archive-session"), streamStatus: document.querySelector("#stream-status"), appBanner: document.querySelector("#app-banner"), onboarding: document.querySelector("#onboarding"), }; let bannerTimer = null; let fallbackPoll = null; function escapeHtml(input) { return String(input) .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } function setBanner(message, tone = "info", persistent = false) { state.banner = { message, tone }; renderBanner(); if (bannerTimer) { window.clearTimeout(bannerTimer); bannerTimer = null; } if (!persistent) { bannerTimer = window.setTimeout(() => { if (state.banner?.message === message) { clearBanner(); } }, 3200); } } function clearBanner() { state.banner = null; renderBanner(); } function renderBanner() { if (!state.banner) { elements.appBanner.className = "banner hidden"; elements.appBanner.textContent = ""; return; } elements.appBanner.className = `banner ${state.banner.tone}`; elements.appBanner.textContent = state.banner.message; } async function request(url, options = {}) { const response = await fetch(url, { headers: { "Content-Type": "application/json" }, ...options, }); let payload = {}; try { payload = await response.json(); } catch { payload = {}; } if (!response.ok) { const message = payload.message || payload.error || `${response.status} ${response.statusText}`; throw new Error(message); } return payload; } async function runAction(label, operation, successMessage, options = {}) { const { refreshBootstrap = true, refreshSession = Boolean(state.selectedSessionId), persistent = false, } = options; setBanner(`${label}中...`, "info", true); try { const result = await operation(); if (refreshBootstrap) { await loadBootstrap(); } if (refreshSession && state.selectedSessionId) { await loadSession(state.selectedSessionId); } render(); if (successMessage) { setBanner(successMessage, "success", persistent); } else { clearBanner(); } return result; } catch (error) { console.error(error); setBanner(`${label}失败:${error.message}`, "error", true); render(); return null; } } function selectedSession() { return state.sessions.find((session) => session.id === state.selectedSessionId) ?? null; } function findTask(taskId) { return state.tasks.find((task) => task.id === taskId) ?? null; } function findWorker(workerId) { return state.workers.find((worker) => worker.id === workerId) ?? null; } function tasksForSelectedSession() { return state.tasks.filter((task) => task.sessionId === state.selectedSessionId); } function messagesForSelectedSession() { return state.messages.filter((message) => message.sessionId === state.selectedSessionId); } function approvalsForSelectedSession() { return state.approvals.filter((approval) => approval.sessionId === state.selectedSessionId); } function eventsForSelectedSession() { return state.events .filter((event) => event.sessionId === null || event.sessionId === state.selectedSessionId) .slice(-50) .reverse(); } function formatClock(value) { return new Date(value).toLocaleTimeString(); } function formatAge(value) { const diffMs = Date.now() - new Date(value).getTime(); const seconds = Math.max(0, Math.round(diffMs / 1000)); if (seconds < 5) { return "刚刚"; } if (seconds < 60) { return `${seconds} 秒前`; } const minutes = Math.round(seconds / 60); if (minutes < 60) { return `${minutes} 分钟前`; } const hours = Math.round(minutes / 60); return `${hours} 小时前`; } function workerHealth(worker) { if (worker.status === "offline") { return { tone: "offline", label: "离线", description: "已手动下线或被系统判定为离线。", }; } const ageMs = Date.now() - new Date(worker.lastSeenAt).getTime(); if (ageMs > 30_000) { return { tone: "stale", label: "疑似掉线", description: `最近心跳 ${formatAge(worker.lastSeenAt)}`, }; } if (ageMs > 10_000) { return { tone: "lagging", label: "连接抖动", description: `最近心跳 ${formatAge(worker.lastSeenAt)}`, }; } return { tone: "live", label: "在线", description: `最近心跳 ${formatAge(worker.lastSeenAt)}`, }; } function renderSessions() { if (state.sessions.length === 0) { elements.sessionList.innerHTML = `

先创建一个项目会话。

`; return; } elements.sessionList.innerHTML = state.sessions .map((session) => { const active = session.id === state.selectedSessionId ? "active" : ""; const archived = session.status === "archived" ? "archived" : ""; return ` `; }) .join(""); elements.sessionList.querySelectorAll("[data-session-id]").forEach((button) => { button.addEventListener("click", async () => { state.selectedSessionId = button.dataset.sessionId; await loadSession(state.selectedSessionId); render(); }); }); } function renderWorkers() { if (state.workers.length === 0) { elements.workerList.innerHTML = `

还没有 worker。可以手动注册,或直接运行 \`npm run demo\`。

`; return; } elements.workerList.innerHTML = [...state.workers] .sort((left, right) => left.name.localeCompare(right.name)) .map((worker) => { const currentTask = findTask(worker.currentTaskId); const health = workerHealth(worker); return `
${escapeHtml(worker.name)}
${escapeHtml(worker.status)} ${escapeHtml(health.label)}
${escapeHtml(worker.os)}
当前任务:${escapeHtml(currentTask?.title || "空闲")}
${escapeHtml(health.description)}
负载:${escapeHtml(worker.load)}
${worker.capabilities.map((capability) => `${escapeHtml(capability)}`).join("")}
${ worker.status !== "offline" ? `` : "" }
`; }) .join(""); elements.workerList.querySelectorAll("[data-worker-action]").forEach((button) => { button.addEventListener("click", async () => { const workerId = button.dataset.workerId; await runAction( "更新 worker 状态", () => request(`/api/workers/${workerId}/offline`, { method: "POST", body: "{}" }), "worker 已标记离线。", { refreshSession: false }, ); }); }); } function renderStreamStatus() { const labels = { connecting: "连接中", live: "实时同步", reconnecting: "重连中", }; elements.streamStatus.className = `badge ${state.connection}`; elements.streamStatus.textContent = labels[state.connection] ?? "未知状态"; } function renderOnboarding() { const needsOnboarding = state.workers.length === 0 || state.sessions.length === 0; elements.onboarding.classList.toggle("hidden", !needsOnboarding); if (!needsOnboarding) { elements.onboarding.innerHTML = ""; return; } const steps = [ { done: state.workers.length > 0, title: "接入至少一台设备 worker", detail: state.workers.length > 0 ? `当前已登记 ${state.workers.length} 台设备,可以开始接单。` : "最快路径是运行 `npm run demo`,它会同时起服务和 3 台示例 worker。", }, { done: state.sessions.length > 0, title: "创建一个项目会话", detail: state.sessions.length > 0 ? "会话已经创建,可以持续对话和改需求。" : "左侧填写项目标题创建会话,之后右侧就能开始对话式调度。", }, { done: Boolean(state.selectedSessionId && messagesForSelectedSession().length > 0), title: "给主控发送第一条需求", detail: state.selectedSessionId && messagesForSelectedSession().length > 0 ? "系统已经收到了需求,正在持续调度子任务。" : "示例:先调研登录失败根因,不要急着改代码。", }, ]; elements.onboarding.innerHTML = `

首次上手

按这 3 步就能跑通一轮协同开发
${steps .map( (step) => `
${step.done ? "已完成" : "待完成"} · ${escapeHtml(step.title)}

${escapeHtml(step.detail)}

`, ) .join("")}
`; } function renderSessionHeader() { const session = selectedSession(); if (!session) { elements.sessionTitleDisplay.textContent = "选择一个项目会话"; elements.sessionSummary.textContent = "创建会话后,在这里持续对话并观察任务状态。"; elements.archiveSession.disabled = true; elements.archiveSession.textContent = "归档会话"; elements.messageInput.disabled = true; elements.messageInput.placeholder = "先创建或选择一个项目会话。"; return; } elements.sessionTitleDisplay.textContent = `${session.title} (${session.status})`; elements.sessionSummary.textContent = session.lastPlannerSummary || session.activeObjective || "等待用户输入。"; elements.archiveSession.disabled = false; elements.archiveSession.textContent = session.status === "archived" ? "恢复会话" : "归档会话"; elements.messageInput.disabled = session.status === "archived"; elements.messageInput.placeholder = session.status === "archived" ? "当前会话已归档。先恢复会话,再继续发送消息。" : "输入需求。示例:先调研登录失败根因,不要急着改代码。"; } function renderMessages() { const messages = messagesForSelectedSession(); elements.messageList.innerHTML = messages.length ? messages .map( (message) => `
${escapeHtml(message.role)} ${formatClock(message.createdAt)}

${escapeHtml(message.content)}

`, ) .join("") : `

当前没有消息。

`; } function taskGroupSections() { const tasks = tasksForSelectedSession(); return TASK_GROUPS.map((group) => ({ ...group, tasks: tasks.filter((task) => group.statuses.includes(task.status)), })).filter((group) => group.tasks.length > 0); } function renderTaskCard(task) { const worker = findWorker(task.assignedWorkerId); const dependencyTitles = task.dependencyIds .map((taskId) => findTask(taskId)?.title) .filter(Boolean); const pendingApproval = approvalsForSelectedSession().find( (approval) => approval.taskId === task.id && approval.status === "pending", ); return `
${escapeHtml(task.title)} ${escapeHtml(task.status)}

${escapeHtml(task.description)}

worker:${escapeHtml(worker?.name || "未分配")} 进度:${escapeHtml(task.progressPercent)}% 下一步:${escapeHtml(task.nextStep || "暂无")} 依赖:${escapeHtml(dependencyTitles.join("、") || "无")} 审批:${escapeHtml(task.approvalStatus === "pending" ? "等待批准" : task.approvalStatus)}
状态摘要:${escapeHtml(task.summary || "暂无")}
${ pendingApproval ? `
当前被审批卡住:${escapeHtml(pendingApproval.summary)}
` : "" }
${ ["queued", "assigned", "running"].includes(task.status) ? `` : "" } ${ ["paused", "blocked", "waiting_approval"].includes(task.status) ? `` : "" } ${ ["paused", "failed", "cancelled"].includes(task.status) ? `` : "" } ${ !["completed", "cancelled", "failed"].includes(task.status) ? `` : "" }
`; } function renderTasks() { const latestPlan = [...state.events] .reverse() .find((event) => event.sessionId === state.selectedSessionId && event.type === "plan.created"); if (latestPlan) { const createdCount = Array.isArray(latestPlan.payload.taskIds) ? latestPlan.payload.taskIds.length : 0; const pausedCount = Array.isArray(latestPlan.payload.pausedTaskIds) ? latestPlan.payload.pausedTaskIds.length : 0; elements.planHint.textContent = pausedCount > 0 ? `本轮重规划新增 ${createdCount} 个任务,并暂停了 ${pausedCount} 个旧任务。` : `本轮计划新增 ${createdCount} 个任务。`; } else { elements.planHint.textContent = ""; } const sections = taskGroupSections(); elements.taskList.innerHTML = sections.length ? sections .map( (section) => `

${escapeHtml(section.title)}

${section.tasks.length} 项
${section.tasks.map((task) => renderTaskCard(task)).join("")}
`, ) .join("") : `

当前没有任务。

`; elements.taskList.querySelectorAll("[data-action]").forEach((button) => { button.addEventListener("click", async () => { const taskId = button.dataset.taskId; const action = button.dataset.action; await runAction( "更新任务", () => request(`/api/tasks/${taskId}/${action}`, { method: "POST", body: "{}" }), "任务状态已更新。", ); }); }); } function renderApprovals() { const approvals = approvalsForSelectedSession(); elements.approvalList.innerHTML = approvals.length ? approvals .map((approval) => { const task = findTask(approval.taskId); const followUpText = approval.status === "pending" ? "批准后任务会重新进入队列;拒绝后任务会被取消。" : approval.status === "approved" ? "这条审批已经通过,对应任务可继续执行。" : "这条审批已被拒绝,对应任务已停止。"; return `
${escapeHtml(task?.title || approval.summary)} ${escapeHtml(approval.status)}

${escapeHtml(approval.summary)}

风险等级:${escapeHtml(approval.riskLevel)} 触发类型:${escapeHtml(approval.kind)} 关联任务:${escapeHtml(task?.title || approval.taskId)}
${escapeHtml(followUpText)}
${ approval.status === "pending" ? `
` : "" }
`; }) .join("") : `

当前没有待审批项。

`; elements.approvalList.querySelectorAll("[data-approval-id]").forEach((button) => { button.addEventListener("click", async () => { const approvalId = button.dataset.approvalId; const approved = button.dataset.approved === "true"; await runAction( approved ? "批准审批" : "拒绝审批", () => request(`/api/approvals/${approvalId}/respond`, { method: "POST", body: JSON.stringify({ approved, responder: "web-user" }), }), approved ? "审批已通过。" : "审批已拒绝。", ); }); }); } function renderEvents() { const events = eventsForSelectedSession(); elements.eventList.innerHTML = events.length ? events .map( (event) => `
${escapeHtml(event.type)} ${formatClock(event.timestamp)}
${escapeHtml(JSON.stringify(event.payload, null, 2))}
`, ) .join("") : `

当前没有事件。

`; } function render() { renderBanner(); renderStreamStatus(); renderOnboarding(); renderSessions(); renderWorkers(); renderSessionHeader(); renderMessages(); renderTasks(); renderApprovals(); renderEvents(); } async function loadBootstrap() { const bootstrap = await request("/api/bootstrap"); state.sessions = bootstrap.sessions; state.messages = bootstrap.messages; state.tasks = bootstrap.tasks; state.workers = bootstrap.workers; state.approvals = bootstrap.approvals; state.events = bootstrap.events; if (!state.selectedSessionId && state.sessions[0]) { state.selectedSessionId = state.sessions[0].id; } } async function loadSession(sessionId) { if (!sessionId) return; const details = await request(`/api/sessions/${sessionId}`); state.sessions = state.sessions.map((session) => (session.id === sessionId ? details.session : session)); state.messages = [...state.messages.filter((message) => message.sessionId !== sessionId), ...details.messages]; state.tasks = [...state.tasks.filter((task) => task.sessionId !== sessionId), ...details.tasks]; state.approvals = [ ...state.approvals.filter((approval) => approval.sessionId !== sessionId), ...details.approvals, ]; } function startFallbackPolling() { if (fallbackPoll) { return; } fallbackPoll = window.setInterval(async () => { try { await loadBootstrap(); if (state.selectedSessionId) { await loadSession(state.selectedSessionId); } render(); } catch (error) { console.error(error); } }, 5000); } function stopFallbackPolling() { if (!fallbackPoll) { return; } window.clearInterval(fallbackPoll); fallbackPoll = null; } elements.createSessionForm.addEventListener("submit", async (event) => { event.preventDefault(); const title = elements.sessionTitleInput.value.trim(); const details = await runAction( "创建会话", () => request("/api/sessions", { method: "POST", body: JSON.stringify({ title }), }), "项目会话已创建。", { refreshBootstrap: false, refreshSession: false }, ); if (!details) { return; } state.sessions.unshift(details.session); state.selectedSessionId = details.session.id; await loadSession(details.session.id); elements.sessionTitleInput.value = ""; render(); }); elements.createWorkerForm.addEventListener("submit", async (event) => { event.preventDefault(); const name = elements.workerName.value.trim(); if (!name) return; const os = elements.workerOs.value; const capabilities = elements.workerCapabilities.value .split(",") .map((item) => item.trim()) .filter(Boolean); const result = await runAction( "注册 worker", () => request("/api/workers/register", { method: "POST", body: JSON.stringify({ name, os, capabilities }), }), "worker 已注册。", { refreshSession: false }, ); if (!result) { return; } elements.workerName.value = ""; elements.workerCapabilities.value = "terminal"; }); elements.messageForm.addEventListener("submit", async (event) => { event.preventDefault(); if (!state.selectedSessionId) return; const content = elements.messageInput.value.trim(); if (!content) return; const result = await runAction( "发送需求", () => request(`/api/sessions/${state.selectedSessionId}/messages`, { method: "POST", body: JSON.stringify({ content, channel: "web" }), }), "需求已发送给主控。", ); if (!result) { return; } elements.messageInput.value = ""; }); elements.archiveSession.addEventListener("click", async () => { const session = selectedSession(); if (!session) return; const archived = session.status === "archived"; await runAction( archived ? "恢复会话" : "归档会话", () => request(`/api/sessions/${state.selectedSessionId}/${archived ? "restore" : "archive"}`, { method: "POST", body: "{}", }), archived ? "会话已恢复。" : "会话已归档。", ); }); elements.resetDemo.addEventListener("click", async () => { const result = await runAction( "重置 Demo", () => request("/api/demo/reset", { method: "POST", body: JSON.stringify({ preserveWorkers: true }), }), "Demo 数据已重置。", { refreshSession: false }, ); if (!result) { return; } state.sessions = []; state.messages = []; state.tasks = []; state.approvals = []; state.events = []; state.selectedSessionId = null; await loadBootstrap(); render(); }); const stream = new EventSource("/api/events/stream"); stream.onopen = () => { const wasReconnecting = state.connection === "reconnecting"; state.connection = "live"; stopFallbackPolling(); render(); if (wasReconnecting) { setBanner("实时事件流已恢复。", "success"); } }; stream.onmessage = async (event) => { const payload = JSON.parse(event.data); state.events.push(payload); if (payload.sessionId) { await loadSession(payload.sessionId); } await loadBootstrap(); render(); }; stream.onerror = () => { if (state.connection !== "reconnecting") { state.connection = "reconnecting"; setBanner("实时事件流暂时断开,正在重连并降级轮询。", "warn", true); startFallbackPolling(); render(); } }; loadBootstrap() .then(async () => { if (state.selectedSessionId) { await loadSession(state.selectedSessionId); } render(); }) .catch((error) => { console.error(error); setBanner(`初始化失败:${error.message}`, "error", true); render(); });