Files
MailUI4Agents/docs/PHASE7-REMAINING.md

418 lines
24 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 — 组件级测试
**现状**有结构性回归`client/electron/test/narrow-layout.test.mjs`28 条断言
读源码验形态与一套 playwright 手工脚本**没有渲染组件跑断言的测试**。
**范围**关键组件AddressInput 补全PermissionPanel 决策WorkCard 预算渲染)。
**已有的浏览器实测**`client/electron/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` 伪元素在宽屏不生效、无溢出)。
`client/electron/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 判成走通(实测踩过)。
---
## 日历与待办 ✅ 后端 + 前端
参照 Outlook 的逻辑与 UI事件/提醒/重复规则、iCal 导入导出、
提醒经邮件通知指定 Agent正文可编辑、支持变量预填充、提醒可带附件。
### 三层分离
事件是日历实体,提醒是触发器,邮件是投递通道。三者刻意不合并:
- **事件**`calendar_events`)有时间、重复规则、收件方
- **提醒**由调度器在 `event_time remind_before` 触发
- **邮件**由 `SendCalendarMail` 投递,`from_name = "calendar"`
`from_name` 刻意既不是人类用户名也不是 Agent 名。用创建者的名字会让 Agent
以为人在实时找它,而人此刻可能在睡觉 —— 模型据此判断「要不要马上追问」,
来源写错会让它问一个不在线的人。
**日历提醒不扣会话预算**:预算的语义是「这件事值得模型自主发多少封信」,
提醒是人预先设定的定时任务,不是模型的自主行为。让它扣预算会出现
「每天 9 点的日报提醒把当天预算吃掉一格」这种反直觉结果。
### 已完成
**后端**
- schema 两份(`calendar_events` + `calendar_attachments`SQLite 与 PG
- `repo/calendar.go`CRUD、`DueEvents``MarkEventFired``AdvanceRecurrence`
附件增删列、`AttachCalendarFilesToMail`
- `handler/calendar.go`9 端点 + iCal 导入导出
- `scheduler/calendar.go`30 秒 ticker、`RenderReminder``fireEvent`
**前端**
- `lib/calendar.ts`:日期边界、月/周格子、分桶、模板渲染、`datetime-local` 往返
- `CalendarView.tsx`:月/周/日三粒度 + 工具条 + iCal 导入导出按钮
- `CalendarEventEditor.tsx`:整页编辑器 + 变量按钮 + 实时预览 + 附件区
- 导航项Sidebar + NarrowNav+ `App.tsx` 路由
### 修掉的六个真问题
**1. 默认提醒模板把时间烤成字面值**
原来 `Sprintf` 出一份含字面时间的正文存进 `reminder_text`。对重复事件是错的:
`AdvanceRecurrence` 只推进 `event_time``reminder_text` 保持不动 ——
「每天 9 点」的提醒从第二天起永远写着第一天的日期,且不报任何错。
改为存变量形式(`{title}`/`{time}`/`{description}`),触发时才替换。
后端 `defaultReminderTemplate` 与前端 `DEFAULT_TEMPLATE` 逐字一致,有测试钉住。
**2. `{time}` 渲染成 UTC**
DSN 带 `_timezone=UTC`,从库里读回的 `EventTime` 是 UTC。直接 `Format`
会把人在 +0800 输入的 14:30 写成 06:30而前端预览用的是本地时间 ——
两边差 8 小时且两边都不报错。修法:`e.EventTime.Local().Format(...)`
**3. 同一提醒每个 tick 重发一次(生产实测 4 封)**
`DueEvents` 有 60 秒 lookahead调度周期 30 秒,不提前看会让提醒迟到)。
去重判据原本是 `last_fired_at < event_time` —— 触发时刻now本来就早于
落在窗口内的 `event_time`,条件恒真。实测一条 12:53:17 的事件在
12:52:30 / 12:53:00 / 12:53:06 / 12:53:36 各发了一封。
修法:新增 `fired_for` 列记录**已触发的 occurrence**(值 = 当时的 `event_time`
判据改为 `fired_for <> event_time``AdvanceRecurrence` 改了 `event_time`
就重新到期,没改就永不重发。
**4. 附件从未落盘**
`data := make([]byte, header.Size); file.Read(data)` 两处错:单次 `Read`
不保证填满缓冲大文件必然短读sha256 因此算的是半截内容),而且
**文件内容压根没写进 blob 存储**,只往库里写了一条元数据。
结果是附件「上传成功」、清单里看得见、发提醒时取不到任何字节。
改为走 `Blobs.Put`(与邮件附件同一套双层大小限制)。
**5. 事件附件不会随提醒发出**
事件附件与邮件附件是两张表。缺了复制这一步,附件只存在于日历侧 ——
UI 里看得见、提醒按时发出、而 Agent 收到的那封信附件清单是空的。
新增 `AttachCalendarFilesToMail`:内容寻址下只增元数据不拷磁盘文件。
失败不阻断投递 —— 提醒正文比附件重要,少一个附件比整条提醒发不出去好。
**6. iCal TRIGGER 往返是断的**
导出写 `-P15M`、导入找 `-PT%dM`,自己导出的文件自己都读不回来。
更糟的是 `-P15M` 在任何合规客户端里都是「提前 **15 个月**」——
iCal duration 的 `M``T` 之前是月、之后才是分钟。
而且原来用 `maxInt(RemindBefore, 15)` 兜底,把用户明确设的「到点提醒」(0)
悄悄改成提前 15 分钟;导出不该修改语义。
修法:导出写 `-PT<n>M`,导入换成正经的 duration 解析
`parseTriggerMinutes` 支持 `-PT30M`/`-PT1H30M`/`-P1D`/`-P1W`
T 前的 `M`(月)与正号 trigger 一律忽略而不是乱换算)。
### 其他修正
- **PG schema 整块缺失日历两张表** —— `DATABASE_URL` 一旦非空,所有
`/calendar/*``relation does not exist` 上 500而 SQLite 下一切正常,
问题只在切外部库时才暴露
- **`DeleteCalendarAttachment` 曾返回 501**,让人「删整个事件来清附件」——
撤一个错传的文件不该要求把整条日程连提醒配置一起重建
- **导出忽略 `from`/`to`** —— 写死 ±1 年会让人点导出后得到一堆与屏幕上不符的事件
- **导入只接受 multipart** —— 命令行调用者收到含糊的「Missing file field」。
改为同时接受 raw `text/calendar``curl --data-binary @x.ics`
- **上传附件不校验事件存在** —— 会攒下孤儿附件记录,而 `ON DELETE CASCADE`
永远清不掉它们(没有父行可删)
### 前端的三个易错点(都有测试钉住)
**周首必须是周一**`getDay()` 把周日算作 0直接减它会让周日归到上一周
末尾,月视图第一行整体错位。
**分桶用本地日期串而不是 `toISOString().slice(0,10)`**。后者给 UTC 日期,
东八区晚上 8 点后的事件会被归到第二天的格子里。
**月视图固定 42 格**。按需 4~6 行会让网格高度随月份跳动,翻月时页面弹动。
另有一个 TS 陷阱:`replaceAll` 在 tsconfig 的 `target: ES2020` 下不存在
TS2550。改用 `split/join`**不能**退回 `replace`,那只换第一个,
同一变量写两次时第二个会原样漏进邮件。
### 验证
- `internal/repo/calendar_test.go`CRUD、到期判定、幂等、重复推进、区间查询、
附件增删列、**lookahead 窗口内不重发**、**推进后重新到期**、
**事件附件复制成邮件附件**(含空 sha256 脏数据跳过)
- `internal/handler/ics_test.go` 14 例:解析基本形态/多事件/无 DTSTART 丢弃/
重复规则/三种日期格式/带 TZID 参数/转义换行/LF 换行/垃圾输入/
**TRIGGER duration 全形态**/**导出导入往返**/**默认模板必须是变量形式**
- `internal/scheduler/calendar_test.go`:模板渲染 7 例(含**本地时区**与
**同一时刻不同 Location 渲染一致**
- `client/electron/test/components/calendar.test.tsx` 35 例
- 生产端到端:建事件 → 30 秒内触发 → 邮件入库、正文变量已替换成本地时间 →
重复事件推进到次日 → **连续两个 tick 零重发**(修复前每 tick 一封)→
iCal 导入 `-PT45M`/`FREQ=WEEKLY` 正确落库 → 导出再导入闭环一致 →
清理验证数据
### 未做
- 附件下载端点(日历侧):目前只能通过提醒邮件里的附件下载
- `parseICS` 不处理折叠续行RFC 5545 的 75 字节折行):长 DESCRIPTION
会被截断。常见客户端导出的短字段不受影响
- `RRULE` 只认 `FREQ=`,忽略 `INTERVAL`/`BYDAY`/`COUNT`/`UNTIL`
- 周/日视图不按小时定位色块高度(事件都是等高行,不体现时长)
---
## B-8权限询问转邮件在 HomeAgent 上不适用
四平台能力矩阵里 homeagent 的 B-8 一直标着「❌ 要先摸清 homed approval API」。
本轮查清了:**那个 API 不存在,而且不该存在。**
判据:在 SDK`third_party/homeagent-sdk/sdk/*.go`)与核心
`internal/**/*.go`)两处 grep `approval|consent|permission|confirm`
命中数均为 **0**
原因是设计取向不同。另三个平台各有一个现成的审批环节,桥做的只是把它从
本地 TUI **改道**到邮件通道:
| 平台 | 审批钩子 |
|---|---|
| opencode | `permission.ask` |
| DSH | `approval/request` |
| pi | `tool_call` |
HomeAgent 的核心是一个**纯思维核** —— 本身没有任何对外交互能力,
全部能力来自插件,也没有会话这一层(单事件循环)。它不问人:
工具调用直接执行。
它确实有一个可以拦下调用的位置:`StageBeforeToolcall`
`internal/agent/core/process.go:273`,插件给 `ctx.Response` 赋值即视为拒绝,
核心会把「工具 X 已被插件拒绝」当作 tool 结果喂回模型并发 `status: "denied"`
事件)。但那是「插件可以否决」而不是「平台在征求同意」:没有待批准的请求、
没有选项、也没有等人的语义。
**因此 B-8 在这个平台上是 N/A不是待实现项。** 硬要补等于给平台加一层
它本来没有的能力:要自己划高风险工具白名单、自己定义超时与 fail closed
语义、自己决定人不在时怎么办 —— 那些都是产品决策而不是契约合规。
(技术上可行:`RegisterTool` 的 handler 是我们的代码,能在执行前发权限邮件
并阻塞等待;`InjectInputSync` 证明这套 SDK 里「同步等外部答复」是既有形态。
但没有需求驱动就不做。)
### 随之而来的一个事实,需要知道
homeagent 的工具**全部无条件执行**,其中包括 `cmd_run` 这类能力。
它接入 AgentMail 之后,任何能给 `homeagent@…` 发信的人或 Agent 都能间接
触发这些工具,中间没有人类确认环节。
另三条链上至少有一道兜底:桥收到 409Gateway 判定整条会话树上没有人类
可路由时当场表态拒绝。homeagent 这条链没有这一环 —— 因为它根本不发起询问。
这不是缺陷是那个平台的信任模型homed 及其插件都跑在用户自己的机器上,
默认完整权限。记在这里是为了让「谁能给 homeagent 发信」这个问题被当作
访问控制来对待,而不是当作邮件权限。
### 契约文档的改动
- `B-8` 标题从「若平台支持MUST」改为「若平台有**审批环节**MUST没有则 N/A」
并补一段说明前置条件与 `StageBeforeToolcall` 的语义差异
- 平台差异对照表补齐 **HomeAgent 一列**14 行)。它是四个平台里
唯一不需要为每封邮件开平台侧会话的 —— 没有会话概念,所有邮件注入同一个
事件循环,靠 `output_send__agentmail` 输出通道送回复
- 检查清单里「未决权限询问 fail closed」标注适用条件