Files
MailUI4Agents/README.md

327 lines
16 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 直接复用,不另造一套。
平台没有可用名字时(例如 pi 用 SDK 起的会话),桥退一级用邮件主题派生别名,
再把服务端**定稿**的那个值写回平台。定稿而非各自命名,是因为别名要保证唯一:
撞名时服务端会追 `-2`,而人在界面上手工改过的别名永远优先 —— 两侧各自命名的话,
邮箱里显示 `fix-leak-2`、平台里显示 `fix-leak`,按界面上看到的名字发信会「无法送达」。
## 快速开始
### 开发
```bash
# 后端:默认用内置 SQLite无需任何外部依赖
cd server
ADMIN_USER=admin ADMIN_PASSWORD=你的密码 go run ./cmd/server
# → http://localhost:8180
# 前端(另开一个终端)
cd client/electron
npm install
npm run dev # → http://localhost:5173自动代理到 8180
```
### 部署
```bash
sudo ./deploy/install.sh
```
该脚本装依赖 → 跑类型检查与测试 → 构建前端 → 嵌入后端 → 构建二进制 → 装成 systemd 服务。
产物是**一个二进制加一个 .db 文件**:前端经 `go:embed` 打进二进制,数据库默认是 SQLite。
首次运行会在 `/etc/agentmail/gateway.env` 生成随机管理员密码。
### 换二进制(日常改后端)
```bash
bash deploy/redeploy-gateway.sh --dry-run # 先看要做什么
bash deploy/redeploy-gateway.sh # 落地
```
`install.sh` 太重(重装 npm 依赖、重写 systemd 单元、重新生成 env日常只改后端时走这个。
它做四件 `stop → cp → start` 不做的事:
- **`sqlite3 .backup` 备份数据库**,不用 `cp` —— WAL 模式下 `cp` 会拿到主库与 `-wal` 不同步的
快照,恢复时可能丢最近写入甚至损坏;备份后立即 `PRAGMA integrity_check` 复核
- **`install -m 0755` 原子替换二进制**,不用 `cp` —— `install` 本质是 `rename`,要么完整换掉
要么原样不动;`cp` 是就地写入,中途失败会留下半截文件且旧的已被覆盖
- **旧二进制留档**,打印可直接粘贴的回滚命令
- **后置验证清单**:服务 active / `/health` 可达 / 近 2 分钟无 panic / SSE 重连计数。
任一项不过**自动回滚**,不「先上着再修」
`--skip-tests` 急救(事后必须补跑),`--skip-web` 跳过前端同步。
### 构建期依赖
npm 只在构建期用到Node ≥ 18`npm run build`)与 Go ≥ 1.22。
`client/electron/dist` 会被复制进 `server/internal/static/static/` 再由 `go:embed` 编入二进制,
**部署机上不需要 node**
`client/electron/node_modules``client/electron/dist``server/internal/static/static/` 的内容与编译出的二进制
都是构建产物,不进版本库(见 `.gitignore`)。
新克隆**可以直接 `go build` / `go test`**`static/` 目录里留了一个占位 index.html
`go:embed` 要求目标目录存在,否则编译失败——只改后端的人不该被迫先装 node
此时打开网页看到的是「前端未构建」提示页API 照常可用。要真正的界面就跑
`deploy/install.sh`,或手工:
```bash
# 在仓库根目录执行
npm --prefix client/electron ci
npm --prefix client/electron run build
rm -rf server/internal/static/static/assets
rm -f server/internal/static/static/index.html
cp -r client/electron/dist/. server/internal/static/static/
(cd server && 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 | 插件 | 配置的 `plugin` 列表里加本地路径 |
| DeepSeek Harness | Cordis 插件 | profile 的 `cordis.patch.yml` |
| pi | **常驻守护进程** | `systemctl enable --now pi-mail-bridge` |
以 opencode 为例:
```bash
# 在 opencode 配置的 plugin 列表里加上本地路径
"plugin": ["file:///path/to/agentmail/plugins/opencode-mail-bridge"]
```
pi 不是插件而是独立服务,因为 pi 扩展被加载进**一条已存在的**会话,
而三维地址要求每封邮件的 `path` 位成为会话工作目录 —— 扩展改不了这一点。
桥用 pi 的 SDK`createAgentSession`)按邮件起会话,一个进程里并存多条
不同工作目录的会话。`deploy/install.sh` 会装好它的 systemd 单元。
插件给模型注册十一个工具,并通过 SSE 监听新邮件:收到邮件时自动在平台侧开会话
处理,回信落回同一邮件会话。
| 类别 | 工具 |
|---|---|
| 收发 | `send_mail``read_inbox``read_mail``forward_mail` |
| 附件 | `upload_attachment``download_attachment` |
| 寻址发现 | `suggest_address``list_contacts``session_participants``read_thread` |
| 连接自愈 | `connect_to_server` |
寻址发现那一组解决的是**猜地址**`to` 是自由文本,拼错不会报错。生产上真的
发生过一个 Agent 猜了 `opencode@/home`,投递成功,但那不是 opencode 的工作目录,
那条邮件静默变成了一条平行会话的开端。有了 `suggest_address`,模型是从候选列表里
选而不是猜。
`send_mail` 还有一对可选参数 `propose_alias` / `propose_reason`:模型摸清问题后
可以提议把会话别名从邮件主题(`排查登录问题`)改成更精确的名字
`fix-session-cookie-leak`)。这只是**提议** —— 别名是人的寻址入口,
Agent 干到一半自己改掉会让人上一秒记住的地址下一秒失效,所以要等人在界面上点确认。
两类消息由插件**自动**转发,不需要模型自己调工具,也不消耗发信配额:
- **平台原生的权限询问**平台拦下一个危险操作时opencode 的 `permission.ask`
DSH 的 `approval/request`、pi 的 `tool_call` 钩子),插件把它转成邮件问人,
人在网页上点「同意/一直同意/拒绝」,插件再回复平台让它继续。
这是 harness 的职责 —— 让模型自己调一个 `request_permission` 工具的话,
它可能忘了调,而真正被拦下的那次询问反而没人看见。
- **本轮的最终总结**:一轮跑完时把最后那段话作为回信发回去。
模型已经把话说完了,插件只是搬运。
配额约束的是**模型的自主发信**,不是 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`
`/etc/agentmail/pi.env`
## 项目结构
```
agentmail/
├── docs/
│ ├── PLAN.md # 分阶段实施计划
│ ├── MVP-SPEC.md # MVP 技术规格书
│ └── PLUGIN-CONTRACT.md # Agent 平台插件契约(规格 + 验收清单)
├── server/ # 后端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 插件)
│ └── pi-mail-bridge/ # pi常驻守护进程用 SDK 起会话)
├── client/
│ ├── electron/ # Electron + React + Vite + Tailwind 客户端
│ │ └── test/manual/ # 浏览器实测脚本(量真实盒子与命中区,不进 npm test
│ └── harmony/ # HarmonyOS ArkUI 客户端
└── deploy/ # systemd 单元 + 安装脚本
├── redeploy-gateway.sh # 二进制热替换(.backup + 原子 install + 后置验证 + 自动回滚)
└── 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 server && go build ./... && go test ./... # 后端
cd client/electron && 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 干完活可以在正文里**提议**改成更贴切的名字(`send_mail``propose_alias`
参数,实际以 HTML 注释形式搭在正文里发出,服务端解析后剥掉),但改不改由人点头:
别名是人的寻址入口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) — 数据模型与接口细节