Files
MailUI4Agents/docs/PHASE7-REMAINING.md
JianFeeeee 89356d4a9b feat: 每平台可用模型范围 + 降级尝试 + 失败回报
配置页为每个 Agent 平台划定「邮件场景下可用的模型」,插件按顺序逐个尝试,
全部失败把原因封装成邮件回复。目录由插件上报、管理员只做勾选 —— 手打模型名
会打错,而打错的后果要到真发邮件时才暴露成一次失败。

## 目录上报走心跳,不另设端点

模型清单会在运行中变(换 provider 配置、上游上下线、换 API key)。
只在注册时报一次的话目录会静静变陈,管理员在配置页选中一个平台其实调不到的
模型。心跳本来就是 30 秒一次的现成通道;另设一个 POST 等于给「目录是谁写的」
留两个答案,排查时要同时看两处。

心跳响应回传 `allowed_models`,因此管理员改了范围后最多一个周期生效,
不必重启插件。

与 platform_sessions 同一约定:拉不到目录时**省略字段**(保留现有目录),
传空数组会把配置页清成空白。

## 目录与选择分两张表

模型会从平台目录里消失(上游临时下线、换了 provider 配置)。合成一张带
allowed 标记的表时,整行被删就连带把管理员的选择也删了,模型回来还得重配一遍。
分开存之后「选了什么」是持久的,目录只决定「这一项现在是否可用」;
已选但不在目录里的标为 stale 显示出来 —— 不显示会让人以为自己没选过它。

## 最难的一点:模型失败不是同步抛出的

两个平台都踩了。`promptAsync()` 立即返回、`ctx.agents.create()` 不校验模型,
只包 try/catch 的话第二个模型永远不会被试到 —— 第一个无效模型会被判成成功。

必须等异步结论:
- opencode → `session.error` 事件(event 钩子在 deliverMail 之外,
  因此用 turnWatchers 表把两者接起来)
- DSH → `turn/end` 的 `reason.kind === 'error'`

DSH 还有个陷阱:**`assistant/chunk` 不能当成功信号**,它的 `finish` 子类型
也带错误 —— `{chunk:{type:'finish',reason:{kind:'error',failure:{code:'NO_ADAPTER'}}}}`。
实测「无效 provider 却判成功」正是因为把任意 chunk 当成了走通。判据要落在
chunk 的类型上:finish 看 reason,其余才意味着模型真的在产出。

超时按成功处理(60 秒窗口):模型可能只是很慢,把慢当成失败会在换模型的同时
把已经在跑的那一轮丢掉。

DSH 换模型要换会话 id(`<原 id>-r1`)并 dispose 失败那个 agent:复用同一个 id
会让重试接在一条已经出错的会话后面,不 dispose 则 agent/status 还会为那个
死会话触发一次自动转发。

## 其他决策

- **范围优先于环境变量**:范围是运行时可改的策略,`AGENTMAIL_REPLY_*` 是部署时
  的兜底。反过来的话管理员在配置页改了却不生效,得去改 service 文件重启
- **范围为空返回 `[undefined]` 而非 `[]`**:空数组会让调用方一次都不试,
  而「管理员没配」的正确含义是不限定,不是「一个都不许用」
- **上限 10 个**:降级是串行的,选 50 个意味着最坏情况下一封邮件要等 50 次超时
- 前端 key 按**第一个** `/` 切分 provider/model:model id 可能含 `/`
  (如 `org/model-name`),按最后一个切会把 provider 切错
- 保存后用服务端返回的结果刷新界面而非回显入参:repo 层会跳过重复与空字段

## 验证

- Go 10 个新测试(含「模型从目录消失后选择必须留存」的直接回归)
- 两插件各 18 个模型范围测试,共 180 个
- 端到端四轮:正常路由 → 全部无效(收到失败回报邮件,used_rounds 保持 0
  确认走了免配额通道)→ DSH 降级(fake-a 失败 → llmsproxy/AUTO 成功)→
  opencode 降级(nonexistent/bad 失败 → AUTO 成功,日志确认「前 1 个失败」)
- 生产已部署,前端「模型范围」页可用
2026-09-02 21:34:55 +08:00

78 lines
4.1 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-GUIDE.md` 与 PLAN.md §7.7)。
## 无法立即推进(缺基础设施)
### 7.8 跨主机 Agent 发现
- Gateway + Registry 拆分为独立服务
- etcd / Consul 服务注册与发现
- Agent 跨主机路由
## 可以立即推进的生产缺陷
### 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 — 组件级测试
**现状**前端无任何组件测试前端回归只靠 lint 与构建
**范围**关键组件AddressInput 补全PermissionPanel 决策WorkCard 预算渲染)。
### 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 判成走通实测踩过)。