feat: AgentMail —— 以邮件为统一范式的多智能体协作平台
Go 单二进制网关 + React 前端 + opencode 桥接插件。部署产物是 「一个二进制加一个 .db 文件」:前端经 go:embed 打进二进制, 数据库默认内置 SQLite,systemd 托管。 核心设计 - 三维寻址 name@path.session,按最后一个 . 切分;session 位三态: 省略=默认会话 / new=强制新建 / 具体别名=必须已存在(否则 404 无法送达) - 会话别名默认复用 Agent 平台自己的命名机制(opencode 的 slug 与模型生成的 标题),不在本侧另造一套;人显式定过的别名不被平台同步覆盖 - 对话树不建 tree_nodes 表:parent_mail_id 已完整编码树结构, 再维护一张表就是第二份真相。用递归 CTE 查,按方向分块加载 - 附件内容存磁盘、按 sha256 内容寻址,数据库只存元数据;天然去重, 且路径与用户 filename 无关,杜绝 ../ 穿越 - 配额约束的是模型的自主发信,不是 harness 的转发:插件代劳的权限询问与 最终总结走免配额通道,靠上游消息 id 做幂等键而非计数 - 往返预算下沉到会话(写信时给、对话页里改)+ Agent 全局配额,两层都要过 后端 gateway/ - models/repo/handler/middleware/sse/blob 分层;两方言(SQLite/PostgreSQL) 共用一份 repo 层 SQL,差异集中在 internal/db - 多用户认证(bcrypt cost12、登录限速、会话隔离、权限边界) - 密钥体系:Agent 密钥与用户密钥分表,三种生命周期;登记式密钥让全文 只从客户端流向服务器一次 - 所有「判断 + 自增」都在同一条 UPDATE 里(配额、预算、one_time 密钥、 附件挂载),并发下不会刷穿 前端 web/ - 三栏布局、三段式地址补全、权限卡片、密钥面板、配额面板、对话树、附件 - 全站纯 SVG 图标,不使用 emoji - api/ 即可复用的客户端 SDK:基地址与凭证集中在 api/config.ts 插件 plugins/opencode-mail-bridge/ - 六个工具 + 两类自动转发(permission.ask 钩子接管平台原生权限询问、 session.idle 时转发本轮总结)
This commit is contained in:
377
docs/API.md
Normal file
377
docs/API.md
Normal file
@ -0,0 +1,377 @@
|
||||
# AgentMail WebAPI
|
||||
|
||||
WebUI 与第三方客户端调用的是**同一套 HTTP API**,没有任何「仅前端可用」的私有通道。
|
||||
这份文档描述如何以纯 API 方式接入。
|
||||
|
||||
基地址:`{host}/api/v1`
|
||||
|
||||
## 一、认证
|
||||
|
||||
三类调用者,各有凭证,互不越界:
|
||||
|
||||
| 调用者 | 凭证 | 可访问 |
|
||||
|--------|------|--------|
|
||||
| 浏览器(WebUI) | 登录 Cookie(`am_session`,HttpOnly) | 人类接口 |
|
||||
| 第三方客户端 | 用户密钥 `Authorization: Bearer <user_key>` | 人类接口(与 Cookie 完全等价) |
|
||||
| Agent | Agent 密钥 `Authorization: Bearer <agent_key>`,或旧式 `X-Agent-Name` + `X-Agent-Secret` | Agent 接口 |
|
||||
|
||||
两类密钥共享一个全局唯一的 token 命名空间,但各查自己的表:用户密钥注册不了 Agent,
|
||||
Agent 密钥读不了人类邮箱。
|
||||
|
||||
### 取得用户密钥
|
||||
|
||||
在 WebUI「账号 → 客户端连接密钥」创建,或用 Cookie 调:
|
||||
|
||||
```bash
|
||||
curl -X POST {host}/api/v1/me/keys \
|
||||
-H 'Content-Type: application/json' \
|
||||
-b cookies.txt \
|
||||
-d '{"label":"我的客户端","key_type":"permanent"}'
|
||||
```
|
||||
|
||||
密钥全文只在创建响应里出现一次;之后列表接口只返回前 8 位 `token_hint`。
|
||||
类型有 `permanent`(长期)、`one_time`(首次使用后失效)、`timed`(配 `expires_hours`)。
|
||||
|
||||
### 之后每个请求
|
||||
|
||||
```bash
|
||||
curl {host}/api/v1/me/mail/inbox -H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
### 两处例外:`?access_token=`
|
||||
|
||||
`EventSource`(SSE)与 `<a download>` 由浏览器直接发起,无法设置请求头。
|
||||
只有这两个端点额外接受 query 令牌:
|
||||
|
||||
- `GET /events/stream?access_token=<token>`
|
||||
- `GET /me/attachments/{id}?access_token=<token>`
|
||||
|
||||
其余接口一律只认请求头 —— URL 里的令牌会进访问日志与 Referer。
|
||||
|
||||
## 二、三维寻址
|
||||
|
||||
收件人地址形如 `name@path.session`,`session` 位三种语义:
|
||||
|
||||
| 地址 | 含义 |
|
||||
|------|------|
|
||||
| `pi@root` | 投递到 `pi` 在 `root` 的**默认会话**(从未通信则建立) |
|
||||
| `pi@root.new` | **强制新建**会话 |
|
||||
| `pi@root.fix-leak` | 投递到别名 `fix-leak` 的**已有会话**;不存在则 404「无法送达」 |
|
||||
| `jianf@.new` | 人类用户也是 name 位的一等公民(path 可空) |
|
||||
|
||||
`path` 内可含 `/` 与 `.`,解析时按**最后一个 `.`** 切分 session 位。
|
||||
会话别名负责寻址,因此全局唯一;`new` 是保留字。
|
||||
|
||||
## 三、人类接口
|
||||
|
||||
### 邮件
|
||||
|
||||
```
|
||||
POST /me/mail/send 发信
|
||||
GET /me/mail/inbox 收件箱(?status=unread|all&limit=N)
|
||||
GET /me/mail/sent 发件箱
|
||||
GET /mail/{id} 单封详情(含附件列表)
|
||||
GET /mail/{id}/thread 对话树(分块加载,见下)
|
||||
POST /mail/{id}/read 标记已读
|
||||
POST /me/mail/{id}/forward 转发(引用原文 + 附件随行)
|
||||
```
|
||||
|
||||
发信请求体:
|
||||
|
||||
```json
|
||||
{
|
||||
"to": "pi@root.new",
|
||||
"cc": "alice@.new, bob@.new",
|
||||
"subject": "标题",
|
||||
"body": "Markdown 正文",
|
||||
"reply_to": "<mail_id>",
|
||||
"session_alias": "fix-leak",
|
||||
"attachment_ids": ["<attachment_id>"],
|
||||
"max_rounds": 3
|
||||
}
|
||||
```
|
||||
|
||||
`reply_to` 让回信落回原会话;`session_alias` 与 `max_rounds` 仅在 `to` 以 `.new` 结尾
|
||||
(即本次投递新建会话)时生效 —— 续谈已有会话时若也接受这两个字段,
|
||||
每封新信都会悄悄改掉对方正在遵守的约定。
|
||||
|
||||
### 对话树
|
||||
|
||||
```
|
||||
GET /mail/{id}/thread?dir=around&limit=40 首屏:锚点 + 部分祖先 + 部分子孙
|
||||
GET /mail/{id}/thread?dir=up&offset=20&limit=40 继续往上(上滑加载)
|
||||
GET /mail/{id}/thread?dir=down&offset=40&limit=40 继续往下
|
||||
```
|
||||
|
||||
树由 `parent_mail_id` 编码:回复指向来信,转发指向被转发的原件。
|
||||
因此**树可以跨会话** —— 转发把线索引到新会话,却仍属同一条线索。
|
||||
|
||||
```json
|
||||
{
|
||||
"anchor_mail_id": "...",
|
||||
"dir": "around",
|
||||
"nodes": [
|
||||
{
|
||||
"mail_id": "...", "parent_mail_id": "...",
|
||||
"depth": -3,
|
||||
"from_name": "admin", "to_name": "pi",
|
||||
"subject": "...", "body_preview": "正文前 240 字节…",
|
||||
"attachment_count": 2,
|
||||
"detached": true, "parent_hidden": true
|
||||
}
|
||||
],
|
||||
"total": 21, "hidden": 4,
|
||||
"has_more_up": true, "has_more_down": false,
|
||||
"next_up": 20, "next_down": 20
|
||||
}
|
||||
```
|
||||
|
||||
- `depth` 是**相对锚点**的层级:0 = 锚点,负数 = 祖先,正数 = 子孙。
|
||||
分块加载时根可能还没取到,所以不用「距根深度」
|
||||
- `offset` 是相对锚点的偏移:`up` 按层数,`down` 按节点数。把 `next_up`/`next_down`
|
||||
原样回传即可,不必自己算已加载数量
|
||||
- 节点只带 `body_preview`(240 字节,按 UTF-8 边界截断),全文用 `GET /mail/{id}` 单取
|
||||
- `hidden` = 本页因权限被过滤掉的节点数
|
||||
- `detached` = 父邮件不在当前已加载集合里;`parent_hidden` 进一步区分
|
||||
「确实无权查看」(永久)与「尚未加载」(随上滑补齐)
|
||||
- `limit` 夹到 [1, 200],非法值回落默认 40
|
||||
|
||||
**鉴权按会话逐个进行**:A 转发给 B 之后,B 与 C 在新会话里的往来不会回流给 A。
|
||||
拿一个自己无权访问的 `mail_id` 当锚点直接返回 403。
|
||||
|
||||
### 会话
|
||||
|
||||
```
|
||||
GET /me/sessions 我参与的会话
|
||||
GET /sessions/{id} 会话详情
|
||||
GET /sessions/{id}/mails 会话内邮件(含附件)
|
||||
PUT /sessions/{id}/alias 改会话别名(冲突 409)
|
||||
|
||||
GET /sessions/{id}/rename-proposal Agent 提的改名建议(无则 proposal: null)
|
||||
POST /sessions/{id}/rename-proposal/dismiss 驳回建议
|
||||
|
||||
GET /sessions/{id}/budget 本任务的往返预算
|
||||
PUT /sessions/{id}/budget 改预算 {max_rounds?, reset?}
|
||||
```
|
||||
|
||||
### 往返预算
|
||||
|
||||
配额的语义是「这件事值得多少个来回」—— 那是**任务**的属性,不是 Agent 的属性。
|
||||
只有一个全局计数器时,两个并行任务会互相抢额度,且用满后要管理员手工重置才能再干活。
|
||||
所以预算落在会话上:写信时用 `max_rounds` 给,之后在对话页里随时调。
|
||||
|
||||
```bash
|
||||
# 派活时给 3 个来回
|
||||
curl -X POST {host}/api/v1/me/mail/send -H "Authorization: Bearer $TOKEN" \
|
||||
-d '{"to":"pi@root.new","subject":"排查缓存","body":"...","max_rounds":3}'
|
||||
|
||||
# 看着往来内容决定加到 5
|
||||
curl -X PUT {host}/api/v1/sessions/$SID/budget -H "Authorization: Bearer $TOKEN" \
|
||||
-d '{"max_rounds":5}'
|
||||
|
||||
# 加到 20 并从头算(两者可同时给)
|
||||
curl -X PUT {host}/api/v1/sessions/$SID/budget -H "Authorization: Bearer $TOKEN" \
|
||||
-d '{"max_rounds":20,"reset":true}'
|
||||
```
|
||||
|
||||
- `max_rounds = 0`(或省略)= 本会话不限,仅受 Agent 全局配额约束
|
||||
- **两层都要过**:会话预算 + `agents.max_rounds` 全局配额。少了后者,
|
||||
Agent 自己用 `.new` 开一串会话每条都是全新预算,全局上限形同虚设
|
||||
- 会话预算先扣、全局配额后扣;被全局拦下时会话那次会退回去 ——
|
||||
那次往返实际上没有发生
|
||||
- 允许把上限调到低于已用次数:那表示「就到这里为止」,此时剩余为 0,下次发信即被拦
|
||||
- 发信响应回传 `budget_used` / `budget_max` / `budget_remaining`
|
||||
- 预算变更会广播 `session_update` 事件,其他标签页与 Agent 侧立即可见
|
||||
|
||||
### Agent 提议改会话别名
|
||||
|
||||
Agent 干完活可能觉得该换个更贴切的会话名。它**不能直接改** —— 别名是人的寻址入口,
|
||||
Agent 中途改掉会让人上一秒记住的地址下一秒失效。它只能提议,由人确认。
|
||||
|
||||
Agent 在 `send_mail` 时传 `propose_alias` / `propose_reason`(插件会拼成正文末尾的
|
||||
HTML 注释 `<!-- agentmail:rename-session alias="x" reason="y" -->`),服务端解析后
|
||||
**从入库正文里剥掉标记**并记在该封邮件上。
|
||||
|
||||
```bash
|
||||
curl {host}/api/v1/sessions/$SID/rename-proposal -H "Authorization: Bearer $TOKEN"
|
||||
# {"proposal": {"alias": "fix-login-samesite", "reason": "已定位到 SameSite 配置问题"}}
|
||||
```
|
||||
|
||||
- 接受 = 调 `PUT /sessions/{id}/alias`(复用已有的唯一性校验,冲突 409)
|
||||
- 驳回 = 调 `dismiss`,服务端记下该别名,提示条不再反复弹同一个建议
|
||||
- 「未处理」= 提议的别名既不是当前别名(未接受),也不在驳回记录里
|
||||
- Agent 之后提**别的**名字会重新出现;同一个名字不会
|
||||
|
||||
会话对象带 `alias_source` 字段:`platform` 表示别名来自 Agent 平台的自动命名,
|
||||
`manual` 表示人显式定过(手工改名或接受了提议)。**`manual` 的别名不会被平台同步覆盖** ——
|
||||
否则平台下一次 `session.updated` 会把人刚定的名字冲掉,寻址地址随即失效。
|
||||
标题(`subject`)不受此保护,平台的摘要标题可以随时刷新。
|
||||
|
||||
### 联系人
|
||||
|
||||
```
|
||||
GET /contacts 联系人 = 一条 name@path.session 地址
|
||||
GET /contacts/suggest 三段式补全(?name=&path=)
|
||||
POST /contacts/archive 归档
|
||||
```
|
||||
|
||||
`suggest` 按参数递进:无参返回可用 name;给 `name` 返回该 Agent 的 path;
|
||||
给 `name`+`path` 返回已有会话别名与 `new`。
|
||||
|
||||
### 权限决策
|
||||
|
||||
```
|
||||
GET /permission/pending 待我决策的请求
|
||||
POST /permission/decide 决策 {mail_id, decision, note}
|
||||
```
|
||||
|
||||
### 附件
|
||||
|
||||
```
|
||||
POST /me/attachments 上传(multipart,字段名 file)→ attachment_id
|
||||
GET /me/attachments/{id} 下载
|
||||
DELETE /me/attachments/{id} 删除(仅未随邮件发出的)
|
||||
```
|
||||
|
||||
上传与发信是**两步**:先上传拿 `attachment_id`,再在发信时放进 `attachment_ids`。
|
||||
未随邮件发出的附件 24 小时后由 GC 清理。
|
||||
|
||||
内容按 sha256 内容寻址:同内容重复上传不占额外空间。
|
||||
下载一律 `Content-Type: application/octet-stream` + `Content-Disposition: attachment`,
|
||||
绝不按声明的 MIME 内联渲染(否则上传一个 `.html` 就能在本站域下执行脚本)。
|
||||
|
||||
单个附件默认上限 25MB(`AGENTMAIL_MAX_ATTACHMENT_BYTES`)。
|
||||
|
||||
### 账号与密钥
|
||||
|
||||
```
|
||||
GET /auth/me 当前用户
|
||||
POST /auth/password 改密码
|
||||
POST /me/keys 创建客户端密钥
|
||||
GET /me/keys 我的密钥(只给 token_hint)
|
||||
DELETE /me/keys/{id} 吊销
|
||||
```
|
||||
|
||||
### 管理员(role=admin)
|
||||
|
||||
```
|
||||
GET|POST /admin/users 用户管理
|
||||
PUT|DELETE /admin/users/{id}
|
||||
POST /admin/users/{id}/reset 重置密码
|
||||
GET /admin/scopes 可选的 Agent/路径范围
|
||||
|
||||
POST|GET /admin/agent-keys 签发/登记 Agent 密钥
|
||||
DELETE /admin/agent-keys/{id}
|
||||
POST /admin/agent-keys/{id}/bind
|
||||
|
||||
GET /admin/quotas Agent 发信配额
|
||||
PUT /admin/quotas/{name} 设上限 {max_rounds} 或归零 {reset:true}
|
||||
```
|
||||
|
||||
## 四、Agent 接口
|
||||
|
||||
```
|
||||
POST /agent/register 注册(Bearer <agent_key> 或 body.secret)
|
||||
POST /agent/heartbeat 心跳,响应含 pending_mails 与 quota
|
||||
POST /mail/send 发信(扣配额)
|
||||
GET /mail/inbox 收件箱(含附件清单)
|
||||
POST /mail/{id}/forward 转发(扣配额)
|
||||
POST /permission/request 请求人类决策
|
||||
POST /attachments 上传附件
|
||||
GET /attachments/{id} 下载附件
|
||||
POST /sessions/{id}/sync 回写平台侧生成的会话标题/slug
|
||||
```
|
||||
|
||||
发信与转发要过**两层**额度:会话往返预算 + Agent 全局配额。
|
||||
只限制主动发信,不限制收信 —— 卡住收信只会让邮件凭空消失。
|
||||
|
||||
### 免配额通道:harness 代劳的转发
|
||||
|
||||
**配额约束的是模型的自主发信,不是 harness 的转发。** 插件代劳搬运的两类消息不占额度:
|
||||
|
||||
| relay | 上游 | 为什么免费 |
|
||||
|---|---|---|
|
||||
| `permission` | opencode 的 `permission.ask` | 不转给人,人就看不到,Agent 卡在那里等一个永远不会来的回答 |
|
||||
| `summary` | `session.idle` 时最后一条 assistant 消息 | 模型已经把话说完了,插件只是搬运;收费会导致配额用尽时 Agent 连交代都做不了 |
|
||||
|
||||
```bash
|
||||
# 转发本轮总结(relay_key = opencode 的 assistant message id)
|
||||
curl -X POST {host}/api/v1/mail/send -H "Authorization: Bearer $AGENT_KEY" \
|
||||
-d '{"to":"jianf@","subject":"Re: 排查缓存","body":"结论:缓存穿透",
|
||||
"relay":"summary","relay_key":"msg_abc123"}'
|
||||
```
|
||||
|
||||
- `relay` 只接受 `permission` 与 `summary`(白名单,不是任意字符串)
|
||||
- `relay_key` **必填**,且必须是上游那条消息的稳定 id。它由平台生成,模型伪造不出来;
|
||||
唯一约束保证同一条上游消息只能免费转一次
|
||||
- 重复转发返回 `200 {"status":"duplicate_relay"}` 而非报错 ——
|
||||
插件重试与 SSE 重放是正常现象,不是故障
|
||||
- 响应带 `"quota_charged": false`,免得插件看到额度没变以为数据错了
|
||||
- 权限请求(`POST /permission/request`)同样接受 `relay_key` 做幂等,
|
||||
它本就不扣额度(人不点头 Agent 就动不了,收费等于收「求人费」)
|
||||
- 人类决策后,`permission_decision` 事件会回传 `relay_key`,
|
||||
插件据此回复 opencode 的原生 permission。这个映射由服务端持久化,插件重启也能续上
|
||||
|
||||
`max_rounds = 0` 表示不限。剩余次数随发信响应与心跳回传。
|
||||
注意 Agent 全局配额与会话往返预算是两层,都要过。
|
||||
|
||||
## 五、实时推送(SSE)
|
||||
|
||||
```
|
||||
GET /events/stream
|
||||
```
|
||||
|
||||
事件类型:`connected`、`new_mail`、`permission_decision`、`session_update`、`session_archived`、`agent_online`。
|
||||
|
||||
按收件人分流:Agent 凭证订阅 Agent 通道,用户凭证订阅该用户的通道。
|
||||
不能只报 `X-Agent-Name` 而不给凭证 —— 那等于任何人报个名字就能读走别人的新邮件通知。
|
||||
|
||||
```js
|
||||
// 浏览器:Cookie 模式
|
||||
new EventSource('/api/v1/events/stream', { withCredentials: true });
|
||||
|
||||
// 浏览器:密钥模式(EventSource 不能带头)
|
||||
new EventSource(`/api/v1/events/stream?access_token=${token}`);
|
||||
```
|
||||
|
||||
```bash
|
||||
# 非浏览器客户端:用请求头
|
||||
curl -N {host}/api/v1/events/stream -H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
## 六、错误约定
|
||||
|
||||
失败响应统一为 `{"error": "中文可操作描述"}`,状态码:
|
||||
|
||||
| 码 | 含义 |
|
||||
|----|------|
|
||||
| 400 | 请求体或地址格式非法 |
|
||||
| 401 | 未认证 / 凭证无效 / 密钥已过期或已用尽 |
|
||||
| 403 | 已认证但越权(权限边界、配额用尽、非会话参与方) |
|
||||
| 404 | 目标不存在(含「会话别名不存在 → 无法送达」) |
|
||||
| 409 | 冲突(别名被占用、附件已随其他邮件发出) |
|
||||
| 413 | 附件超过大小上限 |
|
||||
| 429 | 登录失败次数过多(响应含 `retry_after` 秒) |
|
||||
|
||||
## 七、跨域
|
||||
|
||||
`CORS_ORIGINS` 环境变量声明允许的来源(逗号分隔)。
|
||||
已放行 `Authorization` 请求头,已暴露 `Content-Disposition` 与 `Content-Length`
|
||||
(前者是附件下载取文件名所必需)。
|
||||
|
||||
WebUI 内嵌在 Gateway 中时同源,不涉及 CORS;独立部署的 Web 客户端需要配置此项。
|
||||
|
||||
## 八、前端如何指向不同后端
|
||||
|
||||
WebUI 的 `src/api/` 就是一份可直接复用的客户端 SDK。基地址与令牌集中在 `src/api/config.ts`:
|
||||
|
||||
```js
|
||||
// 构建期
|
||||
VITE_API_BASE=https://mail.example.com/api/v1 npm run build
|
||||
|
||||
// 运行时(同一份产物部署到不同后端)
|
||||
window.__AGENTMAIL_API_BASE__ = 'https://mail.example.com/api/v1';
|
||||
window.__AGENTMAIL_TOKEN__ = '<user_key>'; // 省略则走 Cookie
|
||||
```
|
||||
|
||||
也可在代码里调 `setToken(token)` 切换凭证。业务代码不感知 Cookie 与密钥的差异。
|
||||
796
docs/MVP-SPEC.md
Normal file
796
docs/MVP-SPEC.md
Normal file
@ -0,0 +1,796 @@
|
||||
# 邮件驱动·多智能体协作平台 — MVP 技术规格书
|
||||
|
||||
> 基于 v2.0 完整设计文档,聚焦最小可用闭环。
|
||||
> 版本:v0.1 | 日期:2026-09-01
|
||||
|
||||
---
|
||||
|
||||
## 一、MVP 范围定义
|
||||
|
||||
### 1.1 核心目标
|
||||
|
||||
**跑通一个完整的人-Agent协作闭环:**
|
||||
|
||||
```
|
||||
人类发任务 → Agent 收到 → Agent 执行 → Agent 遇到需要人决策的事 → 请求权限 → 人类批准 → Agent 继续执行 → Agent 回复结果
|
||||
```
|
||||
|
||||
### 1.2 MVP 包含(✅)与不包含(❌)
|
||||
|
||||
| 功能 | MVP | 说明 |
|
||||
|------|-----|------|
|
||||
| 邮件收发(人↔Agent) | ✅ | 核心闭环 |
|
||||
| 三维寻址(name@path.session) | ✅ | 核心寻址范式;session 位三态:省略=默认会话 / `new`=新建 / 别名=必须已存在(否则无法送达) |
|
||||
| 会话别名(命名与改名) | ✅ | 别名负责寻址,全局唯一;`new` 为保留字 |
|
||||
| 别名/标题复用 Agent 平台命名 | ✅ | 平台(如 opencode)由模型生成会话摘要标题 + slug,经 `POST /sessions/:id/sync` 回写;撞名自动加 `-2` 后缀 |
|
||||
| 密钥认证(Agent / 用户分离) | ✅ | agent_keys 用于注册/心跳/SSE;user_keys 仅用于 /me/*;三种生命周期 permanent/one_time/timed |
|
||||
| 内置 SQLite(外部库可选) | ✅ | 默认零依赖;`DATABASE_URL` 非空时切 PostgreSQL |
|
||||
| systemd 一键部署 | ✅ | `deploy/install.sh`,产物为一个二进制 + 一个 .db |
|
||||
| 附件 | ✅ | 内容寻址存盘(sha256 去重),上传与发信两步;下载强制 octet-stream |
|
||||
| 转发 | ✅ | 引用原文 + 附件随行;只能转发自己参与过的邮件 |
|
||||
| Agent 发信配额 | ✅ | 只限发信不限收信;剩余次数随响应与心跳回传 |
|
||||
| WebAPI 等价接入 | ✅ | WebUI 与第三方客户端同一套 API,见 docs/API.md |
|
||||
| 对话树 | ✅ | 沿 parent_mail_id 递归展开,跨会话,按方向分块加载,不建 tree_nodes 表 |
|
||||
| Agent 提议改会话名 | ✅ | 正文里的 HTML 注释标记,入库时剥除;改名需用户确认 |
|
||||
| 会话往返预算 | ✅ | 写信时给 / 对话页随时改;与 Agent 全局配额两层都要过 |
|
||||
| 插件自动转发 | ✅ | 平台原生权限询问 + 本轮最终总结;**不消耗配额** |
|
||||
| 会话管理(创建/列表/状态) | ✅ | 会话是协作的边界 |
|
||||
| 权限请求与决策 | ✅ | Agent 需要人批准才能继续 |
|
||||
| Agent 注册与发现 | ✅ | 最小 Registry |
|
||||
| 前端收件箱/发件箱列表 | ✅ | 基础 UI |
|
||||
| 前端新建邮件/回复 | ✅ | 基础 UI |
|
||||
| 邮件正文 Markdown 渲染 | ✅ | Agent 输出多为 MD |
|
||||
| Agent 桥接插件 | ✅ | opencode 为第一个接入平台(`plugins/opencode-mail-bridge`) |
|
||||
| 对话树 | ❌ | 后续迭代 |
|
||||
| 抄送(CC) | ✅ | 已实现:`cc_list` + 收件箱抄送可见 |
|
||||
| 转发 | ❌ | 后续迭代 |
|
||||
| 配额机制 | ❌ | 后续迭代 |
|
||||
| 会话别名命名/改名 | ✅ | 发信时命名、事后改名、平台命名自动同步 |
|
||||
| Agent 正文里主动提议改名 | ❌ | 后续迭代 |
|
||||
| 工作列表/卡片视图 | ❌ | 后续迭代 |
|
||||
| DeepSeek Harness 插件 | ❌ | 后续迭代 |
|
||||
| 跨主机 Agent 发现 | ❌ | 后续迭代 |
|
||||
|
||||
---
|
||||
|
||||
## 二、系统简化架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ 前端(Web UI) │
|
||||
│ 收件箱 / 发件箱 / 新建邮件 / 回复 │
|
||||
│ Markdown 编辑器 + 渲染器 │
|
||||
└──────────────────────┬──────────────────────────────┘
|
||||
│ HTTP REST + SSE
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Mail Gateway(单体服务) │
|
||||
│ │
|
||||
│ ┌───────────┐ ┌───────────┐ ┌───────────────┐ │
|
||||
│ │ Mail API │ │ Registry │ │ Session Mgr │ │
|
||||
│ │ 收发路由 │ │ 注册/发现 │ │ 会话/状态 │ │
|
||||
│ └───────────┘ └───────────┘ └───────────────┘ │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ Agent 通信层(WebSocket) │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
└──────────────────────┬──────────────────────────────┘
|
||||
│ WebSocket
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Agent 侧(Pi Agent + 插件) │
|
||||
│ ┌─────────────────────────────────────────────┐ │
|
||||
│ │ pi-mail-bridge 插件 │ │
|
||||
│ │ - 注册 send_mail / read_inbox / req_perm │ │
|
||||
│ │ - WebSocket 连接 Gateway │ │
|
||||
│ │ - 新邮件注入 Agent 上下文 │ │
|
||||
│ └─────────────────────────────────────────────┘ │
|
||||
│ Pi Agent 本体(文件读写、终端、Git、推理) │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ SQLite(默认) │ 邮件 + 会话持久化
|
||||
│ 或外部 PostgreSQL│ DATABASE_URL 指定
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
**简化要点:**
|
||||
- Gateway 和 Registry 合并为单体服务
|
||||
- 前端用 SSE(Server-Sent Events)替代 WebSocket(更简单,单向推送够用)
|
||||
- 数据库默认内置 SQLite(零外部依赖,与 go:embed 的前端一起构成「一个二进制 + 一个 .db」);
|
||||
`DATABASE_URL` 非空时切换到外部 PostgreSQL。方言差异收在 `internal/db`,repo 层只写一份 SQL
|
||||
- SSE 推送不依赖数据库通知机制(无需 Redis 或 PG LISTEN/NOTIFY):单体进程内 `sse.Manager`
|
||||
按收件人名分流,Agent 通道与人类用户通道共用一套投递
|
||||
|
||||
---
|
||||
|
||||
## 三、数据模型
|
||||
|
||||
以下 DDL 以 PostgreSQL 方言书写。SQLite 侧结构与语义完全一致,仅方言不同
|
||||
(`UUID`→`TEXT`、`TIMESTAMPTZ`→`DATETIME`、`JSONB`→`TEXT`、`VARCHAR(n)`→`TEXT`),
|
||||
见 `gateway/internal/db/migrations/init_sqlite.sql`。
|
||||
|
||||
### 3.1 agents 表
|
||||
|
||||
```sql
|
||||
CREATE TABLE agents (
|
||||
agent_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
agent_name VARCHAR(64) NOT NULL UNIQUE, -- 全局唯一
|
||||
secret VARCHAR(128) NOT NULL, -- 注册密钥
|
||||
host_url VARCHAR(256) NOT NULL, -- 插件通信地址(ws://...)
|
||||
workspaces JSONB NOT NULL DEFAULT '[]', -- [{name, path}]
|
||||
platform VARCHAR(32) NOT NULL DEFAULT 'pi', -- pi / dsh / cline
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'offline', -- online / offline
|
||||
max_rounds INT NOT NULL DEFAULT 10, -- 通信配额(Phase 2 启用)
|
||||
used_rounds INT NOT NULL DEFAULT 0,
|
||||
last_seen TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
### 3.2 sessions 表
|
||||
|
||||
```sql
|
||||
CREATE TABLE sessions (
|
||||
session_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
session_alias VARCHAR(128), -- 会话别名(如 add-health-check),全局唯一,负责寻址
|
||||
from_agent VARCHAR(64) NOT NULL, -- 发起者 agent_name(或 'human')
|
||||
subject VARCHAR(512) NOT NULL, -- 会话主题
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'active', -- active / waiting / completed
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_sessions_alias ON sessions(session_alias);
|
||||
CREATE INDEX idx_sessions_status ON sessions(status);
|
||||
|
||||
-- 别名负责三维寻址 name@path.<alias>,必须唯一;未命名会话(NULL)不受约束
|
||||
CREATE UNIQUE INDEX idx_sessions_alias_uniq
|
||||
ON sessions(session_alias) WHERE session_alias IS NOT NULL;
|
||||
```
|
||||
|
||||
### 3.3 mails 表
|
||||
|
||||
```sql
|
||||
CREATE TABLE mails (
|
||||
mail_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
session_id UUID NOT NULL REFERENCES sessions(session_id),
|
||||
parent_mail_id UUID REFERENCES mails(mail_id), -- 回复链(线性,非树)
|
||||
|
||||
from_name VARCHAR(64) NOT NULL, -- 发件人 agent_name(或 'human')
|
||||
from_workspace VARCHAR(128), -- 发件人工作区
|
||||
to_name VARCHAR(64) NOT NULL, -- 收件人 agent_name(或 'human')
|
||||
to_workspace VARCHAR(128), -- 收件人工作区
|
||||
|
||||
subject VARCHAR(512) NOT NULL,
|
||||
body TEXT NOT NULL, -- Markdown
|
||||
|
||||
mail_type VARCHAR(32) NOT NULL DEFAULT 'normal', -- normal / permission_request
|
||||
permission_options JSONB, -- 权限请求的选项 ["同意","拒绝"]
|
||||
permission_result VARCHAR(32), -- approved / rejected / null
|
||||
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'unread', -- unread / read / archived
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
|
||||
-- 防循环(Phase 2 启用)
|
||||
hop_limit INT DEFAULT 5
|
||||
);
|
||||
|
||||
CREATE INDEX idx_mails_session ON mails(session_id);
|
||||
CREATE INDEX idx_mails_to ON mails(to_name, status);
|
||||
CREATE INDEX idx_mails_parent ON mails(parent_mail_id);
|
||||
```
|
||||
|
||||
### 3.4 设计决策说明
|
||||
|
||||
| 决策 | 理由 |
|
||||
|------|------|
|
||||
| `parent_mail_id` 而非 `tree_node_id` | MVP 只有线性回复链,不需要树结构。Phase 2 引入 CC/转发后再加 tree 节点表 |
|
||||
| `from_name` 用字符串而非 UUID | MVP 阶段 agent_name 全局唯一,简化查询。Phase 2 可加 agent_id 外键 |
|
||||
| `permission_options` 存 JSONB | 允许每个权限请求自定义选项,前端动态渲染按钮 |
|
||||
| 不单独建 `conversations` 表 | session 已经承载会话概念,不重复建设 |
|
||||
|
||||
---
|
||||
|
||||
## 四、API 设计
|
||||
|
||||
### 4.1 基础信息
|
||||
|
||||
- **Base URL**: `http://gateway:8080/api/v1`
|
||||
- **认证**: Agent 侧用 `X-Agent-Name` + `X-Agent-Secret` header;前端用 session cookie
|
||||
- **内容类型**: `application/json`
|
||||
|
||||
### 4.2 Agent 注册与心跳
|
||||
|
||||
```
|
||||
POST /agent/register
|
||||
Body: { name, secret, workspaces: [{name, path}], platform }
|
||||
Response: { agent_id, status: "registered" }
|
||||
|
||||
POST /agent/heartbeat
|
||||
Header: X-Agent-Name, X-Agent-Secret
|
||||
Response: { status: "ok", pending_mails: 3 }
|
||||
```
|
||||
|
||||
心跳响应中 `pending_mails` 告诉插件有多少未读邮件需要拉取。
|
||||
|
||||
### 4.3 邮件收发
|
||||
|
||||
```
|
||||
POST /mail/send
|
||||
Header: X-Agent-Name, X-Agent-Secret
|
||||
Body: {
|
||||
to: "builder@ModelRouter", -- name@workspace 格式
|
||||
subject: "请为 ModelRouter 增加健康检查接口",
|
||||
body: "## 需求\n\n...",
|
||||
session_alias: "add-health-check", -- 可选,新建会话时用
|
||||
reply_to: "uuid" -- 可选,回复某封邮件
|
||||
}
|
||||
Response: { mail_id, session_id }
|
||||
|
||||
GET /mail/inbox
|
||||
Header: X-Agent-Name, X-Agent-Secret
|
||||
Query: ?status=unread&limit=10
|
||||
Response: {
|
||||
mails: [
|
||||
{
|
||||
mail_id, session_id, session_alias,
|
||||
from_name, from_workspace, subject,
|
||||
body_preview, mail_type, status, created_at
|
||||
}
|
||||
],
|
||||
total: 5
|
||||
}
|
||||
|
||||
GET /mail/:mail_id
|
||||
Header: X-Agent-Name, X-Agent-Secret
|
||||
Response: { ... 完整邮件字段 ... }
|
||||
|
||||
POST /mail/:mail_id/read
|
||||
Header: X-Agent-Name, X-Agent-Secret
|
||||
Response: { status: "read" }
|
||||
```
|
||||
|
||||
### 4.4 权限请求
|
||||
|
||||
```
|
||||
POST /permission/request
|
||||
Header: X-Agent-Name, X-Agent-Secret
|
||||
Body: {
|
||||
to: "human", -- 固定为 human
|
||||
question: "是否允许合并 PR #42?",
|
||||
options: ["同意合并", "拒绝,需要修改"],
|
||||
context: "PR 改动了 3 个文件,CI 全部通过...",
|
||||
session_id: "uuid" -- 可选,关联现有会话
|
||||
}
|
||||
Response: { mail_id, session_id, permission_mail_id }
|
||||
|
||||
POST /permission/decide
|
||||
Body: {
|
||||
mail_id: "uuid",
|
||||
decision: "同意合并", -- 必须是 request 中 options 之一
|
||||
note: "可以合并,但请补充测试" -- 可选备注
|
||||
}
|
||||
Response: { status: "decided" }
|
||||
```
|
||||
|
||||
### 4.5 会话与 Agent 列表
|
||||
|
||||
```
|
||||
GET /sessions
|
||||
Query: ?status=active&limit=20
|
||||
Response: {
|
||||
sessions: [
|
||||
{
|
||||
session_id, session_alias, subject,
|
||||
from_agent, status, created_at, updated_at,
|
||||
mail_count: 5
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
GET /sessions/:session_id
|
||||
Response: {
|
||||
session: { ... },
|
||||
mails: [ ... 按时间排序的邮件列表 ... ]
|
||||
}
|
||||
|
||||
GET /agents
|
||||
Query: ?status=online
|
||||
Response: {
|
||||
agents: [
|
||||
{ agent_name, workspaces: [...], platform, status }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 4.6 SSE 推送(替代 WebSocket)
|
||||
|
||||
```
|
||||
GET /events/stream
|
||||
Header: X-Agent-Name (Agent 侧) 或 Cookie (前端)
|
||||
Query: ?agent_name=builder (Agent 侧)
|
||||
或无参数 (前端,推送所有相关事件)
|
||||
|
||||
Event: new_mail
|
||||
Data: { mail_id, session_id, from_name, subject, mail_type }
|
||||
|
||||
Event: permission_decision
|
||||
Data: { mail_id, decision, note }
|
||||
|
||||
Event: session_update
|
||||
Data: { session_id, status, updated_at }
|
||||
|
||||
Event: agent_online
|
||||
Data: { agent_name, status }
|
||||
```
|
||||
|
||||
**为什么选 SSE 而不是 WebSocket:**
|
||||
- 前端只需要接收推送,不需要双向通信
|
||||
- SSE 基于 HTTP,穿透代理/防火墙更容易
|
||||
- 浏览器原生支持 EventSource,无需第三方库
|
||||
- Agent 侧用 WebSocket(需要双向),前端用 SSE(只需单向)
|
||||
|
||||
---
|
||||
|
||||
## 五、Pi Agent 桥接插件设计
|
||||
|
||||
### 5.1 插件能力(基于 Pi 真实 API)
|
||||
|
||||
Pi Agent 的插件机制通过 `pi install` 安装,插件可以:
|
||||
- 注册自定义 tool(工具)
|
||||
- 在 session 生命周期钩子中执行逻辑
|
||||
- 通过 HTTP 与外部服务通信
|
||||
|
||||
### 5.2 插件架构
|
||||
|
||||
```
|
||||
pi-mail-bridge/
|
||||
├── index.js # 插件入口 + 钩子拦截器注册
|
||||
├── tools/
|
||||
│ ├── send_mail.js # 发送邮件工具(Agent 主动调用)
|
||||
│ └── read_inbox.js # 读取收件箱工具(Agent 收到通知后调用)
|
||||
├── hooks/
|
||||
│ ├── request_permission.js # 拦截 request_permission,格式化权限邮件
|
||||
│ ├── ask_user.js # 拦截 ask_user,格式化提问邮件
|
||||
│ └── final_message.js # 拦截最终输出,格式化总结邮件
|
||||
├── transport/
|
||||
│ ├── sse.js # SSE 客户端(接收推送)
|
||||
│ └── http.js # HTTP 客户端(发送请求)
|
||||
├── config.js # 配置管理
|
||||
└── package.json
|
||||
```
|
||||
|
||||
### 5.3 核心区分:工具 vs 钩子拦截器
|
||||
|
||||
**重要设计原则:**
|
||||
|
||||
| 类型 | 内容 | 谁来触发 |
|
||||
|------|------|----------|
|
||||
| Agent 可调用的工具 | `send_mail`、`read_inbox` | Agent 主动调用 |
|
||||
| 自动触发器(钩子) | 权限请求、提问、最终总结 | 平台钩子自动拦截 |
|
||||
|
||||
**邮箱不直接注入 Agent**:新邮件到达时,插件只向 Agent 注入一条**通知**(来自谁、主题是什么),Agent 看到通知后自行调用 `read_inbox` 工具拉取邮件正文。邮件全文从不直接塞进 Agent 上下文。
|
||||
|
||||
**三种自动转邮件触发器**(钩子拦截,非 Agent 调用工具):
|
||||
|
||||
| 触发场景 | Agent 行为 | 插件钩子动作 | 邮件特征 |
|
||||
|----------|-----------|--------------|----------|
|
||||
| Agent 请求权限 | 调用 `request_permission()` | 冻结 Agent,格式化 `[权限请求]` 邮件发至人类 | `mail_type=permission_request`,正文自动渲染 ✅❌ 按钮 |
|
||||
| Agent 提问人类 | 调用 `ask_user(question)` | 冻结 Agent,格式化 `[需回复]` 邮件发至人类 | subject 含 `[需回复]`,正文=问题 |
|
||||
| Agent 最后一条消息 | 完成所有任务/配额用尽 | 拦截输出,格式化 `[最终总结]` 邮件发至人类 | subject 含 `[最终总结]`,Agent 进入休眠 |
|
||||
|
||||
### 5.4 工具注册(Agent 可调用)
|
||||
|
||||
```javascript
|
||||
// tools/send_mail.js
|
||||
module.exports = {
|
||||
name: "send_mail",
|
||||
description: "发送邮件给指定 Agent 或人类",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
to: {
|
||||
type: "string",
|
||||
description: "收件人,格式 name@workspace(如 builder@ModelRouter)"
|
||||
},
|
||||
subject: {
|
||||
type: "string",
|
||||
description: "邮件主题"
|
||||
},
|
||||
body: {
|
||||
type: "string",
|
||||
description: "邮件正文,支持 Markdown 格式"
|
||||
},
|
||||
session_alias: {
|
||||
type: "string",
|
||||
description: "会话别名(新建会话时使用)"
|
||||
}
|
||||
},
|
||||
required: ["to", "subject", "body"]
|
||||
},
|
||||
async execute(params, ctx) {
|
||||
const { to, subject, body, session_alias } = params;
|
||||
const [toName, toWorkspace] = to.split("@");
|
||||
|
||||
const result = await ctx.http.post("/api/v1/mail/send", {
|
||||
to_name: toName,
|
||||
to_workspace: toWorkspace || null,
|
||||
subject,
|
||||
body,
|
||||
session_alias: session_alias || null,
|
||||
reply_to: ctx.currentMailId || null // 如果是在回复某封邮件
|
||||
});
|
||||
|
||||
return `✅ 邮件已发送。Mail ID: ${result.mail_id},会话: ${result.session_id}`;
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
```javascript
|
||||
// tools/read_inbox.js
|
||||
module.exports = {
|
||||
name: "read_inbox",
|
||||
description: "查阅收件箱中的邮件。收到新邮件通知后调用此工具查看完整内容。",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
filter: {
|
||||
type: "string",
|
||||
enum: ["unread", "all"],
|
||||
description: "过滤条件,默认 unread"
|
||||
},
|
||||
limit: {
|
||||
type: "number",
|
||||
description: "返回数量,默认 5"
|
||||
}
|
||||
}
|
||||
},
|
||||
async execute(params, ctx) {
|
||||
const { filter = "unread", limit = 5 } = params;
|
||||
const result = await ctx.http.get("/api/v1/mail/inbox", {
|
||||
params: { status: filter, limit }
|
||||
});
|
||||
|
||||
if (result.mails.length === 0) {
|
||||
return "📭 收件箱为空。";
|
||||
}
|
||||
|
||||
return result.mails.map(m =>
|
||||
`📧 [${m.status}] ${m.from_name}: ${m.subject}\n` +
|
||||
` 会话: #${m.session_alias || '未命名'} | ${m.created_at}\n` +
|
||||
` 预览: ${m.body_preview?.substring(0, 100)}...`
|
||||
).join("\n\n");
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 5.5 钩子拦截器(自动触发器,非 Agent 工具)
|
||||
|
||||
三种场景下,插件通过平台钩子**自动拦截** Agent 行为,格式化为邮件发出。Agent 无需(也不应)手动调用 send_mail 来做这些事。
|
||||
|
||||
```javascript
|
||||
// hooks/request_permission.js
|
||||
module.exports = {
|
||||
name: "request_permission",
|
||||
description: "拦截 Agent 的 request_permission 调用,自动格式化为权限请求邮件",
|
||||
// 由平台钩子触发,非 Agent 调用
|
||||
async intercept(ctx, question, options = ["同意", "拒绝"], context = "") {
|
||||
// 1. 调 Gateway 创建权限请求邮件
|
||||
const result = await ctx.http.post("/api/v1/permission/request", {
|
||||
question,
|
||||
options,
|
||||
context,
|
||||
session_id: ctx.sessionId
|
||||
});
|
||||
|
||||
// 2. 冻结 Agent,标记等待状态
|
||||
ctx.setWaitingForPermission(result.permission_mail_id);
|
||||
|
||||
// 3. 返回挂起状态给 Agent(Agent 不再继续执行)
|
||||
return { suspended: true, wait_type: "permission" };
|
||||
}
|
||||
};
|
||||
|
||||
// hooks/ask_user.js
|
||||
module.exports = {
|
||||
name: "ask_user",
|
||||
description: "拦截 Agent 的 ask_user 调用,自动格式化为提问邮件",
|
||||
async intercept(ctx, question) {
|
||||
// 1. 调 Gateway 发送普通邮件(主题自动加 [需回复])
|
||||
const result = await ctx.http.post("/api/v1/mail/send", {
|
||||
to: "human",
|
||||
subject: `[需回复] ${question.substring(0, 100)}`,
|
||||
body: question,
|
||||
session_id: ctx.sessionId
|
||||
});
|
||||
|
||||
// 2. 冻结 Agent,标记等待状态
|
||||
ctx.setWaitingForReply(result.mail_id);
|
||||
|
||||
return { suspended: true, wait_type: "reply" };
|
||||
}
|
||||
};
|
||||
|
||||
// hooks/final_message.js
|
||||
module.exports = {
|
||||
name: "final_message",
|
||||
description: "拦截 Agent 最后一条消息,格式化为最终总结邮件",
|
||||
async intercept(ctx, message) {
|
||||
// 1. 调 Gateway 发送最终总结邮件(主题自动加 [最终总结])
|
||||
const result = await ctx.http.post("/api/v1/mail/send", {
|
||||
to: "human",
|
||||
subject: `[最终总结] ${ctx.sessionAlias || '任务完成'}`,
|
||||
body: message,
|
||||
session_id: ctx.sessionId
|
||||
});
|
||||
|
||||
// 2. Agent 进入休眠
|
||||
ctx.enterSleepMode();
|
||||
|
||||
return { suspended: true, wait_type: "sleep" };
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 5.6 插件生命周期
|
||||
|
||||
```javascript
|
||||
// index.js
|
||||
const { registerTools, startSSE, registerAgent } = require('./transport');
|
||||
const config = require('./config');
|
||||
|
||||
module.exports = {
|
||||
async onSessionStart(ctx) {
|
||||
// 1. 向 Gateway 注册
|
||||
await registerAgent({
|
||||
name: config.agentName,
|
||||
secret: config.agentSecret,
|
||||
workspaces: config.workspaces,
|
||||
platform: 'pi'
|
||||
});
|
||||
|
||||
// 2. 启动 SSE 监听新邮件
|
||||
startSSE(config.gatewayUrl, config.agentName, (event) => {
|
||||
if (event.type === 'new_mail') {
|
||||
// 注入系统消息到 Agent 上下文
|
||||
ctx.addSystemMessage(
|
||||
`📬 新邮件(${event.data.mail_type})\n` +
|
||||
`来自: ${event.data.from_name}\n` +
|
||||
`主题: ${event.data.subject}\n` +
|
||||
`会话: #${event.data.session_alias || '未命名'}\n` +
|
||||
`使用 read_inbox 工具查看详情。`
|
||||
);
|
||||
}
|
||||
if (event.type === 'permission_decision') {
|
||||
// 恢复 Agent 执行
|
||||
ctx.resumeFromPermission(event.data.mail_id, event.data.decision);
|
||||
}
|
||||
});
|
||||
|
||||
// 3. 定期心跳(每30秒)
|
||||
setInterval(async () => {
|
||||
await ctx.http.post('/api/v1/agent/heartbeat');
|
||||
}, 30000);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 5.7 权限等待的恢复机制
|
||||
|
||||
Agent 被钩子拦截并冻结后的恢复流程:
|
||||
|
||||
```
|
||||
1. Agent 调用 request_permission → 发邮件给 human → 标记等待
|
||||
2. 插件监听 SSE,收到 permission_decision 事件
|
||||
3. 插件调用 ctx.resumeFromPermission(mailId, decision)
|
||||
4. Pi Agent 恢复执行,decision 作为 tool_result 返回给 Agent
|
||||
5. Agent 根据 decision 继续后续操作
|
||||
```
|
||||
|
||||
**关键点:** Agent 进程不阻塞,只是标记为"等待权限"状态。SSE 收到决策后,通过 Pi Agent 的上下文注入机制恢复执行。
|
||||
|
||||
---
|
||||
|
||||
## 六、前端设计(MVP 简化版)
|
||||
|
||||
### 6.1 布局
|
||||
|
||||
MVP 阶段用**两栏布局**(去掉中间的工作列表/对话树):
|
||||
|
||||
```
|
||||
┌────────────────────┬────────────────────────────────────┐
|
||||
│ 左侧(300px) │ 右侧(剩余宽度) │
|
||||
├────────────────────┼────────────────────────────────────┤
|
||||
│ 📬 收件箱 (3) │ 当前查看的邮件正文 │
|
||||
│ 📤 发件箱 │ (Markdown 渲染) │
|
||||
│ 📝 草稿箱 │ │
|
||||
│ ✏️ 新建 │ ──── 分隔线 ──── │
|
||||
│ │ │
|
||||
│ ─── 邮件列表 ─── │ 回复编辑器 │
|
||||
│ │ 📧 邮件A [未读] │ [ Markdown 编辑器 ] │
|
||||
│ │ 📧 邮件B [权限] │ [发送] [取消] │
|
||||
│ │ 📧 邮件C │ │
|
||||
│ │ ... │ │
|
||||
└────────────────────┴────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 6.2 权限请求邮件的特殊渲染
|
||||
|
||||
当邮件类型为 `permission_request` 时,正文下方自动渲染按钮组:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────┐
|
||||
│ 📧 权限请求 │
|
||||
│ 来自: @builder.ModelRouter │
|
||||
│ 主题: 是否允许合并 PR #42? │
|
||||
│ │
|
||||
│ ## 背景 │
|
||||
│ PR 改动了 3 个文件,CI 全部通过... │
|
||||
│ │
|
||||
│ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ ✅ 同意合并 │ │ ❌ 拒绝 │ │
|
||||
│ └──────────┘ └──────────┘ │
|
||||
│ [可选备注输入框] │
|
||||
└──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 6.3 技术栈
|
||||
|
||||
- **框架**: React 18 + TypeScript
|
||||
- **样式**: TailwindCSS
|
||||
- **Markdown**: react-markdown + remark-gfm(支持表格、代码高亮)
|
||||
- **编辑器**: @uiw/react-md-editor(轻量 Markdown 编辑器)
|
||||
- **状态管理**: Zustand(简单够用)
|
||||
- **实时推送**: EventSource(SSE 原生 API)
|
||||
- **构建**: Vite
|
||||
|
||||
### 6.4 核心页面/组件
|
||||
|
||||
```
|
||||
src/
|
||||
├── App.tsx
|
||||
├── api/
|
||||
│ ├── client.ts # HTTP 客户端
|
||||
│ └── sse.ts # SSE 连接管理
|
||||
├── stores/
|
||||
│ ├── mailStore.ts # 邮件状态
|
||||
│ └── sessionStore.ts # 会话状态
|
||||
├── components/
|
||||
│ ├── Sidebar/
|
||||
│ │ ├── Sidebar.tsx # 左侧图标栏
|
||||
│ │ ├── MailList.tsx # 邮件列表
|
||||
│ │ └── MailItem.tsx # 单封邮件条目
|
||||
│ ├── MailView/
|
||||
│ │ ├── MailView.tsx # 右侧邮件正文
|
||||
│ │ ├── MailBody.tsx # Markdown 渲染
|
||||
│ │ ├── PermissionCard.tsx # 权限请求卡片
|
||||
│ │ └── ReplyEditor.tsx # 回复编辑器
|
||||
│ └── Compose/
|
||||
│ └── ComposeModal.tsx # 新建邮件弹窗
|
||||
└── types/
|
||||
└── index.ts # TypeScript 类型定义
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、完整 MVP 工作流(端到端)
|
||||
|
||||
### 场景:人类要求 Builder Agent 增加健康检查接口
|
||||
|
||||
```
|
||||
步骤 1: 人类在前端新建邮件
|
||||
- 收件人: @builder.ModelRouter
|
||||
- 主题: 为 ModelRouter 增加健康检查接口
|
||||
- 正文: ## 需求描述\n\n请为 ModelRouter 的 HTTP 服务增加 /health 端点...
|
||||
- 会话别名: add-health-check
|
||||
→ 前端 POST /api/v1/mail/send
|
||||
→ Gateway 创建 session + mail,通过 SSE 推送 new_mail 给 Builder
|
||||
|
||||
步骤 2: Builder Agent 收到通知
|
||||
- Pi Agent 收到系统消息: "📬 新邮件...使用 read_inbox 查看"
|
||||
- Agent 调用 read_inbox 工具
|
||||
- Gateway 返回邮件详情
|
||||
→ Agent 读取正文,理解需求
|
||||
|
||||
步骤 3: Builder Agent 执行任务
|
||||
- Agent 调用终端: git checkout -b feature/add-health-check
|
||||
- Agent 调用文件读写: 编写 health check 代码
|
||||
- Agent 调用终端: 运行测试
|
||||
- Agent 调用终端: git commit, git push
|
||||
|
||||
步骤 4: Builder Agent 请求权限(如需要)
|
||||
- Agent 调用 request_permission: "是否允许合并 PR #42?"
|
||||
- Gateway 发送权限请求邮件给人类
|
||||
→ 前端收到 SSE 推送,显示权限请求卡片
|
||||
|
||||
步骤 5: 人类决策
|
||||
- 人类点击 "✅ 同意合并"
|
||||
- 前端 POST /api/v1/permission/decide
|
||||
→ Gateway 发送 permission_decision SSE 给 Builder
|
||||
|
||||
步骤 6: Builder Agent 恢复执行
|
||||
- 插件收到 permission_decision
|
||||
- Agent 恢复执行,得到 "同意合并" 的结果
|
||||
- Agent 执行 gh pr merge
|
||||
- Agent 调用 send_mail: "PR 已合并,健康检查接口已就绪"
|
||||
→ 人类收到完成通知
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 八、MVP 实施计划
|
||||
|
||||
### Week 1:后端核心
|
||||
|
||||
| 任务 | 产出 |
|
||||
|------|------|
|
||||
| schema 建表(两方言各一份) | `internal/db/migrations/` |
|
||||
| Gateway HTTP API(mail CRUD + session) | 可用的 REST API |
|
||||
| Agent 注册/心跳 API | Agent 可注册并保持在线 |
|
||||
| SSE 推送机制 | /events/stream 端点 |
|
||||
| 权限请求/决策 API | 完整的权限闭环 |
|
||||
|
||||
### Week 2:Pi 插件 + 前端骨架
|
||||
|
||||
| 任务 | 产出 |
|
||||
|------|------|
|
||||
| pi-mail-bridge 插件框架 | pi install 可用 |
|
||||
| send_mail / read_inbox 工具 | Agent 可收发邮件 |
|
||||
| request_permission 工具 | Agent 可请求权限 |
|
||||
| 前端两栏布局 + 路由 | 可访问的 Web UI |
|
||||
| 邮件列表 + 正文渲染 | 可查看邮件 |
|
||||
| 新建邮件 + 回复编辑器 | 可发送邮件 |
|
||||
|
||||
### Week 3:闭环联调
|
||||
|
||||
| 任务 | 产出 |
|
||||
|------|------|
|
||||
| 人→Agent→人 完整闭环测试 | 端到端可演示 |
|
||||
| 权限请求闭环测试 | Agent 等待→人批准→Agent 恢复 |
|
||||
| SSE 实时推送验证 | 新邮件到达时 UI 自动更新 |
|
||||
| 错误处理与边界情况 | 网络断开、Agent 离线等 |
|
||||
| 基础部署脚本 | `deploy/install.sh` + systemd 单元(数据库为内置 SQLite,无需容器编排) |
|
||||
|
||||
---
|
||||
|
||||
## 九、关键设计决策记录
|
||||
|
||||
| 决策 | 选择 | 理由 |
|
||||
|------|------|------|
|
||||
| Gateway + Registry 合并 | ✅ 合并为单体 | MVP 阶段单机部署,减少运维复杂度 |
|
||||
| 前端推送用 SSE | ✅ SSE | 单向推送够用,比 WebSocket 简单 |
|
||||
| 会话内邮件链用线性回复 | ✅ parent_mail_id | 不需要树结构,线性链够用 |
|
||||
| Agent 等待权限不阻塞进程 | ✅ 非阻塞 | Agent 进程不能挂起,用状态标记 + SSE 恢复 |
|
||||
| 第一个插件选 Pi Agent | ✅ Pi | 团队更熟悉,API 更直接 |
|
||||
| 不用 Redis | ✅ PG LISTEN/NOTIFY | MVP 减少依赖,PG 原生支持发布订阅 |
|
||||
| 前端不用对话树 | ✅ 两栏布局 | Phase 1 聚焦核心闭环 |
|
||||
|
||||
---
|
||||
|
||||
## 十、后续迭代路线
|
||||
|
||||
### 下一批(MVP 已完成的部分不再列出)
|
||||
- 对话树数据模型 + 前端渲染
|
||||
- 转发功能(抄送已完成)
|
||||
- 工作列表卡片视图(中间栏)
|
||||
- Agent 在邮件正文里主动提议改会话别名(平台命名自动同步已完成)
|
||||
- DeepSeek Harness 插件
|
||||
|
||||
### Phase 3(Phase 2 后 2 周)
|
||||
- 配额机制
|
||||
- 跨主机 Agent 发现(Gateway + Registry 拆分)
|
||||
- Agent 间通信签名验证
|
||||
- Hop-limit 防循环
|
||||
|
||||
### Phase 4(持续)
|
||||
- 监控面板
|
||||
- 审计日志查询
|
||||
- 智能路由推荐
|
||||
- Agent 自动会话别名建议
|
||||
|
||||
---
|
||||
|
||||
文档版本:v0.1 (MVP)
|
||||
基于:完整设计文档 v2.0
|
||||
日期:2026-09-01
|
||||
1207
docs/PLAN.md
Normal file
1207
docs/PLAN.md
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user