Files
MailUI4Agents/README.md
JianFeeeee 289f37f7fb docs: PLUGIN-GUIDE 重写为 PLUGIN-CONTRACT(可核对的插件规格)
原 PLUGIN-GUIDE 是叙事式的「怎么做 + 踩过的坑」,读者要自己从散文里推断
「我到底必须做什么」。接第三个平台时这不够用 —— 尤其当照着实现的是一个代理。

改为规格式,编号可引用、强度明确标注、每条尽量给出可机械核对的判据。
旧文档的内容全部保留(迁进第八、九节),另补上原先没有的四类:

## 一、能力矩阵(新增)

回答「这个平台能不能接」。七项必需能力(C-1..C-7)加七项可选(C-8..C-14),
每项给出判据。附一个七问自检 —— 任何一问答不出来就先别写代码。

其中 C-4「轮次结束信号必须能区分成功与出错」在两次适配里都被漏掉过,
两次都造成「无效模型被判成成功」,所以单独标了出来。

## 二、行为约定(重写)

原先散在各节的要求收拢成一个状态机,按事件逐条规定:B-1 启动 / B-2 心跳 /
B-3 new_mail / B-4 permission_decision / B-5 轮次结束 / B-6 无法处理时回信 /
B-7 启动补拉 / B-8 权限询问 / B-9 关停。

## 四、降级语义(新增)

平台缺某项能力时的确切退化路径(D-1..D-7)。原文档只说了「可选」,
没说缺了之后该怎么办 —— 于是「不支持权限钩子」很容易被实现成
「提供 request_permission 工具补偿」,而那正是 I-1 反对的模式。

## 六、不变量与禁止事项(新增)

12 条 MUST NOT,每条附「违反会怎样」。这些是测试全绿、跑起来也不报错,
但行为就是错的那类问题 —— 例如拉取失败时传 [] 而非省略字段会清空服务端目录。

## 七、验收清单(新增)

八组可勾选项,每条给出具体命令:grep 自查禁止事项、sqlite3 查在线状态与
配额未被消耗、停插件发信再启动看补投日志。

## 核对过的事实

写完逐项核对了代码,不是凭记忆:
- 12 个端点全部在 main.go 里存在且方法一致
- 发信 9 个字段名与 sendMailRequest 的 json tag 一致
- 心跳响应 12 个字段名与 handler 一致
- 九个数字(30s 心跳 / 25MB 附件 / 20 次每小时 / 补投 5 封 / 快照 200 条 /
  模型上限 10 / 目录上限 300 / 降级超时 60s / inbox 默认 5)都能在代码里找到出处
- 验收清单里的六条 sqlite 查询都在生产库上跑通
- 38 个编号无重复,18 处交叉引用全部有定义

引用同步:PLAN.md、PHASE7-REMAINING.md、API.md、README.md、
install.sh、check-shared-libs.sh。
2026-09-03 08:09:54 +08:00

261 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-CONTRACT.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
│ └── test/manual/ # 浏览器实测脚本(量真实盒子与命中区,不进 npm test
└── deploy/ # systemd 单元 + 安装脚本
└── remote-agent-demo.py # 最小跨主机 Agent纯标准库验证协议层能力
```
## 技术栈
| 组件 | 技术 |
|------|------|
| 后端 | 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-CONTRACT.md) — 接一个新 Agent 平台的规格:能力矩阵、行为约定、
降级语义、线协议、不变量、验收清单,以及两次适配踩过的坑
- [实施计划](docs/PLAN.md) — 分阶段任务与验收标准
- [MVP 技术规格书](docs/MVP-SPEC.md) — 数据模型与接口细节