${escapeHtml(step.detail)}
+选择一个项目会话
-创建会话后,在这里持续对话并观察任务状态。
+选择一个项目会话
+创建会话后,在这里持续对话并观察任务状态。
+diff --git a/.gitignore b/.gitignore index 1f24404..3b88bf4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ node_modules dist .boss-data +.playwright-cli npm-debug.log* - diff --git a/README.md b/README.md index 1fa87d7..4dfe354 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Boss 是一个面向多设备开发协作的 agent control plane。 ## 当前状态 -当前仓库已经完成第一轮产品设计文档: +当前仓库已经包含产品设计文档与一版可直接跑起来的本地控制台: - [文档总览](./docs/README.md) - [竞品对比](./docs/competitor-comparison.md) @@ -16,13 +16,14 @@ Boss 是一个面向多设备开发协作的 agent control plane。 - [消息协议与状态机](./docs/message-protocol-and-state-machine.md) - [实施路线图](./docs/implementation-roadmap.md) -并且已经补入首版可运行原型: +当前原型能力: - Fastify API - 文件持久化状态存储 - SSE 实时事件流 - Web 控制台 - `boss-worker` 模拟执行器 +- `boss-worker` 外部命令执行模式,可接本地 Codex / Claude / 自定义脚本 - `npm run smoke` 自动跑端到端验证 - `Dockerfile` + `compose.yaml` 支持容器启动 @@ -34,15 +35,6 @@ Boss 是一个面向多设备开发协作的 agent control plane。 - 工具层:MCP - 调度:持久队列或工作流引擎 -## 下一步 - -建议直接开始: - -1. 建立 Web 控制台和后端骨架 -2. 实现 `boss-worker` 注册与心跳 -3. 打通会话、任务树和子任务分发 -4. 接入审批和中途重规划 - ## 本地运行 ```bash @@ -62,6 +54,12 @@ http://127.0.0.1:43210 npm run dev ``` +如果你想把数据写到独立文件,避免和默认 demo 数据混用: + +```bash +BOSS_DATA_FILE=.boss-data/local-dev.json npm run dev +``` + 如果你要手工启动 worker: ```bash @@ -70,6 +68,46 @@ npm run worker -- --name win-b --os windows --capability terminal --capability t npm run worker -- --name mac-a --os macos --capability terminal --capability test --capability browser ``` +如果你要接真实本地执行器,而不是模拟执行: + +```bash +npm run worker -- \ + --name mac-codex \ + --os macos \ + --capability terminal \ + --capability test \ + --mode command \ + --workspace /path/to/project \ + --executor ./scripts/codex_executor.sh +``` + +也可以接 Claude Code 或任意你自己的脚本,只要命令能从环境变量里读取任务上下文: + +- `BOSS_TASK_TITLE` +- `BOSS_TASK_DESCRIPTION` +- `BOSS_TASK_KIND` +- `BOSS_TASK_JSON` +- `BOSS_WORKSPACE` +- `BOSS_WORKER_NAME` + +仓库里已经自带两个可直接改造的适配脚本: + +- `./scripts/codex_executor.sh` +- `./scripts/claude_executor.sh` + +例如: + +```bash +npm run worker -- \ + --name win-claude \ + --os windows \ + --capability terminal \ + --capability browser \ + --mode command \ + --workspace /path/to/project \ + --executor ./scripts/claude_executor.sh +``` + 一键本地 demo: ```bash @@ -100,6 +138,8 @@ docker compose up --build - 创建项目会话并持续对话 - 自动生成任务树并调度到不同 worker - worker 心跳、掉线回收、任务重排 +- worker 真实外部命令执行,支持本地命令适配 - 审批、暂停、恢复、取消、重排 - SSE 实时事件流和 Web 控制台 +- 会话归档与恢复 - 一键 demo 启动 diff --git a/public/app.js b/public/app.js index 35244d9..920b5bb 100644 --- a/public/app.js +++ b/public/app.js @@ -6,8 +6,18 @@ const state = { 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"), @@ -28,8 +38,14 @@ const elements = { 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("&", "&") @@ -39,6 +55,40 @@ function escapeHtml(input) { .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" }, @@ -53,16 +103,57 @@ async function request(url, options = {}) { } if (!response.ok) { - throw new Error(payload.error || `${response.status} ${response.statusText}`); + 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); } @@ -82,6 +173,60 @@ function eventsForSelectedSession() { .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 = `
先创建一个项目会话。
`; @@ -117,24 +262,116 @@ function renderWorkers() { return; } - elements.workerList.innerHTML = state.workers - .map( - (worker) => ` + 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(step.detail)}
+当前没有消息。
`; } +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.description)}
+ +${escapeHtml(task.description)}
-${escapeHtml(approval.summary)}
+ +当前没有待审批项。
`; @@ -267,13 +576,15 @@ function renderApprovals() { button.addEventListener("click", async () => { const approvalId = button.dataset.approvalId; const approved = button.dataset.approved === "true"; - await request(`/api/approvals/${approvalId}/respond`, { - method: "POST", - body: JSON.stringify({ approved, responder: "web-user" }), - }); - await loadSession(state.selectedSessionId); - await loadBootstrap(); - render(); + await runAction( + approved ? "批准审批" : "拒绝审批", + () => + request(`/api/approvals/${approvalId}/respond`, { + method: "POST", + body: JSON.stringify({ approved, responder: "web-user" }), + }), + approved ? "审批已通过。" : "审批已拒绝。", + ); }); }); } @@ -287,7 +598,7 @@ function renderEvents() {${escapeHtml(JSON.stringify(event.payload, null, 2))}
创建会话后,在这里持续对话并观察任务状态。
+创建会话后,在这里持续对话并观察任务状态。
+