初始化 Agents Team 工作台

This commit is contained in:
WuYongJie 2026-03-16 15:22:39 +08:00
commit 8e08f8f13d
33 changed files with 21195 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
node_modules/
dist/
.npm-cache/
.agents-team/
data/

139
README.md Normal file
View File

@ -0,0 +1,139 @@
# Agents Team 项目级 Lead 工作台 v1
这是一个本地优先的项目工作台。
它的核心不是“全局固定团队”,而是:
- 每个项目都有自己的 `lead`
- 用户默认只和这个项目的 `lead` 对话
- 团队先讨论出草案,再确认创建真实成员
- 正式团队变更会先给出新增 / 移出 / 调整清单,再单独确认
- 团队内正式成员可以互相直连发消息
- 默认不自动 `commit` / `push`
## 这版已经能做什么
- 首页查看项目列表,手动接入本地项目目录
- 新建项目时先创建一个真实 `lead` 运行会话
- 在项目页和 `lead` 对话,生成项目专属团队草案
- 确认团队后创建真实成员线程和运行态
- 任务派发、送审、打回、复审、lead 汇总
- 成员之间通过 mailbox 直接协作
- 维护共享记忆、任务活动流、人工接管记录
- 展示 Codex CLI 默认模型,并支持按会话覆盖模型
- 支持按角色同步恢复入口、重建角色会话
- 一键打开角色对应的原生 Codex 接管口
## Codex CLI 集成现状
当前已经接入真实 Codex CLI,用在这些环节:
- lead 主对话回复
- 团队草案生成
- 成员线程回复
- 任务启动回执
- reviewer 审查接单回执
- reviewer 审查通过 / 打回结论
- 执行成员返工后的复审回执
默认模型跟随本机 Codex CLI 配置。
Web 端可以对单个角色会话临时覆盖模型,也可以切回继承默认模型。
可选模型列表现在优先通过 Codex app-server 的 `model/list` 动态获取,只有失败时才退回配置文件兜底。
## 本地存储现在放在哪里
全局索引只保留在:
```text
data/db.json
```
这个文件现在只保存项目索引,不再塞整份项目运行状态。
每个项目自己的工作台数据会放到该项目目录下:
```text
<项目目录>\.agents-team\workbench\
```
目前会生成这些文件:
```text
project-state.json 项目主状态
mailbox.json 队内消息
shared-memory.json 结构化共享记忆
shared-memory.md 可直接阅读的共享记忆摘要
task-activity.json 任务活动流
```
这样项目级的“乱七八糟文件”都会被收口到项目自己的隐藏目录里,不会把用户项目根目录弄乱。
另外,mailbox 现在已经有服务端摘要分组:
- lead 先看摘要卡
- 原始成员往来默认折叠
- 同任务或同成员对话会自动收进同一组
## 还没做完的部分
- 固定角色已经支持按 thread 恢复,但还没完全做到“所有长期身份都只靠原生会话驱动”
- 页内还没有嵌入原生终端
- mailbox 目前已经可用,但“长对话自动折叠成更强摘要卡”还可以继续加强
- README 之外的设计文档还会继续补齐
## 运行方式
安装依赖:
```bash
npm install
```
开发模式:
```bash
npm run dev
```
构建:
```bash
npm run build
```
启动构建产物:
```bash
npm run start
```
默认地址:
```text
http://localhost:8787
```
## 当前目录结构
```text
server/ 本地后端 API
web/ 前端工作台
data/ 全局项目索引
docs/ 设计与交接文档
```
## 本轮已验证
已经实际验证过:
- `npm run build`
- 旧 `data/db.json` 自动迁移到项目自己的 `.agents-team/workbench`
- `/api/health`
- `/api/projects`
- `/api/projects/:projectId`
如果你正在推进这个仓库本身,直接打开当前仓库目录就能看到:
```text
D:\AI Code\Agents Team\.agents-team\workbench
```

View File

@ -0,0 +1,53 @@
# 给新 Codex App 对话的启动提示词
把下面这段直接发给新的 Codex app 对话即可:
---
先不要假设这是个空白仓库,也不要把它理解成只有 mock 的 MVP。
请先完整阅读这些文件:
1. `D:\AI Code\Agents Team\README.md`
2. `D:\AI Code\Agents Team\docs\design.md`
3. `D:\AI Code\Agents Team\docs\CODEX_APP_PRODUCT_HANDOFF.md`
4. `D:\AI Code\Agents Team\docs\OLD_MAIN_20260314_PLAN_SUMMARY.md`
然后按下面顺序执行:
1. 先检查当前仓库的真实状态,至少查看这些文件:
- `D:\AI Code\Agents Team\package.json`
- `D:\AI Code\Agents Team\web\src\pages\HomePage.tsx`
- `D:\AI Code\Agents Team\web\src\pages\ProjectPage.tsx`
- `D:\AI Code\Agents Team\web\src\services\api.ts`
- `D:\AI Code\Agents Team\server\src\index.ts`
- `D:\AI Code\Agents Team\server\src\store.ts`
- `D:\AI Code\Agents Team\server\src\types.ts`
- `D:\AI Code\Agents Team\server\src\codexConfig.ts`
- `D:\AI Code\Agents Team\server\src\runtime.ts`
2. 先用中文总结你现在的理解:
- 产品定位
- 已锁定的产品约束
- 当前仓库已经实现到什么程度
- 当前最关键的工作流
- 当前还没做完的尾项
- 哪些地方最容易跑偏
3. 再告诉我:
- 你认为接下来最值得优先推进的 3 到 5 个具体动作
- 每个动作为什么优先
- 验收标准是什么
4. 然后直接开始动手,不要没有必要地停下来等确认
额外要求:
- 默认中文沟通,面向非程序员表达。
- 不要把产品做成“全局固定团队系统”。
- 不要做成后台管理表单页。
- 核心必须围绕“项目级 lead + 项目级团队 + lead 对话驱动工作台”。
- 项目摸底必须用户主动确认触发,不能自动偷偷跑。
- 默认不自动 commit,不自动 push。
- 项目级协作状态要放在项目自己的 `.agents-team\workbench`。
- 能继续推进就继续推进,不要频繁停下来问。
- 优先以当前代码和当前文档为准,不要被旧聊天误导。
如果你读完后发现有不确定的地方,先列出来;如果没有阻塞,就直接继续做。

View File

@ -0,0 +1,191 @@
# Codex App 产品交接文档
更新时间:2026-03-14
## 1. 先看什么
如果后面在 Codex app 里开新对话,先按这个顺序读:
1. `D:\AI Code\Agents Team\README.md`
2. `D:\AI Code\Agents Team\docs\design.md`
3. `D:\AI Code\Agents Team\docs\OLD_MAIN_20260314_PLAN_SUMMARY.md`
4. `C:\Users\54972\.openclaw\agents\main\sessions\2b99b736-9bb5-4444-a72e-36fe796ccf3a.jsonl`
优先级也按这个顺序理解:
1. 当前仓库真实代码和文档
2. 本文件
3. 旧总结
4. 旧原始会话
## 2. 当前仓库已经实现到哪里
当前仓库不是空壳,也不再只是“mock MVP”。
现在已经有:
- 项目级 `lead`
- 项目级团队草案
- 正式团队确认
- 团队变更确认
- lead 主对话
- 成员线程
- mailbox
- 共享记忆
- 任务推进
- reviewer 审查闭环
- 角色运行态
- 原生接管口
- 会话恢复 / 重建
- 动态模型列表
而且已经接入真实 Codex CLI,用在这些环节:
- lead 回复
- 团队草案生成
- 成员线程回复
- 任务启动回执
- reviewer 接单回执
- reviewer 审查结论
- 成员返工后复审回执
## 3. 现在这个产品到底是什么
一句话:
> 这是一个本地优先、项目级 lead 驱动、面向非程序员的多-agent 工作台。
再拆开说:
- 不是全局固定团队系统
- 不是后台管理表单页
- 不是点开就自动扫描项目的工具
- 不是一堆 agent 名字平铺展示的假看板
它的核心一直是:
- 项目级 `lead`
- 项目级团队
- `lead` 对话驱动工作台
## 4. 已经锁死的产品原则
后面新对话不要再把这些重新讨论一遍:
- 用户默认只和当前项目的 `lead` 聊
- 团队先讨论出草案,再确认正式创建
- 正式团队变更要先出变更清单,再单独确认
- 正式成员之间允许直接互相通讯
- mailbox 默认“任务优先,但允许自由消息”
- 共享记忆默认由 lead 整理入库
- 默认不自动 `commit`
- 默认不自动 `push`
- 项目摸底必须显式确认
- 不改 Codex CLI 本体,只做外层薄适配
- 项目里的协作状态放在项目自己的 `.agents-team\workbench`
## 5. 当前 UI 方向
当前项目页已经是下面这个结构:
- 左侧:像素办公室 / 成员状态
- 中间:大面积主对话区
- 右侧:收纳区
收纳区现在已经有:
- 团队草案
- 团队变更确认清单
- 任务推进
- Mailbox
- 共享记忆
- 项目摸底
- 运行态 / 接管口
- 人工接管
- Git 状态
当前风格方向:
- 默认深色工作台
- 中文白话文案
- 面向非程序员
- lead 主窗口始终是默认入口
## 6. 当前架构要点
### 6.1 存储
全局索引:
- `D:\AI Code\Agents Team\data\db.json`
项目状态:
- `<项目目录>\.agents-team\workbench\project-state.json`
- `<项目目录>\.agents-team\workbench\mailbox.json`
- `<项目目录>\.agents-team\workbench\shared-memory.json`
- `<项目目录>\.agents-team\workbench\shared-memory.md`
- `<项目目录>\.agents-team\workbench\task-activity.json`
### 6.2 角色会话
每个正式角色都有独立运行态。
当前已经支持:
- `lastThreadId`
- `resumeCommand`
- `takeoverCommand`
- 身份状态
- 模型覆盖
- 同步恢复入口
- 重建角色会话
### 6.3 模型来源
当前模型能力已经升级成:
1. 优先走 Codex app-server `model/list`
2. 失败时退回配置文件
所以现在页面里拿到的模型,不只是字符串列表,而是带元数据的真实可用模型集合。
## 7. 当前最值得继续推进的尾项
如果后面继续推进,优先顺序建议是:
1. 继续把角色长期身份往“更彻底的原生会话连续性”收口
2. 继续增强 mailbox 的超长对话摘要和冲突压缩
3. 评估页内原生终端 / 更顺手接管体验
4. 同步剩余历史文档
## 8. 最容易跑偏的地方
后面最容易犯的错还是这几个:
- 把它做成全局固定团队系统
- 把它做成后台管理表单页
- 先做一堆视觉炫技,主工作流反而变假
- 偷偷自动做项目摸底
- 过早把产品做成黑盒自动驾驶
- 把整个产品焊死在某个 CLI 私有实现上
## 9. 给后续 Codex app 的执行要求
如果后面继续用 Codex app 做:
- 默认中文输出
- 面向非程序员表达
- 没有阻塞就继续推进,不要频繁停下来问
- 优先看当前代码,不要只看历史聊天
- 修改后一定要自己验证
如果要做长任务或隐藏执行:
- 要有日志 / 输出监控
- 要能看到文件变化
- 要做阶段性验收
核心提醒:
> 每一步都要反问自己,这一步是不是让它更像一个真正的“项目级 lead 工作台”。

View File

@ -0,0 +1,276 @@
# 旧主会话方案整理(main_old_20260314)
来源:
- 当前整理文档位置:`D:\AI Code\Agents Team\docs\OLD_MAIN_20260314_PLAN_SUMMARY.md`
- 旧主会话键:`agent:main:main_old_20260314`
- 原始会话文件(档案):`C:\Users\54972\.openclaw\agents\main\sessions\2b99b736-9bb5-4444-a72e-36fe796ccf3a.jsonl`
这份文档只保留当时讨论里真正重要的产品方向、执行优先级和工作规则,方便后续继续做项目时直接参考。
补充说明:
- 这份文档是“历史方案与方向总结”,不是当前仓库实现状态说明。
- 后续如果和 `D:\AI Code\Agents Team` 里的当前代码、`README.md`、`docs/design.md` 冲突,应优先以当前仓库现状为准。
## 一、项目核心定位
- 这是一个“项目级 lead + 项目级团队”的系统,不是全局固定团队。
- 每个项目都有自己独立的 lead,用户默认只和该项目的 lead 对话。
- 团队不是全局写死,而是用户先和项目 lead 讨论,再由 lead 产出团队草案,用户确认后创建。
- 同岗位允许多个成员,但应优先复用该项目里的老成员。
- reviewer 默认有较强否决权,但允许 lead 或用户 override。
- 允许重建项目 lead,但新 lead 上线前需要前一个 lead 做交接。
- 默认不自动 commit、不自动 push。
- 第一阶段先锁定“当前项目目录”范围,后续再考虑全局访问。
- 整体目标是本地优先:本地网页端 + 本地后台服务,不是云端产品。
## 二、MVP 骨架阶段结论
当时已经确认的第一版 MVP 骨架方向:
- 首页 / 项目列表页
- 添加项目(手动输入本地路径)
- 项目工作台页
- lead 对话区
- 团队草案区
- 任务状态区
- 项目状态 / Git 状态区
- 人工接管入口区
- 本地 JSON 持久化
- 中文 README
- 中文设计说明
同时明确:
- “项目摸底”必须由用户主动确认触发,不能点开项目就自动跑。
- 第一版先可用,但要面向非程序员,尽量全中文、清晰、好理解。
## 三、从参考项目里得出的产品判断
### 1. 对 oh my opencode 的判断
值得借的点:
- 规划和执行分离
- 主调度者 / lead 的思路
- 后台并行成员
- 把前面学到的约定和坑往后传
- 强调“别半路摆烂”,完成前要验证
- 先判断任务类型,再决定怎么做
不适合直接照搬的点:
- 太偏终端极客工作流
- 角色命名过于神话化,不适合产品化
- 更像全局总控,不是项目级团队
- 一键全自动太黑盒,不适合当前产品方向
### 2. 对 Pixel Agents 的判断
结论:
- 值得借的是“施工可视化层”
- 不值得直接照搬的是“业务逻辑层”
适合借的内容:
- 办公室 / 工位 / 小人这类可视化表达
- 把抽象的 agent 工作状态变成一眼能看懂的画面
- 不改 CLI 本体,走外层观察和可视化适配
不适合直接搬的内容:
- VS Code 扩展壳子
- Claude Code 专用监听逻辑
- 终端和 transcript 的专用适配
后续正确姿势:
- 先做我们自己的业务骨架
- 再把 Pixel Agents 风格的可视化层接到我们自己的状态源上
## 四、UI 与工作台方向
后续 UI 方向后来被明确修正为:
- 页面中心必须是 lead 对话区
- lead 对话区要占最大面积
- 参考 Codex app 的“主对话驱动”感受,但不能照抄
- 次级功能要收纳,不再平铺一地
- 在 lead 对话区下沿放成员岗位标签 / 线程切换
- 点击不同岗位标签后,看到对应成员线程或工作内容
明确的视觉偏好:
- 默认做成深色工作台风格
- 页面要更像真正的工作台,不像表单后台
- 中文文案尽量白话
## 五、整晚冲刺版目标(后来确认的最终版)
后来目标从“只改页面”升级为“整晚尽量把项目推进成像真产品”。
最终优先级被确认成:
### P0:完整多-agent 编排内核
至少要把一条真正能跑通的主链路做出来:
- 项目级 lead
- 项目级团队配置
- lead 创建团队
- 成员线程 / 会话结构
- 任务拆分
- 任务派发
- reviewer 打回
- coder 修复
- 再审
- 结果回到 lead
要求:
- 做成真工作流
- 不是只做假壳子
### P1:像素办公室 / 施工可视化
要求:
- 不是 VS Code 插件
- 是网页里的一个模块
- 先接我们自己的状态源
- 至少能看到:
- lead
- reviewer
- 实现成员
- 测试 / 验收成员
- 当前状态(工作中 / 等待 / 需要确认 / 完成 / 打回)
### P2:lead 对话工作台彻底重做
要求:
- 大面积主对话区
- 下沿成员线程 tags
- 次级功能收纳
- 更像主对话驱动的工作台体验
## 六、当时制定的执行路线
### 第一阶段:重构数据模型
计划中的核心对象:
- Project
- Lead
- TeamConfig
- Member
- Thread
- Task
- Review
- Handover
- ManualAction
- Discovery
目标:
- 后面接真实内核和像素办公室时,不需要推翻重来
### 第二阶段:把项目摸底做真
目标:
- 真实读取项目目录
- 识别技术栈
- 读取 Git 基本状态
- 给出中文项目摘要
- 给 lead 一个可用的摸底结果
### 第三阶段:把 lead 变成“真 lead”
重点:
- 用户意图识别
- 是否需要摸底
- 是否需要讨论团队
- 是否需要开始执行
- 是否需要 review
- 是否需要打回
- 是否需要重建 lead
也就是把 lead workflow 做成状态机 / 规则流。
### 第四阶段:把多-agent 内核接起来
计划推进内容:
- 成员线程结构
- 任务路由
- review 流
- 返工流
- 结果汇总
- 项目内老成员优先复用逻辑
### 第五阶段:做像素办公室 v0
条件:
- 前面的状态流先成型
优先内容:
- 嵌在网页里的施工可视化区
- 成员卡 / 小人 / 工位类视觉表达
- 状态变化驱动 UI
- 至少做到“能一眼看懂谁在干嘛”
### 第六阶段:整体验收和打磨
要自己验:
- build
- start
- 页面实际打开
- 项目页工作流
- 摸底链路
- 团队链路
- 成员线程
- 像素办公室入口 / 模块
- 中文文案
- 视觉细节
## 七、后来补充确认的偏好
- 深色工作台风格
- 像素办公室默认放在对话框左侧
- 最好支持左右互换布局
## 八、后台调用 Codex 的硬规则
后来用户明确允许:
- 下班后可用隐藏后台模式调用 Codex
但附带强约束:
- 必须带监控
- 不能无监控放养
最终确认的三层监控规则:
### 1. 实时输出监控
- 持续写日志
- 可以实时 tail
- 一旦报错或停滞,第一时间发现
### 2. 文件变化监控
- 盯改了哪些文件
- 看有没有异常大改
- 看 git 状态变化
- 看关键产物是否落地
### 3. 阶段性验收
- 不管 Codex 说没说完成,都要自己验
- 至少验:
- build
- start
- 页面
- API
- 关键流程
这条规则的核心表述是:
> 后台模式可以用,但必须带监控。
> 继续由人来当 lead / 监工 / 验收人,Codex 只是施工队之一。
## 九、这份文档怎么用
以后如果你继续做这个项目,优先看这几个部分:
- 想看产品定位:看“项目核心定位”
- 想看 UI 方向:看“UI 与工作台方向”
- 想看最终冲刺目标:看“整晚冲刺版目标”
- 想看执行分期:看“执行路线”
- 想看后台 Codex 规则:看“后台调用 Codex 的硬规则”
如果后面需要,我还可以继续把旧主会话里的内容再拆成两份:
- 一份“产品方案版”
- 一份“执行路线版”

795
docs/UI_VISUAL_STANDARDS.md Normal file
View File

@ -0,0 +1,795 @@
# Agents Team - UI 视觉标准文档
> 版本: v1.0
> 日期: 2026-03-14
> 适用范围: Web 前端界面开发
---
## 1. 设计原则
### 1.1 核心理念
- **简洁优先**: 去除冗余装饰,聚焦内容展示
- **深色主题**: 采用专业深色配色,减少视觉疲劳
- **信息层级**: 通过颜色和间距清晰区分信息层级
- **流畅交互**: 平滑的过渡动画提升用户体验
### 1.2 布局架构
采用经典的三栏式布局:
```
┌──────────────────────────────────────────────────────────────────────┐
│ [左侧边栏] [中间主区域] [右侧面板 - 像素办公室] │
│ ───────── ───────────── ─────────────────── │
│ 可折叠 对话/内容区 Agent详情/像素办公室 │
│ 项目列表 输入框(底部) 420px 宽度 │
│ 设置入口 480px 宽度 │
└──────────────────────────────────────────────────────────────────────┘
```
**布局尺寸规范:**
- 左侧边栏: 260px (可折叠至 60px)
- 中间对话区域: 最大 480px,居中显示
- 右侧像素办公室: 420px 固定宽度
- 输入框区域: 最大 640px,与对话区域协调
---
## 2. 色彩系统
### 2.1 基础色彩变量
```css
:root {
/* 背景色 */
--bg-primary: #0d0d0d; /* 主背景 - 纯黑 */
--bg-secondary: #141414; /* 次级背景 - 侧边栏 */
--bg-tertiary: #1a1a1a; /* 三级背景 */
--bg-elevated: #242424; /* elevated 背景 - 卡片/输入框 */
--bg-hover: #2a2a2a; /* 悬停状态 */
/* 文字色 */
--text-primary: #ffffff; /* 主文字 */
--text-secondary: #a1a1a1; /* 次级文字 */
--text-tertiary: #6e6e6e; /* 三级文字 - 提示/禁用 */
/* 边框 */
--border-color: #2a2a2a; /* 默认边框 */
--border-hover: #3a3a3a; /* 悬停边框 */
}
```
### 2.2 强调色
```css
:root {
/* 主强调色 - 青绿色系 */
--accent-primary: #10a37f; /* 主按钮/高亮 */
--accent-primary-hover: #0d8c6d; /* 悬停状态 */
--accent-secondary: #19c59f; /* 渐变/装饰 */
/* Agent 角色色 */
--agent-lead: #f59e0b; /* Lead - 琥珀色 */
--agent-worker: #3b82f6; /* Worker - 蓝色 */
--agent-reviewer: #8b5cf6; /* Reviewer - 紫色 */
/* 状态色 */
--status-success: #10a37f; /* 成功/运行中 */
--status-warning: #f59e0b; /* 警告/等待 */
--status-error: #ef4444; /* 错误 */
--status-neutral: #6b7280; /* 中性/默认 */
}
```
### 2.3 色彩使用规范
| 场景 | 颜色变量 | 示例 |
|------|----------|------|
| 页面背景 | `--bg-primary` | #0d0d0d |
| 侧边栏背景 | `--bg-secondary` | #141414 |
| 卡片/输入框 | `--bg-elevated` | #242424 |
| 主要按钮 | `--accent-primary` | #10a37f |
| 主要文字 | `--text-primary` | #ffffff |
| 次要说明 | `--text-secondary` | #a1a1a1 |
| 边框 | `--border-color` | #2a2a2a |
---
## 3. 间距系统
### 3.1 间距变量
```css
:root {
--spacing-xs: 4px;
--spacing-sm: 8px;
--spacing-md: 12px;
--spacing-lg: 16px;
--spacing-xl: 24px;
--spacing-2xl: 32px;
}
```
### 3.2 使用规范
| 场景 | 间距值 | 用途 |
|------|--------|------|
| 图标与文字 | `--spacing-xs` (4px) | 内联图标间距 |
| 紧凑元素 | `--spacing-sm` (8px) | 小按钮、标签 |
| 标准间距 | `--spacing-md` (12px) | 卡片内边距、表单字段 |
| 区块间距 | `--spacing-lg` (16px) | 区块分隔 |
| 大区块 | `--spacing-xl` (24px) | 页面边距、大区块 |
| 超大间距 | `--spacing-2xl` (32px) | Hero 区域、特殊区块 |
---
## 4. 圆角系统
```css
:root {
--radius-sm: 6px; /* 小按钮、标签 */
--radius-md: 8px; /* 标准按钮、输入框 */
--radius-lg: 12px; /* 卡片、面板 */
--radius-xl: 16px; /* 大卡片、模态框 */
--radius-full: 9999px; /* 胶囊形、圆形 */
}
```
### 使用规范
| 组件 | 圆角值 |
|------|--------|
| 小按钮/标签 | `--radius-md` (8px) |
| 输入框 | `--radius-lg` (12px) |
| 卡片/面板 | `--radius-lg` (12px) |
| 大卡片 | `--radius-xl` (16px) |
| 状态标签 | `--radius-full` (胶囊形) |
| 头像 | `--radius-lg` (12px) 或圆形 |
---
## 5. 字体规范
### 5.1 字体栈
```css
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
```
### 5.2 字号层级
| 层级 | 大小 | 字重 | 用途 |
|------|------|------|------|
| 页面标题 | 32px | 700 | 首页 Hero |
| 区块标题 | 18px | 600 | 表单标题 |
| 卡片标题 | 16px | 600 | 面板标题 |
| 正文 | 14px | 400 | 普通文字 |
| 小字 | 13px | 400 | 次要信息 |
| 标签 | 12px | 500 | 标签、时间戳 |
| 微字 | 11px | 500 | 状态标签 uppercase |
---
## 6. 组件规范
### 6.1 按钮
#### 主要按钮 (Primary)
```css
.primary-button {
padding: 12px 16px;
background: var(--accent-primary);
color: white;
border-radius: var(--radius-lg);
font-size: 14px;
font-weight: 500;
transition: background 150ms ease;
}
.primary-button:hover {
background: var(--accent-primary-hover);
}
```
#### 次要按钮 (Secondary)
```css
.secondary-button {
padding: 12px 16px;
background: var(--bg-elevated);
color: var(--text-secondary);
border-radius: var(--radius-lg);
font-size: 14px;
font-weight: 500;
transition: all 150ms ease;
}
.secondary-button:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
```
#### 图标按钮
```css
.icon-button {
width: 40px;
height: 40px;
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
transition: all 150ms ease;
}
.icon-button:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
```
### 6.2 输入框
#### 标准输入框
```css
.input-field {
width: 100%;
padding: 12px 16px;
background: var(--bg-elevated);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
color: var(--text-primary);
font-size: 14px;
transition: all 150ms ease;
}
.input-field:focus {
outline: none;
border-color: var(--accent-primary);
background: var(--bg-hover);
}
.input-field::placeholder {
color: var(--text-tertiary);
}
```
#### Kimi 风格聊天输入框
```css
.chat-input-container {
display: flex;
align-items: flex-end;
gap: 8px;
background: var(--bg-elevated);
border: 1px solid var(--border-color);
border-radius: 24px; /* 大圆角,胶囊形状 */
padding: 8px 8px 8px 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition: all 150ms ease;
}
.chat-input-container:focus-within {
border-color: var(--accent-primary);
box-shadow: 0 2px 12px rgba(16, 163, 127, 0.15);
}
/* 左侧工具按钮区 */
.chat-input-tools {
display: flex;
align-items: center;
gap: 4px;
padding-right: 8px;
border-right: 1px solid var(--border-color);
}
.input-tool-btn {
width: 28px;
height: 28px;
border: none;
background: transparent;
color: var(--text-secondary);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.input-tool-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
/* 发送按钮 - 圆形 */
.input-action-btn.send {
width: 32px;
height: 32px;
border: none;
border-radius: 50%; /* 圆形 */
background: var(--accent-primary);
color: white;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.input-action-btn.send:hover:not(:disabled) {
background: var(--accent-primary-hover);
transform: scale(1.05);
}
```
### 6.3 卡片/面板
```css
.card {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 16px;
}
.card-elevated {
background: var(--bg-elevated);
border: 1px solid var(--border-color);
border-radius: var(--radius-xl);
padding: 24px;
}
```
### 6.4 状态标签 (Status Badge)
```css
.status-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 8px;
border-radius: 9999px;
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* 状态变体 */
.status-badge.success {
background: rgba(16, 163, 127, 0.15);
color: #34d399;
border: 1px solid rgba(16, 163, 127, 0.3);
}
.status-badge.warning {
background: rgba(245, 158, 11, 0.15);
color: #fbbf24;
border: 1px solid rgba(245, 158, 11, 0.3);
}
.status-badge.neutral {
background: rgba(100, 116, 139, 0.15);
color: #94a3b8;
border: 1px solid rgba(100, 116, 139, 0.3);
}
```
### 6.5 消息气泡
```css
/* Agent 消息 */
.message-bubble {
padding: 16px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
max-width: 80%;
}
/* 用户消息 */
.message-bubble.user {
background: var(--bg-elevated);
border-color: var(--accent-primary);
margin-left: auto;
}
```
### 6.6 像素办公室 (Pixel Office)
像素办公室是右侧面板的核心功能,以像素风格展示项目中的 Agents 状态。
#### 办公室布局
```css
.pixel-office-panel {
width: 420px;
height: 100%;
background: var(--bg-secondary);
border-left: 1px solid var(--border-color);
display: flex;
flex-direction: column;
}
.office-room {
position: relative;
background: var(--bg-tertiary);
border-radius: 12px;
padding: 24px;
min-height: 320px;
}
/* 地板网格 */
.office-floor {
position: absolute;
display: flex;
flex-direction: column;
gap: 8px;
}
.office-floor-tile {
flex: 1;
background: var(--bg-hover);
border-radius: 6px;
opacity: 0.5;
}
```
#### 像素角色
```css
.pixel-character {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
transition: transform 150ms ease;
}
.pixel-character:hover {
transform: translateY(-2px);
}
.pixel-avatar {
position: relative;
width: 44px;
height: 44px;
background: var(--bg-elevated);
border: 2px solid var(--border-color);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
/* 角色徽章 */
.pixel-role-badge {
position: absolute;
top: -6px;
right: -6px;
width: 18px;
height: 18px;
background: var(--bg-elevated);
border: 1px solid var(--border-color);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
}
/* 状态指示点 */
.pixel-status-dot {
position: absolute;
bottom: -2px;
left: 50%;
transform: translateX(-50%);
width: 8px;
height: 8px;
border-radius: 50%;
border: 2px solid var(--bg-secondary);
}
.pixel-status-dot.typing {
background: #10a37f;
animation: pulse 1.5s ease-in-out infinite;
}
.pixel-status-dot.reading { background: #3b82f6; }
.pixel-status-dot.waiting { background: #f59e0b; }
.pixel-status-dot.idle { background: #6b7280; }
```
#### 角色图标映射
| 角色 | 图标 |
|------|------|
| Lead | 👑 |
| Developer | 💻 |
| Reviewer | 👀 |
| Architect | 🏗️ |
| Tester | 🧪 |
| 其他 | 🤖 |
---
## 7. 布局规范
### 7.1 三栏布局
```css
:root {
/* 布局尺寸变量 */
--sidebar-width: 260px;
--sidebar-collapsed-width: 60px;
--agent-panel-width: 420px;
--chat-max-width: 480px;
--input-max-width: 640px;
}
.app-layout {
display: flex;
height: 100vh;
width: 100vw;
overflow: hidden;
}
/* 左侧边栏 */
.sidebar {
width: var(--sidebar-width);
background: var(--bg-secondary);
border-right: 1px solid var(--border-color);
flex-shrink: 0;
}
.sidebar-collapsed {
width: var(--sidebar-collapsed-width);
}
/* 中间主区域 */
.main-content {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
background: var(--bg-primary);
}
/* 对话消息区域 - 收窄居中 */
.messages-container {
max-width: var(--chat-max-width);
margin: 0 auto;
width: 100%;
}
/* 输入框区域 - Kimi 风格 */
.chat-input-wrapper {
max-width: var(--input-max-width);
margin: 0 auto;
width: 100%;
}
/* 右侧面板 - 像素办公室 */
.agent-panel {
width: var(--agent-panel-width);
background: var(--bg-secondary);
border-left: 1px solid var(--border-color);
flex-shrink: 0;
}
```
### 7.2 内容区域结构
```
中间主区域:
├── header (60px 高度)
│ ├── 标题 + 状态标签
│ └── 操作按钮
├── agent-selector (可滚动)
│ └── Agent 切换标签
├── chat-area (flex: 1, 可滚动)
│ └── 消息列表
└── chat-input-container
├── toolbar (模型/上下文)
└── input-area (输入框+发送)
```
---
## 8. 交互规范
### 8.1 过渡动画
```css
:root {
--transition-fast: 150ms ease;
--transition-normal: 250ms ease;
}
/* 通用过渡 */
.interactive {
transition: all var(--transition-fast);
}
/* 悬停效果 */
.hover-lift:hover {
transform: translateY(-1px);
}
/* 按钮点击 */
.button:active {
transform: scale(0.98);
}
```
### 8.2 滚动条样式
```css
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 9999px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--border-hover);
}
```
---
## 9. 图标规范
### 9.1 图标尺寸
| 场景 | 尺寸 |
|------|------|
| 小图标 (内联) | 16px |
| 标准图标 | 18px |
| 大图标 | 20px |
| 按钮图标 | 与文字搭配 |
### 9.2 图标风格
- 使用 SVG 图标
- 线条粗细: 2px
- 风格: 线性图标 (outline)
- 颜色: 继承父元素颜色
### 9.3 常用图标
```tsx
// 发送
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z" />
</svg>
// 暂停
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="6" y="4" width="4" height="16" rx="1" />
<rect x="14" y="4" width="4" height="16" rx="1" />
</svg>
// 设置
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="3" />
<path d="M12 1v6m0 6v6m4.22-10.22l4.24-4.24M6.34 17.66l-4.24 4.24M23 12h-6m-6 0H1m20.24 4.24l-4.24-4.24M6.34 6.34L2.1 2.1" />
</svg>
```
---
## 10. 响应式设计
### 10.1 断点
```css
/* 大屏幕 - 三栏完整显示 */
@media (min-width: 1201px) {
/* 默认样式 */
}
/* 中等屏幕 - 右侧面板可收起 */
@media (max-width: 1200px) {
.agent-panel {
position: fixed;
right: 0;
transform: translateX(100%);
}
.agent-panel.open {
transform: translateX(0);
}
}
/* 小屏幕 - 左侧边栏可收起 */
@media (max-width: 768px) {
.sidebar {
position: fixed;
left: 0;
transform: translateX(-100%);
}
.sidebar.open {
transform: translateX(0);
}
}
```
### 10.2 移动端适配
- 侧边栏变为抽屉式
- 输入框变为垂直布局
- 触摸目标最小 44px
---
## 11. 代码示例
### 11.1 完整组件示例
```tsx
// StatusBadge 组件
function StatusBadge({
tone,
children
}: {
tone: "success" | "warning" | "neutral" | "error";
children: React.ReactNode;
}) {
const toneClasses = {
success: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30",
warning: "bg-amber-500/15 text-amber-400 border-amber-500/30",
neutral: "bg-slate-500/15 text-slate-400 border-slate-500/30",
error: "bg-rose-500/15 text-rose-400 border-rose-500/30",
};
return (
<span className={`px-2 py-0.5 text-xs font-medium rounded-full border ${toneClasses[tone]}`}>
{children}
</span>
);
}
```
### 11.2 CSS 类名命名规范
使用语义化命名:
- `.sidebar` - 侧边栏
- `.main-content` - 主内容区
- `.chat-input-container` - 聊天输入容器
- `.agent-panel` - Agent 面板
- `.message-bubble` - 消息气泡
避免使用:
- 无意义的缩写
- 颜色相关的类名 (如 `.red-button`)
- 具体尺寸 (如 `.width-200`)
---
## 12. 检查清单
在提交代码前,请确认:
- [ ] 使用了正确的色彩变量
- [ ] 间距符合规范
- [ ] 圆角一致
- [ ] 字体层级清晰
- [ ] 过渡动画流畅
- [ ] 响应式适配完成
- [ ] 暗色主题下对比度足够
- [ ] 交互状态完整 (hover, active, disabled)
---
## 附录: 参考资源
- 设计灵感: Kimi (kimi.moonshot.cn), Claude (claude.ai)
- 色彩工具: https://colorhunt.co/
- 图标库: Lucide Icons (https://lucide.dev/)
- CSS 变量: 现代浏览器均支持
---
**文档维护**: 如有更新需求,请同步修改本文档。

261
docs/design.md Normal file
View File

@ -0,0 +1,261 @@
# 设计说明
更新时间:2026-03-14
## 1. 当前产品定义
这不是“全局固定团队系统”,而是:
- 项目级 `lead`
- 项目级团队
- `lead` 对话驱动工作台
用户默认只和当前项目的 `lead` 聊。
团队由 `lead` 先提出草案,用户确认后才创建正式成员。
团队内正式成员之间允许直接互相发消息,不限制成“执行成员只能找 reviewer”。
## 2. 当前已经成立的主工作流
当前仓库已经不只是 MVP 骨架,而是已经有一条真实可跑的主链路:
1. 创建项目
2. 生成项目专属团队草案
3. 确认团队
4. lead 派发任务
5. 成员执行
6. reviewer 接单审查
7. 打回 / 通过
8. 成员返工后复审
9. 结果回到 lead 汇总
其中 lead 回复、团队草案生成、成员线程回复、任务启动回执、reviewer 接单、reviewer 审查结论、成员复审回执,已经接入真实 Codex CLI。
## 3. 当前核心数据结构
服务端类型定义在:
- `D:\AI Code\Agents Team\server\src\types.ts`
当前比较关键的对象有:
- `Project`
- `LeadProfile`
- `TeamProposal`
- `TeamState`
- `TeamChangeState`
- `ProjectMember`
- `RuntimeSession`
- `MailboxMessage`
- `MailboxDigest`
- `SharedMemoryEntry`
- `TaskItem`
- `ReviewItem`
- `TaskActivityEntry`
### 3.1 阶段状态
项目阶段已经固定为:
- `lead_only`
- `discovery_pending`
- `team_drafting`
- `team_pending_confirmation`
- `team_active`
- `executing`
- `reviewing`
- `blocked`
- `done`
### 3.2 团队变更确认
当正式团队已经存在时,再改团队草案,不会直接覆盖正式团队。
当前会先生成:
- 新增项
- 移出项
- 调整项
然后再单独确认团队变更。
### 3.3 运行态 / 会话身份
每个正式角色都会保存:
- 当前模型
- 模型来源
- 最近 thread id
- 恢复命令
- 接管命令
- 最近输出
- 错误状态
- 身份状态
身份状态当前分为:
- `fresh`
- `resume_ready`
- `needs_rebuild`
## 4. Codex CLI 接法
当前策略是“薄适配层”,不修改 Codex CLI 本体。
核心文件:
- `D:\AI Code\Agents Team\server\src\runtime.ts`
- `D:\AI Code\Agents Team\server\src\codexConfig.ts`
### 4.1 已经做成的事情
- 真实调用 Codex CLI 生成 lead / 成员 / reviewer 关键回复
- 角色 thread 建立后,后续优先走 `codex exec resume`
- 原生接管口使用 `codex resume ...`
- Web 端可以对单个角色覆盖模型
- 模型列表优先通过 Codex app-server 的 `model/list` 动态获取
### 4.2 当前真实模型能力
Web 端现在看到的可用模型,不再主要靠 `config.toml` 猜,而是:
1. 优先调用 Codex app-server `model/list`
2. 失败时退回配置文件兜底
所以页面里已经能看到:
- 模型显示名
- 模型说明
- 默认推理档位
- 支持的推理档位
- 是否支持 personality
- 是否是当前默认模型
## 5. 存储策略
### 5.1 全局索引
全局只保留项目索引:
- `D:\AI Code\Agents Team\data\db.json`
这个文件现在只保存:
- `id`
- `name`
- `path`
- `description`
- `createdAt`
- `updatedAt`
### 5.2 项目级状态
每个项目自己的真实状态保存在:
- `<项目目录>\.agents-team\workbench\`
当前已经落地的文件:
- `project-state.json`
- `mailbox.json`
- `shared-memory.json`
- `shared-memory.md`
- `task-activity.json`
这样项目的协作状态会跟着项目走,不会继续把所有项目状态堆在全局一个 JSON 里。
## 6. 当前页面结构
前端主页面在:
- `D:\AI Code\Agents Team\web\src\pages\HomePage.tsx`
- `D:\AI Code\Agents Team\web\src\pages\ProjectPage.tsx`
### 6.1 项目页
项目页当前已经是:
- 左侧像素办公室 / 成员状态区
- 中间大面积 lead / 成员对话区
- 右侧收纳区
收纳区当前已经包含:
- 团队草案
- 团队变更确认清单
- 任务推进
- Mailbox
- 共享记忆
- 项目摸底
- 运行态 / 接管口
- 人工接管
- Git 状态
### 6.2 Mailbox 展示
Mailbox 现在不是简单平铺消息。
当前已经有:
- 服务端摘要分组
- lead 汇总视角
- 按任务 / 成员往来折叠
- 参与人信息
- 建议下一步
- 高亮重点
### 6.3 运行态展示
运行态面板当前已经支持:
- 当前模型切换
- 切回继承默认模型
- 同步恢复入口
- 重建角色会话
- 打开原生接管口
- 复制恢复命令
- 复制接管命令
## 7. 当前 API
后端入口:
- `D:\AI Code\Agents Team\server\src\index.ts`
当前主要 API 已包括:
- 项目列表 / 项目详情
- 创建项目
- 保存团队草案
- 确认团队
- 确认团队变更
- 发起 / 确认项目摸底
- lead / 成员消息发送
- 创建任务
- 送审
- 复审
- reviewer 审查
- mailbox 发送 / 状态更新
- 写入共享记忆
- 会话模型切换
- 会话恢复
- 会话重建
- 打开原生接管口
- 人工接管记录
## 8. 现在还没做完的部分
还有这些尾项没有完全收口:
- 固定角色虽然已经支持按 thread 恢复,但还没做到“所有长期身份都只靠原生会话驱动”
- 页内还没有嵌入原生终端
- mailbox 摘要卡还能继续增强
- `docs/` 里其它历史文档还没完全同步
## 9. 接下来最合理的方向
按当前进度,后面最值得继续推的是:
1. 继续收口角色长期身份,让更多长期动作都复用同一原生会话
2. 继续加强 mailbox 的长对话摘要和冲突压缩
3. 评估是否要做页内原生终端 / 更顺手的接管体验
4. 同步剩余设计 / 交接文档

3580
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

34
package.json Normal file
View File

@ -0,0 +1,34 @@
{
"name": "agents-team-workbench",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "concurrently \"npm:dev:server\" \"npm:dev:web\"",
"dev:server": "tsx watch server/src/index.ts",
"dev:web": "vite --config web/vite.config.ts",
"build": "npm run build:web && npm run build:server",
"build:web": "vite build --config web/vite.config.ts",
"build:server": "tsc -p server/tsconfig.json",
"start": "node dist/server/index.js"
},
"dependencies": {
"cors": "^2.8.5",
"express": "^4.21.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^6.30.1"
},
"devDependencies": {
"@types/cors": "^2.8.18",
"@types/express": "^5.0.1",
"@types/node": "^24.0.0",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^4.3.4",
"concurrently": "^9.1.2",
"tsx": "^4.19.3",
"typescript": "^5.8.2",
"vite": "^5.4.14"
}
}

337
server/src/codexConfig.ts Normal file
View File

@ -0,0 +1,337 @@
import os from "node:os";
import path from "node:path";
import { existsSync, readFileSync } from "node:fs";
import { spawn } from "node:child_process";
import type { CodexCliInfo, CodexModelOption, RuntimeSession } from "./types.js";
import { buildCodexExecCommand, buildCodexTakeoverCommand, resolveCodexInvocation } from "./runtime.js";
const fallbackModels = ["gpt-5.4", "gpt-5.2", "gpt-5.2-codex"];
const CODEX_INFO_CACHE_TTL_MS = 5 * 60 * 1000;
let codexCliInfoCache: { value: CodexCliInfo; fetchedAt: number } | null = null;
const stripQuotes = (value: string): string => value.replace(/^['"]|['"]$/g, "");
const quoteForDisplay = (value: string): string => `"${value.replace(/"/g, '\\"')}"`;
const createConfigModelOption = (model: string, isDefault = false): CodexModelOption => ({
model,
displayName: model,
description: "从本机 Codex 配置读取到的模型。",
defaultReasoningEffort: "medium",
supportedReasoningEfforts: [],
supportsPersonality: true,
isDefault,
hidden: false,
source: "config"
});
const mergeModelOptions = (
preferred: CodexModelOption[],
fallback: CodexModelOption[],
defaultModel: string
): CodexModelOption[] => {
const merged = new Map<string, CodexModelOption>();
for (const item of [...fallback, ...preferred]) {
merged.set(item.model, {
...item,
isDefault: item.model === defaultModel
});
}
if (!merged.has(defaultModel)) {
merged.set(defaultModel, createConfigModelOption(defaultModel, true));
}
return [...merged.values()].sort((left, right) => {
if (left.model === defaultModel) {
return -1;
}
if (right.model === defaultModel) {
return 1;
}
if (left.isDefault && !right.isDefault) {
return -1;
}
if (!left.isDefault && right.isDefault) {
return 1;
}
return left.model.localeCompare(right.model);
});
};
const readCodexCliInfoFromConfig = (): CodexCliInfo => {
const configPath = path.join(os.homedir(), ".codex", "config.toml");
const info: CodexCliInfo = {
defaultModel: "gpt-5.4",
activeProfile: "",
provider: "",
contextWindow: null,
autoCompactTokenLimit: null,
suggestedModels: [...fallbackModels],
availableModels: fallbackModels.map((model, index) => createConfigModelOption(model, index === 0))
};
if (!existsSync(configPath)) {
return info;
}
const raw = readFileSync(configPath, "utf8");
let currentSection = "";
const discoveredModels = new Set<string>(fallbackModels);
for (const originalLine of raw.split(/\r?\n/)) {
const line = originalLine.replace(/\s+#.*$/, "").trim();
if (!line) {
continue;
}
const sectionMatch = line.match(/^\[(.+)\]$/);
if (sectionMatch) {
currentSection = sectionMatch[1];
continue;
}
const kvMatch = line.match(/^([A-Za-z0-9_.-]+)\s*=\s*(.+)$/);
if (!kvMatch) {
continue;
}
const key = kvMatch[1];
const value = stripQuotes(kvMatch[2].trim());
if (!currentSection) {
if (key === "model") {
info.defaultModel = value;
discoveredModels.add(value);
}
if (key === "profile") {
info.activeProfile = value;
}
if (key === "model_provider") {
info.provider = value;
}
if (key === "model_context_window") {
const parsed = Number(value);
info.contextWindow = Number.isFinite(parsed) ? parsed : info.contextWindow;
}
if (key === "model_auto_compact_token_limit") {
const parsed = Number(value);
info.autoCompactTokenLimit = Number.isFinite(parsed) ? parsed : info.autoCompactTokenLimit;
}
}
if (key === "model") {
discoveredModels.add(value);
}
if (currentSection === "notice.model_migrations") {
discoveredModels.add(stripQuotes(key));
discoveredModels.add(value);
}
}
info.availableModels = mergeModelOptions(
[],
Array.from(new Set([info.defaultModel, ...discoveredModels])).map((model) => createConfigModelOption(model, model === info.defaultModel)),
info.defaultModel
);
info.suggestedModels = info.availableModels.map((item) => item.model);
return info;
};
type AppServerRpcResponse =
| { id: number; result?: unknown; error?: { code?: number; message?: string } }
| { method: string; params?: unknown };
const parseJsonLines = (buffer: string): AppServerRpcResponse[] =>
buffer
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
try {
return JSON.parse(line) as AppServerRpcResponse;
} catch {
return null;
}
})
.filter((item): item is AppServerRpcResponse => item !== null);
const readModelsFromAppServer = async (): Promise<CodexModelOption[]> =>
new Promise((resolve, reject) => {
const invocation = resolveCodexInvocation();
const child = spawn(invocation.command, [...invocation.prefixArgs, "app-server"], {
stdio: ["pipe", "pipe", "pipe"],
windowsHide: true
});
let stdout = "";
let stderr = "";
let settled = false;
let modelListRequested = false;
const timeoutHandle = setTimeout(() => {
finish(new Error(stderr.trim() || "读取 Codex app-server 模型列表超时"));
}, 15000);
const finish = (value: CodexModelOption[] | Error) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeoutHandle);
child.kill();
if (value instanceof Error) {
reject(value);
} else {
resolve(value);
}
};
child.stdout.on("data", (chunk) => {
stdout += chunk.toString();
const messages = parseJsonLines(stdout);
const initializeAck = messages.find((message) => "id" in message && message.id === 1);
if (initializeAck && !modelListRequested) {
modelListRequested = true;
child.stdin.write(
`${JSON.stringify({
jsonrpc: "2.0",
id: 2,
method: "model/list",
params: { limit: 100, includeHidden: false }
})}\n`
);
}
const modelList = messages.find((message) => "id" in message && message.id === 2);
if (modelList && "result" in modelList) {
const data = Array.isArray((modelList.result as { data?: unknown[] })?.data)
? ((modelList.result as {
data: Array<{
model?: string;
displayName?: string;
description?: string;
defaultReasoningEffort?: string;
supportedReasoningEfforts?: Array<{ reasoningEffort?: string; description?: string }>;
supportsPersonality?: boolean;
isDefault?: boolean;
hidden?: boolean;
}>;
}).data || [])
: [];
const models: CodexModelOption[] = [];
for (const item of data) {
const model = typeof item.model === "string" ? item.model : "";
if (!model) {
continue;
}
models.push({
model,
displayName: typeof item.displayName === "string" && item.displayName ? item.displayName : model,
description: typeof item.description === "string" ? item.description : "",
defaultReasoningEffort:
typeof item.defaultReasoningEffort === "string" && item.defaultReasoningEffort ? item.defaultReasoningEffort : "medium",
supportedReasoningEfforts: Array.isArray(item.supportedReasoningEfforts)
? item.supportedReasoningEfforts
.map((effort) => ({
reasoningEffort:
typeof effort?.reasoningEffort === "string" && effort.reasoningEffort ? effort.reasoningEffort : "",
description: typeof effort?.description === "string" ? effort.description : ""
}))
.filter((effort) => effort.reasoningEffort)
: [],
supportsPersonality: Boolean(item.supportsPersonality),
isDefault: Boolean(item.isDefault),
hidden: Boolean(item.hidden),
source: "app-server"
});
}
finish(models);
}
if (modelList && "error" in modelList && modelList.error) {
finish(new Error(modelList.error.message || "Codex app-server model/list 失败"));
}
});
child.stderr.on("data", (chunk) => {
stderr += chunk.toString();
});
child.on("error", (error) => finish(error));
child.on("exit", (code) => {
if (!settled && code !== 0) {
finish(new Error(stderr.trim() || `Codex app-server 退出码 ${code}`));
}
});
child.stdin.write(
`${JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: {
clientInfo: {
name: "agents-team-workbench",
title: "Agents Team Workbench",
version: "0.1.0"
},
capabilities: {
experimentalApi: true
}
}
})}\n`
);
});
export const getCodexCliInfo = (): CodexCliInfo => codexCliInfoCache?.value || readCodexCliInfoFromConfig();
export const refreshCodexCliInfo = async (force = false): Promise<CodexCliInfo> => {
if (!force && codexCliInfoCache && Date.now() - codexCliInfoCache.fetchedAt < CODEX_INFO_CACHE_TTL_MS) {
return codexCliInfoCache.value;
}
const base = readCodexCliInfoFromConfig();
try {
const appServerModels = await readModelsFromAppServer();
const availableModels = mergeModelOptions(appServerModels, base.availableModels, base.defaultModel);
const merged: CodexCliInfo = {
...base,
availableModels,
suggestedModels: availableModels.map((item) => item.model)
};
codexCliInfoCache = { value: merged, fetchedAt: Date.now() };
return merged;
} catch {
codexCliInfoCache = { value: base, fetchedAt: Date.now() };
return base;
}
};
export const applyCodexConfigToSession = (
session: RuntimeSession,
codex: CodexCliInfo,
projectPath: string
): RuntimeSession => {
const effectiveModel = session.modelSource === "session-override" && session.model ? session.model : codex.defaultModel;
const resumeCommand = session.lastThreadId
? `codex resume ${quoteForDisplay(session.lastThreadId)} -m ${quoteForDisplay(effectiveModel)} -C ${quoteForDisplay(projectPath)}`
: session.resumeCommand;
const identityState =
session.lastThreadId
? "resume_ready"
: session.status === "blocked" || session.status === "offline"
? "needs_rebuild"
: "fresh";
return {
...session,
identityState,
model: effectiveModel,
lastCommand: buildCodexExecCommand(projectPath, `以 ${session.roleLabel} 身份接手当前项目上下文`, effectiveModel),
takeoverCommand: buildCodexTakeoverCommand(projectPath, effectiveModel),
resumeCommand
};
};

461
server/src/defaults.ts Normal file
View File

@ -0,0 +1,461 @@
import path from "node:path";
import { randomUUID } from "node:crypto";
import type {
ChatMessage,
CodexCliInfo,
ConversationThread,
GitSnapshot,
LeadProfile,
MailboxKind,
MailboxMessage,
ManualAction,
MemberCapabilityHint,
MemberLifecycle,
MemberRuntimeType,
NativeSessionRef,
Project,
ProjectCreateInput,
ProjectMember,
ProjectStorageInfo,
ProposalRole,
RuntimeSession,
SharedMemoryEntry,
TaskItem,
TaskActivityEntry,
TeamProposal,
TeamState,
ThreadStatus
} from "./types.js";
import { buildCodexExecCommand, createRuntimeSession, updateRuntimeSession } from "./runtime.js";
const createId = (prefix: string): string => `${prefix}_${randomUUID()}`;
export const now = (): string => new Date().toISOString();
const trimPath = (value: string): string => value.replace(/[\\/]+$/, "");
export const createProjectStorageInfo = (projectPath: string): ProjectStorageInfo => {
const agentsDir = path.join(projectPath, ".agents-team");
const workbenchDir = path.join(agentsDir, "workbench");
return {
agentsDir,
workbenchDir,
projectStateFile: path.join(workbenchDir, "project-state.json"),
mailboxFile: path.join(workbenchDir, "mailbox.json"),
sharedMemoryFile: path.join(workbenchDir, "shared-memory.json"),
sharedMemoryMarkdownFile: path.join(workbenchDir, "shared-memory.md"),
taskActivityFile: path.join(workbenchDir, "task-activity.json")
};
};
export const inferProjectName = (projectPath: string): string => {
const baseName = path.basename(trimPath(projectPath));
return baseName || "未命名项目";
};
export const createPlaceholderGitStatus = (): GitSnapshot => ({
mode: "placeholder",
branch: "未读取",
summary: "还没有读取 Git 状态。",
dirty: false,
trackedChanges: 0,
lastCheckedAt: null,
raw: ""
});
export const createSystemMessage = (content: string): ChatMessage => ({
id: createId("msg"),
sender: "system",
content,
createdAt: now(),
senderName: "系统"
});
export const createMailboxMessage = (input: {
fromMemberId: string | null;
fromLabel: string;
toMemberId: string | null;
toLabel: string;
kind: MailboxKind;
relatedTaskId?: string | null;
summary: string;
body: string;
}): MailboxMessage => ({
id: createId("mailbox"),
fromMemberId: input.fromMemberId,
fromLabel: input.fromLabel,
toMemberId: input.toMemberId,
toLabel: input.toLabel,
kind: input.kind,
relatedTaskId: input.relatedTaskId || null,
summary: input.summary,
body: input.body,
status: "open",
createdAt: now()
});
export const createSharedMemoryEntry = (input: {
title: string;
body: string;
source: SharedMemoryEntry["source"];
}): SharedMemoryEntry => ({
id: createId("memory"),
title: input.title,
body: input.body,
source: input.source,
createdAt: now()
});
export const createTaskActivityEntry = (input: {
taskId?: string | null;
kind: TaskActivityEntry["kind"];
actorId?: string | null;
actorLabel: string;
summary: string;
detail: string;
relatedMailboxId?: string | null;
}): TaskActivityEntry => ({
id: createId("activity"),
taskId: input.taskId || null,
kind: input.kind,
actorId: input.actorId || null,
actorLabel: input.actorLabel,
summary: input.summary,
detail: input.detail,
relatedMailboxId: input.relatedMailboxId || null,
createdAt: now()
});
export const createLeadMessage = (content: string, leadName = "项目 lead"): ChatMessage => ({
id: createId("msg"),
sender: "lead",
content,
createdAt: now(),
senderName: leadName
});
export const createMemberMessage = (content: string, senderName: string): ChatMessage => ({
id: createId("msg"),
sender: "member",
content,
createdAt: now(),
senderName
});
export const inferRuntimeType = (text: string): MemberRuntimeType =>
/review|审|复审/i.test(text) ? "reviewer" : "worker";
export const inferCapabilityHint = (text: string): MemberCapabilityHint => {
if (/review|审|复审/i.test(text)) {
return "review";
}
if (/front|ui|页面|交互|样式|react|vue/i.test(text)) {
return "frontend";
}
if (/back|api|服务|后端|node|express|server/i.test(text)) {
return "backend";
}
if (/qa|test|验收|测试/i.test(text)) {
return "qa";
}
return "custom";
};
export const createProposalRole = (input: {
displayName: string;
title: string;
runtimeType: MemberRuntimeType;
capabilityHint?: MemberCapabilityHint;
responsibility: string;
reason: string;
source?: "existing" | "new";
}): ProposalRole => ({
id: createId("proposal_role"),
displayName: input.displayName,
title: input.title,
runtimeType: input.runtimeType,
capabilityHint: input.capabilityHint || inferCapabilityHint(`${input.displayName} ${input.title} ${input.responsibility}`),
responsibility: input.responsibility,
reason: input.reason,
source: input.source || "new"
});
export const ensureReviewerRole = (roles: ProposalRole[]): ProposalRole[] => {
if (roles.some((role) => role.runtimeType === "reviewer")) {
return roles;
}
return [
createProposalRole({
displayName: "项目 reviewer",
title: "风险审查成员",
runtimeType: "reviewer",
capabilityHint: "review",
responsibility: "负责把关风险、提出打回意见、给出复审结论。",
reason: "reviewer 是 v1 固定核心岗位,用来保住主工作流的审查闭环。"
}),
...roles
];
};
const lifecycleToSessionStatus = (lifecycle: MemberLifecycle): RuntimeSession["status"] => {
switch (lifecycle) {
case "working":
return "running";
case "waiting_review":
return "waiting";
case "blocked":
case "paused":
return "blocked";
case "done":
return "done";
default:
return "idle";
}
};
const lifecycleToThreadStatus = (lifecycle: MemberLifecycle): ThreadStatus => {
switch (lifecycle) {
case "working":
return "active";
case "waiting_review":
return "standby";
case "blocked":
case "paused":
return "blocked";
case "done":
return "done";
default:
return "standby";
}
};
export const createDefaultTeamProposal = (createdAt = now()): TeamProposal => ({
summary: "先由项目 lead 理解目标,再产出项目专属团队草案,确认后再正式建队。",
naturalLanguageBrief:
"你只需要先跟项目 lead 说清楚目标、风险边界和节奏。lead 会先给你一版团队草案,再等你确认后创建成员。",
status: "draft",
changeMode: "initial",
reuseFirst: true,
reviewerRule: "reviewer 默认有较强否决权,发现明显风险时可以打回。",
leadOverrideRule: "lead 可以说明原因后申请 override,但必须留痕。",
userOverrideRule: "你可以最终拍板 override,系统会保留人工干预记录。",
roles: [],
archivedMembers: [],
updatedAt: createdAt
});
export const createDefaultTeamState = (createdAt = now()): TeamState => ({
status: "inactive",
members: [],
archivedMembers: [],
confirmedAt: null,
updatedAt: createdAt,
lastChangeSummary: "项目刚创建,还没有正式团队。"
});
const createLeadProfile = (projectPath: string, leadName: string, defaultModel: string): LeadProfile => {
const session = updateRuntimeSession(createRuntimeSession(leadName, projectPath, defaultModel, ["Lead 会在这里保留最近输出。"]), {
appendOutput: ["项目刚创建,Lead 正在等待你说清楚目标。"]
});
return {
id: createId("lead"),
name: leadName,
version: 1,
status: "active",
handoverNote: "如需重建 lead,新 lead 上线前必须由上一任 lead 先做交接。",
session
};
};
export const promoteProposalRoleToMember = (role: ProposalRole, projectPath: string, defaultModel: string): ProjectMember => {
const createdAt = now();
const threadId = `thread_member_${createId("member_thread")}`;
return {
id: createId("member"),
displayName: role.displayName,
title: role.title,
runtimeType: role.runtimeType,
capabilityHint: role.capabilityHint,
responsibility: role.responsibility,
lifecycle: "idle",
source: role.source,
createdAt,
lastUsedAt: null,
threadId,
session: updateRuntimeSession(createRuntimeSession(role.displayName, projectPath, defaultModel, [`${role.displayName} 的运行日志会出现在这里。`]), {
status: "waiting",
appendOutput: [`已根据团队草案创建 ${role.displayName}。等待 lead 派发任务。`]
})
};
};
export const createTaskItem = (
title: string,
summary: string,
ownerMemberId: string | null,
ownerName: string,
createdBy: string
): TaskItem => ({
id: createId("task"),
title,
summary,
status: "in_progress",
ownerMemberId,
ownerName,
createdBy,
reviewState: "not_requested",
currentReviewId: null,
updatedAt: now()
});
export const createManualAction = (
action: string,
note: string,
targetMemberId: string | null,
targetMemberName: string | null
): ManualAction => ({
id: createId("manual"),
action,
operator: "用户人工接管",
targetMemberId,
targetMemberName,
note,
createdAt: now()
});
const buildLeadThread = (lead: LeadProfile, existing?: ConversationThread, seedMessages?: ChatMessage[]): ConversationThread => ({
id: "thread_lead",
kind: "lead",
runtimeType: "lead",
label: lead.name,
subtitle: "主对话窗口。你默认只需要和项目 lead 说目标、边界和拍板。",
status: lead.status === "handover_pending" ? "blocked" : "active",
actorId: lead.id,
messages: existing?.messages.length ? existing.messages : seedMessages || [createLeadMessage("Lead 已就位,你可以直接开始说项目目标。", lead.name)]
});
const buildMemberThread = (member: ProjectMember, existing?: ConversationThread): ConversationThread => ({
id: member.threadId,
kind: "member",
runtimeType: member.runtimeType,
label: member.displayName,
subtitle: member.title,
status: lifecycleToThreadStatus(member.lifecycle),
actorId: member.id,
messages:
existing?.messages.length
? existing.messages
: [
createSystemMessage(
member.runtimeType === "reviewer"
? `${member.displayName} 已挂到 reviewer 线程,后面会在这里给出打回与复审结论。`
: `${member.displayName} 已创建完成,后面会在这里显示执行回执和最近输出。`
)
]
});
export const syncThreadsWithTeam = (
existingThreads: ConversationThread[],
lead: LeadProfile,
members: ProjectMember[],
seedLeadMessages?: ChatMessage[]
): ConversationThread[] => {
const leadThread = buildLeadThread(
lead,
existingThreads.find((thread) => thread.id === "thread_lead"),
seedLeadMessages
);
const memberThreads = members.map((member) =>
buildMemberThread(
{
...member,
session: updateRuntimeSession(member.session, { status: lifecycleToSessionStatus(member.lifecycle) })
},
existingThreads.find((thread) => thread.id === member.threadId)
)
);
return [leadThread, ...memberThreads];
};
export const buildNativeSessions = (lead: LeadProfile, members: ProjectMember[]): NativeSessionRef[] => [
{
actorId: lead.id,
actorLabel: lead.name,
runtimeType: "lead",
session: lead.session
},
...members.map((member) => ({
actorId: member.id,
actorLabel: member.displayName,
runtimeType: member.runtimeType,
session: member.session
}))
];
export const createDefaultProject = (input: ProjectCreateInput, codex: CodexCliInfo): Project => {
const createdAt = now();
const projectName = input.name?.trim() || inferProjectName(input.path);
const projectPath = input.path.trim();
const leadName = input.leadName?.trim() || `${projectName} 项目 lead`;
const lead = createLeadProfile(projectPath, leadName, codex.defaultModel);
const storage = createProjectStorageInfo(projectPath);
const introMessages = [
createSystemMessage("项目已接入。当前只锁定这个项目目录,不会自动摸底,也不会自动 commit / push。"),
createLeadMessage("我是这个项目的专属 lead。先和我聊目标,我会再给你项目专属团队草案。", leadName)
];
return {
id: createId("project"),
name: projectName,
path: projectPath,
description: input.description?.trim() || "本地优先项目级 Lead 工作台",
createdAt,
updatedAt: createdAt,
stage: "lead_only",
codex,
lead,
teamProposal: createDefaultTeamProposal(createdAt),
team: createDefaultTeamState(createdAt),
nativeSessions: buildNativeSessions(lead, []),
discovery: {
status: "idle",
requiresUserConfirmation: true,
requestNote: "",
requestedAt: null,
confirmedAt: null,
lastBrief: "",
facts: [],
stack: [],
topLevelEntries: []
},
teamChange: {
pendingConfirmation: false,
summary: "当前没有待确认的团队变更。",
entries: [],
updatedAt: null
},
messages: introMessages,
threads: syncThreadsWithTeam([], lead, [], introMessages),
tasks: [],
reviews: [],
mailbox: [],
mailboxSummaries: [],
sharedProjectMemory: [],
taskActivity: [],
git: createPlaceholderGitStatus(),
manualActions: [],
storage
};
};
export const createLeadSummaryCommand = (projectPath: string, goal: string): string =>
buildCodexExecCommand(projectPath, `以项目 lead 的身份整理目标并决定下一步:${goal}`);

202
server/src/discovery.ts Normal file
View File

@ -0,0 +1,202 @@
import { readdir, readFile, stat } from "node:fs/promises";
import { randomUUID } from "node:crypto";
import path from "node:path";
import type { DiscoveryResult, MemberCapabilityHint, MemberRuntimeType, ProposalRole, SuggestedTask } from "./types.js";
const createSuggestedRole = (
displayName: string,
title: string,
runtimeType: MemberRuntimeType,
responsibility: string,
reason: string,
capabilityHint: MemberCapabilityHint
): ProposalRole => ({
id: `proposal_role_${randomUUID()}`,
displayName,
title,
runtimeType,
capabilityHint,
responsibility,
reason,
source: "new"
});
const createSuggestedTask = (title: string, summary: string): SuggestedTask => ({ title, summary });
const safeReadJson = async <T>(filePath: string): Promise<T | null> => {
try {
const raw = await readFile(filePath, "utf8");
return JSON.parse(raw) as T;
} catch {
return null;
}
};
const pushUnique = (target: string[], value: string | null | undefined) => {
if (!value || target.includes(value)) {
return;
}
target.push(value);
};
const pushRole = (target: ProposalRole[], role: ProposalRole) => {
if (target.some((item) => item.title === role.title)) {
return;
}
target.push(role);
};
export const analyzeProject = async (projectPath: string, leadName: string): Promise<DiscoveryResult> => {
const topLevelEntries = (await readdir(projectPath, { withFileTypes: true }))
.map((entry) => entry.name)
.sort((left, right) => left.localeCompare(right, "zh-CN"));
const packageJsonPath = path.join(projectPath, "package.json");
const packageJson = await safeReadJson<{
name?: string;
scripts?: Record<string, string>;
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
}>(packageJsonPath);
const dependencies = {
...(packageJson?.dependencies || {}),
...(packageJson?.devDependencies || {})
};
const stack: string[] = [];
const facts: string[] = [];
if (packageJson) {
pushUnique(stack, "Node.js 项目");
facts.push(`检测到 package.json,项目名:${packageJson.name || "未写项目名"}。`);
}
const hasTypeScript = topLevelEntries.includes("tsconfig.json") || Object.keys(dependencies).some((name) => name.includes("typescript"));
const hasReact = "react" in dependencies;
const hasVite = Object.keys(dependencies).some((name) => name.includes("vite"));
const hasExpress = "express" in dependencies;
const hasNext = "next" in dependencies;
const hasVue = "vue" in dependencies;
const hasTailwind = Object.keys(dependencies).some((name) => name.includes("tailwind"));
const hasServerDir = topLevelEntries.includes("server");
const hasWebDir = topLevelEntries.includes("web") || topLevelEntries.includes("src");
if (hasTypeScript) pushUnique(stack, "TypeScript");
if (hasReact) pushUnique(stack, "React");
if (hasVite) pushUnique(stack, "Vite");
if (hasExpress) pushUnique(stack, "Express");
if (hasNext) pushUnique(stack, "Next.js");
if (hasVue) pushUnique(stack, "Vue");
if (hasTailwind) pushUnique(stack, "Tailwind CSS");
if (topLevelEntries.includes(".git")) {
facts.push("当前目录本身就是一个 Git 仓库。");
}
if (hasServerDir && hasWebDir) {
facts.push("目录里同时有前端和后端结构,适合按 lead 工作台 + 本地后台服务推进。");
} else if (hasReact || hasVue || hasNext) {
facts.push("目录里有明显前端框架特征,界面和交互很可能是主战场。");
}
if (packageJson?.scripts) {
const scriptNames = Object.keys(packageJson.scripts);
facts.push(`可见脚本:${scriptNames.join("、") || "未配置脚本"}。`);
}
const keyDirs: string[] = [];
for (const candidate of ["server", "web", "src", "app", "pages", "components"]) {
try {
const info = await stat(path.join(projectPath, candidate));
if (info.isDirectory()) {
keyDirs.push(candidate);
}
} catch {
// ignore
}
}
if (keyDirs.length > 0) {
facts.push(`关键目录:${keyDirs.join("、")}。`);
}
const suggestedRoles: ProposalRole[] = [];
pushRole(
suggestedRoles,
createSuggestedRole(
"项目 reviewer",
"风险审查成员",
"reviewer",
"负责拦风险、给出打回意见、做最终复审。",
"reviewer 是 v1 固定核心岗位,先把主工作流的审查闭环稳住。",
"review"
)
);
if (hasReact || hasVue || hasNext || hasWebDir) {
pushRole(
suggestedRoles,
createSuggestedRole(
"界面推进成员",
"界面推进成员",
"worker",
"负责主工作台、页面结构、交互和视觉细节。",
"当前目录里有明显前端特征,建议单独安排一个偏界面的执行成员。",
"frontend"
)
);
}
if (hasExpress || hasServerDir) {
pushRole(
suggestedRoles,
createSuggestedRole(
"流程推进成员",
"流程推进成员",
"worker",
"负责 API、数据流、工作流状态和本地服务层。",
"当前目录里有后端或服务层结构,建议安排一个偏流程实现的执行成员。",
"backend"
)
);
}
if (suggestedRoles.filter((role) => role.runtimeType === "worker").length === 0) {
pushRole(
suggestedRoles,
createSuggestedRole(
"主实现成员",
"主实现成员",
"worker",
"负责把 lead 当前确定的第一批任务真正落下来。",
"当前项目还没识别出明确前后端分层,先用一个通用执行成员起步最稳。",
"custom"
)
);
}
const suggestedTasks: SuggestedTask[] = [
createSuggestedTask("先确认第一批主链路", `${leadName} 先锁定一条最能体现产品方向的真实工作流。`),
createSuggestedTask("根据真实目录调整团队草案", "优先复用项目内历史成员,没有合适的再补新成员。"),
createSuggestedTask("给 reviewer 留出清晰的打回口", "不要把审查做成一句装饰性文案,要让返工链路能看见。")
];
const briefParts = [
"我已经看过当前项目目录了。",
stack.length > 0 ? `这更像一个 ${stack.join(" + ")} 的本地项目。` : "暂时还没有识别出特别明确的技术栈标签。",
keyDirs.length > 0 ? `关键目录主要有 ${keyDirs.join("、")}。` : "目录结构目前还比较轻。",
"按现在的情况,最适合先走“项目级 lead + reviewer 固定 + 自定义执行成员”的路线。"
];
return {
brief: briefParts.join(""),
facts,
stack,
topLevelEntries,
suggestedRoles,
suggestedTasks
};
};

57
server/src/git.ts Normal file
View File

@ -0,0 +1,57 @@
import { access } from "node:fs/promises";
import { constants } from "node:fs";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import type { GitSnapshot } from "./types.js";
import { createPlaceholderGitStatus } from "./defaults.js";
const execFileAsync = promisify(execFile);
const parseGitStatus = (output: string): GitSnapshot => {
const lines = output.split(/\r?\n/).filter(Boolean);
const header = lines[0]?.replace(/^##\s*/, "") || "未命名分支";
const changes = lines.slice(1);
const dirty = changes.length > 0;
return {
mode: "live",
branch: header,
summary: dirty ? `检测到 ${changes.length} 项工作区变更。` : "工作区干净,可以继续安排任务。",
dirty,
trackedChanges: changes.length,
lastCheckedAt: new Date().toISOString(),
raw: output.trim()
};
};
export const readGitStatus = async (projectPath: string): Promise<GitSnapshot> => {
if (!projectPath.trim()) {
return {
...createPlaceholderGitStatus(),
summary: "还没有填写项目路径,暂时无法读取 Git 状态。"
};
}
try {
await access(projectPath, constants.R_OK);
} catch {
return {
...createPlaceholderGitStatus(),
summary: "项目路径暂时不可读,请先确认本地路径是否正确。"
};
}
try {
const { stdout } = await execFileAsync("git", ["-C", projectPath, "status", "--short", "--branch"], {
timeout: 3000,
windowsHide: true
});
return parseGitStatus(stdout);
} catch {
return {
...createPlaceholderGitStatus(),
summary: "当前目录还没有可读取的 Git 状态,或 Git 暂时不可用。"
};
}
};

484
server/src/index.ts Normal file
View File

@ -0,0 +1,484 @@
import path from "node:path";
import { existsSync } from "node:fs";
import express from "express";
import cors from "cors";
import {
addSharedMemoryEntry,
confirmDiscovery,
confirmTeamChange,
confirmTeamProposal,
createProject,
createTask,
ensureStore,
getProject,
getProjectOfficeState,
getProjectMessages,
listProjects,
requestDiscovery,
rebuildSession,
restoreSession,
resubmitTask,
reviewTask,
saveManualAction,
sendMailboxMessage,
saveTeamProposal,
saveUserMessage,
streamUserMessage,
submitTask,
updateMailboxStatus,
updateSessionModel,
triggerSessionTakeover
} from "./store.js";
import { getPixelAgentsAssetFilePath, getPixelAgentsAssetsManifest } from "./pixelAgents.js";
import type {
MailboxMessageCreateInput,
MailboxStatusUpdateInput,
ReviewDecisionInput,
SessionModelUpdateInput,
TaskCreateInput,
TeamProposalUpdateInput
} from "./types.js";
const app = express();
const port = Number(process.env.PORT || 8787);
const webDist = path.resolve(process.cwd(), "dist", "web");
app.use(cors());
app.use(express.json({ limit: "1mb" }));
app.get("/api/health", (_request, response) => {
response.json({
ok: true,
service: "agents-team-workbench-local-api",
time: new Date().toISOString()
});
});
app.get("/api/projects", async (_request, response) => {
response.json(await listProjects());
});
app.post("/api/projects", async (request, response) => {
const projectPath = String(request.body?.path || "").trim();
if (!projectPath) {
response.status(400).json({ message: "请先填写本地项目路径。" });
return;
}
const project = await createProject({
name: String(request.body?.name || "").trim(),
path: projectPath,
leadName: String(request.body?.leadName || "").trim(),
description: String(request.body?.description || "").trim()
});
response.status(201).json(project);
});
app.get("/api/projects/:projectId", async (request, response) => {
const project = await getProject(request.params.projectId);
if (!project) {
response.status(404).json({ message: "没有找到这个项目。" });
return;
}
response.json(project);
});
app.get("/api/projects/:projectId/messages", async (request, response) => {
const messages = await getProjectMessages(request.params.projectId);
if (!messages) {
response.status(404).json({ message: "没有找到这个项目。" });
return;
}
response.json(messages);
});
app.get("/api/projects/:projectId/office", async (request, response) => {
const office = await getProjectOfficeState(request.params.projectId, String(request.query.threadId || "").trim() || null);
if (!office) {
response.status(404).json({ message: "没有找到这个项目。" });
return;
}
response.json(office);
});
const handleSaveProposal = async (request: express.Request, response: express.Response) => {
const payload = request.body as TeamProposalUpdateInput;
const projectId = String(request.params.projectId);
if (!payload?.summary || !payload?.naturalLanguageBrief) {
response.status(400).json({ message: "团队草案至少需要一句总结和一段自然语言说明。" });
return;
}
const project = await saveTeamProposal(projectId, payload);
if (!project) {
response.status(404).json({ message: "没有找到这个项目。" });
return;
}
response.json(project);
};
app.put("/api/projects/:projectId/team-proposal", handleSaveProposal);
app.put("/api/projects/:projectId/team-draft", handleSaveProposal);
app.post("/api/projects/:projectId/team-proposal/confirm", async (request, response) => {
const project = await confirmTeamProposal(request.params.projectId);
if (!project) {
response.status(404).json({ message: "没有找到这个项目。" });
return;
}
response.json(project);
});
app.post("/api/projects/:projectId/team-change/confirm", async (request, response) => {
const project = await confirmTeamChange(request.params.projectId);
if (!project) {
response.status(404).json({ message: "没有找到这个项目。" });
return;
}
response.json(project);
});
app.post("/api/projects/:projectId/discovery/request", async (request, response) => {
const project = await requestDiscovery(request.params.projectId, String(request.body?.note || "").trim());
if (!project) {
response.status(404).json({ message: "没有找到这个项目。" });
return;
}
response.json(project);
});
app.post("/api/projects/:projectId/discovery/confirm", async (request, response) => {
const existingProject = await getProject(request.params.projectId);
if (!existingProject) {
response.status(404).json({ message: "没有找到这个项目。" });
return;
}
if (existingProject.discovery.status !== "pending_confirmation") {
response.status(409).json({ message: "当前没有待确认的项目摸底请求。" });
return;
}
response.json(await confirmDiscovery(request.params.projectId));
});
app.post("/api/projects/:projectId/messages", async (request, response) => {
const content = String(request.body?.content || "").trim();
const threadId = String(request.body?.threadId || "").trim() || null;
if (!content) {
response.status(400).json({ message: "消息内容不能为空。" });
return;
}
const project = await saveUserMessage(request.params.projectId, content, threadId);
if (!project) {
response.status(404).json({ message: "没有找到这个项目。" });
return;
}
response.json(project);
});
app.post("/api/projects/:projectId/messages/stream", async (request, response) => {
const content = String(request.body?.content || "").trim();
const threadId = String(request.body?.threadId || "").trim() || null;
if (!content) {
response.status(400).json({ message: "消息内容不能为空。" });
return;
}
response.status(200);
response.setHeader("Content-Type", "application/x-ndjson; charset=utf-8");
response.setHeader("Cache-Control", "no-cache, no-transform");
response.setHeader("Connection", "keep-alive");
response.flushHeaders();
const writeEvent = (event: unknown) => {
if (!response.writableEnded) {
response.write(`${JSON.stringify(event)}\n`);
}
};
try {
const project = await streamUserMessage(request.params.projectId, content, threadId, writeEvent);
if (!project && !response.writableEnded) {
writeEvent({
type: "error",
threadId,
actorId: null,
actorLabel: "系统",
message: "没有找到这个项目。"
});
}
} catch (error) {
writeEvent({
type: "error",
threadId,
actorId: null,
actorLabel: "系统",
message: error instanceof Error ? error.message : "流式消息处理失败。"
});
} finally {
response.end();
}
});
app.post("/api/projects/:projectId/tasks", async (request, response) => {
const payload = request.body as TaskCreateInput;
if (!payload?.title?.trim()) {
response.status(400).json({ message: "请先给这条任务一个标题。" });
return;
}
const project = await createTask(request.params.projectId, {
title: payload.title.trim(),
summary: String(payload.summary || "").trim() || "还没有任务说明。",
ownerMemberId: payload.ownerMemberId || null
});
if (!project) {
response.status(404).json({ message: "没有找到这个项目。" });
return;
}
response.json(project);
});
app.post("/api/projects/:projectId/tasks/:taskId/submit", async (request, response) => {
const project = await submitTask(request.params.projectId, request.params.taskId, String(request.body?.note || "").trim() || "成员已提交当前结果,请 reviewer 审查。");
if (!project) {
response.status(404).json({ message: "没有找到这个项目或任务。" });
return;
}
response.json(project);
});
app.post("/api/projects/:projectId/tasks/:taskId/resubmit", async (request, response) => {
const project = await resubmitTask(request.params.projectId, request.params.taskId, String(request.body?.note || "").trim() || "已按 reviewer 意见修复,重新提交审查。");
if (!project) {
response.status(404).json({ message: "没有找到这个项目或任务。" });
return;
}
response.json(project);
});
app.post("/api/projects/:projectId/tasks/:taskId/review", async (request, response) => {
const payload = request.body as ReviewDecisionInput;
if (payload?.decision !== "approve" && payload?.decision !== "changes_requested") {
response.status(400).json({ message: "review 结果只能是 approve 或 changes_requested。" });
return;
}
const project = await reviewTask(request.params.projectId, request.params.taskId, {
decision: payload.decision,
note: String(payload.note || "").trim() || (payload.decision === "approve" ? "这条任务通过了。" : "这条任务需要继续修复。")
});
if (!project) {
response.status(404).json({ message: "没有找到这个项目或任务。" });
return;
}
response.json(project);
});
app.post("/api/projects/:projectId/manual-actions", async (request, response) => {
const action = String(request.body?.action || "").trim();
const note = String(request.body?.note || "").trim();
const targetMemberId = String(request.body?.targetMemberId || "").trim() || null;
if (!action) {
response.status(400).json({ message: "请先选择人工接管动作。" });
return;
}
const project = await saveManualAction(request.params.projectId, action, note, targetMemberId);
if (!project) {
response.status(404).json({ message: "没有找到这个项目。" });
return;
}
response.json(project);
});
app.post("/api/projects/:projectId/mailbox", async (request, response) => {
const payload = request.body as MailboxMessageCreateInput;
if (!payload?.fromMemberId || !payload?.toMemberId || !payload?.summary?.trim()) {
response.status(400).json({ message: "mailbox 消息至少需要发送人、接收人和一句摘要。" });
return;
}
const project = await sendMailboxMessage(String(request.params.projectId), {
fromMemberId: String(payload.fromMemberId),
toMemberId: String(payload.toMemberId),
kind: payload.kind || "question",
relatedTaskId: payload.relatedTaskId || null,
summary: String(payload.summary).trim(),
body: String(payload.body || "").trim()
});
if (!project) {
response.status(404).json({ message: "没有找到这个项目或成员。" });
return;
}
response.json(project);
});
app.put("/api/projects/:projectId/mailbox/:mailboxId/status", async (request, response) => {
const payload = request.body as MailboxStatusUpdateInput;
if (payload?.status !== "open" && payload?.status !== "acknowledged" && payload?.status !== "resolved") {
response.status(400).json({ message: "mailbox 状态只能是 open、acknowledged 或 resolved。" });
return;
}
const project = await updateMailboxStatus(String(request.params.projectId), String(request.params.mailboxId), payload.status);
if (!project) {
response.status(404).json({ message: "没有找到这个项目或消息。" });
return;
}
response.json(project);
});
app.post("/api/projects/:projectId/shared-memory", async (request, response) => {
const title = String(request.body?.title || "").trim();
const body = String(request.body?.body || "").trim();
if (!title || !body) {
response.status(400).json({ message: "共享记忆至少需要标题和正文。" });
return;
}
const project = await addSharedMemoryEntry(String(request.params.projectId), title, body);
if (!project) {
response.status(404).json({ message: "没有找到这个项目。" });
return;
}
response.json(project);
});
app.put("/api/projects/:projectId/sessions/:sessionId/model", async (request, response) => {
const payload = request.body as SessionModelUpdateInput;
if (payload?.mode !== "inherit" && payload?.mode !== "override") {
response.status(400).json({ message: "模型模式只能是 inherit 或 override。" });
return;
}
if (payload.mode === "override" && !String(payload.model || "").trim()) {
response.status(400).json({ message: "自定义模型名不能为空。" });
return;
}
const project = await updateSessionModel(String(request.params.projectId), String(request.params.sessionId), {
mode: payload.mode,
model: String(payload.model || "").trim()
});
if (!project) {
response.status(404).json({ message: "没有找到这个项目或会话。" });
return;
}
response.json(project);
});
app.post("/api/projects/:projectId/sessions/:sessionId/takeover", async (request, response) => {
const result = await triggerSessionTakeover(request.params.projectId, request.params.sessionId);
if (!result.project) {
response.status(404).json({ message: result.message });
return;
}
response.json({ ok: true, message: result.message });
});
app.post("/api/projects/:projectId/sessions/:sessionId/restore", async (request, response) => {
const result = await restoreSession(request.params.projectId, request.params.sessionId);
if (!result.project) {
response.status(404).json({ message: result.message });
return;
}
response.json({ ok: true, message: result.message, project: result.project });
});
app.post("/api/projects/:projectId/sessions/:sessionId/rebuild", async (request, response) => {
const result = await rebuildSession(request.params.projectId, request.params.sessionId);
if (!result.project) {
response.status(404).json({ message: result.message });
return;
}
response.json({ ok: true, message: result.message, project: result.project });
});
app.get("/api/pixel-agents-assets/*", (request, response) => {
const requestedAssetPath = decodeURIComponent(request.path.replace(/^\/api\/pixel-agents-assets\/+/, "")).trim();
const assetFilePath = getPixelAgentsAssetFilePath(requestedAssetPath);
if (!assetFilePath) {
response.status(404).type("text/plain").send("没有找到对应的 Pixel Agents 资源。");
return;
}
response.sendFile(assetFilePath);
});
app.get("/api/pixel-agents-manifest", (_request, response) => {
response.json(getPixelAgentsAssetsManifest());
});
if (existsSync(path.join(webDist, "index.html"))) {
app.use(express.static(webDist));
app.get("*", (_request, response) => {
response.sendFile(path.join(webDist, "index.html"));
});
} else {
app.get("/", (_request, response) => {
response.type("text/plain").send("前端还没有 build。开发模式请运行 npm run dev。");
});
}
await ensureStore();
app.listen(port, () => {
console.log(`Agents Team 本地服务已启动: http://localhost:${port}`);
});

215
server/src/pixelAgents.ts Normal file
View File

@ -0,0 +1,215 @@
import os from "node:os";
import path from "node:path";
import { existsSync, readFileSync, readdirSync } from "node:fs";
import type { OfficeAssetsManifest } from "./types.js";
const extensionBaseDirs = [path.join(os.homedir(), ".vscode", "extensions"), path.join(os.homedir(), ".vscode-insiders", "extensions")];
const extensionPrefix = "pablodelucca.pixel-agents-";
const assetBaseUrl = "/api/pixel-agents-assets";
interface PixelAgentsInstallation {
extensionPath: string;
version: string | null;
assetsDir: string;
}
interface PixelFurnitureCatalogAsset {
id: string;
label?: string;
isDesk?: boolean;
footprintW?: number;
footprintH?: number;
}
interface PixelFurnitureCatalog {
assets?: PixelFurnitureCatalogAsset[];
}
interface PixelLayoutFurnitureItem {
uid?: string;
type?: string;
col?: number;
row?: number;
}
interface PixelDefaultLayout {
cols?: number;
rows?: number;
furniture?: PixelLayoutFurnitureItem[];
}
export interface PixelDeskSlotTemplate {
id: string;
label: string;
assetId: string;
col: number;
row: number;
width: number;
height: number;
}
export interface PixelOfficeLayoutTemplate {
cols: number | null;
rows: number | null;
deskSlots: PixelDeskSlotTemplate[];
}
const compareExtensionNames = (left: string, right: string): number =>
right.localeCompare(left, undefined, { numeric: true, sensitivity: "base" });
const listInstalledPixelAgentsExtensions = (): PixelAgentsInstallation[] =>
extensionBaseDirs.flatMap((baseDir) => {
if (!existsSync(baseDir)) {
return [];
}
return readdirSync(baseDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && entry.name.startsWith(extensionPrefix))
.sort((left, right) => compareExtensionNames(left.name, right.name))
.map((entry) => {
const extensionPath = path.join(baseDir, entry.name);
return {
extensionPath,
version: entry.name.slice(extensionPrefix.length) || null,
assetsDir: path.join(extensionPath, "dist", "webview", "assets")
};
})
.filter((entry) => existsSync(entry.assetsDir));
});
const getLatestPixelAgentsInstallation = (): PixelAgentsInstallation | null => listInstalledPixelAgentsExtensions()[0] || null;
const buildAssetUrl = (relativePath: string): string => `${assetBaseUrl}/${relativePath.replace(/\\/g, "/").replace(/^\/+/, "")}`;
const readJsonFile = <T>(filePath: string): T | null => {
if (!existsSync(filePath)) {
return null;
}
try {
return JSON.parse(readFileSync(filePath, "utf8")) as T;
} catch {
return null;
}
};
const listCharacterUrls = (assetsDir: string): string[] => {
const charactersDir = path.join(assetsDir, "characters");
if (!existsSync(charactersDir)) {
return [];
}
return readdirSync(charactersDir, { withFileTypes: true })
.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(".png"))
.sort((left, right) => left.name.localeCompare(right.name, undefined, { numeric: true, sensitivity: "base" }))
.map((entry) => buildAssetUrl(path.posix.join("characters", entry.name)));
};
export const getPixelAgentsOfficeLayoutTemplate = (): PixelOfficeLayoutTemplate => {
const installation = getLatestPixelAgentsInstallation();
if (!installation) {
return {
cols: null,
rows: null,
deskSlots: []
};
}
const layout = readJsonFile<PixelDefaultLayout>(path.join(installation.assetsDir, "default-layout.json"));
const catalog = readJsonFile<PixelFurnitureCatalog>(path.join(installation.assetsDir, "furniture", "furniture-catalog.json"));
const catalogAssets = catalog?.assets || [];
const catalogById = new Map(catalogAssets.map((asset) => [asset.id, asset]));
const deskSlots = (layout?.furniture || [])
.filter(
(item): item is Required<Pick<PixelLayoutFurnitureItem, "type" | "col" | "row">> & PixelLayoutFurnitureItem =>
typeof item.type === "string" && typeof item.col === "number" && typeof item.row === "number"
)
.map((item) => {
const asset = catalogById.get(item.type);
return {
item,
asset
};
})
.filter((entry) => Boolean(entry.asset?.isDesk))
.sort((left, right) => (left.item.row - right.item.row) || (left.item.col - right.item.col))
.map((entry, index) => ({
id: entry.item.uid || `desk-slot-${index + 1}`,
label: entry.asset?.label || `Desk ${index + 1}`,
assetId: entry.item.type,
col: entry.item.col,
row: entry.item.row,
width: Math.max(1, Math.round(entry.asset?.footprintW || 1)),
height: Math.max(1, Math.round(entry.asset?.footprintH || 1))
}));
return {
cols: typeof layout?.cols === "number" ? layout.cols : null,
rows: typeof layout?.rows === "number" ? layout.rows : null,
deskSlots
};
};
export const getPixelAgentsExtensionPath = (): string | null => getLatestPixelAgentsInstallation()?.extensionPath || null;
export const getPixelAgentsAssetFilePath = (relativePath: string): string | null => {
const installation = getLatestPixelAgentsInstallation();
if (!installation) {
return null;
}
const normalizedPath = relativePath.replace(/\\/g, "/").replace(/^\/+/, "");
if (!normalizedPath) {
return null;
}
const resolvedPath = path.resolve(installation.assetsDir, normalizedPath);
const relativeToAssets = path.relative(installation.assetsDir, resolvedPath);
if (relativeToAssets.startsWith("..") || path.isAbsolute(relativeToAssets)) {
return null;
}
return existsSync(resolvedPath) ? resolvedPath : null;
};
export const getPixelAgentsAssetsManifest = (): OfficeAssetsManifest => {
const installation = getLatestPixelAgentsInstallation();
if (!installation) {
return {
available: false,
source: "none",
extensionPath: null,
extensionVersion: null,
assetBaseUrl: null,
defaultLayoutUrl: null,
floorsUrl: null,
wallsUrl: null,
officeTilesetUrl: null,
furnitureCatalogUrl: null,
furnitureBaseUrl: null,
characterUrls: [],
notes: ["本机还没有检测到已安装的 Pixel Agents VS Code 扩展。"]
};
}
const characterUrls = listCharacterUrls(installation.assetsDir);
return {
available: true,
source: "vscode-extension",
extensionPath: installation.extensionPath,
extensionVersion: installation.version,
assetBaseUrl,
defaultLayoutUrl: buildAssetUrl("default-layout.json"),
floorsUrl: buildAssetUrl("floors.png"),
wallsUrl: buildAssetUrl("walls.png"),
officeTilesetUrl: buildAssetUrl("office_tileset_16x16.png"),
furnitureCatalogUrl: buildAssetUrl("furniture/furniture-catalog.json"),
furnitureBaseUrl: buildAssetUrl("furniture"),
characterUrls,
notes: [
`已检测到 Pixel Agents ${installation.version || "unknown"} 扩展资源。`,
"当前已直接暴露默认布局、地板、墙体、办公室 tileset、家具目录和角色素材。"
]
};
};

594
server/src/runtime.ts Normal file
View File

@ -0,0 +1,594 @@
import { randomUUID } from "node:crypto";
import { spawn } from "node:child_process";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { existsSync } from "node:fs";
import { writeFile, unlink } from "node:fs/promises";
import path from "node:path";
import { spawnSync } from "node:child_process";
import os from "node:os";
import type { RuntimeSession, SessionStatus } from "./types.js";
type JsonRecord = Record<string, unknown>;
export type CodexPromptStreamEvent =
| {
type: "thread_started";
threadId: string;
}
| {
type: "assistant_delta";
delta: string;
}
| {
type: "reasoning_delta";
delta: string;
}
| {
type: "tool_event";
toolCallId: string;
toolName: string;
summary: string;
state: "started" | "completed";
}
| {
type: "command_output";
toolCallId: string | null;
chunk: string;
};
const now = (): string => new Date().toISOString();
const execFileAsync = promisify(execFile);
let codexInvocationCache: { command: string; prefixArgs: string[] } | null = null;
const quoteForDisplay = (value: string): string => `"${value.replace(/"/g, '\\"')}"`;
const psEscape = (value: string): string => value.replace(/'/g, "''");
const isRecord = (value: unknown): value is JsonRecord => Boolean(value) && typeof value === "object" && !Array.isArray(value);
const knownNoisyWarnings = new Set([
"unknown feature key in config: plan_tool",
"unknown feature key in config: rmcp_client",
"unknown feature key in config: streamable_shell",
"unknown feature key in config: view_image_tool",
"Failed to create shell snapshot for powershell"
]);
const firstString = (...values: unknown[]): string => {
for (const value of values) {
if (typeof value === "string" && value.trim()) {
return value.trim();
}
}
return "";
};
const decodeBase64Chunk = (value: unknown): string => {
if (typeof value !== "string" || !value.trim()) {
return "";
}
try {
return Buffer.from(value, "base64").toString("utf8");
} catch {
return "";
}
};
const eventPayload = (raw: JsonRecord): JsonRecord => {
const payload = raw.params;
return isRecord(payload) ? payload : raw;
};
const eventName = (raw: JsonRecord): string => {
const name = firstString(raw.type, raw.method, raw.event, eventPayload(raw).type, eventPayload(raw).method, eventPayload(raw).event);
return name.replace(/\//g, ".").replace(/\s+/g, "").toLowerCase();
};
const itemRecord = (raw: JsonRecord): JsonRecord => {
const payload = eventPayload(raw);
if (isRecord(raw.item)) {
return raw.item;
}
if (isRecord(payload.item)) {
return payload.item;
}
return payload;
};
const itemTypeName = (item: JsonRecord): string =>
firstString(item.type, item.kind, item.name, item.toolName, item.tool_name, item.commandType, item.command_type).replace(/\//g, ".").toLowerCase();
const itemText = (item: JsonRecord, payload?: JsonRecord): string =>
firstString(
item.text,
item.delta,
item.content,
item.summary,
item.title,
payload?.text,
payload?.delta,
payload?.content,
payload?.summary,
payload?.title
);
const itemId = (item: JsonRecord, payload?: JsonRecord): string =>
firstString(item.id, item.callId, item.call_id, item.toolCallId, item.tool_call_id, payload?.id, payload?.callId, payload?.call_id);
const itemToolName = (item: JsonRecord, payload?: JsonRecord): string => {
const explicit = firstString(item.name, item.toolName, item.tool_name, item.command, payload?.name, payload?.toolName, payload?.tool_name, payload?.command);
if (explicit) {
return explicit;
}
const type = itemTypeName(item);
if (!type) {
return "工具调用";
}
if (type.includes("apply_patch")) {
return "apply_patch";
}
if (type.includes("commandexecution") || type.includes("local_shell_call")) {
return "命令执行";
}
if (type.includes("web_search")) {
return "网页搜索";
}
if (type.includes("function_call")) {
return "函数调用";
}
return explicit || type;
};
const classifyCodexStreamEvent = (raw: JsonRecord): CodexPromptStreamEvent[] => {
const name = eventName(raw);
const payload = eventPayload(raw);
const item = itemRecord(raw);
const normalizedItemType = itemTypeName(item);
const events: CodexPromptStreamEvent[] = [];
if (name === "thread.started") {
const threadId = firstString(raw.thread_id, raw.threadId, payload.thread_id, payload.threadId);
if (threadId) {
events.push({ type: "thread_started", threadId });
}
return events;
}
if (name.includes("agentmessage") && name.includes("delta")) {
const delta = itemText(item, payload);
if (delta) {
events.push({ type: "assistant_delta", delta });
}
return events;
}
if (name.includes("reasoning") && (name.includes("delta") || name.includes("summarypartadded"))) {
const delta = itemText(item, payload);
if (delta) {
events.push({ type: "reasoning_delta", delta });
}
return events;
}
if (name.includes("command.exec.outputdelta") || name.includes("commandexecution.outputdelta")) {
const chunk =
firstString(payload.chunk, payload.text, payload.delta, payload.output, payload.stdout, payload.stderr) ||
decodeBase64Chunk(payload.deltaBase64);
if (chunk) {
events.push({
type: "command_output",
toolCallId: firstString(payload.toolCallId, payload.tool_call_id, payload.callId, payload.call_id) || null,
chunk
});
}
return events;
}
if (name === "item.started" || name === "item.completed") {
const state = name === "item.started" ? "started" : "completed";
if (normalizedItemType === "agent_message" || normalizedItemType === "agentmessage") {
const text = itemText(item, payload);
if (state === "completed" && text) {
events.push({ type: "assistant_delta", delta: text });
}
return events;
}
const isToolLike =
normalizedItemType.includes("tool") ||
normalizedItemType.includes("function_call") ||
normalizedItemType.includes("shell") ||
normalizedItemType.includes("commandexecution") ||
normalizedItemType.includes("web_search") ||
normalizedItemType.includes("apply_patch");
if (isToolLike) {
const toolCallId = itemId(item, payload) || `${itemToolName(item, payload)}-${state}`;
const summary =
firstString(item.summary, item.title, payload.summary, payload.title, payload.command, item.command) ||
`${state === "started" ? "开始" : "完成"} ${itemToolName(item, payload)}`;
events.push({
type: "tool_event",
toolCallId,
toolName: itemToolName(item, payload),
summary,
state
});
}
}
return events;
};
export const buildCodexTakeoverCommand = (projectPath: string, model?: string): string =>
model
? `codex --cd ${quoteForDisplay(projectPath)} -m ${quoteForDisplay(model)}`
: `codex --cd ${quoteForDisplay(projectPath)}`;
export const buildCodexExecCommand = (projectPath: string, prompt: string, model?: string): string =>
model
? `codex exec --cd ${quoteForDisplay(projectPath)} -m ${quoteForDisplay(model)} ${quoteForDisplay(prompt)}`
: `codex exec --cd ${quoteForDisplay(projectPath)} ${quoteForDisplay(prompt)}`;
export const createRuntimeSession = (
roleLabel: string,
projectPath: string,
model: string,
seedOutput: string[] = []
): RuntimeSession => ({
id: `session_${randomUUID()}`,
adapter: "codex-cli",
roleLabel,
status: "idle",
identityState: "fresh",
model,
modelSource: "codex-config",
estimatedContextTokens: 0,
contextUsagePercent: 0,
lastThreadId: null,
recentOutput: seedOutput.slice(-6),
lastUpdatedAt: now(),
lastCommand: buildCodexExecCommand(projectPath, `以 ${roleLabel} 身份接手当前项目上下文`, model),
takeoverCommand: buildCodexTakeoverCommand(projectPath, model),
resumeCommand: "codex resume --last",
lastError: ""
});
export const updateRuntimeSession = (
session: RuntimeSession,
patch: Partial<RuntimeSession> & { status?: SessionStatus; appendOutput?: string[] }
): RuntimeSession => {
const { appendOutput, ...rest } = patch;
const mergedOutput = patch.appendOutput ? [...session.recentOutput, ...patch.appendOutput].slice(-6) : session.recentOutput;
return {
...session,
...rest,
recentOutput: mergedOutput,
lastUpdatedAt: now()
};
};
export const launchCodexTakeover = async (
roleLabel: string,
projectPath: string,
model?: string,
resumeCommand?: string
): Promise<void> => {
const takeoverCommand = resumeCommand && resumeCommand !== "codex resume --last"
? resumeCommand
: model
? `codex --cd '${psEscape(projectPath)}' -m '${psEscape(model)}'`
: `codex --cd '${psEscape(projectPath)}'`;
const script = [
`$Host.UI.RawUI.WindowTitle = 'Agents Team - ${psEscape(roleLabel)}'`,
`Set-Location -LiteralPath '${psEscape(projectPath)}'`,
`Write-Host '已打开 ${psEscape(roleLabel)} 的原生 Codex 接管窗口。' -ForegroundColor Cyan`,
"Write-Host '你现在可以继续用 Codex CLI 原生命令操作。' -ForegroundColor DarkGray",
takeoverCommand
].join("; ");
const child = spawn("powershell.exe", ["-NoExit", "-Command", script], {
detached: true,
stdio: "ignore"
});
child.unref();
};
export const resolveCodexInvocation = (): { command: string; prefixArgs: string[] } => {
if (codexInvocationCache) {
return codexInvocationCache;
}
if (process.platform === "win32") {
const lookup = spawnSync("where", ["codex.cmd"], { encoding: "utf8", windowsHide: true });
const cmdPath = lookup.stdout?.split(/\r?\n/).map((line) => line.trim()).find(Boolean);
if (cmdPath) {
const binScript = path.join(path.dirname(cmdPath), "node_modules", "@openai", "codex", "bin", "codex.js");
if (existsSync(binScript)) {
codexInvocationCache = {
command: process.execPath,
prefixArgs: [binScript]
};
return codexInvocationCache;
}
}
}
codexInvocationCache = {
command: "codex",
prefixArgs: []
};
return codexInvocationCache;
};
export const runCodexReadOnlyPrompt = async (input: {
projectPath: string;
model: string;
prompt: string;
resumeSessionId?: string;
timeoutMs?: number;
}): Promise<{ reply: string; threadId: string; logLines: string[]; errorLines: string[] }> => {
return runCodexReadOnlyPromptStreaming(input);
};
export const runCodexReadOnlyPromptStreaming = async (input: {
projectPath: string;
model: string;
prompt: string;
resumeSessionId?: string;
timeoutMs?: number;
onEvent?: (event: CodexPromptStreamEvent) => void;
}): Promise<{ reply: string; threadId: string; logLines: string[]; errorLines: string[] }> => {
const args = input.resumeSessionId
? [
"-s",
"read-only",
"-a",
"never",
"exec",
"resume",
"--json",
"--skip-git-repo-check",
"-m",
input.model,
input.resumeSessionId,
input.prompt
]
: [
"-s",
"read-only",
"-a",
"never",
"exec",
"--json",
"--skip-git-repo-check",
"-C",
input.projectPath,
"-m",
input.model,
input.prompt
];
const invocation = resolveCodexInvocation();
return new Promise((resolve, reject) => {
const child = spawn(invocation.command, [...invocation.prefixArgs, ...args], {
cwd: input.projectPath,
windowsHide: true,
stdio: ["ignore", "pipe", "pipe"]
});
const timeout = setTimeout(() => {
child.kill();
reject(new Error("Codex CLI 调用超时。"));
}, input.timeoutMs || 120000);
let stdoutBuffer = "";
let stderrBuffer = "";
let threadId = "";
let assistantReply = "";
let sawAssistantDelta = false;
const assistantSnapshots: string[] = [];
const errorLines: string[] = [];
const handleStdoutLine = (rawLine: string) => {
const line = rawLine.trim();
if (!line) {
return;
}
try {
const rawEvent = JSON.parse(line) as JsonRecord;
for (const streamEvent of classifyCodexStreamEvent(rawEvent)) {
if (streamEvent.type === "thread_started") {
threadId = streamEvent.threadId;
} else if (streamEvent.type === "assistant_delta") {
sawAssistantDelta = true;
assistantReply += streamEvent.delta;
}
input.onEvent?.(streamEvent);
}
const event = rawEvent;
if (event.type === "thread.started" && typeof event.thread_id === "string") {
threadId = event.thread_id;
}
if (event.type === "item.completed") {
const item = isRecord(event.item) ? event.item : undefined;
if (item?.type === "agent_message" && typeof item.text === "string" && item.text.trim()) {
assistantSnapshots.push(item.text.trim());
if (!sawAssistantDelta) {
assistantReply = item.text.trim();
}
}
}
} catch {
// ignore non-json stdout lines
}
};
const handleStderrLine = (rawLine: string) => {
const line = rawLine.trim();
if (!line) {
return;
}
if (!Array.from(knownNoisyWarnings).some((noise) => line.includes(noise))) {
errorLines.push(line);
}
};
child.stdout.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => {
stdoutBuffer += chunk;
const lines = stdoutBuffer.split(/\r?\n/);
stdoutBuffer = lines.pop() || "";
for (const line of lines) {
handleStdoutLine(line);
}
});
child.stderr.setEncoding("utf8");
child.stderr.on("data", (chunk: string) => {
stderrBuffer += chunk;
const lines = stderrBuffer.split(/\r?\n/);
stderrBuffer = lines.pop() || "";
for (const line of lines) {
handleStderrLine(line);
}
});
child.on("error", (error) => {
clearTimeout(timeout);
reject(error);
});
child.on("close", (code) => {
clearTimeout(timeout);
if (stdoutBuffer.trim()) {
handleStdoutLine(stdoutBuffer);
}
if (stderrBuffer.trim()) {
handleStderrLine(stderrBuffer);
}
const reply = assistantReply.trim() || assistantSnapshots.join("\n").trim();
if (code && code !== 0 && !reply) {
reject(new Error(errorLines.at(-1) || `Codex CLI 退出码异常:${code}`));
return;
}
resolve({
reply,
threadId,
logLines: [`模型:${input.model}`, threadId ? `Thread:${threadId}` : "Thread:未返回", ...(reply ? [reply] : [])],
errorLines
});
});
});
};
export const runCodexReadOnlyStructuredPrompt = async <T>(input: {
projectPath: string;
model: string;
prompt: string;
schema: Record<string, unknown>;
resumeSessionId?: string;
timeoutMs?: number;
}): Promise<{ data: T; threadId: string; logLines: string[]; errorLines: string[] }> => {
const schemaPath = path.join(os.tmpdir(), `codex-schema-${randomUUID()}.json`);
await writeFile(schemaPath, JSON.stringify(input.schema, null, 2), "utf8");
try {
const invocation = resolveCodexInvocation();
const args = input.resumeSessionId
? [
"-s",
"read-only",
"-a",
"never",
"exec",
"resume",
"--json",
"--skip-git-repo-check",
"-m",
input.model,
"--output-schema",
schemaPath,
input.resumeSessionId,
input.prompt
]
: [
"-s",
"read-only",
"-a",
"never",
"exec",
"--json",
"--skip-git-repo-check",
"-C",
input.projectPath,
"-m",
input.model,
"--output-schema",
schemaPath,
input.prompt
];
const { stdout, stderr } = await execFileAsync(invocation.command, [...invocation.prefixArgs, ...args], {
cwd: input.projectPath,
timeout: input.timeoutMs || 120000,
windowsHide: true,
maxBuffer: 1024 * 1024 * 8
});
let replyText = "";
let threadId = "";
for (const rawLine of stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean)) {
try {
const event = JSON.parse(rawLine) as Record<string, unknown>;
if (event.type === "thread.started" && typeof event.thread_id === "string") {
threadId = event.thread_id;
}
if (event.type === "item.completed") {
const item = event.item as Record<string, unknown> | undefined;
if (item?.type === "agent_message" && typeof item.text === "string") {
replyText = item.text.trim();
}
}
} catch {
// ignore non-json stdout lines
}
}
const noisyWarnings = ["unknown feature key in config", "Failed to create shell snapshot for powershell"];
const errorLines = stderr
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line && !noisyWarnings.some((noise) => line.includes(noise)));
return {
data: JSON.parse(replyText) as T,
threadId,
logLines: [`模型:${input.model}`, threadId ? `Thread:${threadId}` : "Thread:未返回", "已收到结构化结果"],
errorLines
};
} finally {
await unlink(schemaPath).catch(() => undefined);
}
};

3748
server/src/store.ts Normal file

File diff suppressed because it is too large Load Diff

562
server/src/types.ts Normal file
View File

@ -0,0 +1,562 @@
export type ProjectStage =
| "lead_only"
| "discovery_pending"
| "team_drafting"
| "team_pending_confirmation"
| "team_active"
| "executing"
| "reviewing"
| "blocked"
| "done";
export type DiscoveryStatus = "idle" | "pending_confirmation" | "completed";
export type TeamProposalStatus = "draft" | "pending_confirmation" | "confirmed";
export type TeamStatus = "inactive" | "active";
export type ProposalChangeMode = "initial" | "update";
export type TaskStatus = "todo" | "in_progress" | "in_review" | "changes_requested" | "blocked" | "done";
export type ReviewStatus = "requested" | "changes_requested" | "approved";
export type MessageSender = "user" | "lead" | "system" | "member";
export type MemberSource = "existing" | "new";
export type MemberRuntimeType = "reviewer" | "worker";
export type MemberCapabilityHint = "review" | "frontend" | "backend" | "qa" | "custom";
export type MemberLifecycle = "idle" | "working" | "waiting_review" | "blocked" | "paused" | "done";
export type SessionStatus = "idle" | "running" | "waiting" | "needs_confirmation" | "blocked" | "done" | "offline";
export type SessionModelSource = "codex-config" | "session-override";
export type ThreadKind = "lead" | "member";
export type ThreadStatus = "active" | "standby" | "waiting" | "blocked" | "done";
export type SummaryTeamStatus = "lead_only" | "drafting" | "pending_confirmation" | "active";
export type MailboxKind =
| "question"
| "dependency_request"
| "blocker"
| "handoff"
| "review_feedback"
| "resubmit_request"
| "broadcast";
export type MailboxStatus = "open" | "acknowledged" | "resolved";
export type MemoryEntrySource = "lead" | "system";
export type SessionIdentityState = "fresh" | "resume_ready" | "needs_rebuild";
export type TeamChangeKind = "add" | "remove" | "update" | "keep";
export type TaskActivityKind =
| "task_created"
| "task_assigned"
| "task_started"
| "task_submitted"
| "task_reviewed"
| "task_blocked"
| "task_resumed"
| "task_done"
| "mailbox_linked";
export type ChatStreamStage = "received" | "workflow" | "codex" | "persisting" | "done";
export type ChatToolCallState = "started" | "completed";
export interface ProjectStorageInfo {
agentsDir: string;
workbenchDir: string;
projectStateFile: string;
mailboxFile: string;
sharedMemoryFile: string;
sharedMemoryMarkdownFile: string;
taskActivityFile: string;
}
export interface CodexReasoningEffortOption {
reasoningEffort: string;
description: string;
}
export interface CodexModelOption {
model: string;
displayName: string;
description: string;
defaultReasoningEffort: string;
supportedReasoningEfforts: CodexReasoningEffortOption[];
supportsPersonality: boolean;
isDefault: boolean;
hidden: boolean;
source: "app-server" | "config";
}
export interface CodexCliInfo {
defaultModel: string;
activeProfile: string;
provider: string;
contextWindow: number | null;
autoCompactTokenLimit: number | null;
suggestedModels: string[];
availableModels: CodexModelOption[];
}
export interface RuntimeSession {
id: string;
adapter: "codex-cli";
roleLabel: string;
status: SessionStatus;
identityState: SessionIdentityState;
model: string;
modelSource: SessionModelSource;
estimatedContextTokens: number;
contextUsagePercent: number;
lastThreadId: string | null;
recentOutput: string[];
lastUpdatedAt: string;
lastCommand: string;
takeoverCommand: string;
resumeCommand: string;
lastError: string;
}
export interface NativeSessionRef {
actorId: string;
actorLabel: string;
runtimeType: "lead" | MemberRuntimeType;
session: RuntimeSession;
}
export interface ProposalRole {
id: string;
displayName: string;
title: string;
runtimeType: MemberRuntimeType;
capabilityHint: MemberCapabilityHint;
responsibility: string;
reason: string;
source: MemberSource;
}
export interface ProjectMember {
id: string;
displayName: string;
title: string;
runtimeType: MemberRuntimeType;
capabilityHint: MemberCapabilityHint;
responsibility: string;
lifecycle: MemberLifecycle;
source: MemberSource;
createdAt: string;
lastUsedAt: string | null;
threadId: string;
session: RuntimeSession;
}
export interface TeamProposal {
summary: string;
naturalLanguageBrief: string;
status: TeamProposalStatus;
changeMode: ProposalChangeMode;
reuseFirst: boolean;
reviewerRule: string;
leadOverrideRule: string;
userOverrideRule: string;
roles: ProposalRole[];
archivedMembers: ProjectMember[];
updatedAt: string;
}
export interface TeamState {
status: TeamStatus;
members: ProjectMember[];
archivedMembers: ProjectMember[];
confirmedAt: string | null;
updatedAt: string;
lastChangeSummary: string;
}
export interface TeamChangeEntry {
kind: TeamChangeKind;
currentMemberId: string | null;
proposalRoleId: string | null;
displayName: string;
title: string;
summary: string;
}
export interface TeamChangeState {
pendingConfirmation: boolean;
summary: string;
entries: TeamChangeEntry[];
updatedAt: string | null;
}
export interface LeadProfile {
id: string;
name: string;
version: number;
status: "active" | "handover_pending";
handoverNote: string;
session: RuntimeSession;
}
export interface DiscoveryState {
status: DiscoveryStatus;
requiresUserConfirmation: boolean;
requestNote: string;
requestedAt: string | null;
confirmedAt: string | null;
lastBrief: string;
facts: string[];
stack: string[];
topLevelEntries: string[];
}
export interface ChatMessage {
id: string;
sender: MessageSender;
content: string;
createdAt: string;
senderName?: string;
}
export type ChatStreamEvent =
| {
type: "ack";
threadId: string;
actorId: string | null;
actorLabel: string;
message: ChatMessage;
}
| {
type: "status";
threadId: string;
actorId: string | null;
actorLabel: string;
stage: ChatStreamStage;
text: string;
}
| {
type: "reasoning";
threadId: string;
actorId: string | null;
actorLabel: string;
text: string;
}
| {
type: "tool";
threadId: string;
actorId: string | null;
actorLabel: string;
toolCallId: string;
toolName: string;
summary: string;
state: ChatToolCallState;
}
| {
type: "command_output";
threadId: string;
actorId: string | null;
actorLabel: string;
toolCallId: string | null;
chunk: string;
}
| {
type: "assistant_delta";
threadId: string;
actorId: string | null;
actorLabel: string;
delta: string;
}
| {
type: "final";
threadId: string;
actorId: string | null;
actorLabel: string;
replyMessageId: string;
reply: string;
project: Project;
}
| {
type: "error";
threadId: string | null;
actorId: string | null;
actorLabel: string;
message: string;
};
export interface ConversationThread {
id: string;
kind: ThreadKind;
runtimeType: "lead" | MemberRuntimeType;
label: string;
subtitle: string;
status: ThreadStatus;
actorId: string | null;
messages: ChatMessage[];
}
export interface TaskItem {
id: string;
title: string;
summary: string;
status: TaskStatus;
ownerMemberId: string | null;
ownerName: string;
createdBy: string;
reviewState: "not_requested" | ReviewStatus;
currentReviewId: string | null;
updatedAt: string;
}
export interface ReviewItem {
id: string;
taskId: string;
reviewerMemberId: string;
status: ReviewStatus;
note: string;
createdAt: string;
}
export interface MailboxMessage {
id: string;
fromMemberId: string | null;
fromLabel: string;
toMemberId: string | null;
toLabel: string;
kind: MailboxKind;
relatedTaskId: string | null;
summary: string;
body: string;
status: MailboxStatus;
createdAt: string;
}
export interface SharedMemoryEntry {
id: string;
title: string;
body: string;
source: MemoryEntrySource;
createdAt: string;
}
export interface TaskActivityEntry {
id: string;
taskId: string | null;
kind: TaskActivityKind;
actorId: string | null;
actorLabel: string;
summary: string;
detail: string;
relatedMailboxId: string | null;
createdAt: string;
}
export interface MailboxDigest {
id: string;
title: string;
summary: string;
subtitle: string;
primaryKind: MailboxKind;
participants: string[];
nextAction: string;
highlights: string[];
latestAt: string;
unresolvedCount: number;
messageCount: number;
relatedTaskId: string | null;
messageIds: string[];
}
export interface OfficeAssetsManifest {
available: boolean;
source: "vscode-extension" | "none";
extensionPath: string | null;
extensionVersion: string | null;
assetBaseUrl: string | null;
defaultLayoutUrl: string | null;
floorsUrl: string | null;
wallsUrl: string | null;
officeTilesetUrl: string | null;
furnitureCatalogUrl: string | null;
furnitureBaseUrl: string | null;
characterUrls: string[];
notes: string[];
}
export interface OfficeDeskSlot {
id: string;
label: string;
assetId: string;
col: number;
row: number;
width: number;
height: number;
assignedActorId: string | null;
}
export interface OfficeLayoutState {
cols: number | null;
rows: number | null;
deskSlots: OfficeDeskSlot[];
}
export interface OfficeAgentState {
actorId: string;
threadId: string;
label: string;
title: string;
runtimeType: "lead" | MemberRuntimeType;
badge: string;
presence: "typing" | "reading" | "waiting" | "idle" | "blocked" | "done";
sessionStatus: SessionStatus;
identityState: SessionIdentityState;
currentModel: string;
contextUsagePercent: number;
estimatedContextTokens: number;
characterIndex: number | null;
characterUrl: string | null;
officeSlotId: string | null;
positionCol: number | null;
positionRow: number | null;
unresolvedMailboxCount: number;
activeTaskTitle: string | null;
latestMailboxSummary: string | null;
lastOutput: string;
ruleSummary: string;
detailSummary: string;
}
export interface OfficeState {
projectId: string;
activeActorId: string | null;
assets: OfficeAssetsManifest;
layout: OfficeLayoutState;
agents: OfficeAgentState[];
}
export interface GitSnapshot {
mode: "live" | "placeholder";
branch: string;
summary: string;
dirty: boolean;
trackedChanges: number;
lastCheckedAt: string | null;
raw: string;
}
export interface ManualAction {
id: string;
action: string;
operator: string;
targetMemberId: string | null;
targetMemberName: string | null;
note: string;
createdAt: string;
}
export interface Project {
id: string;
name: string;
path: string;
description: string;
createdAt: string;
updatedAt: string;
stage: ProjectStage;
codex: CodexCliInfo;
lead: LeadProfile;
teamProposal: TeamProposal;
team: TeamState;
nativeSessions: NativeSessionRef[];
discovery: DiscoveryState;
teamChange: TeamChangeState;
messages: ChatMessage[];
threads: ConversationThread[];
tasks: TaskItem[];
reviews: ReviewItem[];
mailbox: MailboxMessage[];
mailboxSummaries: MailboxDigest[];
sharedProjectMemory: SharedMemoryEntry[];
taskActivity: TaskActivityEntry[];
git: GitSnapshot;
manualActions: ManualAction[];
storage: ProjectStorageInfo;
}
export interface ProjectIndexEntry {
id: string;
name: string;
path: string;
description: string;
createdAt: string;
updatedAt: string;
}
export interface Database {
projects: ProjectIndexEntry[];
}
export interface ProjectCreateInput {
name?: string;
path: string;
leadName?: string;
description?: string;
}
export interface TeamProposalUpdateInput {
summary: string;
naturalLanguageBrief: string;
reuseFirst: boolean;
reviewerRule: string;
leadOverrideRule: string;
userOverrideRule: string;
roles: ProposalRole[];
}
export interface TaskCreateInput {
title: string;
summary: string;
ownerMemberId?: string | null;
}
export interface SessionModelUpdateInput {
model: string;
mode: "inherit" | "override";
}
export interface SessionOperationResult {
message: string;
project: Project | null;
}
export interface MailboxMessageCreateInput {
fromMemberId: string;
toMemberId: string;
kind: MailboxKind;
relatedTaskId?: string | null;
summary: string;
body: string;
}
export interface MailboxStatusUpdateInput {
status: MailboxStatus;
}
export interface ReviewDecisionInput {
decision: "approve" | "changes_requested";
note: string;
}
export interface ProjectSummary {
id: string;
name: string;
path: string;
updatedAt: string;
leadName: string;
stage: ProjectStage;
discoveryStatus: DiscoveryStatus;
teamStatus: SummaryTeamStatus;
currentTask: string;
lastMessagePreview: string;
}
export interface SuggestedTask {
title: string;
summary: string;
}
export interface DiscoveryResult {
brief: string;
facts: string[];
stack: string[];
topLevelEntries: string[];
suggestedRoles: ProposalRole[];
suggestedTasks: SuggestedTask[];
}

16
server/tsconfig.json Normal file
View File

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "../dist/server",
"rootDir": "./src",
"strict": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"types": ["node"]
},
"include": ["src/**/*.ts"]
}

12
web/index.html Normal file
View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Agents Team 本地开发工作台</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

1940
web/src/App.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,572 @@
import { useEffect, useMemo, useRef, useState } from "react";
import type { OfficeState as WorkbenchOfficeState } from "../types";
import { OfficeCanvas as VendorOfficeCanvas } from "../../../.agents-team/vendor/pixel-agents/webview-ui/src/office/components/OfficeCanvas";
import { EditorState } from "../../../.agents-team/vendor/pixel-agents/webview-ui/src/office/editor/editorState";
import { EditorToolbar } from "../../../.agents-team/vendor/pixel-agents/webview-ui/src/office/editor/EditorToolbar";
import { OfficeState as VendorOfficeState } from "../../../.agents-team/vendor/pixel-agents/webview-ui/src/office/engine/officeState";
import { buildDynamicCatalog } from "../../../.agents-team/vendor/pixel-agents/webview-ui/src/office/layout/furnitureCatalog";
import { migrateLayoutColors } from "../../../.agents-team/vendor/pixel-agents/webview-ui/src/office/layout/layoutSerializer";
import { setFloorSprites } from "../../../.agents-team/vendor/pixel-agents/webview-ui/src/office/floorTiles";
import { setWallSprites } from "../../../.agents-team/vendor/pixel-agents/webview-ui/src/office/wallTiles";
import { setCharacterTemplates } from "../../../.agents-team/vendor/pixel-agents/webview-ui/src/office/sprites/spriteData";
import type { FloorColor, OfficeLayout, SpriteData } from "../../../.agents-team/vendor/pixel-agents/webview-ui/src/office/types";
import { useEditorActions } from "../../../.agents-team/vendor/pixel-agents/webview-ui/src/hooks/useEditorActions";
type FurnitureCatalogAsset = {
id: string;
label: string;
category: string;
file: string;
width: number;
height: number;
footprintW: number;
footprintH: number;
isDesk: boolean;
groupId?: string;
orientation?: string;
state?: string;
canPlaceOnSurfaces?: boolean;
backgroundTiles?: number;
canPlaceOnWalls?: boolean;
};
type FurnitureCatalogResponse = {
assets: FurnitureCatalogAsset[];
};
type CharacterDirectionSprites = {
down: SpriteData[];
up: SpriteData[];
right: SpriteData[];
};
const TILE_SIZE = 16;
const WALL_PIECE_WIDTH = 16;
const WALL_PIECE_HEIGHT = 32;
const WALL_GRID_COLS = 4;
const WALL_BITMASK_COUNT = 16;
const FLOOR_PATTERN_COUNT = 7;
const CHAR_FRAME_W = 16;
const CHAR_FRAME_H = 32;
const CHAR_FRAMES_PER_ROW = 7;
const CHAR_DIRECTIONS = ["down", "up", "right"] as const;
const PNG_ALPHA_THRESHOLD = 128;
const createEmptyLayout = (): OfficeLayout =>
migrateLayoutColors({
version: 1,
cols: 21,
rows: 21,
tiles: [],
furniture: []
});
const loadImage = (url: string): Promise<HTMLImageElement> =>
new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error(`无法加载图片资源:${url}`));
image.src = url;
});
const readSpriteData = (
image: HTMLImageElement,
sx: number,
sy: number,
width: number,
height: number
): SpriteData => {
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d");
if (!context) {
return Array.from({ length: height }, () => Array(width).fill(""));
}
context.imageSmoothingEnabled = false;
context.clearRect(0, 0, width, height);
context.drawImage(image, sx, sy, width, height, 0, 0, width, height);
const pixels = context.getImageData(0, 0, width, height).data;
const sprite: string[][] = [];
for (let row = 0; row < height; row += 1) {
const line: string[] = [];
for (let col = 0; col < width; col += 1) {
const index = (row * width + col) * 4;
const r = pixels[index];
const g = pixels[index + 1];
const b = pixels[index + 2];
const a = pixels[index + 3];
if (a < PNG_ALPHA_THRESHOLD) {
line.push("");
} else {
line.push(
`#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`.toUpperCase()
);
}
}
sprite.push(line);
}
return sprite;
};
const loadFloorSpritesFromImage = async (url: string): Promise<SpriteData[]> => {
const image = await loadImage(url);
return Array.from({ length: FLOOR_PATTERN_COUNT }, (_, index) =>
readSpriteData(image, index * TILE_SIZE, 0, TILE_SIZE, TILE_SIZE)
);
};
const loadWallSpritesFromImage = async (url: string): Promise<SpriteData[]> => {
const image = await loadImage(url);
return Array.from({ length: WALL_BITMASK_COUNT }, (_, mask) =>
readSpriteData(
image,
(mask % WALL_GRID_COLS) * WALL_PIECE_WIDTH,
Math.floor(mask / WALL_GRID_COLS) * WALL_PIECE_HEIGHT,
WALL_PIECE_WIDTH,
WALL_PIECE_HEIGHT
)
);
};
const loadCharacterSprites = async (urls: string[]): Promise<CharacterDirectionSprites[]> =>
Promise.all(
urls.map(async (url) => {
const image = await loadImage(url);
const result: CharacterDirectionSprites = { down: [], up: [], right: [] };
for (let dirIndex = 0; dirIndex < CHAR_DIRECTIONS.length; dirIndex += 1) {
const direction = CHAR_DIRECTIONS[dirIndex];
result[direction] = Array.from({ length: CHAR_FRAMES_PER_ROW }, (_, frameIndex) =>
readSpriteData(image, frameIndex * CHAR_FRAME_W, dirIndex * CHAR_FRAME_H, CHAR_FRAME_W, CHAR_FRAME_H)
);
}
return result;
})
);
const loadFurnitureSprites = async (
catalogUrl: string,
assetBaseUrl: string
): Promise<{ catalog: FurnitureCatalogAsset[]; sprites: Record<string, SpriteData> }> => {
const catalogResponse = await fetch(catalogUrl);
if (!catalogResponse.ok) {
throw new Error("无法读取家具目录。");
}
const catalogData = (await catalogResponse.json()) as FurnitureCatalogResponse;
const catalog = catalogData.assets || [];
const spritesEntries = await Promise.all(
catalog.map(async (asset) => {
const image = await loadImage(`${assetBaseUrl}/${asset.file}`);
return [asset.id, readSpriteData(image, 0, 0, asset.width, asset.height)] as const;
})
);
return {
catalog,
sprites: Object.fromEntries(spritesEntries)
};
};
const pickNearestSeatId = (
officeState: VendorOfficeState,
targetCol: number | null,
targetRow: number | null
): string | null => {
const freeSeats = Array.from(officeState.seats.entries()).filter(([, seat]) => !seat.assigned);
if (freeSeats.length === 0) {
return null;
}
if (targetCol === null || targetRow === null) {
return freeSeats[0][0];
}
freeSeats.sort((left, right) => {
const leftDistance = Math.abs(left[1].seatCol - targetCol) + Math.abs(left[1].seatRow - targetRow);
const rightDistance = Math.abs(right[1].seatCol - targetCol) + Math.abs(right[1].seatRow - targetRow);
return leftDistance - rightDistance;
});
return freeSeats[0][0];
};
const syncOfficeState = (
vendorOfficeState: VendorOfficeState,
office: WorkbenchOfficeState,
actorIdToNumericId: Map<string, number>,
selectedActorId: string | null
) => {
const activeIds = new Set(actorIdToNumericId.values());
for (const characterId of Array.from(vendorOfficeState.characters.keys())) {
if (characterId > 0 && !activeIds.has(characterId)) {
vendorOfficeState.removeAgent(characterId);
}
}
for (const agent of office.agents) {
const numericId = actorIdToNumericId.get(agent.actorId);
if (!numericId) {
continue;
}
if (!vendorOfficeState.characters.has(numericId)) {
const preferredSeatId = pickNearestSeatId(vendorOfficeState, agent.positionCol, agent.positionRow);
vendorOfficeState.addAgent(numericId, agent.characterIndex ?? 0, 0, preferredSeatId || undefined, true);
}
const isActive = agent.presence !== "blocked" && agent.presence !== "done";
vendorOfficeState.setAgentActive(numericId, isActive);
vendorOfficeState.setAgentTool(
numericId,
agent.presence === "reading" ? "Read" : isActive ? agent.activeTaskTitle || "Write" : null
);
if (agent.presence === "waiting") {
vendorOfficeState.showWaitingBubble(numericId);
} else {
vendorOfficeState.dismissBubble(numericId);
}
}
vendorOfficeState.selectedAgentId = selectedActorId ? actorIdToNumericId.get(selectedActorId) ?? null : null;
};
export function PixelAgentsOffice({
office,
selectedActorId,
onSelectActor
}: {
office: WorkbenchOfficeState;
selectedActorId: string | null;
onSelectActor: (actorId: string) => void;
}) {
const actorIdToNumericId = useMemo(
() => new Map(office.agents.map((agent, index) => [agent.actorId, index + 1])),
[office.agents]
);
const numericIdToActorId = useMemo(
() => new Map(office.agents.map((agent, index) => [index + 1, agent.actorId])),
[office.agents]
);
const officeStateRef = useRef<VendorOfficeState | null>(null);
const editorStateRef = useRef(new EditorState());
const panRef = useRef({ x: 0, y: 0 });
const frameRef = useRef<HTMLDivElement | null>(null);
const importInputRef = useRef<HTMLInputElement | null>(null);
const defaultLayoutRef = useRef<OfficeLayout | null>(null);
const [ready, setReady] = useState(false);
const [zoom, setZoom] = useState(2);
const [loadError, setLoadError] = useState("");
const [loadedAssets, setLoadedAssets] = useState<{ catalog: FurnitureCatalogAsset[]; sprites: Record<string, SpriteData> }>();
const [showUtilityPanel, setShowUtilityPanel] = useState(false);
const [showEditorPanel, setShowEditorPanel] = useState(false);
const userZoomRef = useRef(false);
const editor = useEditorActions(
() => officeStateRef.current || new VendorOfficeState(createEmptyLayout()),
editorStateRef.current
);
useEffect(() => {
let disposed = false;
const loadAssets = async () => {
try {
setLoadError("");
const [layoutResponse, floorSprites, wallSprites, characterSprites, furnitureAssets] = await Promise.all([
office.assets.defaultLayoutUrl ? fetch(office.assets.defaultLayoutUrl) : Promise.resolve(null),
office.assets.floorsUrl ? loadFloorSpritesFromImage(office.assets.floorsUrl) : Promise.resolve([]),
office.assets.wallsUrl ? loadWallSpritesFromImage(office.assets.wallsUrl) : Promise.resolve([]),
loadCharacterSprites(office.assets.characterUrls),
office.assets.furnitureCatalogUrl && office.assets.assetBaseUrl
? loadFurnitureSprites(office.assets.furnitureCatalogUrl, office.assets.assetBaseUrl)
: Promise.resolve({ catalog: [], sprites: {} })
]);
if (disposed) {
return;
}
const rawLayout = layoutResponse && layoutResponse.ok ? ((await layoutResponse.json()) as OfficeLayout) : createEmptyLayout();
const layout = migrateLayoutColors(rawLayout);
defaultLayoutRef.current = structuredClone(layout);
setFloorSprites(floorSprites);
setWallSprites(wallSprites);
setCharacterTemplates(characterSprites);
buildDynamicCatalog(furnitureAssets);
setLoadedAssets(furnitureAssets);
officeStateRef.current = new VendorOfficeState(layout);
editor.setLastSavedLayout(layout);
syncOfficeState(officeStateRef.current, office, actorIdToNumericId, selectedActorId);
setReady(true);
} catch (error) {
if (!disposed) {
setLoadError(error instanceof Error ? error.message : "像素办公室资源加载失败。");
setReady(false);
}
}
};
void loadAssets();
return () => {
disposed = true;
};
}, [
office.assets.defaultLayoutUrl,
office.assets.floorsUrl,
office.assets.wallsUrl,
office.assets.characterUrls,
office.assets.furnitureCatalogUrl,
office.assets.assetBaseUrl
]);
useEffect(() => {
if (!officeStateRef.current) {
return;
}
syncOfficeState(officeStateRef.current, office, actorIdToNumericId, selectedActorId);
}, [office, actorIdToNumericId, selectedActorId]);
useEffect(() => {
if (!ready || !officeStateRef.current) {
return;
}
const frame = frameRef.current;
if (!frame) {
return;
}
const updateZoom = () => {
const layout = officeStateRef.current?.getLayout();
if (!layout) {
return;
}
const availableWidth = Math.max(240, frame.clientWidth - 8);
const availableHeight = Math.max(240, frame.clientHeight - 8);
const widthZoom = Math.floor(availableWidth / (layout.cols * TILE_SIZE));
const heightZoom = Math.floor(availableHeight / (layout.rows * TILE_SIZE));
const nextZoom = Math.max(1, Math.min(10, Math.min(widthZoom, heightZoom)));
if (!userZoomRef.current) {
setZoom((current) => (current === nextZoom ? current : nextZoom));
}
};
updateZoom();
const observer = new ResizeObserver(updateZoom);
observer.observe(frame);
return () => observer.disconnect();
}, [ready, office]);
const resetToDefaultLayout = useMemo(
() => () => {
const layout = defaultLayoutRef.current;
const officeState = officeStateRef.current;
if (!layout || !officeState) {
return;
}
const restored = structuredClone(layout);
officeState.rebuildFromLayout(restored);
editorStateRef.current.reset();
editor.setLastSavedLayout(restored);
userZoomRef.current = false;
setShowEditorPanel(false);
setShowUtilityPanel(false);
},
[editor]
);
useEffect(() => {
if (!ready) {
return;
}
const resetFlag = "agents-team:pixel-office-reset-v1";
if (!window.sessionStorage.getItem(resetFlag)) {
resetToDefaultLayout();
window.sessionStorage.setItem(resetFlag, "1");
}
}, [ready, resetToDefaultLayout]);
if (!ready || !officeStateRef.current) {
return <div className="pixel-agents-office-loading">{loadError || "正在加载原版像素办公室..."}</div>;
}
const selectedFurnitureColor =
officeStateRef.current
.getLayout()
.furniture.find((item) => item.uid === editorStateRef.current.selectedFurnitureUid)?.color ?? null;
const exportLayout = () => {
const layout = officeStateRef.current?.getLayout();
if (!layout) {
return;
}
const blob = new Blob([JSON.stringify(layout, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = "pixel-office-layout.json";
anchor.click();
URL.revokeObjectURL(url);
};
const importLayout = async (file: File) => {
const content = await file.text();
const parsed = JSON.parse(content) as OfficeLayout;
const migrated = migrateLayoutColors(parsed);
officeStateRef.current?.rebuildFromLayout(migrated);
editor.setLastSavedLayout(migrated);
};
return (
<div className="pixel-agents-office-root">
<div className="pixel-agents-office-toolbar">
<button
type="button"
className={`pixel-agents-office-btn ${editor.isEditMode ? "is-active" : ""}`}
onClick={() => {
const nextEditMode = !editor.isEditMode;
editor.handleToggleEditMode();
setShowEditorPanel(nextEditMode);
}}
>
{editor.isEditMode ? "退出布局编辑" : "布局编辑"}
</button>
<button
type="button"
className={`pixel-agents-office-btn ${showUtilityPanel ? "is-active" : ""}`}
onClick={() => setShowUtilityPanel((current) => !current)}
>
工具
</button>
{editor.isEditMode ? (
<button
type="button"
className={`pixel-agents-office-btn ${showEditorPanel ? "is-active" : ""}`}
onClick={() => setShowEditorPanel((current) => !current)}
>
装扮面板
</button>
) : null}
</div>
{showUtilityPanel ? (
<div className="pixel-agents-office-subpanel">
<button type="button" className="pixel-agents-office-btn" onClick={exportLayout}>
导出布局
</button>
<button type="button" className="pixel-agents-office-btn" onClick={() => importInputRef.current?.click()}>
导入布局
</button>
<button
type="button"
className="pixel-agents-office-btn"
onClick={() => {
userZoomRef.current = false;
const frame = frameRef.current;
const layout = officeStateRef.current?.getLayout();
if (!frame || !layout) {
return;
}
const availableWidth = Math.max(240, frame.clientWidth - 8);
const availableHeight = Math.max(240, frame.clientHeight - 8);
const widthZoom = Math.floor(availableWidth / (layout.cols * TILE_SIZE));
const heightZoom = Math.floor(availableHeight / (layout.rows * TILE_SIZE));
setZoom(Math.max(1, Math.min(10, Math.min(widthZoom, heightZoom))));
}}
>
适配宽度
</button>
<button type="button" className="pixel-agents-office-btn" onClick={resetToDefaultLayout}>
恢复默认
</button>
</div>
) : null}
<input
ref={importInputRef}
type="file"
accept="application/json,.json"
style={{ display: "none" }}
onChange={(event) => {
const file = event.target.files?.[0];
if (!file) {
return;
}
void importLayout(file);
event.target.value = "";
}}
/>
<div
ref={frameRef}
className="pixel-agents-office-frame"
onWheelCapture={(event) => {
event.preventDefault();
}}
>
{editor.isEditMode && showEditorPanel ? (
<EditorToolbar
activeTool={editorStateRef.current.activeTool}
selectedTileType={editorStateRef.current.selectedTileType}
selectedFurnitureType={editorStateRef.current.selectedFurnitureType}
selectedFurnitureUid={editorStateRef.current.selectedFurnitureUid}
selectedFurnitureColor={selectedFurnitureColor}
floorColor={editorStateRef.current.floorColor}
wallColor={editorStateRef.current.wallColor}
onToolChange={editor.handleToolChange}
onTileTypeChange={editor.handleTileTypeChange}
onFloorColorChange={editor.handleFloorColorChange}
onWallColorChange={editor.handleWallColorChange}
onSelectedFurnitureColorChange={editor.handleSelectedFurnitureColorChange}
onFurnitureTypeChange={editor.handleFurnitureTypeChange}
loadedAssets={loadedAssets}
/>
) : null}
<VendorOfficeCanvas
officeState={officeStateRef.current}
onClick={(numericId) => {
const actorId = numericIdToActorId.get(numericId);
if (actorId) {
onSelectActor(actorId);
}
}}
isEditMode={editor.isEditMode}
editorState={editorStateRef.current}
onEditorTileAction={editor.handleEditorTileAction}
onEditorEraseAction={editor.handleEditorEraseAction}
onEditorSelectionChange={editor.handleEditorSelectionChange}
onDeleteSelected={editor.handleDeleteSelected}
onRotateSelected={editor.handleRotateSelected}
onDragMove={editor.handleDragMove}
editorTick={editor.editorTick}
zoom={zoom}
onZoomChange={(nextZoom) => {
userZoomRef.current = true;
editor.handleZoomChange(nextZoom);
setZoom(nextZoom);
}}
panRef={panRef}
/>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

45
web/src/components/Ui.tsx Normal file
View File

@ -0,0 +1,45 @@
import type { ReactNode } from "react";
export function SectionCard({
title,
subtitle,
actions,
children
}: {
title: string;
subtitle: string;
actions?: ReactNode;
children: ReactNode;
}) {
return (
<section className="panel-card">
<div className="panel-head">
<div>
<h2>{title}</h2>
<p>{subtitle}</p>
</div>
{actions ? <div className="panel-actions">{actions}</div> : null}
</div>
<div className="panel-body">{children}</div>
</section>
);
}
export function StatusPill({
label,
tone
}: {
label: string;
tone: "accent" | "warning" | "neutral";
}) {
return <span className={`status-pill tone-${tone}`}>{label}</span>;
}
export function InfoBlock({ label, value }: { label: string; value: string }) {
return (
<div className="info-block">
<span>{label}</span>
<strong>{value}</strong>
</div>
);
}

13
web/src/main.tsx Normal file
View File

@ -0,0 +1,13 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);

191
web/src/pages/HomePage.tsx Normal file
View File

@ -0,0 +1,191 @@
import { type FormEvent, useEffect, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { api } from "../services/api";
import type { CreateProjectInput, ProjectSummary } from "../types";
import { SectionCard, StatusPill } from "../components/Ui";
import {
discoveryLabel,
discoveryTone,
formatDateTime,
shortenText,
stageLabel,
stageTone,
teamStatusLabel
} from "../viewHelpers";
const emptyProjectForm: CreateProjectInput = {
name: "",
path: "",
leadName: "",
description: ""
};
export function HomePage() {
const navigate = useNavigate();
const [projects, setProjects] = useState<ProjectSummary[]>([]);
const [form, setForm] = useState<CreateProjectInput>(emptyProjectForm);
const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false);
const [error, setError] = useState("");
const loadProjects = async () => {
setLoading(true);
setError("");
try {
setProjects(await api.fetchProjects());
} catch (requestError) {
setError(requestError instanceof Error ? requestError.message : "项目列表读取失败。");
} finally {
setLoading(false);
}
};
useEffect(() => {
void loadProjects();
}, []);
const handleCreateProject = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setCreating(true);
setError("");
try {
const project = await api.createProject(form);
setForm(emptyProjectForm);
navigate(`/projects/${project.id}`);
} catch (requestError) {
setError(requestError instanceof Error ? requestError.message : "项目创建失败。");
} finally {
setCreating(false);
}
};
return (
<main className="page page-home">
<section className="hero-card hero-card-home">
<div className="hero-copy">
<p className="eyebrow">项目级 Lead 工作台</p>
<h2>先让项目 lead 理解目标,再围绕这个项目专门建队和推进工作流。</h2>
<p className="support-text">
这里不是全局固定团队,也不是后台管理表单。每个项目先只有一个专属 lead,其他角色必须先讨论出草案,再确认创建。
</p>
</div>
<div className="hero-points">
<div className="hero-point">
<span>01</span>
<strong>先接项目</strong>
<p>只绑定当前本地目录,不做偷跑扫描,也不碰云端账号体系。</p>
</div>
<div className="hero-point">
<span>02</span>
<strong>再出草案</strong>
<p>lead 先给项目专属团队建议,你确认后才真正创建成员和会话。</p>
</div>
<div className="hero-point">
<span>03</span>
<strong>看得见施工</strong>
<p>工作台里能看到阶段、线程、运行态和原生 Codex 接管口。</p>
</div>
</div>
</section>
<section className="home-grid">
<SectionCard title="添加项目" subtitle="新项目创建后先只有 lead,不会自动摸底,也不会自动 commit / push。">
<form className="stack-form" onSubmit={handleCreateProject}>
<label className="field">
<span>项目名称</span>
<input
value={form.name}
onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))}
placeholder="比如:客户官网改版"
/>
</label>
<label className="field">
<span>本地路径</span>
<input
value={form.path}
onChange={(event) => setForm((current) => ({ ...current, path: event.target.value }))}
placeholder="比如:D:\\Projects\\client-site"
required
/>
</label>
<label className="field">
<span>项目 lead 名称</span>
<input
value={form.leadName}
onChange={(event) => setForm((current) => ({ ...current, leadName: event.target.value }))}
placeholder="不填会自动生成"
/>
</label>
<label className="field">
<span>一句说明</span>
<textarea
value={form.description}
onChange={(event) => setForm((current) => ({ ...current, description: event.target.value }))}
rows={4}
placeholder="比如:先打通 lead -> 建队 -> 派工 -> review -> 返工 这条主链路"
/>
</label>
<button className="primary-button" type="submit" disabled={creating}>
{creating ? "正在创建项目..." : "创建并进入项目"}
</button>
<p className="inline-hint">项目创建后默认只有 lead。其余成员要先讨论出团队草案,再确认创建。</p>
</form>
</SectionCard>
<SectionCard
title="继续项目"
subtitle="每张卡片都代表一个项目工作台入口。进去之后默认先和该项目自己的 lead 对话。"
actions={
<button className="ghost-button" type="button" onClick={() => void loadProjects()}>
刷新列表
</button>
}
>
{error ? <p className="feedback error">{error}</p> : null}
{loading ? <p className="empty-panel">正在读取项目列表...</p> : null}
{!loading && projects.length === 0 ? (
<div className="empty-panel">
<strong>还没有项目</strong>
<p>先在左侧接入一个本地项目路径,就能进入项目级 lead 工作台。</p>
</div>
) : null}
<div className="project-list">
{projects.map((project) => (
<Link key={project.id} to={`/projects/${project.id}`} className="project-card project-card-dark">
<div className="project-card-head">
<div>
<h3>{project.name}</h3>
<p>{project.path}</p>
</div>
<StatusPill tone={stageTone(project.stage)} label={stageLabel(project.stage)} />
</div>
<div className="card-metadata">
<span>lead:{project.leadName}</span>
<span>{teamStatusLabel(project.teamStatus)}</span>
<span>{discoveryLabel(project.discoveryStatus)}</span>
</div>
<p className="card-highlight">当前重点:{project.currentTask}</p>
<p className="card-preview">{shortenText(project.lastMessagePreview)}</p>
<div className="project-card-foot">
<span>最近更新:{formatDateTime(project.updatedAt)}</span>
<strong>进入工作台</strong>
</div>
<div className="card-chip-row">
<StatusPill tone={discoveryTone(project.discoveryStatus)} label={discoveryLabel(project.discoveryStatus)} />
</div>
</Link>
))}
</div>
</SectionCard>
</section>
</main>
);
}

File diff suppressed because it is too large Load Diff

188
web/src/services/api.ts Normal file
View File

@ -0,0 +1,188 @@
import type {
ChatStreamEvent,
CreateProjectInput,
MailboxKind,
OfficeState,
Project,
ProjectSummary,
TaskCreateInput,
TeamProposal,
TeamProposalUpdateInput
} from "../types";
const request = async <T>(url: string, init?: RequestInit): Promise<T> => {
const response = await fetch(url, {
headers: {
"Content-Type": "application/json"
},
...init
});
if (!response.ok) {
const payload = (await response.json().catch(() => null)) as { message?: string } | null;
throw new Error(payload?.message || "请求失败,请稍后再试。");
}
return (await response.json()) as T;
};
export const api = {
fetchProjects: () => request<ProjectSummary[]>("/api/projects"),
fetchProject: (projectId: string) => request<Project>(`/api/projects/${projectId}`),
fetchProjectOffice: (projectId: string, threadId?: string | null) =>
request<OfficeState>(`/api/projects/${projectId}/office${threadId ? `?threadId=${encodeURIComponent(threadId)}` : ""}`),
createProject: (payload: CreateProjectInput) =>
request<Project>("/api/projects", {
method: "POST",
body: JSON.stringify(payload)
}),
saveTeamProposal: (projectId: string, payload: TeamProposalUpdateInput | TeamProposal) =>
request<Project>(`/api/projects/${projectId}/team-proposal`, {
method: "PUT",
body: JSON.stringify(payload)
}),
confirmTeamProposal: (projectId: string) =>
request<Project>(`/api/projects/${projectId}/team-proposal/confirm`, {
method: "POST"
}),
confirmTeamChange: (projectId: string) =>
request<Project>(`/api/projects/${projectId}/team-change/confirm`, {
method: "POST"
}),
requestDiscovery: (projectId: string, note: string) =>
request<Project>(`/api/projects/${projectId}/discovery/request`, {
method: "POST",
body: JSON.stringify({ note })
}),
confirmDiscovery: (projectId: string) =>
request<Project>(`/api/projects/${projectId}/discovery/confirm`, {
method: "POST"
}),
streamMessage: async (
projectId: string,
content: string,
threadId: string | undefined,
options: {
signal?: AbortSignal;
onEvent: (event: ChatStreamEvent) => void;
}
) => {
const response = await fetch(`/api/projects/${projectId}/messages/stream`, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ content, threadId }),
signal: options.signal
});
if (!response.ok) {
const payload = (await response.json().catch(() => null)) as { message?: string } | null;
throw new Error(payload?.message || "流式消息请求失败。");
}
if (!response.body) {
throw new Error("当前浏览器环境不支持流式读取。");
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
const lines = buffer.split(/\r?\n/);
buffer = lines.pop() || "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) {
continue;
}
options.onEvent(JSON.parse(trimmed) as ChatStreamEvent);
}
if (done) {
break;
}
}
if (buffer.trim()) {
options.onEvent(JSON.parse(buffer.trim()) as ChatStreamEvent);
}
},
sendMessage: (projectId: string, content: string, threadId?: string) =>
request<Project>(`/api/projects/${projectId}/messages`, {
method: "POST",
body: JSON.stringify({ content, threadId })
}),
createTask: (projectId: string, payload: TaskCreateInput) =>
request<Project>(`/api/projects/${projectId}/tasks`, {
method: "POST",
body: JSON.stringify(payload)
}),
submitTask: (projectId: string, taskId: string, note: string) =>
request<Project>(`/api/projects/${projectId}/tasks/${taskId}/submit`, {
method: "POST",
body: JSON.stringify({ note })
}),
resubmitTask: (projectId: string, taskId: string, note: string) =>
request<Project>(`/api/projects/${projectId}/tasks/${taskId}/resubmit`, {
method: "POST",
body: JSON.stringify({ note })
}),
reviewTask: (projectId: string, taskId: string, decision: "approve" | "changes_requested", note: string) =>
request<Project>(`/api/projects/${projectId}/tasks/${taskId}/review`, {
method: "POST",
body: JSON.stringify({ decision, note })
}),
sendMailboxMessage: (
projectId: string,
payload: {
fromMemberId: string;
toMemberId: string;
kind: MailboxKind;
relatedTaskId?: string | null;
summary: string;
body: string;
}
) =>
request<Project>(`/api/projects/${projectId}/mailbox`, {
method: "POST",
body: JSON.stringify(payload)
}),
updateMailboxStatus: (projectId: string, mailboxId: string, status: "open" | "acknowledged" | "resolved") =>
request<Project>(`/api/projects/${projectId}/mailbox/${mailboxId}/status`, {
method: "PUT",
body: JSON.stringify({ status })
}),
addSharedMemoryEntry: (projectId: string, title: string, body: string) =>
request<Project>(`/api/projects/${projectId}/shared-memory`, {
method: "POST",
body: JSON.stringify({ title, body })
}),
updateSessionModel: (projectId: string, sessionId: string, mode: "inherit" | "override", model: string) =>
request<Project>(`/api/projects/${projectId}/sessions/${sessionId}/model`, {
method: "PUT",
body: JSON.stringify({ mode, model })
}),
saveManualAction: (projectId: string, action: string, note: string, targetMemberId?: string | null) =>
request<Project>(`/api/projects/${projectId}/manual-actions`, {
method: "POST",
body: JSON.stringify({ action, note, targetMemberId })
}),
launchTakeover: (projectId: string, sessionId: string) =>
request<{ ok: boolean; message: string }>(`/api/projects/${projectId}/sessions/${sessionId}/takeover`, {
method: "POST"
}),
restoreSession: (projectId: string, sessionId: string) =>
request<{ ok: boolean; message: string; project: Project }>(`/api/projects/${projectId}/sessions/${sessionId}/restore`, {
method: "POST"
}),
rebuildSession: (projectId: string, sessionId: string) =>
request<{ ok: boolean; message: string; project: Project }>(`/api/projects/${projectId}/sessions/${sessionId}/rebuild`, {
method: "POST"
})
};

2600
web/src/styles.css Normal file

File diff suppressed because it is too large Load Diff

523
web/src/types.ts Normal file
View File

@ -0,0 +1,523 @@
export type ProjectStage =
| "lead_only"
| "discovery_pending"
| "team_drafting"
| "team_pending_confirmation"
| "team_active"
| "executing"
| "reviewing"
| "blocked"
| "done";
export type DiscoveryStatus = "idle" | "pending_confirmation" | "completed";
export type TeamProposalStatus = "draft" | "pending_confirmation" | "confirmed";
export type TeamStatus = "inactive" | "active";
export type SummaryTeamStatus = "lead_only" | "drafting" | "pending_confirmation" | "active";
export type MessageSender = "user" | "lead" | "system" | "member";
export type TaskStatus = "todo" | "in_progress" | "in_review" | "changes_requested" | "blocked" | "done";
export type ReviewStatus = "requested" | "changes_requested" | "approved";
export type MemberRuntimeType = "reviewer" | "worker";
export type MemberCapabilityHint = "review" | "frontend" | "backend" | "qa" | "custom";
export type MemberLifecycle = "idle" | "working" | "waiting_review" | "blocked" | "paused" | "done";
export type SessionStatus = "idle" | "running" | "waiting" | "needs_confirmation" | "blocked" | "done" | "offline";
export type SessionModelSource = "codex-config" | "session-override";
export type ThreadKind = "lead" | "member";
export type ThreadStatus = "active" | "standby" | "waiting" | "blocked" | "done";
export type MailboxKind =
| "question"
| "dependency_request"
| "blocker"
| "handoff"
| "review_feedback"
| "resubmit_request"
| "broadcast";
export type MailboxStatus = "open" | "acknowledged" | "resolved";
export type MemoryEntrySource = "lead" | "system";
export type SessionIdentityState = "fresh" | "resume_ready" | "needs_rebuild";
export type TeamChangeKind = "add" | "remove" | "update" | "keep";
export type TaskActivityKind =
| "task_created"
| "task_assigned"
| "task_started"
| "task_submitted"
| "task_reviewed"
| "task_blocked"
| "task_resumed"
| "task_done"
| "mailbox_linked";
export type ChatStreamStage = "received" | "workflow" | "codex" | "persisting" | "done";
export type ChatToolCallState = "started" | "completed";
export interface ProjectStorageInfo {
agentsDir: string;
workbenchDir: string;
projectStateFile: string;
mailboxFile: string;
sharedMemoryFile: string;
sharedMemoryMarkdownFile: string;
taskActivityFile: string;
}
export interface CodexReasoningEffortOption {
reasoningEffort: string;
description: string;
}
export interface CodexModelOption {
model: string;
displayName: string;
description: string;
defaultReasoningEffort: string;
supportedReasoningEfforts: CodexReasoningEffortOption[];
supportsPersonality: boolean;
isDefault: boolean;
hidden: boolean;
source: "app-server" | "config";
}
export interface ProjectSummary {
id: string;
name: string;
path: string;
updatedAt: string;
leadName: string;
stage: ProjectStage;
discoveryStatus: DiscoveryStatus;
teamStatus: SummaryTeamStatus;
currentTask: string;
lastMessagePreview: string;
}
export interface CodexCliInfo {
defaultModel: string;
activeProfile: string;
provider: string;
contextWindow: number | null;
autoCompactTokenLimit: number | null;
suggestedModels: string[];
availableModels: CodexModelOption[];
}
export interface RuntimeSession {
id: string;
adapter: "codex-cli";
roleLabel: string;
status: SessionStatus;
identityState: SessionIdentityState;
model: string;
modelSource: SessionModelSource;
estimatedContextTokens: number;
contextUsagePercent: number;
lastThreadId: string | null;
recentOutput: string[];
lastUpdatedAt: string;
lastCommand: string;
takeoverCommand: string;
resumeCommand: string;
lastError: string;
}
export interface NativeSessionRef {
actorId: string;
actorLabel: string;
runtimeType: "lead" | MemberRuntimeType;
session: RuntimeSession;
}
export interface ProposalRole {
id: string;
displayName: string;
title: string;
runtimeType: MemberRuntimeType;
capabilityHint: MemberCapabilityHint;
responsibility: string;
reason: string;
source: "existing" | "new";
}
export interface ProjectMember {
id: string;
displayName: string;
title: string;
runtimeType: MemberRuntimeType;
capabilityHint: MemberCapabilityHint;
responsibility: string;
lifecycle: MemberLifecycle;
source: "existing" | "new";
createdAt: string;
lastUsedAt: string | null;
threadId: string;
session: RuntimeSession;
}
export interface TeamProposal {
summary: string;
naturalLanguageBrief: string;
status: TeamProposalStatus;
changeMode: "initial" | "update";
reuseFirst: boolean;
reviewerRule: string;
leadOverrideRule: string;
userOverrideRule: string;
roles: ProposalRole[];
archivedMembers: ProjectMember[];
updatedAt: string;
}
export interface TeamState {
status: TeamStatus;
members: ProjectMember[];
archivedMembers: ProjectMember[];
confirmedAt: string | null;
updatedAt: string;
lastChangeSummary: string;
}
export interface TeamChangeEntry {
kind: TeamChangeKind;
currentMemberId: string | null;
proposalRoleId: string | null;
displayName: string;
title: string;
summary: string;
}
export interface TeamChangeState {
pendingConfirmation: boolean;
summary: string;
entries: TeamChangeEntry[];
updatedAt: string | null;
}
export interface DiscoveryState {
status: DiscoveryStatus;
requiresUserConfirmation: boolean;
requestNote: string;
requestedAt: string | null;
confirmedAt: string | null;
lastBrief: string;
facts: string[];
stack: string[];
topLevelEntries: string[];
}
export interface ChatMessage {
id: string;
sender: MessageSender;
content: string;
createdAt: string;
senderName?: string;
}
export type ChatStreamEvent =
| {
type: "ack";
threadId: string;
actorId: string | null;
actorLabel: string;
message: ChatMessage;
}
| {
type: "status";
threadId: string;
actorId: string | null;
actorLabel: string;
stage: ChatStreamStage;
text: string;
}
| {
type: "reasoning";
threadId: string;
actorId: string | null;
actorLabel: string;
text: string;
}
| {
type: "tool";
threadId: string;
actorId: string | null;
actorLabel: string;
toolCallId: string;
toolName: string;
summary: string;
state: ChatToolCallState;
}
| {
type: "command_output";
threadId: string;
actorId: string | null;
actorLabel: string;
toolCallId: string | null;
chunk: string;
}
| {
type: "assistant_delta";
threadId: string;
actorId: string | null;
actorLabel: string;
delta: string;
}
| {
type: "final";
threadId: string;
actorId: string | null;
actorLabel: string;
replyMessageId: string;
reply: string;
project: Project;
}
| {
type: "error";
threadId: string | null;
actorId: string | null;
actorLabel: string;
message: string;
};
export interface ConversationThread {
id: string;
kind: ThreadKind;
runtimeType: "lead" | MemberRuntimeType;
label: string;
subtitle: string;
status: ThreadStatus;
actorId: string | null;
messages: ChatMessage[];
}
export interface TaskItem {
id: string;
title: string;
summary: string;
status: TaskStatus;
ownerMemberId: string | null;
ownerName: string;
createdBy: string;
reviewState: "not_requested" | ReviewStatus;
currentReviewId: string | null;
updatedAt: string;
}
export interface ReviewItem {
id: string;
taskId: string;
reviewerMemberId: string;
status: ReviewStatus;
note: string;
createdAt: string;
}
export interface MailboxMessage {
id: string;
fromMemberId: string | null;
fromLabel: string;
toMemberId: string | null;
toLabel: string;
kind: MailboxKind;
relatedTaskId: string | null;
summary: string;
body: string;
status: MailboxStatus;
createdAt: string;
}
export interface SharedMemoryEntry {
id: string;
title: string;
body: string;
source: MemoryEntrySource;
createdAt: string;
}
export interface TaskActivityEntry {
id: string;
taskId: string | null;
kind: TaskActivityKind;
actorId: string | null;
actorLabel: string;
summary: string;
detail: string;
relatedMailboxId: string | null;
createdAt: string;
}
export interface MailboxDigest {
id: string;
title: string;
summary: string;
subtitle: string;
primaryKind: MailboxKind;
participants: string[];
nextAction: string;
highlights: string[];
latestAt: string;
unresolvedCount: number;
messageCount: number;
relatedTaskId: string | null;
messageIds: string[];
}
export interface OfficeAssetsManifest {
available: boolean;
source: "vscode-extension" | "none";
extensionPath: string | null;
extensionVersion: string | null;
assetBaseUrl: string | null;
defaultLayoutUrl: string | null;
floorsUrl: string | null;
wallsUrl: string | null;
officeTilesetUrl: string | null;
furnitureCatalogUrl: string | null;
furnitureBaseUrl: string | null;
characterUrls: string[];
notes: string[];
}
export interface OfficeDeskSlot {
id: string;
label: string;
assetId: string;
col: number;
row: number;
width: number;
height: number;
assignedActorId: string | null;
}
export interface OfficeLayoutState {
cols: number | null;
rows: number | null;
deskSlots: OfficeDeskSlot[];
}
export interface OfficeAgentState {
actorId: string;
threadId: string;
label: string;
title: string;
runtimeType: "lead" | MemberRuntimeType;
badge: string;
presence: "typing" | "reading" | "waiting" | "idle" | "blocked" | "done";
sessionStatus: SessionStatus;
identityState: SessionIdentityState;
currentModel: string;
contextUsagePercent: number;
estimatedContextTokens: number;
characterIndex: number | null;
characterUrl: string | null;
officeSlotId: string | null;
positionCol: number | null;
positionRow: number | null;
unresolvedMailboxCount: number;
activeTaskTitle: string | null;
latestMailboxSummary: string | null;
lastOutput: string;
ruleSummary: string;
detailSummary: string;
}
export interface OfficeState {
projectId: string;
activeActorId: string | null;
assets: OfficeAssetsManifest;
layout: OfficeLayoutState;
agents: OfficeAgentState[];
}
export interface GitSnapshot {
mode: "live" | "placeholder";
branch: string;
summary: string;
dirty: boolean;
trackedChanges: number;
lastCheckedAt: string | null;
raw: string;
}
export interface ManualAction {
id: string;
action: string;
operator: string;
targetMemberId: string | null;
targetMemberName: string | null;
note: string;
createdAt: string;
}
export interface LeadProfile {
id: string;
name: string;
version: number;
status: "active" | "handover_pending";
handoverNote: string;
session: RuntimeSession;
}
export interface Project {
id: string;
name: string;
path: string;
description: string;
createdAt: string;
updatedAt: string;
stage: ProjectStage;
codex: CodexCliInfo;
lead: LeadProfile;
teamProposal: TeamProposal;
team: TeamState;
nativeSessions: NativeSessionRef[];
discovery: DiscoveryState;
teamChange: TeamChangeState;
messages: ChatMessage[];
threads: ConversationThread[];
tasks: TaskItem[];
reviews: ReviewItem[];
mailbox: MailboxMessage[];
mailboxSummaries: MailboxDigest[];
sharedProjectMemory: SharedMemoryEntry[];
taskActivity: TaskActivityEntry[];
git: GitSnapshot;
manualActions: ManualAction[];
storage: ProjectStorageInfo;
}
export interface CreateProjectInput {
name: string;
path: string;
leadName: string;
description: string;
}
export interface TeamProposalUpdateInput {
summary: string;
naturalLanguageBrief: string;
reuseFirst: boolean;
reviewerRule: string;
leadOverrideRule: string;
userOverrideRule: string;
roles: ProposalRole[];
}
export interface TaskCreateInput {
title: string;
summary: string;
ownerMemberId?: string | null;
}
export interface SessionModelUpdateInput {
model: string;
mode: "inherit" | "override";
}
export interface MailboxMessageCreateInput {
fromMemberId: string;
toMemberId: string;
kind: MailboxKind;
relatedTaskId?: string | null;
summary: string;
body: string;
}
export interface MailboxStatusUpdateInput {
status: MailboxStatus;
}

425
web/src/viewHelpers.ts Normal file
View File

@ -0,0 +1,425 @@
import type {
ChatMessage,
ConversationThread,
DiscoveryStatus,
MailboxKind,
MailboxMessage,
MailboxStatus,
ManualAction,
MessageSender,
Project,
ProjectMember,
ProjectStage,
ProposalRole,
RuntimeSession,
SessionStatus,
SessionIdentityState,
SharedMemoryEntry,
SummaryTeamStatus,
TeamChangeKind,
TaskActivityEntry,
TaskStatus,
ThreadStatus
} from "./types";
const dateFormatter = new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit"
});
export const proposalRoleTemplates = [
{
label: "补 reviewer",
displayName: "项目 reviewer",
title: "风险审查成员",
runtimeType: "reviewer" as const,
capabilityHint: "review" as const,
responsibility: "负责把关风险、提出打回意见、给出复审结论。",
reason: "reviewer 是当前 v1 固定核心岗位。"
},
{
label: "补界面成员",
displayName: "界面推进成员",
title: "界面推进成员",
runtimeType: "worker" as const,
capabilityHint: "frontend" as const,
responsibility: "负责主工作台、页面结构、交互和视觉细节。",
reason: "当前任务里有明显界面工作。"
},
{
label: "补流程成员",
displayName: "流程推进成员",
title: "流程推进成员",
runtimeType: "worker" as const,
capabilityHint: "backend" as const,
responsibility: "负责 API、数据流、工作流状态和本地服务层。",
reason: "当前任务里有后端或流程工作。"
},
{
label: "补通用成员",
displayName: "主实现成员",
title: "主实现成员",
runtimeType: "worker" as const,
capabilityHint: "custom" as const,
responsibility: "负责把 lead 当前确认的第一批任务真正落下来。",
reason: "先用一个通用执行成员跑通主链路。"
}
] as const;
export const manualActionOptions = [
{ value: "pause_member", label: "暂停成员" },
{ value: "retry_member", label: "重试成员" },
{ value: "kick_member", label: "移出团队" },
{ value: "request_reteam", label: "要求 lead 重组团队" },
{ value: "rebuild_lead", label: "申请重建 lead" }
] as const;
export const formatDateTime = (value: string | null): string => {
if (!value) {
return "暂无";
}
return dateFormatter.format(new Date(value));
};
export const shortenText = (value: string, maxLength = 56): string =>
value.length > maxLength ? `${value.slice(0, maxLength)}...` : value;
export const cloneProposalRole = (role: ProposalRole): ProposalRole => ({ ...role });
export const cloneProjectMember = (member: ProjectMember): ProjectMember => ({
...member,
session: {
...member.session,
recentOutput: [...member.session.recentOutput]
}
});
export const cloneTeamProposal = (project: Project): Project["teamProposal"] => ({
...project.teamProposal,
roles: project.teamProposal.roles.map(cloneProposalRole),
archivedMembers: project.teamProposal.archivedMembers.map(cloneProjectMember)
});
export const discoveryTone = (status: DiscoveryStatus): "accent" | "warning" | "neutral" => {
if (status === "completed") {
return "accent";
}
if (status === "pending_confirmation") {
return "warning";
}
return "neutral";
};
export const discoveryLabel = (status: DiscoveryStatus): string => {
if (status === "completed") {
return "摸底已完成";
}
if (status === "pending_confirmation") {
return "待你确认摸底";
}
return "未发起摸底";
};
export const stageTone = (stage: ProjectStage): "accent" | "warning" | "neutral" => {
if (stage === "done" || stage === "executing" || stage === "reviewing") {
return "accent";
}
if (stage === "blocked" || stage === "discovery_pending" || stage === "team_pending_confirmation") {
return "warning";
}
return "neutral";
};
export const stageLabel = (stage: ProjectStage): string => {
switch (stage) {
case "discovery_pending":
return "等确认摸底";
case "team_drafting":
return "团队讨论中";
case "team_pending_confirmation":
return "待确认团队";
case "team_active":
return "团队已就位";
case "executing":
return "正在执行";
case "reviewing":
return "审查中";
case "blocked":
return "被卡住";
case "done":
return "主链路完成";
default:
return "只有 lead";
}
};
export const teamStatusLabel = (status: SummaryTeamStatus): string => {
switch (status) {
case "drafting":
return "团队草案中";
case "pending_confirmation":
return "待确认团队";
case "active":
return "团队已激活";
default:
return "还没建队";
}
};
export const taskStatusLabel = (status: TaskStatus): string => {
switch (status) {
case "in_progress":
return "执行中";
case "in_review":
return "审查中";
case "changes_requested":
return "被打回";
case "blocked":
return "卡住";
case "done":
return "已完成";
default:
return "待开始";
}
};
export const senderLabel = (sender: MessageSender, senderName?: string): string => {
if (sender === "lead") {
return senderName || "项目 lead";
}
if (sender === "member") {
return senderName || "项目成员";
}
if (sender === "system") {
return "系统记录";
}
return "你";
};
export const manualActionLabel = (action: ManualAction["action"]): string =>
manualActionOptions.find((item) => item.value === action)?.label || action;
export const mailboxKindLabel = (kind: MailboxKind): string => {
switch (kind) {
case "dependency_request":
return "依赖请求";
case "blocker":
return "阻塞提醒";
case "handoff":
return "任务交接";
case "review_feedback":
return "审查反馈";
case "resubmit_request":
return "复审请求";
case "broadcast":
return "广播";
default:
return "问题沟通";
}
};
export const mailboxStatusLabel = (status: MailboxStatus): string => {
switch (status) {
case "acknowledged":
return "已知晓";
case "resolved":
return "已解决";
default:
return "待处理";
}
};
export const mailboxTone = (status: MailboxStatus): "accent" | "warning" | "neutral" => {
if (status === "resolved") {
return "accent";
}
if (status === "open") {
return "warning";
}
return "neutral";
};
export const createTemplateRole = (template: (typeof proposalRoleTemplates)[number]): ProposalRole => ({
id: `proposal_${crypto.randomUUID()}`,
displayName: template.displayName,
title: template.title,
runtimeType: template.runtimeType,
capabilityHint: template.capabilityHint,
responsibility: template.responsibility,
reason: template.reason,
source: "new"
});
export const threadStatusLabel = (status: ThreadStatus): string => {
switch (status) {
case "active":
return "工作中";
case "standby":
return "待命";
case "waiting":
return "等待中";
case "blocked":
return "被卡住";
case "done":
return "已完成";
}
};
export const threadTone = (status: ThreadStatus): "accent" | "warning" | "neutral" => {
if (status === "active" || status === "done") {
return "accent";
}
if (status === "blocked") {
return "warning";
}
return "neutral";
};
export const threadKindLabel = (thread: ConversationThread): string => {
if (thread.kind === "lead") {
return "Lead 主对话";
}
return thread.runtimeType === "reviewer" ? "Reviewer 线程" : "执行成员线程";
};
export const latestThreadPreview = (thread: ConversationThread): string => {
const latest = thread.messages.at(-1);
return latest?.content || "这个线程还没有新消息。";
};
export const officeStatusIcon = (thread: ConversationThread): string => {
if (thread.kind === "lead") {
return "L";
}
return thread.runtimeType === "reviewer" ? "R" : "W";
};
export const officeStatusClass = (thread: ConversationThread): string => `office-${thread.runtimeType}-${thread.status}`;
export const sessionStatusLabel = (status: SessionStatus): string => {
switch (status) {
case "running":
return "运行中";
case "waiting":
return "等待中";
case "needs_confirmation":
return "等确认";
case "blocked":
return "被阻塞";
case "done":
return "已完成";
case "offline":
return "未连上";
default:
return "待命";
}
};
export const sessionTone = (status: SessionStatus): "accent" | "warning" | "neutral" => {
if (status === "running" || status === "done") {
return "accent";
}
if (status === "blocked" || status === "needs_confirmation" || status === "offline") {
return "warning";
}
return "neutral";
};
export const sessionOutputPreview = (session: RuntimeSession): string => session.recentOutput.at(-1) || "还没有最近输出。";
export const sessionIdentityLabel = (state: SessionIdentityState): string => {
switch (state) {
case "resume_ready":
return "可恢复";
case "needs_rebuild":
return "建议重建";
default:
return "待建立";
}
};
export const sessionIdentityTone = (state: SessionIdentityState): "accent" | "warning" | "neutral" => {
if (state === "resume_ready") {
return "accent";
}
if (state === "needs_rebuild") {
return "warning";
}
return "neutral";
};
export const messageCount = (messages: ChatMessage[]): number => messages.length;
export const taskActivityLabel = (entry: TaskActivityEntry): string => {
switch (entry.kind) {
case "task_assigned":
return "已分派";
case "task_started":
return "已开始";
case "task_submitted":
return "已送审";
case "task_reviewed":
return "已评审";
case "task_blocked":
return "被打回";
case "task_resumed":
return "已返工";
case "task_done":
return "已完成";
case "mailbox_linked":
return "协作消息";
default:
return "任务创建";
}
};
export const mailboxPreview = (message: MailboxMessage): string => `${message.fromLabel} -> ${message.toLabel}`;
export const sharedMemoryPreview = (entry: SharedMemoryEntry): string => shortenText(entry.body, 72);
export const teamChangeKindLabel = (kind: TeamChangeKind): string => {
switch (kind) {
case "add":
return "新增";
case "remove":
return "移出";
case "update":
return "调整";
default:
return "保留";
}
};
export const teamChangeTone = (kind: TeamChangeKind): "accent" | "warning" | "neutral" => {
if (kind === "add" || kind === "update") {
return "accent";
}
if (kind === "remove") {
return "warning";
}
return "neutral";
};

17
web/tsconfig.json Normal file
View File

@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"noEmit": true,
"types": ["vite/client"]
},
"include": ["src", "vite.config.ts"]
}

20
web/vite.config.ts Normal file
View File

@ -0,0 +1,20 @@
import { fileURLToPath } from "node:url";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
const root = fileURLToPath(new URL(".", import.meta.url));
export default defineConfig({
root,
plugins: [react()],
server: {
port: 5173,
proxy: {
"/api": "http://localhost:8787"
}
},
build: {
outDir: "../dist/web",
emptyOutDir: true
}
});