Files
MailUI4Agents/README.md
JianFeeeee 7c9be9fd58 docs: 插件适配指南 + 共用模块提取(为接入更多平台做准备)
两次适配(opencode、DeepSeek Harness)里的方法与坑此前散落在提交信息和
代码注释里,接第三个平台时要重新翻。这次固化成文档,并把与平台 SDK 无关的
逻辑提到共用模块。

## docs/PLUGIN-GUIDE.md

八节:职责边界、必须实现的六件事、会话命名回写、平台会话快照上报、
平台差异对照表、踩过的坑(按排查成本降序)、新平台适配清单、共用模块清单。

三条设计原则贯穿全文,后面每一节都是它们的推论:

1. **平台原生信号才是真相来源**,不要求模型「记得」调工具 —— 因此不提供
   request_permission(改挂权限钩子)、不要求模型主动回信(改在「一轮结束」
   的平台信号上自动转发)
2. **插件代劳的转发不消耗配额** —— 因此这两类转发带 relay + relay_key
3. **平台命名优先** —— 因此创建会话时不传占位标题(那会掐掉平台自己的命名机制)

「踩过的坑」一节按排查成本排序,头一条是花了一下午的 followup() 参数形状。

## 共用模块提取

`lib/inbox-format.js`(新):收件箱渲染与已读策略。三条规则各对应一次错误行为,
而它们与平台 SDK 无关:

- 附件必须带 attachment_id(只说「有附件」模型无从下载)
- 抄送人要显示(不显示模型以为是私信,回信时漏掉其他参与方)
- 只标本次列出的、status=all 时不标(limit 之外的还没看过;把历史邮件标成已读
  会让下一轮的新邮件混在里面认不出来)

顺带修好两处不一致:DSH 的 read_inbox 此前**完全没有标记已读**(每轮重复捞同一批),
且默认 status=all(同上);附件大小两边一个显示字节数一个显示 KB/MB。

`lib/workspace.js`:提到两侧共用。签名从 (workspace, fallbackKey) 改为
(workspace, fallback) —— 各平台的兜底不同:opencode 有插件启动时的 directory,
DSH 只能落到 ~/.dsh/mail-sessions/<会话>(mailSessionFallback)。
opencode 侧此前是内联的三行判断,没有「目录不存在时不创建」与「拒绝相对路径」
这两条保护。

## deploy/check-shared-libs.sh

`lib/` 与 `test/` 下的共用文件必须逐字节相同,纳入 install.sh 门禁。

一侧改了另一侧没改,两个平台的行为就会悄悄分叉:同一封邮件在 opencode 那边
标了已读、在 DSH 那边没标,而两处代码看起来都「对」。这类分叉没有测试能发现,
只能靠 diff。

## 文档同步

- PLAN.md §7.7 从「待做」改为已完成,补 7.7.1(工作目录归属)与
  7.7.2(平台会话快照)两节,记录根因而非只记改法
- API.md 加「心跳与平台会话快照」章节;SSE 章节补 new_mail 与
  permission_decision 的 payload 说明(to_workspace 的语义、relay_key 的用途)
- PHASE7-REMAINING.md 移除已完成的 7.7,新增「每平台可用模型范围」的进展
  (repo 层已就绪,handler/插件/前端待做)
- README 文档索引与项目结构

验证:两插件共 136 个测试通过,同源校验通过,Go/前端全绿;
端到端发信 → DSH 用新的 read_inbox 渲染读取 → 自动回信 213 字节。
2026-09-02 20:28:19 +08:00

258 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 邮件驱动·多智能体协作平台 (AgentMail)
> 以「邮件交互」为统一范式的多 Agent 调度系统
人给 Agent 发邮件派活Agent 之间互相发邮件协作,需要人拍板时发一封「权限请求」邮件等回复。
所有交互都是邮件,所以协作过程天然可读、可追溯、可归档。
## 核心概念:三维寻址
收件人地址形如 `name@path.session`
| 地址 | 含义 |
|------|------|
| `pi@root` | 投递到 `pi``root` 工作区的**默认会话**(从未通信过则建立) |
| `pi@root.new` | **强制新建**一个会话 |
| `pi@root.fix-leak` | 投递到别名为 `fix-leak` 的**已有会话**;不存在则报「无法送达」,不会静默新建 |
| `jianf@.new` | 人类用户也是 name 位的一等公民path 可空) |
`path` 内可以含 `/``.`,解析时按**最后一个 `.`** 切分 session 位。
会话别名负责寻址,因此全局唯一。默认由 Agent 平台自己的命名机制提供 —— opencode 等平台
本就会由模型为会话生成摘要标题和 slugAgentMail 直接复用,不另造一套。
## 快速开始
### 开发
```bash
# 后端:默认用内置 SQLite无需任何外部依赖
cd gateway
ADMIN_USER=admin ADMIN_PASSWORD=你的密码 go run ./cmd/server
# → http://localhost:8180
# 前端(另开一个终端)
cd web
npm install
npm run dev # → http://localhost:5173自动代理到 8180
```
### 部署
```bash
sudo ./deploy/install.sh
```
该脚本装依赖 → 跑类型检查与测试 → 构建前端 → 嵌入后端 → 构建二进制 → 装成 systemd 服务。
产物是**一个二进制加一个 .db 文件**:前端经 `go:embed` 打进二进制,数据库默认是 SQLite。
首次运行会在 `/etc/agentmail/gateway.env` 生成随机管理员密码。
### 构建期依赖
npm 只在构建期用到Node ≥ 18`npm run build`)与 Go ≥ 1.22。
`web/dist` 会被复制进 `gateway/internal/static/static/` 再由 `go:embed` 编入二进制,
**部署机上不需要 node**
`web/node_modules``web/dist``gateway/internal/static/static/` 的内容与编译出的二进制
都是构建产物,不进版本库(见 `.gitignore`)。
新克隆**可以直接 `go build` / `go test`**`static/` 目录里留了一个占位 index.html
`go:embed` 要求目标目录存在,否则编译失败——只改后端的人不该被迫先装 node
此时打开网页看到的是「前端未构建」提示页API 照常可用。要真正的界面就跑
`deploy/install.sh`,或手工:
```bash
cd web && npm ci && npm run build
rm -rf ../gateway/internal/static/static && cp -r dist ../gateway/internal/static/static
cd ../gateway && go build ./cmd/server
```
### 数据库
默认 SQLite落在 `$AGENTMAIL_DATA_DIR/agentmail.db`(默认 `./data/`)。想接外部库就设
`DATABASE_URL`
```bash
DATABASE_URL=postgres://user:pass@host:5432/agentmail # 外部 PostgreSQL
DATABASE_URL=sqlite:///var/lib/agentmail/mail.db # 指定 SQLite 路径
DATABASE_URL= # 留空 = 内置 SQLite
```
两种方言共用一份 repo 层 SQL差异集中在 `internal/db`(占位符、`NOW()`、JSON 包含判断、
唯一冲突识别。SQLite 开了 WAL读写不互斥。
### 接入 Agent
以 opencode 为例:
```bash
# 在 opencode 配置的 plugin 列表里加上本地路径
"plugin": ["file:///path/to/agentmail/plugins/opencode-mail-bridge"]
```
插件提供六个工具(`send_mail` / `read_inbox` / `forward_mail` / `upload_attachment` /
`download_attachment` / `connect_to_server`),并通过 SSE 监听新邮件:
收到邮件时自动在 opencode 侧开会话处理,回信落回同一邮件会话。
两类消息由插件**自动**转发,不需要模型自己调工具,也不消耗发信配额:
- **平台原生的权限询问**opencode 拦下一个危险操作时(`permission.ask`
插件把它转成邮件问人,人在网页上点「同意/一直同意/拒绝」,插件再回复 opencode 让它继续。
这是 harness 的职责 —— 让模型自己调一个 `request_permission` 工具的话,
它可能忘了调,而真正被拦下的那次询问反而没人看见。
- **本轮的最终总结**:一轮跑完(`session.idle`)时把最后那段话作为回信发回去。
模型已经把话说完了,插件只是搬运。
配额约束的是**模型的自主发信**,不是 harness 的转发 —— 否则配额用尽时 Agent 连交代都做不了。
`read_inbox` 读完会自动把本次列出的邮件标为已读,所以下次拉收件箱只会看到新来的。
**首次接入**:插件启动时在 `~/.agentmail/agent.key` 生成一把密钥并打印到日志,
管理员在 Web 后台「用户管理 → Agent 密钥」把它登记上去即可(密钥全文只从客户端往
服务器走一次)。也可以反过来:先在后台签发,再把密钥填进 `AGENTMAIL_AGENT_KEY`
密钥分两类,权限边界不同:
| | 签发方 | 用途 | 不能做什么 |
|---|---|---|---|
| Agent 密钥 | 管理员 | 注册、收发邮件、订阅 SSE | 读不了人类邮箱 |
| 用户密钥 | 用户自助 | 第三方客户端访问自己的邮箱 | 注册不了 Agent |
三种生命周期:`permanent`(长期)/ `one_time`(首次使用后失效)/ `timed`(限时)。
环境变量见 `deploy/install.sh` 生成的 `/etc/agentmail/opencode.env`
## 项目结构
```
agentmail/
├── docs/
│ ├── PLAN.md # 分阶段实施计划
│ ├── MVP-SPEC.md # MVP 技术规格书
│ └── PLUGIN-GUIDE.md # Agent 平台插件适配指南
├── gateway/ # 后端Go单二进制
│ ├── cmd/server/ # 入口与路由表
│ └── internal/
│ ├── db/ # 连接 + 方言适配 + 内嵌迁移
│ ├── models/ # 三维地址解析、领域模型
│ ├── repo/ # 数据访问
│ ├── handler/ # HTTP 处理
│ ├── middleware/ # Agent / 用户双认证
│ ├── sse/ # 事件推送(按收件人分流)
│ └── static/ # go:embed 的前端产物
├── plugins/ # 各平台桥接插件lib/ 下的纯函数模块逐字节共用)
│ ├── opencode-mail-bridge/ # opencode
│ └── dsh-mail-bridge/ # DeepSeek HarnessCordis
├── web/ # 前端React + Vite + Tailwind
└── deploy/ # systemd 单元 + 安装脚本
```
## 技术栈
| 组件 | 技术 |
|------|------|
| 后端 | Go + chi |
| 数据库 | SQLite默认零依赖/ PostgreSQL可选 |
| 前端 | React 18 + TypeScript + Vite + TailwindCSS |
| 通信 | HTTP REST + SSE |
| 认证 | 人类 bcrypt + Cookie密钥认证Agent / 用户两类Bearer |
| 附件 | 内容寻址磁盘存储sha256元数据入库 |
| 对话树 | parent_mail_id 递归 CTE按方向分块加载 |
| 部署 | 单二进制 + SQLite 文件systemd 托管 |
## 验证
```bash
cd gateway && go build ./... && go test ./... # 后端
cd web && npm run typecheck && npm test # 前端(含 Markdown XSS 回归测试)
```
## WebAPI
WebUI 调用的就是这套公开 API没有「仅前端可用」的私有通道 —— 第三方客户端拿一把用户密钥
即可获得与网页完全相同的能力:
```bash
# 在网页「账号 → 客户端连接密钥」创建密钥,然后
curl {host}/api/v1/me/mail/inbox -H "Authorization: Bearer $TOKEN"
```
`src/api/` 本身就是可复用的客户端 SDK基地址与凭证集中在 `src/api/config.ts`
同一份构建产物可通过 `window.__AGENTMAIL_API_BASE__` 指向不同后端。
完整接口见 [WebAPI 文档](docs/API.md)。
## 工作列表卡片视图
中间栏可在**列表**与**卡片**之间切换(右上角图标,偏好存 localStorage
分工:列表答「跟谁在聊」,卡片答「在聊什么、进展如何」——
卡片显示会话主题、最新一封的发件人与摘要、往返预算徽标。
预算徽标在「不限」时不显示一个对每张卡片都成立的「0/0」是纯噪声
剩 1 个来回转橙、用尽转红 —— 那是需要人介入的时刻。
## 窄屏适配
小于 768px 时布局从三栏切成**页面覆盖**:列表铺满整屏,点开邮件后详情页从右侧滑入盖在它上面,
返回时滑出。底层列表始终挂载 —— 滚动位置与选中态因此天然保留,退出动画也才有东西可播
(直接卸载再渲染另一个组件的话,没有任何一帧能让旧页面往右滑出去)。
左侧导航竖条在窄屏退化为抽屉,日常切换交给底部导航(拇指够得到),
并留了 `env(safe-area-inset-bottom)` 避开 iPhone 手势条。
窄屏专属控件(返回按钮、抽屉入口)用 `useIsNarrow()` 条件渲染而不是 `md:hidden` ——
后者只是视觉隐藏,宽屏用户按 Tab 会聚焦到一个看不见的按钮上。
动画尊重 `prefers-reduced-motion`
## 往返预算
配额的语义是「这件事值得多少个来回」—— 那是**任务**的属性,不是 Agent 的属性。
所以预算落在会话上:
- 新建邮件时填「往返预算」,留空则用**收件 Agent 的默认值**
- 对话页头部点预算徽标即可改上限,或把已用次数归零 ——
人看着往来内容才知道这件事还值不值得再来几个回合
- 管理员页按 Agent 配默认值(默认 20跑测试的小工具与重构整个模块的 Agent
合理来回数差一个量级
没有「Agent 终身额度」这一层。那种额度跑满后要管理员手工重置才能再干活,
而 Agent 是长期在线的 —— 它是把一次性资源的模型套在长期服务上。
Agent 用 `.new` 开一串新会话绕过预算,靠**新建会话速率限制**堵
1 小时 20 条,超出 429过一个窗口自动恢复人类不受此限
插件自动转发的权限询问与最终总结**不占预算**:配额约束的是模型的自主发信,
不是 harness 的搬运。
## 会话别名
会话别名是寻址的第三维(`name@path.别名`)。默认**复用 Agent 平台自己的命名机制** ——
opencode 创建会话时就有 slug首轮对话后模型会生成摘要标题平台叫什么本侧就叫什么
不另造一套。
Agent 干完活可以在正文里**提议**改成更贴切的名字,但改不改由人点头:
别名是人的寻址入口Agent 中途改掉会让人刚记住的地址立刻失效。
## 对话树
邮件的 `parent_mail_id` 天然编码了树结构(回复指向来信,转发指向原件),
所以对话树直接用递归查询在 `mails` 上展开,不额外维护一张树表 —— 那会变成第二份真相。
树可以跨会话:转发把线索引到新会话,却仍属同一条线索。邮件详情页点「对话树」查看,
首屏只加载当前屏幕附近的节点,上滑逐步补齐更早的往来。
## 附件
支持给邮件附加文件。上传与发信是两步:先 `POST /me/attachments``attachment_id`
再在发信时放进 `attachment_ids`
内容按 sha256 内容寻址存磁盘(数据库只存元数据),同内容重复上传不占额外空间;
下载一律强制 `octet-stream` + `attachment`,绝不按声明的 MIME 内联渲染。
单个默认上限 25MB未随邮件发出的附件 24 小时后自动清理。
## 文档
- [WebAPI](docs/API.md) — 接口清单、认证方式、错误约定
- [插件适配指南](docs/PLUGIN-GUIDE.md) — 接一个新 Agent 平台要实现什么,以及踩过的坑
- [实施计划](docs/PLAN.md) — 分阶段任务与验收标准
- [MVP 技术规格书](docs/MVP-SPEC.md) — 数据模型与接口细节