feat(notify): IM 路由的主动消息也在非前台补浏览器通知(契约 v0.74)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

主人裁定 C 的必然推论:她该不该提醒我,与后端走了哪条路由无关。P0 如实报回的缺口 = 通知只挂在 proactive_message 帧上,而 auto 提示在有已连接 IM 渠道时优先走 IM ⇒ 最常发生的路径恰恰不弹。

判据(先查现状、不猜):行字段**区分不出**主动推送与普通回复(已用真库证据排除 platform/adapterId/turnId 反推)⇒ 新增**结构化的正向声明**:帧顶 data.proactive === true,只由主动推送那条通道打(gateway.BroadcastProactivePersisted);普通路径永远传 false。
- 后端:channelbridge 抽出唯一写入口径 persistMain + 新增 proactive_notify 接缝;gateway 抽出 broadcastPersisted(msg, proactive) 两入口共用
- 前端:handlers/proactive-notice.ts 两条帧路共用同一套提醒运行时(有界去重 + 降级说明 + DOM/Notification 取值);message.ts 读到判据才走提醒
- 对照断言(必须有):普通 IM 回复/主人提问/显式 proactive:false ⇒ **0 通知、3 气泡**(不误伤)
- 变异实测:去掉非主动保护 / 跨两路去重失效 / 前台也弹 / IM 不发判据帧 / 普通广播带判据 ⇒ 5 条全部打红
- 契约 v0.74(纯增量:message_persisted 新增可选 proactive:true)
- 边界(如实):判据**不落库**(翻历史不弹提醒,也不得从行字段反推);落库失败时不发判据帧(无权威 id 的帧前端整帧丢弃)
This commit is contained in:
AskaEth committed 2026-10-05 19:47:54 +08:00
1 parent a6cb44b8a8
commit f4adf0b526
13 files changed
+652 -122

No files matched your search

+30 -5
View File
@@ -1,6 +1,6 @@
# Cyrene v2 客户端 API 契约
> **契约版本:`v0.73`**(上一版 `v0.72`,其条目见 §6;`v0.71` 及更早见本文件 §6,`v0.67` 及更早的基线已归档、只读:`Docs/archive/api-client.v0.65.md`)。
> **契约版本:`v0.74`**(上一版 `v0.73`,其条目见 §6;`v0.72` / `v0.71` 及更早见本文件 §6,`v0.67` 及更早的基线已归档、只读:`Docs/archive/api-client.v0.65.md`)。
> **读者**:Web 前端 / 移动端原生客户端(含 Android)/ IM 桥接等**跨平台客户端开发者**。
> **唯一真源 = 当前代码**(`internal/services/gateway/*` 等)。本文档由代码逐条核对得出;与代码不符时**以代码为准**,并回改本文档。
> **旧的超详细版已归档、只读**:`Docs/archive/api-client.v0.65.md`(412 KB,含 v0.28→v0.65 全部历史条目)与 `Docs/archive/api-reference.v0.65.md`(170 KB,后端全量字段级文档)。归档的原因是**结构性臃肿 + 历史条目并列 + 长期漂移**;本文档**不复制**那段历史,需要考古时去归档。
@@ -16,6 +16,8 @@
> **v0.73 是链路图本地落盘版(已实现,纯增量)**:链路图从「只活在进程内存」变成**每轮一行落进本地库**(新表 `flow_graphs`,迁移 `0022`),`GET /api/v1/admin/flow/graph` 在 v0.71 之上**增量**:新增可选查询参数 `turnId`(省略 = 最近一轮;**先查内存 `LastGraph`,miss 才回退落盘**)、响应新增 `turnId` / `source` / `turns[]` 三键(**多轮历史回放**,`design/19 §4` 的 P3)。修的是主人 2026-10-05 追加要求的那句「链路流程应该有本地数据,不然每次重载之后页面就没数据了」—— v0.71 只覆盖「后端进程活着时的最近一轮」,后端一重启、或翻更早的轮次,仍然什么都查不到。**无破坏性变更**(旧键 `sessionId`/`graph` 形状语义不变,`graph_update` 帧、角色门、发布会话范围一字未改;老客户端不读新键即行为与 v0.71 相同)。落盘范围**只有**发布会话白名单内的会话(`flowSessionAllowed`),隔离渠道会话绝不入库。详见 **§2.2 B13 / §4.11**。
> **v0.74 是 IM 路由主动消息的提醒版(已实现,纯增量)**:`message_persisted` 的 `data` 新增**可选**结构化判据 `proactive:true` —— IM 路由(`im_bridge`)的主动推送在落库之后**追加**一条带该键的同类帧,客户端据此在**非前台**补一发浏览器通知(此前通知只挂在 `proactive_message` 上,而 `auto` 提示在主人有已连接 IM 渠道时**优先走 IM** ⇒ 实际最可能发生的路径恰恰不弹)。判据是**结构化字段**(不是正文/提示词):普通消息(Web 回合 / IM 回复 / IM 镜像)的帧**永不**带该键 ⇒ 不可能被误判成主动消息。同一行仍照旧发一条**不带**该键的普通镜像帧(v0.72 起的行为逐字不变,两帧同 id ⇒ 客户端按 id 去重后仍是一个气泡、仍只提醒一次)。详见 **§3.4.4 后的「`message_persisted` 的 `proactive` 判据」**。
---
## 0. 基础约定
@@ -367,12 +369,19 @@
| type | 帧壳 | data | 说明 |
|---|---|---|---|
| `message_persisted` | sessionId | `{message:<historyMessage>}` | Web 主会话及 `web:*` 子会话的消息落库实时同步。**`thinking` 恒缺席**;**可能**带可选 `turnId`(v0.66,空则键省略) |
| `message_persisted` | sessionId | `{message:<historyMessage>,proactive?:true}` | Web 主会话及 `web:*` 子会话的消息落库实时同步。**`thinking` 恒缺席**;**可能**带可选 `turnId`(v0.66,空则键省略);**可能**带可选结构化判据 `proactive:true`(v0.74,仅 IM 路由的主动推送 —— 见下方说明,普通消息永不带它) |
| `channel_persisted` | sessionId | `{message:<historyMessage>}` | 隔离渠道会话(`sess-*`)的消息落库,供 IM 日志页。**`thinking` 恒缺席**;**可能**带可选 `turnId`(v0.66) |
| `message_edited` | sessionId | `<historyMessage>`(**data 直接就是 DTO**) | 编辑单条消息。**`thinking` 恒缺席**;**可能**带可选 `turnId`(v0.66) |
| `message_deleted` | sessionId | `{id}` | 删除单条消息 |
> **`thinking` 可达性口径(v0.66 口径更正)**:消息 DTO 的 `thinking` 在**全部读取面**(`GET /sessions/{id}/messages`、`GET /admin/channels[/{platform}]/messages`、握手 `history` 回放、`message_persisted` / `channel_persisted` / `message_edited`)**恒为缺席**。原因不是「有意清空」,而是 `history.Message.Thinking` **全仓没有任何写入者**(`loop.Result` 没有该字段、Anthropic 流式收尾直接丢弃思考文本)⇒ 该键在线上**永远不会出现**。
> **`message_persisted` 的 `proactive` 判据(v0.74,纯增量)**:帧 `data` 里**可选**的布尔键 `proactive:true` 是「这条是她**主动**说的话」在线上**唯一**的结构化判据,由后端的主动推送通道打上(`gateway.BroadcastProactivePersisted`)。客户端读到它就应走与 `proactive_message` **同一套**提醒逻辑(页面不在前台时补一发浏览器通知、同一条只打扰一次),因此「她该不该提醒我」**与后端走了哪条路由无关**。
> - **为什么需要它**:`auto` 提示在主人有已连接 IM 渠道时**优先走 IM**,而 IM 路由过去只发普通 `message_persisted` —— 浏览器通知挂在 `proactive_message` 上 ⇒ **实际最可能发生的路径恰恰不弹**。修的是这条边界(主人 2026-10-05 裁定「她说话该不该提醒我,与走哪条路由无关」)。
> - **判据为什么不会误伤普通消息**:只有主动推送那条通道会调用带判据的广播入口;普通消息(Web 回合 / IM 回复 / IM 镜像)走的仍是原入口,那条路径**永不**往帧顶放该键。客户端只应做 `data.proactive === true` 的**严格相等**判断 —— 缺失、`false`、非布尔值都按「不是主动消息」处理。
> - **去重键 = `message.id`**:主动推送镜像回主会话时,**同一条**消息会先发一条**不带**判据的普通帧(v0.72 起的行为,逐字不变,任何客户端都能渲染),随后追加一条带判据的帧。两帧同 `message.id` ⇒ 合并成一个气泡(消息 store 按 id 去重),提醒也只发一次(提醒去重表按同一个 id 记)。**`proactive_message` 与带判据的 `message_persisted` 同时到达时同样只提醒一次**。
> - **落库失败时不发判据帧**:没有落库行就没有权威 `id`,而 `message_persisted` 的载荷要求非空 `id`(缺了客户端会整帧丢弃)⇒ 那种情况下只记 WARN、不弹通知(绝不发一条会被丢掉的帧假装提醒过)。
> - **⚠️ 判据不落库**:它只活在**实时帧**上(写入那一刻才知道这条是不是主动推送),库里的行没有对应列(不改 schema)。因此刷新后从 `GET /sessions/web/messages` / 握手 `history` 回放读到的那一行**不带**该信息 —— 这是预期的:通知只对「实时到达」有意义,翻历史不该弹提醒。
> **思维链的唯一出口是实时帧**:§3.4.2 的 `thinking.start` / `thinking.delta` / `thinking.end`(owner/admin 档)。客户端**不要**为「从历史里读回思维链」写任何代码。
> ⚠️ 本行**更正**本文档 v0.65 及更早的写法(旧版称「历史思维链只有 `GET /sessions/{id}/messages`(owner/admin)返回」)—— **那是文档写错了**,与实现不符。见 §7.3。
> 另注:管理面板人格清单里的 `thinking` 是**人格旋钮**(`GET /admin/personas` 的 `thinking` 块),与消息 DTO 的 `thinking` 不是同一个字段。
@@ -405,7 +414,7 @@
> - 落库行:`sessionId="web"`、`role="assistant"`、`content` = 帧里的 `content`(后端已净化的同一份文本,系统元数据绝不进正文)、`platform` = 帧里的 `platform`、`mode="chat"`、`turn_id` **留空**(主动推送不属于任何一路回合)。因此 `GET /sessions/web/messages`、握手 `history` 回放、`message_persisted` 都能读到它。
> - **`id`/`createdAt` 随帧下发**,客户端应据此把实时气泡对齐到历史行(按 id 去重;刷新后由历史行接续,不是第二个气泡)。**落库失败时这两个键缺席**,但同时:帧照发(实时气泡不丢),后端记 WARN 并返回错误 ⇒ 后台思考记录里的 `route/detail` 会写明**未落库**(不会再说成「已推送」)。
> - **无在线 owner/admin 连接时也会落库**:推送器如实报告「没有活跃客户端」(`pushed=false`),这条话仍作为一条真实记录留在主会话里,下次打开就能看到。
> - **IM 路由不需要客户端做任何事**:`im_bridge` 路由(IM 桥)本就把消息镜像回主会话并广播 `message_persisted`(逐条一条,**不双份**)。两条路由在一次推送里**互斥**(`web` 提示只走活跃客户端,其余先 IM、IM 投递失败才降级活跃客户端),因此同一条主动消息在主会话里**恰好一条**。
> - **IM 路由(v0.74 起也提醒)**:`im_bridge` 路由本就把消息镜像回主会话并广播 `message_persisted`(逐条一条,**不双份**),v0.74 起在落库之后**追加**一条帧顶带 `proactive:true` 的同类帧 ⇒ 客户端走与 `proactive_message` 同一套提醒逻辑(见 §3.4.4 后的「`message_persisted` 的 `proactive` 判据」)。两条路由在一次推送里**互斥**(`web` 提示只走活跃客户端,其余先 IM、IM 投递失败才降级活跃客户端),因此同一条主动消息在主会话里**恰好一条**。
#### 3.4.6 定向帧(不经 `hub.broadcast`,只到指定连接)
@@ -828,7 +837,21 @@
> 只保留最近若干版(**v0.60 起**);更早的全部历史见归档 `Docs/archive/api-client.v0.65.md` §5(v0.28 → v0.59)。
> 每版一句话 + 三档标注。**三档定义见 [§0.4](#04-三档标注惯例)。**
### v0.73(当前契约版本)
### v0.74(当前契约版本)
**IM 路由的主动消息也补浏览器通知 —— `message_persisted` 新增可选结构化判据 `proactive:true`**(依据主人 2026-10-05 裁定 C 的必然推论「她说话该不该提醒我,与走哪条路由无关」;对齐 `design/12 §3` 的主动推送与 `design/16 §4.4` 的镜像口径)。
- **纯增量**:
① **`message_persisted` 的 `data` 新增可选键 `proactive:true`**:IM 路由(`im_bridge`)的主动推送在落库之后**追加**一条带该键的同类帧 —— 帧类型、角色门、`message` DTO 形状**一字未改**,只多一个布尔键(**§3.4.4**)。
② 客户端据此走与 `proactive_message` **同一套**提醒逻辑(非前台补一发通知;去重键同为 `message.id`)。老客户端不读该键即行为与 v0.73 完全相同。
- **修的是什么(根因)**:`auto` 提示在主人有已连接 IM 渠道时**优先走 IM**,而 IM 路由此前只发普通 `message_persisted` —— 浏览器通知挂在 `proactive_message` 上 ⇒ **实际最可能发生的路径恰恰不弹**。这就是 P0(v0.72)如实报回的边界。
- **判据是结构化的**:主会话落库行的 `platform`/`adapter_id` **区分不出**主动推送与普通回复(主动推送写的是解析后的真实平台,例如 `weixin-ilink`,与普通回复行同一形状)⇒ 判据放在**落库那一刻的通知通道**上(`gateway.BroadcastProactivePersisted`),随帧顶到达客户端。**绝不**读正文关键词、**绝不**用提示词/模型判断(项目红线:身份与判定靠元数据)。
- **不误伤**:普通消息(Web 回合 / IM 回复 / IM 镜像)走的仍是原入口 `BroadcastPersisted`,那条路径**永不**放该键 ⇒ 客户端不可能把普通消息误判成主动消息。客户端只应做 `data.proactive === true` 的严格相等判断。
- **行为变更**:无(普通镜像帧照旧发一条;主会话里仍**恰好一条**行,P0 的「不双份」红线未动)。
- **破坏性**:无。
- ⚠️ **诚实边界**:① 判据**不落库**(不改 schema)⇒ 刷新后从历史读回的行不带它(预期:通知只对实时到达有意义)。② **落库失败时不发判据帧**:没有权威 `id` 的 `message_persisted` 会被客户端整帧丢弃,发它等于假装提醒过 —— 那种情况下只记 WARN。③ 「她主动说的话」在三类帧上到达的完整清单见 §3.4.4 与 §3.4.5。
### v0.73(上一版)
**链路图本地落盘 + 多轮历史回放 —— 图从「只活在进程内存」变成每轮一行落进本地库,`GET /api/v1/admin/flow/graph` 增量支持按 `turnId` 取历史轮**(依据主人 2026-10-05 追加要求「链路流程应该有本地数据,不然每次重载之后页面就没数据了」;对齐 `design/19 §4` 的 **P3**「多轮历史回放」—— v0.71 只做了其中最小一步)。
@@ -1078,6 +1101,8 @@
19. **(v0.71 登记 · v0.73 更新)图源是**进程内内存**,但**每轮收尾会落盘**(v0.73)**:`loop.FlowPublisher` 的每会话最近一图仍是**内存**(design/19 §1 的实时口径),但 `EndRound` 会把该轮写进 `flow_graphs`(迁移 `0022`)⇒ 后端重启后、该会话还没跑新轮次时,省略 `turnId` 的查询**回退落盘**即可拿到最近一轮(`source:"persisted"`),不再一律 `graph:null`。⚠️ **仍会 `graph:null` 的情形**:该会话本就在**发布会话白名单之外**(隔离渠道会话,从不建图也从不落盘)、或该会话**从未跑过带 span 的轮次**、或该轮已被**保留期 GC 回收**(默认 168h)。**登记既有裁决不改**:`graph_update` 的两路互顶(同会话并行两路时后发的那张图会顶掉先发的)仍**未修**,本版**不碰**它。
20. **(v0.73 登记)落盘只覆盖「保留期内」且只覆盖白名单会话**:`turns[]` 是**已落盘且未被 GC 回收**的轮次摘要,**不是全历史**(`FLOW_GRAPH_RETENTION` 默认 168h,`≤0` = 关闭 GC ⇒ 那时才会无限增长,属显式选择)。落盘范围与实时发布**同一白名单**(`flowSessionAllowed`:`web` / `web:<uuid>` / `sess-backthink`)—— **群聊 / 陌生人会话永远不会出现在 `turns[]` 里**。此外**正在跑、尚未 `EndRound`** 的那一轮不会在 `turns[]` 中出现(它是内存里的「实时」那一轮,`source:"live"`),这是如实而非缺失。
21. **(v0.74 登记)`message_persisted` 的 `proactive` 判据只活在实时帧上,不落库**:它是「这条是她主动说的话」在线上**唯一**的结构化判据(帧顶布尔键,由 `gateway.BroadcastProactivePersisted` 打上),**没有对应数据库列**(本片不动 schema)。因此 ① `GET /sessions/{id}/messages`、握手 `history` 回放、`GET /admin/channels/**` 读到的历史行**不带**它(预期:通知只对实时到达有意义);② 客户端**不得**从 `platform` / `adapterId` / `turnId` 等行字段反推「是不是主动消息」—— 主会话里主动推送写的是**解析后的真实平台**(例如 `weixin-ilink`),与普通回复行同一形状,反推必然误伤。**落库失败时不发该帧**(没有权威 `id` 的 `message_persisted` 会被客户端整帧丢弃)。
---
*本文件由「以代码为真源」维护(当前 `v0.73`;`v0.72` / `v0.71` / `v0.70` / `v0.69` / `v0.68` 见本文件 §6,`v0.67` 及更早见本文件 §6 与 `Docs/archive/`)。范围:`v0.73` 同步了 `internal/services/gateway/**`、`internal/runtime/loop/flow.go`、`internal/runtime/flowgraph/**`、`internal/db/migrations/0022_flow_graphs.*`、`frontend/web/src/**` 与本文件;`v0.72` 同步了 `internal/services/gateway/**`(`proactive*`)、`internal/services/backthink/**`、`frontend/web/src/**` 与本文件;`v0.71` 同步了 `internal/services/gateway/**`、`internal/runtime/loop/flow.go`、`frontend/web/src/**` 与本文件。*
*本文件由「以代码为真源」维护(当前 `v0.74`;`v0.73` / `v0.72` / `v0.71` / `v0.70` / `v0.69` / `v0.68` 见本文件 §6,`v0.67` 及更早见本文件 §6 与 `Docs/archive/`)。范围:`v0.74` 同步了 `internal/services/gateway/**`(`proactive.go` / `broadcast.go`)、`internal/services/channelbridge/**`(`proactive.go` / `persist.go` / `proactive_notify.go`)、`cmd/cyrene/wire_frontend.go`、`frontend/web/src/lib/ws/handlers/**` 与本文件;`v0.73` 同步了 `internal/services/gateway/**`、`internal/runtime/loop/flow.go`、`internal/runtime/flowgraph/**`、`internal/db/migrations/0022_flow_graphs.*`、`frontend/web/src/**` 与本文件;`v0.72` 同步了 `internal/services/gateway/**`(`proactive*`)、`internal/services/backthink/**`、`frontend/web/src/**` 与本文件;`v0.71` 同步了 `internal/services/gateway/**`、`internal/runtime/loop/flow.go`、`frontend/web/src/**` 与本文件。*