diff --git a/Docs/api-client.md b/Docs/api-client.md index a272165..e3f570d 100644 --- a/Docs/api-client.md +++ b/Docs/api-client.md @@ -18,7 +18,7 @@ > **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` 判据」**。 -> **v0.76 是三面增量版(⚠️ 契约先行:三批形状**已登记**,实现**进行中**,登记时尚**未落地**)**:① **消息读取面新增 `toolCalls[]`** —— `GET /api/v1/sessions/{id}/messages` 的消息对象加可选 `toolCalls`(`omitempty`),每条含工具名 / 状态(`ok`/`error`/`unknown`)/ 耗时毫秒 / **已脱敏**的入参与结果摘要;数据源是**已落盘的轮次链路图**(`flow_graphs`,迁移 `0022`,保留期 168h),**不从正文反解**。🔴 **缺席语义**:图取不到(超保留期 / 该轮未落盘 / 隔离渠道会话从不入库)⇒ 字段**整体缺席**,**绝不回空数组**冒充「这一轮没调过工具」。② **未读面** —— `message_persisted` 的 `data` 加可选 `unreadCount`(`omitempty`),`GET /api/v1/sessions` 列表项加同键;口径 = **绝对未读 + 主人单一已读游标(`chat_sessions.read_at`)⇒ 多端共享**(Web 读了移动端也清),这是与安卓侧已对齐的**取舍**,不是缺陷。③ **记忆整理试算加性异步** —— `POST /api/v1/admin/memories/tidy/dry-run?async=1` ⇒ `202` + `runId`;新增 `GET /api/v1/admin/memories/tidy/dry-run/{runId}` 轮询;**不带 `async` 仍是原同步行为**。**无破坏性变更**(新键 / 新可选参数 / 新端点;老客户端零影响)。详见 §1(会话列表与消息读取)、§2.2 B15 / §4.12.1、§3.4.4。 +> **v0.76 是三面增量版(⚠️ **逐条状态不同**:②③ 见各条;**① 未实现且当前无实现路径**)**:① **消息读取面新增 `toolCalls[]`(🔴 本版**未实现** —— 客户端**不要**按它编程)** —— 原定:`GET /api/v1/sessions/{id}/messages` 的消息对象加可选 `toolCalls`(`omitempty`),每条含工具名 / 状态(`ok`/`error`/`unknown`)/ 耗时毫秒 / **已脱敏**的入参与结果摘要;数据源是**已落盘的轮次链路图**(`flow_graphs`,迁移 `0022`,保留期 168h),**不从正文反解**。🔴 **缺席语义**:图取不到(超保留期 / 该轮未落盘 / 隔离渠道会话从不入库)⇒ 字段**整体缺席**,**绝不回空数组**冒充「这一轮没调过工具」。**未实现的原因(实测确认,非推断)**:`chat_messages.turn_id` 与 `flow_graphs.turn_id` **不同源** —— 前者是编排器生成的随机路标识(`turn-` + 16 位十六进制),后者是 `trace.FlowGraph.ID` = `-<轮次序号>`(发布器**进程内**递增计数);两者**无法按消息字面 join**,映射只能靠猜时间窗 ⇒ 按项目口径**不做**。补齐需要先让链路图带上**回合标识**(新键 + 新迁移),**不在本版**。详见 **§7.2**。② **未读面(✅ 已实现)** —— `message_persisted` 的 `data` 加**可选** `unreadCount`(`omitempty`:只在 **>0 且确实算得出来**时出现;**缺席 = 老行为**),`GET /api/v1/sessions` 列表项同键(该键**自 D4 起就已在**,本版未改它)。口径 = **绝对未读 + 主人单一已读游标(`chat_sessions.read_at`)⇒ 多端共享**(Web 读了移动端也清),这是与安卓侧已对齐的**取舍**,不是缺陷。详见 **§3.4.4、§4.13**。③ **记忆整理试算加性异步** —— `POST /api/v1/admin/memories/tidy/dry-run?async=1` ⇒ `202` + `runId`;新增 `GET /api/v1/admin/memories/tidy/dry-run/{runId}` 轮询;**不带 `async` 仍是原同步行为**。**无破坏性变更**(新键 / 新可选参数 / 新端点;老客户端零影响)。详见 §1(会话列表与消息读取)、§2.2 B15 / §4.12.1、§3.4.4。 > **v0.75 是记忆整理试算版(已实现,纯增量)**:新增 1 条管理端点 **`POST /api/v1/admin/memories/tidy/dry-run`**(**只读语义的 POST**,`writeAuth` = owner/admin)—— 跑**同一套**记忆整理规则(召回门 + 包含判定 + 规则否决,与低峰定时轮共用代码路径),把候选**直接回在响应里**,**一行都不写**(不写 `memory_tidy_candidates` / `memory_tidy_runs`,绝不改 `memory_items`)。修的是「整理器只在 23:00–07:00 跑(design/23 §4),在它第一次开跑前面板只能空着」——主人要「现在就想看到在 736 条上它会建议合并哪些」。**零副作用是硬约束**(后端有逐行快照单测钉死);失败**一律如实作答**(`tidy_unavailable` / `tidy_disabled` / `tidy_recall_unavailable` / `tidy_timeout`),**绝不回空候选数组假装「没有建议」**。**无破坏性变更**(新端点;既有端点/帧一字未改)。详见 **§2.2 B14 / §4.12**。 @@ -375,7 +375,7 @@ | type | 帧壳 | data | 说明 | |---|---|---|---| -| `message_persisted` | sessionId | `{message:,proactive?:true}` | Web 主会话及 `web:*` 子会话的消息落库实时同步。**`thinking` 恒缺席**;**可能**带可选 `turnId`(v0.66,空则键省略);**可能**带可选结构化判据 `proactive:true`(v0.74,仅 IM 路由的主动推送 —— 见下方说明,普通消息永不带它) | +| `message_persisted` | sessionId | `{message:,unreadCount?:,proactive?:true}` | Web 主会话及 `web:*` 子会话的消息落库实时同步。**`thinking` 恒缺席**;**可能**带可选 `turnId`(v0.66,空则键省略);**可能**带可选 `unreadCount`(v0.76,该会话此刻的**绝对未读数**,**只在 >0 且算得出来时**出现 —— 见 §4.13);**可能**带可选结构化判据 `proactive:true`(v0.74,仅 IM 路由的主动推送 —— 见下方说明,普通消息永不带它) | | `channel_persisted` | sessionId | `{message:}` | 隔离渠道会话(`sess-*`)的消息落库,供 IM 日志页。**`thinking` 恒缺席**;**可能**带可选 `turnId`(v0.66) | | `message_edited` | sessionId | ``(**data 直接就是 DTO**) | 编辑单条消息。**`thinking` 恒缺席**;**可能**带可选 `turnId`(v0.66) | | `message_deleted` | sessionId | `{id}` | 删除单条消息 | @@ -856,6 +856,42 @@ --- +### 4.13 未读实时面:`message_persisted.data.unreadCount`(v0.76 新增 · ✅ 已实现) + +**帧形状(纯增量)** + +```json +{"type":"message_persisted","sessionId":"web", + "data":{"message":{…,见 §4.4},"unreadCount":3}} +``` + +| 项 | 口径 | +|---|---| +| 键名 / 类型 | `data.unreadCount`,整数 | +| 含义 | 该会话此刻的**绝对未读数** = 比会话读游标(`chat_sessions.read_at`)**严格更新**的消息条数。**不是**「自上次以来新增几条」,也**不是**每端各一份 | +| 口径来源 | 与 `GET /api/v1/sessions` 的元素 `unreadCount` **同一个谓词**(`created_at > read_at`)、**同一条游标** ⇒ 两条面在同一时刻给出同一个数字(后端有单测逐会话比对两者相等) | +| 出现条件 | **只在 `>0` 且确实算得出来时出现**(Go 的 `omitempty` 语义)。**缺席 = 老行为**(v0.75 及更早即如此),客户端**忽略未知键**即可 | +| 缺席的三种情形 | ① 未注入历史存储;② 注入的存储没有单会话未读视图;③ 该会话**没有会话行**(隔离渠道会话 `sess-*`、后台思考会话 —— 它们连读游标都没有)。三种情形后端都**不**写 `unreadCount:0`:**「算不出来」绝不冒充「已读完」** | +| 何时该用 | 客户端落下这帧时**直接用该数字刷新角标**(不必再拉一次 `GET /sessions`)。键缺席时**保持原值**并回落 `GET /sessions` 收敛 —— 不要当成 0 | +| 何时会归零 | 由 `session_read` 帧(§3.4.1 状态帧表 / §1 U16)在任一客户端 `POST /sessions/{id}/read` 后广播;**多端共享单游标**:Web 读了,移动端的红点也清 | + +**多端共享单游标的取舍(与安卓侧已对齐,不是缺陷)** + +后端只有一个主人账号、这一层没有按设备的身份 ⇒ 游标是**会话作用域、单所有者**(`chat_sessions.read_at`,迁移 `0010`):**任一端已读即所有端已读**。因此 `unreadCount` 是「主人还没看过的消息数」,**不是**「这台设备还没同步的消息数」。 + +- 🔴 客户端**不得**把它当「本端未读」渲染成「这条消息我没收到」(那就把跨端同步误报成了漏消息)。 +- 🔴 **不要**自行改成「每端独立未读」(会引入第二份游标、并在多端之间互相打脸);如确需改,属**契约裁定**事项,须先写进本节。 + +**兼容性(逐字无破坏)** + +- 纯增量:**新可选键**,无新帧、无新端点、无既有键改名或语义变化。 +- 老客户端(不读 `unreadCount`)行为**逐字不变**;新客户端读到缺键时的正确答案是「不知道」,不是「0」。 +- `channel_persisted`(隔离渠道会话)走**同一个**载荷构造函数 ⇒ 口径天然一致;实践上它永远不带该键(那类会话没有 `chat_sessions` 行,见上表第 4 行)。 + +**服务端实现位置**(供对照,非契约):`internal/services/gateway/broadcast.go`(帧构造,Web 路径与 IM 路径**共用同一个函数**)、`internal/services/gateway/session_read.go`(`unreadCountOf` + 可选窄接口 `sessionUnreadCounter`)、`internal/runtime/history/session_read.go`(`UnreadCount`)。 + +--- + ## 5. env 与模型配置(与契约相关) > **`.env` vs 面板文件**:面板「LLM 模型」页保存写 `/models.json`(默认 `./data/models.json`);「渠道管理」页写 `/channels.json`。**只要文件存在且有效,运行时优先用它**,`.env` 只作兜底/播种层。`.env.example` 与实现同步(键名逐字一致、值占位符)。 @@ -920,14 +956,14 @@ > 只保留最近若干版(**v0.60 起**);更早的全部历史见归档 `Docs/archive/api-client.v0.65.md` §5(v0.28 → v0.59)。 > 每版一句话 + 三档标注。**三档定义见 [§0.4](#04-三档标注惯例)。** -### v0.76(当前契约版本 · ⚠️ **契约先行:三批形状已登记,实现进行中**) +### v0.76(当前契约版本 · ⚠️ **逐条状态不同 —— ②已实现;①未实现(阻塞);③契约先行**) -**三面增量 —— ① 消息读取面 `toolCalls[]`(工具回看)|② 未读面 `unreadCount`(多端共享单游标)|③ 记忆整理试算的加性异步**(依据:安卓侧 `Cyrene-For-Android/devdocs/02` 明确「工具回看面比未读面更重要、且要带工具输出/摘要」,以及主人 2026-10-05 裁定的「抬高 + 治入库太宽」导致试算更慢)。 +**三面增量 —— ① 消息读取面 `toolCalls[]`(工具回看 · **未实现**)|② 未读面 `unreadCount`(多端共享单游标 · **已实现**)|③ 记忆整理试算的加性异步(契约先行)**(依据:安卓侧 `Cyrene-For-Android/devdocs/02` 明确「工具回看面比未读面更重要、且要带工具输出/摘要」,以及主人 2026-10-05 裁定的「抬高 + 治入库太宽」导致试算更慢)。 -- **① 工具回看面(纯增量)**:`GET /api/v1/sessions/{id}/messages` 的消息对象加可选 `toolCalls`(`omitempty`)—— 每条含 `name` / `status`(`ok`|`error`|`unknown`)/ `durationMs` / `argsSummary` / `resultSummary`(后两者是**图里已有的、已脱敏**内容并按上限截断)。数据源 = **已落盘的轮次链路图**(`flow_graphs`,迁移 `0022`,保留期 168h),**不从正文反解、不从内存态猜**。🔴 **缺席语义**:图取不到 ⇒ 字段**整体缺席**(**绝不回空数组**冒充「没调过工具」)。修的是安卓侧「回看这条回复时看不到它当时调了什么」。 -- **② 未读面(纯增量)**:`message_persisted` 的 `data` 加可选 `unreadCount`(`omitempty`,**缺席 = 老行为**),`GET /api/v1/sessions` 的列表项加同键。口径 = **绝对未读 + 主人单一已读游标(`chat_sessions.read_at`,迁移 `0010`)⇒ 多端共享**:Web 读了,移动端的红点也清 —— 这是与安卓侧**已对齐**的取舍(他们明确赞成),**不是缺陷**,不许改成「每端独立未读」而不经裁定。 +- **① 工具回看面(🔴 未实现 —— 客户端不要按它编程)**:原定 `GET /api/v1/sessions/{id}/messages` 的消息对象加可选 `toolCalls`(`omitempty`)—— 每条含 `name` / `status`(`ok`|`error`|`unknown`)/ `durationMs` / `argsSummary` / `resultSummary`(后两者是**图里已有的、已脱敏**内容并按上限截断)。原定数据源 = **已落盘的轮次链路图**(`flow_graphs`,迁移 `0022`,保留期 168h),**不从正文反解、不从内存态猜**。🔴 **缺席语义**:图取不到 ⇒ 字段**整体缺席**(**绝不回空数组**冒充「没调过工具」)。修的是安卓侧「回看这条回复时看不到它当时调了什么」。**⚠️ 实测阻塞(非推断)**:`chat_messages.turn_id` 与 `flow_graphs.turn_id` **不同源**(前者 = 编排器生成的随机路标识 `turn-<16 hex>`,后者 = `trace.FlowGraph.ID` = `-<轮次序号>`,进程内递增计数)⇒ **无法按消息 join 到它那一轮的图**,而按时间窗硬凑映射属于「猜」,项目口径**不做**。补齐需先给链路图加**回合标识**(新键 + 新迁移 `0023` 起),**不在本版**。详见 **§7.2**。 +- **② 未读面(纯增量 · ✅ 已实现)**:`message_persisted` 的 `data` 加可选 `unreadCount`(`omitempty`,**只在 >0 且确实算得出来时**出现;**缺席 = 老行为**),`GET /api/v1/sessions` 的列表项同键(**该键自 D4 起就已在**,本版未改它)。口径 = **绝对未读 + 主人单一已读游标(`chat_sessions.read_at`,迁移 `0010`)⇒ 多端共享**:Web 读了,移动端的红点也清 —— 这是与安卓侧**已对齐**的取舍(他们明确赞成),**不是缺陷**,不许改成「每端独立未读」而不经裁定。形状、缺席语义与三种缺席情形见 **§4.13**;帧表已同步(**§3.4.4**)。 - **③ 记忆整理试算加性异步**:`POST …/memories/tidy/dry-run?async=1` ⇒ `202` + `runId`(新增 B15 轮询端点)。**不带 `async` 仍是同步** ⇒ 老客户端(含现有面板)逐字不变。结果活在**进程内存**(TTL 30 min、并发 1、重启即失效 ⇒ 如实 `404`)。 -- **行为变更**:无(①②只在既有响应里加可选键;③只在显式带参时改变路径)。 +- **行为变更**:无(②只在既有帧里加可选键;③只在显式带参时改变路径;①未落地 ⇒ 无变更)。 - **破坏性**:无。 - **⚠️ 迁移号预告**:本版实现若需新迁移,编号从 **`0023`** 起(`0020` 预留给语音切片、`0021`/`0022` 已用)。 @@ -1157,6 +1193,8 @@ | **✅ v0.67 登记的「非消息事件查询面」**(`GET /admin/channels/events`、`GET /admin/channels/events/counters`) | **已实现**(v0.67 登记项本切片落地):形状与错误码见 §2.4(a)(已按代码核对:元素键名 camelCase、`approx` 与 `counters` 同级、`400 bad_request`、`owner\|admin` 档);权威设计 `design/26 §4.5`。未接线存储 ⇒ `500 channel_events_unavailable`。⚠️ 本行留在此表仅为与上一版对照,下版改文档时应移入 §7.1 在飞切片表并回填 §2.1/§2.2 的端点计数 | | **✅ v0.67 登记的「账号在线状态」**(`GET /admin/channels` 的 `accounts[].accountOnline`) | **已实现**(v0.67 登记项本次落地;落地前基线 `f91a83f`,本切片 commit 由主会话补记):形状见 §2.4(b)。管理面按**可选接口断言**读 `AccountStatus()`(`plugins/channels/qq_obv11/probe.go`,`a79fe96`);适配器**没有**该状态面、或实现了但 `known=false` ⇒ **键缺席**(未知 ≠ 离线,绝不用 `false` 冒充)。当前只有 `qq-obv11` 会带该键。⚠️ 本行留在此表仅为与上一版对照,下版改文档时应移入 §7.1 在飞切片表 | | **✅ P1-T:`directMsgPerMin` 的 gateway 写入路径**(`GET /admin/channels` 的 `accounts[].directMsgPerMin` + `PUT .../config` 的顶层 `directMsgPerMin`) | **已实现**(v0.69):缺口 = gateway 面没让该字段通过 ⇒ **面板保存一次渠道配置就把主人手配的「关闭流控(-1)」静默重置回 `0` = 保护性默认 6 条/分**。现 5 处透传(`AdminChannelAccount` DTO / `channelConfigRequest` / `config.ChannelAccount` 拷贝 / `adminChannelAccountOf` / 微信扫码重建账号)。形状见 §2.2 A5 与 §2.3 C28 注 | +| **🔴 v0.76 登记的「工具回看面」**(消息 DTO `toolCalls[]`) | **未实现(本版阻塞,客户端别按它编程)**:形状见 §6 v0.76 ①、§4.4。原定数据源是**已落盘的链路图**,但两者的轮次键**不同源** —— `chat_messages.turn_id` 是编排器生成的随机路标识(`turn-` + 16 位十六进制),`flow_graphs.turn_id` 是 `trace.FlowGraph.ID` = `-<轮次序号>`(发布器**进程内**递增计数)⇒ **无法按消息字面 join 到它那一轮的图**;按时间窗硬凑映射属于「猜」,项目口径**不做**。补齐需要先让链路图带上**回合标识**(新键 + 迁移 `0023` 起),**不在 v0.76** | +| **✅ v0.76 登记的「未读面」**(`message_persisted.data.unreadCount`) | **已实现**(本切片):形状、缺席语义与三条缺席路径见 **§4.13**,帧表见 §3.4.4。⚠️ `GET /api/v1/sessions` 的同名键**自 D4 起就已在**(`historySessionSummary.UnreadCount`),本片**未改**它 —— 所以「加同键」这句对列表面其实是**既有事实**,不是本版新增 | | **`graph_update` 按路分桶** | **未实现**:`graph_update` 只带 `data.sessionId`(会话级),**无 `turnId`** | | **第二路进行态 system 摘要**(`WithParallelNotice`) | **未实现**:全仓无该符号;`session_parallel.go` 明示留待后续切片 | | **env `CHAT_MAX_PARALLEL_TURNS`** | **未接线**:`Deps.MaxParallelTurns` 是构造期旋钮,但组合根未从任何 env 读它 ⇒ **生产恒为默认 2** | @@ -1214,6 +1252,8 @@ 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` 会被客户端整帧丢弃)。 22. **(v0.75 登记)试算端点(B14)是「只读计算」,它**不改**任何状态**:它不写 `memory_tidy_candidates` / `memory_tidy_runs`、不改 `memory_items`、不写审计链、**也不推进心跳**(不写 `lastRun`、不影响 `nextDelay`)。因此 ① 试算**不会**让「候选队列」(尚未实现,见 §7.2)多出任何东西;② 试算结果**不是缓存**:每次调用都重新算一遍,两次调用之间数据变了结果就可能不同(这是「同一套规则」的正常语义,不是不确定性),客户端**不要**把它当成会稳定的列表去 diff 或自动轮询;③ **`recallMode:"lexical"` 是降级信号**:它表示这轮没有走到语义向量(进程没接 embedder),此时召回更保守(命中更少)—— 若进程**接了** embedder 而它不可用,试算**直接报错**(`tidy_recall_unavailable`),**不会**在响应里假装成 lexical。 +23. **(v0.76 登记 · 已按代码与库中数据实测确认)消息 DTO 的 `turnId` 与链路图的 `turnId` / `graph.id` 是**两套互不相干的标识**:前者是编排器发的那一路的**随机路标识**(`turn-` + 16 位十六进制,落 `chat_messages.turn_id`;同路的 user 行与 assistant 行同值),后者是发布器的**会话 + 轮次序号**(`-`,如 `web-6`,即 `trace.FlowGraph.ID` / `flow_graphs.turn_id`,**进程内**递增、重启即从头开始)。**两者不可互推、不可互相查询** —— 客户端**不得**拿消息的 `turnId` 去 `GET /admin/flow/graph?turnId=`(那是图侧的轮次键),也**不得**把图上的轮次序号当成会话里的回合序号(序号会在后端重启后重复)。这也是 v0.76 的 `toolCalls[]` 至今**未实现**的直接原因(见 §7.2)—— 若要按消息回看「那一轮调了什么工具」,必须先让链路图带上回合标识。 + --- -*本文件由「以代码为真源」维护(当前 `v0.75`;`v0.74` / `v0.73` / `v0.72` / `v0.71` / `v0.70` / `v0.69` / `v0.68` 见本文件 §6,`v0.67` 及更早见本文件 §6 与 `Docs/archive/`)。范围:`v0.75` 同步了 `internal/services/gateway/**`(`admin_memory_tidy.go` / `routes.go` / `deps.go` / `server.go`)、`internal/runtime/memory/**`(`tidy_dryrun.go` / `tidy_recall.go` / `tidy.go`)、`cmd/cyrene/wire_frontend.go`、`frontend/web/src/**` 与本文件;`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/**` 与本文件。* +*本文件由「以代码为真源」维护(当前 `v0.76`;`v0.75` / `v0.74` / `v0.73` / `v0.72` / `v0.71` / `v0.70` / `v0.69` / `v0.68` 见本文件 §6,`v0.67` 及更早见本文件 §6 与 `Docs/archive/`)。范围:`v0.76` 的**未读面(②)**同步了 `internal/services/gateway/**`(`broadcast.go` / `session_read.go`)、`internal/runtime/history/session_read.go` 与本文件(§3.4.4 / §4.13 / §6 / §7.2 / §7.4),并**按代码 + 库中真实数据实测确证**了 `chat_messages.turn_id` 与 `flow_graphs.turn_id` **不同源** —— 据此把 ①`toolCalls[]` 明确标为**未实现**(原「契约先行」口径作废);v0.76 的 ③ 试算加性异步由另一片维护、①**未落地**;`v0.75` 同步了 `internal/services/gateway/**`(`admin_memory_tidy.go` / `routes.go` / `deps.go` / `server.go`)、`internal/runtime/memory/**`(`tidy_dryrun.go` / `tidy_recall.go` / `tidy.go`)、`cmd/cyrene/wire_frontend.go`、`frontend/web/src/**` 与本文件;`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/**` 与本文件。* diff --git a/internal/runtime/history/session_read.go b/internal/runtime/history/session_read.go index c26e541..5a4bfd3 100644 --- a/internal/runtime/history/session_read.go +++ b/internal/runtime/history/session_read.go @@ -46,3 +46,39 @@ func (s *Store) MarkSessionRead(ctx context.Context, sessionID string, before ti } return readAt, nil } + +// unreadCountQuery 统计**单个**会话里严格新于读游标的行数。它与 +// listSessionsQuery 的关联子查询是同一条谓词(等值 + 范围扫描,吃 +// (session_id, created_at DESC) 索引),只是从「按会话列表一次算全部」变 +// 「按单个会话算一次」—— 列表接口无 N+1 靠的是关联子查询,这里则是 +// 一次主键点查,供落库那一刻的单会话实时帧用。 +// +// INNER JOIN chat_sessions 是**刻意**的:只有有会话行的会话(Web 主会话与 +// Web 子会话)才存在「读游标」这个概念。没有会话行的消息流(隔离渠道会话 +// sess-*、后台思考会话)因此恒得 0 —— 绝不把它们的全部历史当成未读, +// 那种「未读」在被已读游标面(GET /sessions 根本不列它们)里没有归宿。 +const unreadCountQuery = `SELECT COUNT(*) FROM chat_messages c + JOIN chat_sessions s ON s.id = c.session_id + WHERE c.session_id = ? AND c.created_at > s.read_at` + +// UnreadCount 返回该会话中**比读游标新**的消息数(绝对未读,非「自上次以来」)。 +// 它服务落库那一刻的实时面:`message_persisted` 帧要在同一口径下把角标换成 +// 权威数字,使客户端不必为清角标再拉一次 GET /sessions。 +// +// 口径与 listSessionsQuery 逐字一致(同一个 `created_at > read_at`、同一条游标), +// 因此两条面在同一时刻给出的数字相同;多端共享同一游标的取舍见 session_read.go +// 顶部的包内说明,本函数不改它。 +// +// 空 sessionID 与「会话不存在」都返回 0:没有游标的会话就没有未读这个概念, +// 这与它们在 GET /sessions 里根本不出现是一致的。真正的查询失败**如实上抛** +// (调用方据此省略该字段,绝不拿 0 冒充「已读完」)。 +func (s *Store) UnreadCount(ctx context.Context, sessionID string) (int, error) { + if sessionID == "" { + return 0, nil + } + var n int + if err := s.db.QueryRowContext(ctx, unreadCountQuery, sessionID).Scan(&n); err != nil { + return 0, fmt.Errorf("history: unread count %s: %w", sessionID, err) + } + return n, nil +} diff --git a/internal/runtime/history/session_read_test.go b/internal/runtime/history/session_read_test.go index 6f87806..ef8b150 100644 --- a/internal/runtime/history/session_read_test.go +++ b/internal/runtime/history/session_read_test.go @@ -346,3 +346,68 @@ func TestMigration0010ReadAtBackfill(t *testing.T) { t.Fatal("read_at should be dropped by the 0010 down migration") } } + +// TestStoreUnreadCount 锁定**单会话**未读数(契约 v0.76 的实时面用它换角标): +// 它与 ListSessions 的列表口径同源 —— 同一个 `created_at > read_at` 谓词, +// 因此同一时刻两条面给出同一个数字,不会有第二份未读定义。 +// +// 四条边界一并钉住:空 id ⇒ 0;**没有会话行的消息流** ⇒ 0(不是「全部未读」, +// 那种会话根本没有读游标、也不出现在已读游标面上);严格 `>`(游标压在 T 上的 +// 消息算已读);游标推进后归零、其后的新消息重新计入。 +func TestStoreUnreadCount(t *testing.T) { + t.Parallel() + st := newStore(t) + ctx := context.Background() + sub, err := st.NewSubSession(ctx, "owner") + if err != nil { + t.Fatalf("new sub: %v", err) + } + for _, m := range []Message{ + {SessionID: MainSessionID, Role: RoleUser, Content: "main-1", CreatedAt: 1000}, + {SessionID: MainSessionID, Role: RoleAssistant, Content: "main-2", CreatedAt: 2000}, + {SessionID: sub, Role: RoleUser, Content: "sub-1", CreatedAt: 1500}, + // 没有 chat_sessions 行的消息流(隔离渠道会话 / 后台思考会话的形状): + // 消息落得进去,会话行从不插入。 + {SessionID: "sess-backthink", Role: RoleAssistant, Content: "no session row", CreatedAt: 9000}, + } { + if err := st.Append(ctx, m); err != nil { + t.Fatalf("seed %q: %v", m.Content, err) + } + } + + // 与列表口径逐条同值 —— 这是「不许有第二份未读定义」的机器化断言。 + for _, sessionID := range []string{MainSessionID, sub} { + got, err := st.UnreadCount(ctx, sessionID) + if err != nil { + t.Fatalf("UnreadCount(%s): %v", sessionID, err) + } + if want := sessionOf(t, st, sessionID).UnreadCount; got != want { + t.Fatalf("UnreadCount(%s) = %d, but the session list says %d", sessionID, got, want) + } + } + + if got, err := st.UnreadCount(ctx, ""); err != nil || got != 0 { + t.Fatalf("UnreadCount(\"\") = (%d, %v), want (0, nil)", got, err) + } + if got, err := st.UnreadCount(ctx, "sess-backthink"); err != nil || got != 0 { + t.Fatalf("UnreadCount(sess-backthink) = (%d, %v), want (0, nil): 没有会话行就没有游标,不得把它的历史当成未读", got, err) + } + + // 严格 `>`:游标压在最新消息的毫秒上就把角标清零。 + if _, err := st.MarkSessionRead(ctx, MainSessionID, time.UnixMilli(2000)); err != nil { + t.Fatalf("mark main read: %v", err) + } + if got, err := st.UnreadCount(ctx, MainSessionID); err != nil || got != 0 { + t.Fatalf("UnreadCount(main) after cursor 2000 = (%d, %v), want (0, nil)", got, err) + } + // 游标之后的新消息重新计入,且**只**计它 —— 子会话的游标没被碰过。 + if err := st.Append(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "main-3", CreatedAt: 3000}); err != nil { + t.Fatalf("append main-3: %v", err) + } + if got, err := st.UnreadCount(ctx, MainSessionID); err != nil || got != 1 { + t.Fatalf("UnreadCount(main) after a newer message = (%d, %v), want (1, nil)", got, err) + } + if got, err := st.UnreadCount(ctx, sub); err != nil || got != 1 { + t.Fatalf("UnreadCount(sub) = (%d, %v), want (1, nil): 读主会话不得动子会话", got, err) + } +} diff --git a/internal/services/gateway/broadcast.go b/internal/services/gateway/broadcast.go index 4056a2a..3fd358f 100644 --- a/internal/services/gateway/broadcast.go +++ b/internal/services/gateway/broadcast.go @@ -32,6 +32,11 @@ func (s *Server) BroadcastPersisted(msg history.Message) { // 「这是她主动说的话」的结构化判据 `proactive:true`(切片 P0b,见 proactive.go 的 // BroadcastProactivePersisted)。两条入口走**同一个**函数,因此帧类型推导、thinking // 抹除与角色门(design/11 §7.1)逐字一致 —— 差别只有那一个键。 +// +// 帧的 data 另带**可选**的 `unreadCount`(契约 v0.76):该会话此刻的绝对未读数, +// 与 GET /sessions 的 `unreadCount` 同一口径。它只在 >0 且真能算出来时出现(见 +// unreadCountOf),缺席即老行为。Web 路径与 IM 路径共用本函数 ⇒ 两处口径天然一致, +// 不存在「Web 帧带、IM 帧不带」的分叉。 func (s *Server) broadcastPersisted(msg history.Message, proactive bool) { wire := toHistoryMessage(msg) wire.Thinking = "" @@ -43,6 +48,13 @@ func (s *Server) broadcastPersisted(msg history.Message, proactive bool) { if proactive { data["proactive"] = true } + // 未读数只在**确实算出来、且非零**时进帧(契约 v0.76)。缺席 = 老行为:老客户端 + // 忽略未知键,新客户端落下这帧时角标已经是对的,不必再拉一次 GET /sessions。 + // 不用 0 顶位:0 只能从「会话有游标」推出来,而算不出来(未接线/查询失败/ + // 会话没有游标)时那个 0 就是编的(见 unreadCountOf)。 + if n, ok := s.unreadCountOf(msg.SessionID); ok && n > 0 { + data["unreadCount"] = n + } s.hub.broadcast(wsEvent{Type: typ, SessionID: msg.SessionID, Data: data}) } diff --git a/internal/services/gateway/broadcast_unread_test.go b/internal/services/gateway/broadcast_unread_test.go new file mode 100644 index 0000000..1f1bc1a --- /dev/null +++ b/internal/services/gateway/broadcast_unread_test.go @@ -0,0 +1,159 @@ +package gateway + +import ( + "context" + "testing" + "time" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" +) + +// 本文件钉住契约 v0.76 的**未读实时面**:`message_persisted` 帧的 data 携带该会话 +// 此刻的**绝对未读数**(与 GET /api/v1/sessions 的 unreadCount 同一口径、同一条 +// 读游标)。三条硬约束各有断言:值是对的;只在**确实算得出来且非零**时出现 +// (缺席 = 老行为);算不出来时绝不冒出一个 0 去冒充「已读完」。 + +// unreadOf 从帧 data 里读未读数并返回 (值, 是否在场)。它刻意先断言「在场性」: +// 本面的关键取舍就是「缺席」与「0」是两回事。 +func unreadOf(t *testing.T, ev wsEvent) (int, bool) { + t.Helper() + data, ok := ev.Data.(map[string]any) + if !ok { + t.Fatalf("frame data = %+v, want a JSON object", ev.Data) + } + raw, present := data["unreadCount"] + if !present { + return 0, false + } + n, ok := raw.(float64) + if !ok { + t.Fatalf("frame data.unreadCount = %v (%T), want a number", raw, raw) + } + return int(n), true +} + +// TestBroadcastPersistedCarriesUnreadCount 覆盖顺路径与两条边界:帧上的数字是 +// **绝对**未读数(不是「自上次以来」),游标推进之后同一条广播路径不再带该键 +// (0 不顶位),游标之后的新消息重新点亮并给出刷新后的绝对值。末段顺带钉住 +// `channel_persisted` 与 `message_persisted` 走**同一个**载荷构造函数,因此隔离 +// 渠道会话(没有 chat_sessions 行)同样不带该键 —— 它连读游标都没有。 +func TestBroadcastPersistedCarriesUnreadCount(t *testing.T) { + t.Parallel() + srv, _, _, st := newHistoryEnv(t) + c := newOwnerConn(srv.hub, 32) + srv.hub.register(c) + ctx := context.Background() + + appendAt := func(at int64, content string) history.Message { + t.Helper() + full, err := st.AppendFull(ctx, history.Message{ + SessionID: history.MainSessionID, Role: history.RoleAssistant, Content: content, CreatedAt: at, + }) + if err != nil { + t.Fatalf("append %q: %v", content, err) + } + return full + } + + // ① 历史一条(1000)+ 刚落库的一条(2000),游标仍是 0 ⇒ 两条都未读。 + if err := st.Append(ctx, history.Message{ + SessionID: history.MainSessionID, Role: history.RoleUser, Content: "old", CreatedAt: 1000, + }); err != nil { + t.Fatalf("seed old: %v", err) + } + srv.BroadcastPersisted(appendAt(2000, "new")) + ev := readFakeFrame(t, c) + if ev.Type != "message_persisted" { + t.Fatalf("frame type = %q, want message_persisted", ev.Type) + } + if got, present := unreadOf(t, ev); !present || got != 2 { + t.Fatalf("frame data.unreadCount = %d (present=%v), want 2 (绝对未读,不是本次 +1)", got, present) + } + + // ② 游标推进到最新消息 ⇒ 该会话已读:同一条广播路径**不再**带该键。 + if _, err := st.MarkSessionRead(ctx, history.MainSessionID, time.UnixMilli(2000)); err != nil { + t.Fatalf("mark session read: %v", err) + } + srv.BroadcastPersisted(appendAt(2000, "exactly at the cursor")) + if got, present := unreadOf(t, readFakeFrame(t, c)); present { + t.Fatalf("frame data.unreadCount = %d,want 该键缺席:未读为 0 时不顶位(缺席 = 老行为,客户端回落 GET /sessions 收敛)", got) + } + + // ③ 游标之后的新消息 ⇒ 值是刷新后的绝对值。 + srv.BroadcastPersisted(appendAt(3000, "after the cursor")) + if got, present := unreadOf(t, readFakeFrame(t, c)); !present || got != 1 { + t.Fatalf("frame data.unreadCount = %d (present=%v), want 1", got, present) + } + + // ④ 隔离渠道会话走同一个构造函数 ⇒ 口径不分叉;它没有游标,因此不带该键。 + if err := st.Append(ctx, history.Message{ + SessionID: "sess-g", Role: history.RoleUser, Content: "isolated", CreatedAt: 5000, + }); err != nil { + t.Fatalf("seed isolated: %v", err) + } + srv.BroadcastPersisted(history.Message{ + ID: "m-iso", SessionID: "sess-g", Role: history.RoleUser, Content: "isolated", CreatedAt: 5000, + }) + iso := readFakeFrame(t, c) + if iso.Type != "channel_persisted" { + t.Fatalf("frame type = %q, want channel_persisted", iso.Type) + } + if got, present := unreadOf(t, iso); present { + t.Fatalf("隔离会话的帧带上了 unreadCount = %d,want 该键缺席(没有会话行就没有读游标)", got) + } +} + +// TestBroadcastPersistedUnreadCountAbsentWhenUnknown 钉住三条**诚实缺席**路径: +// 未注入 HistoryStore、注入的存储没实现单会话未读视图(`sessionUnreadCounter`)、 +// 以及隔离渠道会话本身。三条都不许冒出 `unreadCount:0` —— 把「算不出来」写成 +// 「没有未读」就是编数据。 +func TestBroadcastPersistedUnreadCountAbsentWhenUnknown(t *testing.T) { + t.Parallel() + pending := history.Message{ + ID: "row-1", SessionID: history.MainSessionID, Role: history.RoleUser, Content: "hi", CreatedAt: 1000, + } + + t.Run("history not wired", func(t *testing.T) { + srv, _, _ := buildEnv(t, cfgDefault(), Deps{}) + c := newOwnerConn(srv.hub, 32) + srv.hub.register(c) + + srv.BroadcastPersisted(pending) + if got, present := unreadOf(t, readFakeFrame(t, c)); present { + t.Fatalf("未接线历史存储的帧带上了 unreadCount = %d,want 缺席", got) + } + }) + + t.Run("store without the unread view", func(t *testing.T) { + srv, _, _ := buildEnv(t, cfgDefault(), Deps{History: &fakeHistory{}}) + c := newOwnerConn(srv.hub, 32) + srv.hub.register(c) + + srv.BroadcastPersisted(pending) + if got, present := unreadOf(t, readFakeFrame(t, c)); present { + t.Fatalf("未实现 sessionUnreadCounter 的存储替身带上了 unreadCount = %d,want 缺席", got) + } + }) + + t.Run("isolated session has no cursor", func(t *testing.T) { + srv, _, _, st := newHistoryEnv(t) + c := newOwnerConn(srv.hub, 32) + srv.hub.register(c) + if err := st.Append(context.Background(), history.Message{ + SessionID: "sess-g", Role: history.RoleUser, Content: "iso", CreatedAt: 1000, + }); err != nil { + t.Fatalf("seed isolated: %v", err) + } + + srv.BroadcastPersisted(history.Message{ + ID: "m-iso", SessionID: "sess-g", Role: history.RoleUser, Content: "iso", CreatedAt: 1000, + }) + ev := readFakeFrame(t, c) + if ev.Type != "channel_persisted" { + t.Fatalf("frame type = %q, want channel_persisted", ev.Type) + } + if got, present := unreadOf(t, ev); present { + t.Fatalf("隔离会话的帧带上了 unreadCount = %d,want 缺席", got) + } + }) +} diff --git a/internal/services/gateway/session_read.go b/internal/services/gateway/session_read.go index 930cc40..a138fac 100644 --- a/internal/services/gateway/session_read.go +++ b/internal/services/gateway/session_read.go @@ -1,6 +1,7 @@ package gateway import ( + "context" "encoding/json" "errors" "io" @@ -8,6 +9,7 @@ import ( "time" apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors" + "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" ) @@ -18,6 +20,11 @@ import ( // 推导 unreadCount(history.go)。游标是会话级、单所有者的: // 一个账号每个会话一个游标 —— 在任意一端阅读即为所有端标记该 // 会话已读。 +// +// 同一条游标还喂养**落库那一刻**的实时面(契约 v0.76):单会话的绝对未读数 +// 由 unreadCountOf 读回,随 `message_persisted` 帧的 `data.unreadCount` 下发, +// 使客户端不必为换角标再拉一次 GET /sessions。两条面共用同一个数字口径 +// (`created_at > read_at`),因此不会互相打脸。 // wsSessionRead 是已读游标推进的 WS 事件类型。 const wsSessionRead = "session_read" @@ -92,3 +99,45 @@ func (h *hub) broadcastSessionRead(sessionID string, readAt int64) { Data: map[string]any{"readAt": readAt}, }) } + +// sessionUnreadCounter 是 HistoryStore 的一个**可选**更窄视图(与 clientMsgIDReader / +// turnIDWriter / documentStatusWriter 同一做法):按会话读回由已读游标推导的绝对未读数。 +// 未实现该视图的存储替身(测试替身、未接线的构建)**不报错**,只是帧上不带该键 —— +// 未读面是增益,绝不能反过来卡住消息广播。 +type sessionUnreadCounter interface { + UnreadCount(ctx context.Context, sessionID string) (int, error) +} + +const ( + // unreadCountTimeout 是**单次**未读数查询的时长上界。它跑在落库之后的广播路径上 + // (写完一条就发一帧),必须有一个界:一次库卡死绝不允许把广播挂住。 + unreadCountTimeout = 2 * time.Second + + // unreadCountLogSource 是这条冷路径上日志的来源标签(AGENTS §10 禁无来源裸日志)。 + unreadCountLogSource = "gateway/broadcast" +) + +// unreadCountOf 返回该会话当前的**绝对未读数**(比 chat_sessions.read_at 新的消息数), +// 第二个返回值表示「这个数字是真的」—— false 时调用方必须**省略**该键,绝不用 0 冒充 +// 「已读完」(缺席 = 老行为,客户端回落到 GET /sessions 收敛)。 +// +// 三条不产生数字的情形,都不算错误:① 未注入 HistoryStore;② 注入的存储没实现上面那个 +// 窄视图;③ 查询失败(记一条带来源的 WARN,因为「读不到」不是「没有未读」)。 +func (s *Server) unreadCountOf(sessionID string) (int, bool) { + if s.history == nil || sessionID == "" { + return 0, false + } + counter, ok := s.history.(sessionUnreadCounter) + if !ok { + return 0, false + } + ctx, cancel := context.WithTimeout(context.Background(), unreadCountTimeout) + defer cancel() + n, err := counter.UnreadCount(ctx, sessionID) + if err != nil { + logger.WithSource(s.log, unreadCountLogSource).Warn("unread count read failed", + "err", err, "session", sessionID) + return 0, false + } + return n, true +}