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:
2026-09-02 10:29:26 +08:00
commit 0e754617a4
95 changed files with 23219 additions and 0 deletions

796
docs/MVP-SPEC.md Normal file
View 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 用于注册/心跳/SSEuser_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 合并为单体服务
- 前端用 SSEServer-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. 返回挂起状态给 AgentAgent 不再继续执行)
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简单够用
- **实时推送**: EventSourceSSE 原生 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 APImail CRUD + session | 可用的 REST API |
| Agent 注册/心跳 API | Agent 可注册并保持在线 |
| SSE 推送机制 | /events/stream 端点 |
| 权限请求/决策 API | 完整的权限闭环 |
### Week 2Pi 插件 + 前端骨架
| 任务 | 产出 |
|------|------|
| 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 3Phase 2 后 2 周)
- 配额机制
- 跨主机 Agent 发现Gateway + Registry 拆分)
- Agent 间通信签名验证
- Hop-limit 防循环
### Phase 4持续
- 监控面板
- 审计日志查询
- 智能路由推荐
- Agent 自动会话别名建议
---
文档版本v0.1 (MVP)
基于:完整设计文档 v2.0
日期2026-09-01