初始化 Agents Team 工作台
This commit is contained in:
commit
8e08f8f13d
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.npm-cache/
|
||||
.agents-team/
|
||||
data/
|
||||
139
README.md
Normal file
139
README.md
Normal 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
|
||||
```
|
||||
53
docs/CODEX_APP_NEW_CHAT_PROMPT.md
Normal file
53
docs/CODEX_APP_NEW_CHAT_PROMPT.md
Normal 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`。
|
||||
- 能继续推进就继续推进,不要频繁停下来问。
|
||||
- 优先以当前代码和当前文档为准,不要被旧聊天误导。
|
||||
|
||||
如果你读完后发现有不确定的地方,先列出来;如果没有阻塞,就直接继续做。
|
||||
191
docs/CODEX_APP_PRODUCT_HANDOFF.md
Normal file
191
docs/CODEX_APP_PRODUCT_HANDOFF.md
Normal 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 工作台”。
|
||||
276
docs/OLD_MAIN_20260314_PLAN_SUMMARY.md
Normal file
276
docs/OLD_MAIN_20260314_PLAN_SUMMARY.md
Normal 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
795
docs/UI_VISUAL_STANDARDS.md
Normal 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
261
docs/design.md
Normal 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
3580
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
package.json
Normal file
34
package.json
Normal 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
337
server/src/codexConfig.ts
Normal 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
461
server/src/defaults.ts
Normal 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
202
server/src/discovery.ts
Normal 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
57
server/src/git.ts
Normal 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
484
server/src/index.ts
Normal 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
215
server/src/pixelAgents.ts
Normal 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
594
server/src/runtime.ts
Normal 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
3748
server/src/store.ts
Normal file
File diff suppressed because it is too large
Load Diff
562
server/src/types.ts
Normal file
562
server/src/types.ts
Normal 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
16
server/tsconfig.json
Normal 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
12
web/index.html
Normal 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
1940
web/src/App.tsx
Normal file
File diff suppressed because it is too large
Load Diff
572
web/src/components/PixelAgentsOffice.tsx
Normal file
572
web/src/components/PixelAgentsOffice.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1299
web/src/components/ProjectControlConsole.tsx
Normal file
1299
web/src/components/ProjectControlConsole.tsx
Normal file
File diff suppressed because it is too large
Load Diff
45
web/src/components/Ui.tsx
Normal file
45
web/src/components/Ui.tsx
Normal 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
13
web/src/main.tsx
Normal 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
191
web/src/pages/HomePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1340
web/src/pages/ProjectPage.tsx
Normal file
1340
web/src/pages/ProjectPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
188
web/src/services/api.ts
Normal file
188
web/src/services/api.ts
Normal 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
2600
web/src/styles.css
Normal file
File diff suppressed because it is too large
Load Diff
523
web/src/types.ts
Normal file
523
web/src/types.ts
Normal 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
425
web/src/viewHelpers.ts
Normal 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
17
web/tsconfig.json
Normal 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
20
web/vite.config.ts
Normal 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
|
||||
}
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user