Files
MailUI4Agents/docs/PHASE7-REMAINING.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

223 lines
13 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.

# Phase 7 剩余项与已知生产缺陷追踪
7.7 DSH 插件已完成(见 `docs/PLUGIN-CONTRACT.md` 与 PLAN.md §7.7)。
## 7.8 跨主机 Agent —— 协议层面已支持
**原计划**Gateway + Registry 拆分、etcd/Consul 服务注册、跨主机路由)**不做**。
它解决的是「Gateway 怎么找到 Agent」而这个方向从一开始就不成立
**连接方向是单向的 —— Agent 主动连 GatewayGateway 从不外呼。**
因此「发现」不是 Gateway 的问题,是 Agent 的配置问题:它只需要知道一个
公网 URL 加一把密钥。注册中心要解决的「被叫方在哪」在这个架构里不存在
—— 被叫方自己会打进来。
同一个理由让平台会话同步走插件上报(见 PLUGIN-CONTRACT 的 `W-3`
Gateway 不外呼,就不需要知道任何 Agent 的地址。
### 已验证可用2026-09-02从 192.168.2.106 打到公网)
完整一轮往返跑通了:`admin` 在本机发信给 `remotebot@/tmp/remotebot-ws`
`.106` 上的脚本收到并回信入库。
| 能力 | 结果 |
|---|---|
| 注册(`POST /agent/register` | 通,`workspaces` 落库为 `[{"name":"demo","path":"/tmp/remotebot-ws"}]` |
| 心跳(`POST /agent/heartbeat` | 通,`models_synced: 1`、回传 `allowed_models``models_unrestricted` |
| SSE 长连(`GET /events/stream` + Bearer | 通,收到 `connected` |
| 收件箱 + 标记已读 | 通 |
| 发信(`POST /mail/send``reply_to` | 通,`from_workspace` 正确 |
| Gateway 侧在线状态 | `status=online``last_seen` 随心跳推进 |
验证用的是一个**只依赖 python 标准库的脚本**`deploy/remote-agent-demo.py`
它没装 AgentMail 的任何代码。这就是「协议层面已支持」的含义:
跨主机不需要新组件,只需要三个环境变量。
### 写这个脚本时踩的两个坑(新平台接入会重复踩)
**`workspaces` 是对象数组 `[{name, path}]`,不是字符串数组。**
传字符串原先只得到一句固定的 400 `Invalid JSON` —— 完全没指向是哪个字段,
只能靠翻服务端结构体才能发现。两个正式插件都传 `workspaces: []`
所以这个坑一直没暴露过;第三方客户端没有「翻服务端源码」这个条件。
**已修**:新增 `handler.DecodeBody`22 处 `Decode` + 固定文案的调用点全部换过去。
现在同样的请求回:
```json
{"error": "字段 \"workspaces\" 类型不对:期望 object收到 string"}
```
刻意不回显 `encoding/json` 的原文——它带 Go 类型名(`models.Workspace`
那是本侧的实现细节,不该出现在公开 API 的响应里。
`internal/handler/decode_test.go` 钉住这一点(含「不得泄漏 Go 类型名」的断言)。
**SSE 只推连上之后的事件,离线期间的邮件要靠心跳的 `pending_mails` 补拉。**
第一版脚本只挂了 SSE于是启动前发的那封邮件永远不会被处理 ——
日志里 `pending=1` 明明写着有一封未读,却没人去拉。
**这个坑两个正式插件也有**(原以为它们做了补拉,查了才发现没有)。已修:
新增共用模块 `lib/catchup.js`,两插件在首个成功心跳后补投一次。
- 只在**首个**心跳后补,不是每轮:每轮都补会把「模型正在处理中、尚未标已读」
的邮件重复投递
- 串行投递、一次最多 5 封:每封都要起一轮模型,并发放出去等于对上游打 N 个
并发请求,且最后那几封要等前面全部跑完
- 与 SSE 共用 `deliveredMails` 去重:心跳与 SSE 建连之间有个窗口,
那期间到的邮件既在 `pending_mails` 里也会被 SSE 推一次
- 按时间**正序**补投(收件箱是倒序返回的):同一会话里的多封邮件倒着塞进去,
上下文顺序是乱的
- `permission` 类邮件不补投:原来的工具调用早随进程一起没了,
投过去模型没有可恢复的上下文
端到端验证(两平台各一次):停插件 → 发邮件 → 启插件 → 日志出现
「补投 1 封离线期间的邮件」→ 回信入库。随后在线状态再发一封确认只回一次。
### 剩下的确实是运维便利,不是能力缺失
- [ ] 一条命令为远端主机建密钥并打印那三个环境变量(现在要手工调 admin API
- [ ] 密钥轮换(现在换密钥要重启远端 agent
- [ ] Agent 列表显示来源主机(`agents.host_url` 列已存在但没人写,
要写的话应当由心跳带上自报的地址 —— 仍然不是 Gateway 去探测)
这三项都不阻塞跨主机使用,因此不再归入 Phase 7。
## 可以立即推进的生产缺陷
### P0 — SSE Last-Event-ID 补投
**根因**EventSource 断线重连时自带 Last-Event-ID 头,但服务端直接忽略了——
所有断线期间的邮件通知都丢失。用户刷新页面也会错过已推的事件。
**影响**:重连后永远看不到断线期间收到的邮件(除非手动刷新)。
**修法**服务端维护一个有界循环缓冲区ring buffer每次 Broadcast 同时写入,
SSE 连接的 handler 在首次连接时从缓冲区头部开始(客户端传了 Last-Event-ID 就从那里),
没有则从头(只带最近 N 条)。缓冲区大小设 500内存 < 2MB
### P0 — 连接状态指示器
**根因**SSE 断线后前端无任何可见反馈——用户以为系统正常实际通知已停
**影响**实时性是 Agent 协作的核心体验断线无提示会让人以为Agent 没在动」。
**修法**header 旁加一个连接状态点绿//SSE onopen/onerror 事件驱动
### P1 — 登录限速跨进程问题 ✅
**根因**LoginLimiter 是进程内内存计数器多实例部署时每个实例独立计数
**修法**改为 DB 事务rate_limits + IMMEDIATE 事务多实例共享同一份计数
### P1 — 新建会话限速同理 ✅
**根因**sessionRateLimiter 也是进程内计数器
**修法**同上sessionrate.go 重写为调用 RateLimitCheckAndRecord
### P2 — 组件级测试
**现状**有结构性回归`web/test/narrow-layout.test.mjs`28 条断言
读源码验形态与一套 playwright 手工脚本**没有渲染组件跑断言的测试**。
**范围**关键组件AddressInput 补全PermissionPanel 决策WorkCard 预算渲染)。
**已有的浏览器实测**`web/test/manual/``npm run test:narrow` /
`npm run test:wide`)—— 连本机共享 Chromium 量真实盒子与命中区
不在 `npm test` 因为要一个跑着的浏览器加一个活的 Gateway
剩下的是把它接进 CI需要一个 headless 环境与一个测试用 Gateway 实例)。
### 窄屏实测修复2026-09-02
playwright 连本机共享 Chromium 390pxiPhone 14 Pro 320pxiPhone SE
两档实测。**一个功能性 bug 加五处可用性问题**
**抽屉式侧栏遮挡底部导航(真 bug已删抽屉**
抽屉是 `fixed left-0 top-0 bottom-0 z-50`铺满整个视口高度底部导航没有
z-index抽屉打开时点最左那一项收件」,`elementFromPoint` 命中的是抽屉里的
SVG不是导航按钮
修法是**删掉抽屉**而不是给导航加 z-index抽屉里六项收件/发件/联系/用户/
新建/管理与底部导航完全重复唯一独有的是退出登录为一个按钮维护一套
fixed 层级 + 遮罩不划算何况它还引入了遮挡Esc 关不掉底层未锁滚三个问题
退出登录移到我的 —— 它与密码密钥同属账号自身」,而那里此前根本
没有退出入口
**触摸命中区(新增 `.tap`**
详情页那排工具按钮视觉高度只有 15-16px实测标记已读48x16、「对话树
54x16、「转发42x16、「抄送20x15移动端下限是 44x44
直接加 padding 会把本来就挤的头部撑散 320px 上换行因此改用居中的透明
伪元素扩大命中区**视觉一像素不动**。只在 `max-width: 767px` 生效 ——
桌面用鼠标精度足够而扩大后的命中区在密排工具栏里会互相重叠
覆盖 MailView / ContactPanel / WorkCard / ModelScopePanel / AdminUsersPage /
ComposePage / ThreadView / KeyPanel / QuotaPanel / Attachments / BackButton
**悬停才显形的按钮在触摸设备上永远透明却按得动(新增 `.reveal`**
`opacity-0 group-hover:opacity-100` 在没有 hover 的设备上永远是 `opacity: 0`
但仍然接收点击 —— 实测联系人列表里 `elementFromPoint` 命中的就是那个看不见的
归档」。一个看不见却按得动的破坏性按钮比没有按钮更糟人以为点的是卡片
实际归档了一条会话
改为默认可见只在 `(hover: hover) and (pointer: fine)` 时隐藏 ——
单看 `hover` 会把带触摸板的平板算进去
**对话树缩进在 320px 下把卡片压成竖条**
固定每级 20px上限 8 」= 最多 160px320px 屏还要去掉 `px-4` 32px
连接线 18px卡片只剩 110px发件人一行直接被 truncate 吃掉
窄屏改成每级 10px上限 5
**对话树窄屏没有返回出口**
只有关闭」。两者语义不同返回退出整个详情栏回到列表关闭只收起树
留在这封邮件上补了 `BackButton`
**我的页根本没有滚动容器用户反馈**
窄屏外壳是 `h-full flex flex-col overflow-hidden`页面是 `flex-1 flex flex-col`
中间缺一层 `overflow-y-auto` —— 内容超出的部分**直接被裁**滚不到也点不到
实测 390px 下内容需 860px容器 795px,「退出登录连同下面 65px 一起消失;
1280x800 的桌面上同样看不到其余六个页面级组件都有这一层只有它漏了
**登录页 / 初始化页在矮屏滚不到底**
卡片高约 371px `h-full flex items-center` 在内容超高时让它上下**同时**溢出
溢出到顶部那段滚不到`scrollTop` 最小是 0)—— 实测 568x280横屏手机
或软键盘弹出后的可视高度登录按钮完全在视口外光加 `overflow-y-auto`
也够不着改用卡片自己的 `my-auto`auto margin 在空间不足时退化为 0
矮屏变成顶对齐可滚布局高屏仍然垂直居中
#### 验证
playwright 端到端 18 项全通过底部导航六项都命中自己」、
工具按钮命中区 >= 44px」、五个页面各有纵向滚动容器、无横向溢出、
320px 无横向溢出);宽屏回归 5 项全通过(三栏并排、常驻侧栏仍有退出登录、
无返回按钮、`.tap` 伪元素在宽屏不生效、无溢出)。
`web/test/narrow-layout.test.mjs` 从 20 条扩到 37 条,把上述每一条都钉住。
滚动检查的判据是「**有**滚动容器」而不是「当前正在滚动」:
内容暂时不够高时后者为假,但页面是健康的 —— 真正的 bug 是根本没有那一层。
### P2 — 深色主题
**现状**:只有浅色主题,深夜使用刺眼。
**范围**tailwind dark: 前缀覆盖主要组件。
### P1 — 每平台可用模型范围 ✅
**需求**:配置页面为每个 Agent 平台划定「邮箱调用场景下可用的模型范围」,
端侧插件按范围**逐个降级尝试**,全部失败时把失败原因封装成邮件回复。
选择而非手打模型名 —— 平台上报目录,管理员勾选。
**已完成**
- `agent_model_catalog`(平台上报的目录)+ `agent_allowed_models`(管理员的选择)
两张表,两份 schema
- `repo/models_scope.go``ReplaceModelCatalog` / `ListModelCatalog` /
`ListAllowedModels` / `SetAllowedModels`
**为什么分两张表**:模型会从平台目录里消失(换了 provider 配置、上游临时下线),
整行删掉会连带把管理员的选择也删了,模型回来还得重配一遍。分开存之后
「选了什么」是持久的,目录只决定「这一项现在是否可用」。
**已完成(全部)**
- [x] handler + 路由:`GET/PUT /admin/agents/{name}/models``GET /agent/models/allowed`
- [x] **目录上报走心跳**而不是另设端点:模型清单会在运行中变,
心跳本来就是 30 秒一次的现成通道;另设一个 POST 等于给「目录是谁写的」
留两个答案
- [x] 心跳响应回传 `allowed_models`:管理员改了范围后最多一个周期生效,不必重启
- [x] `lib/model-scope.js`:目录整理(两平台)、`modelAttemptOrder``renderFailureReport`
- [x] 插件按 rank 逐个尝试,全部失败发一封说明原因的邮件(走免配额通道)
- [x] 前端 `ModelScopePanel`:勾选 + 上下移调序 + stale 标记
**最难的一点**(两个平台都踩了):**模型失败不是同步抛出的**。
`promptAsync()` 立即返回、`ctx.agents.create()` 不校验模型,只包 try/catch
第二个模型永远不会被试到。要等异步结论:
- opencode → `session.error` 事件
- DSH → `turn/end``reason.kind === 'error'`
DSH 还有个陷阱:`assistant/chunk``finish` 子类型也带错误,
把任意 chunk 当成功会让无效 provider 判成走通(实测踩过)。