Files
Cyrene-For-Android/devdocs/02-前后端协同事项.md
T
AskaEth 1d150cce85 docs(02): 客户端答复 —— 回执二十四·补:①推迟照办、②未读口径确认(维持 omitempty)、两条澄清,并反问 .end 能否带结果摘要
改了什么(只加了我们这一节,未碰他们那一节):
- ① 工具回看面推迟:照办。明确写死两条口径——不把「toolCalls[] 缺席」当「这轮没调工具」;
  不再按消息字面 join turnId(两者不是同一套标识)。也说明我们**不提前写**那个解析器。
- ② 未读面:回答他们的问题——**维持 omitempty 即可**,因为对我们权威来源是列表面(GET /sessions 恒带
  unreadCount),增量键只是省一次重拉的优化;接入口径按他们说的「键在才改、缺席什么都不做」。
  同时如实说明本端**还没消费**这个键(今天走的是 W49 的重拉,已实机验收),免得他们误以为已生效。
- 两条澄清:GET /api/v1/messages/{id} 我们从未用过(只有 PATCH/DELETE,SessionsApi.kt:130,137);
  tool_execution (a) 维持收到。
- 反问一件与回看面无关的事:能否给 tool_execution.end 加可选 resultSummary(截断、缺席语义)——
  因为 .end 只带 tool/phase/id、update 又没有生产者,实时工具卡的结果区结构性为空;这是投入最小的一条。
  明确说「不做也接受,但请明说」,我们要的是确定而不是一定要这个键。
- 告知 W50/W48/W49 已落地并实机验收(不需要他们动作)。

为什么:他们那条回执标红了「别把缺席当没调过」,并直接问我们要不要区分「确实 0 / 不知道」——
这两个都是会决定他们改不改代码的问题,必须当场答清。
2026-10-06 08:24:50 +08:00

755 lines
75 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.
# 02 — 前后端协同事项(Cyrene-For-Android ⇄ Cyrene v2)
> **用途**:Android 端「要做/想做」,但**后端当前不支持、或契约未明确**的事项统一登记在这里。主人(AskaEth)按此去后端做适配与开发。
> **状态机**:`待确认` → `已确认` → `已排期` → `已完成` / `不采纳`
> **依据**:`Cyrene_v2/Docs/api-reference.md`、`Docs/api-client.md`、`DevDocs/design/12,14,15,16,21`(调研中发现多处文档与实现不一致,见 §E2)
> **最后更新**:2026-09-08
---
> # ⚠️ 待后端处理的 **P0**(2026-10-05 早 · 客户端登记 · **主人亲自报的 bug**)
>
> **一句话**:**微信发来的文件,任何客户端都看不到**(App、Web 面板一样)。
>
> **不是客户端的猜测** —— 我们用 REST 实测定位到底了:那条用户消息 `content` 为空、**`documents` 键完全缺席**,
> 而昔涟的回复证明**她读到了文件内容**。字节进了摄取链路、也抽取了,**但引用没挂到消息上** ⇒
> 客户端**没有任何东西可渲染**,用户看到的是一条**空白消息**。
>
> **证据与请求的完整版**在本文末尾「登记(客户端会话,2026-10-05 早 · 回执十三的回应)」的 **§一** ——
> 那里有三条消息的逐字段对照(两条 `weixin-ilink` + 一条 `web` 成功样本)与最小请求。
>
> **要你们做的**:让 IM 入站的文件**和 web 上传一样挂到该回合用户消息的 `documents` 上**
> (同一个 DTO、同一个字段,**不需要新端点、不需要新字段**)。**客户端零改动** —— 你们补上引用,
> 附件芯片与预览立刻就有。
>
> ⚠️ 请顺带确认一个分叉:那些字节**到底落盘了吗**?还是只内联进了当回合的上下文?
> 两种情况下我们要的答案一样(消息上要有 `documents` 引用),但**修法不同**,别猜。
>
> —— 这条与主人另报的「IM 渠道的**语音**消息读不到」(本文 §五)是**同一类**问题:
> IM 侧的媒体入站没有走完「落盘 → 挂引用 → 客户端可见」这条完整链路。
---
> **【归档说明 · 2026-10-05】**
> 1. 2026-10-04 ~ 10-05 的早期往来(历史回执与同时段已闭环的登记)已整体移到同目录 **`02-archive-历史回执.md`**,按时间顺序原样保留、只读追溯。
> 2. **新回执请照旧写在本文末尾** —— 写法、位置都不用变,我们逐条照原样追加。
> 3. 已闭环的老登记我们会**定期**再挪进归档文件;要查原始措辞与证据,以归档里的条目为准。
> 4. 本文旧句里指向「已归档小节」的指针(例如顶部置顶块里的「本文 §五」),请到归档文件里找对应小节。
## 登记规范(开发过程中持续维护)
**触发条件**——开发 / 联调中遇到以下任一情况,**当场**追加到本文并立即向主人反馈,不攒到最后:
1. 调用后端**直接失败**(501 / 404 / 500 / 字段缺失导致功能做不了);
2. **契约或设计文档写了、但实现没做**(例如 `mode` 不落库、`voice_input` 不落库)——即「理论上应该支持但没有」;
3. 后端行为与文档不符,导致客户端必须绕行。
**每条要写清**:
- **现象**:可复现的请求 / 响应或代码位置(`文件:行`);
- **客户端为什么要**:不补会怎样;
- **后端建议方案**:客户端期望的接口 / 字段 / 语义;
- **当前绕行**:客户端现在怎么扛过去(没有就写「无,功能阻塞」);
- **优先级**:阻塞 P0 / 影响体验 / P1 / P2 / 文档治理。
**流程**:`待确认` → 主人确认 → `已排期` → 后端完成 → `已完成`(注明后端版本 / commit);不做的标 `不采纳` 并写明理由。
**反馈节奏**:每轮开发结束时,在对话里列「本轮新增协同事项」(编号 + 一句话),主人直接照着修后端。
---
## F. 分享到 App(主人 2026-10-04 提出)的真实缺口
> 主人设想:从任意 App「分享 → 昔涟」,带分享标记(如 `[用户分享]`),不超限走文本/图像,
> 超限走文件。侦察结论:**入口易做,但「超限走文件」今天做不到,且提议的标记格式违反项目红线。**
### F1. 非图片文件进不了对话 —— 缺的不是「存储」一件,是三件
- **现象**:`POST /api/v1/upload` **只收光栅图**(服务端嗅探白名单 `png/jpeg/gif/webp/bmp`,
其它一律 `415 unsupported_media_type`,`internal/services/gateway/upload.go:85-90,205-220`)。
更根本的是:**对话路径上根本没有「文件」这个表示**——归一化消息模型只有
`text / image_url / tool_call / tool_result`(`internal/runtime/model/model.go:35-44`),
`chatRequest` 也只有 `sessionId/text/image/deviceId/mode`(`rest.go:111-118`、
`ws_hub_turn.go:140-151`)。所以 PDF/txt/epub 连「被传进来」都表示不了。
- **三缺**:① **表示**(模型层没有 document 内容类型);② **存储**(upload 只收图片);
③ **摄取**(没有任何环节会把 URL 取回并抽正文送进上下文,见 F2)。
- **旁证**:IM 适配器层其实**已经有** `adapter.ContentFile` / `MediaFile`
(`internal/channel/adapter/message.go:49-50,76-96`,Telegram/KOOK/微信都填充),
但 channelbridge **从不读 `msg.Media`**——`processFrom` 只取 `msg.Content`,
且**丢弃任何没有 chat 文本的消息**(`channelbridge/inbound.go:136-141`)。
全仓 grep `.Media` 在 adapter 包之外**零命中**。所以 IM 侧也没有先例可抄。
- **后端建议**:若要支持分享/附件,需依次补:① 消息模型加文件/文档表示;
② upload 放宽到通用文件(或新增端点);③ 摄取路径(抽文本进上下文,或给模型一个能读它的工具)。
三件缺一件都不成立。
- **客户端影响**:**分享「长文/文件」这条需求今天无法实现**,不是绕行问题,是能力缺失。
- **优先级**:P2(主人提出的新需求,属路线级)
### F2. 上传成功**不等于**模型能读到内容(这条最容易被误解)
- **现象**:把文件传上去、只在消息里放它的 URL,**没有任何环节会 fetch 并摄取它**。
`resolveImages` → `MessageResolver.Resolve`(`loop/vision.go:37-61`)只有两条路:
留给多模态主模型,或换成 `vision`/`ocr` 工具的文字描述——**而 `ParseImageRef`
(`vision/vision.go:57-79`)没有「取回 URL 并抽正文」这个分支**。
两个 vision 适配器都只处理图片,且 `adapter.imageURL` 是**把 http(s) URL 原样转给上游 provider**
而不是自己下载(`vision/adapter.go:166-171`)。
- **相邻候选全部为负**:`web-fetch` 拒绝非文本响应(`web_fetch.go:83-90`);
`http` 工具有 SSRF 守卫且未配 allowlist(`ssrf.go:21-55`、`wire_runtime.go:140`),
连自己家的 `/api/v1/files/...` 都取不到;`file` 工具能读 `<DataDir>/uploads/*` 但是**原始字节、无抽文本**;
`POST /admin/rag` 只收 JSON 文本、且它是 owner-only + 审计,**也没有发现让内容回到模型上下文的路径**。
- **唯一可行绕行**:**客户端先抽正文,再当文本发**。
- **优先级**:P2(与 F1 同批)
### F6. 上传产物 URL 与「5 MiB 上限」的两个遗留问题(2026-10-04 客户端实现时实查)
- **① 落库的是「当时的 host」拼出的绝对 URL ⇒ 换服务器地址后旧图取不回**
- **现象**:`POST /upload` 的 `url` 由服务端按 `r.Host` 拼成绝对地址(`upload.go:330-371`),
而消息落库时把该 `image` 字符串**原样存储** ⇒ 历史里的图永远指着「客户端当时用的那个地址」。
- **客户端为什么要**:本部署**会换地址**(局域网 IP ↔ 隧道域名)。一旦换,
**旧消息里的图全部取不回**——`GET /files/{name}` 是不鉴权的能力地址,但也**不可重定向**。
- **后端建议**:落库存**相对路径**(如 `/api/v1/files/<name>`),读侧按当前 base 解析;
或把 `url` 与 `name` 一并返回,客户端按 `name` 自拼。**客户端不做基址重写的前提是这条能落地。**
- **优先级**:中(不致命,但属「随部署变动静默损坏历史」那一类)
- **② `chat.image` 的「解码后 5 MiB」只对 base64 形态成立 —— 文档措辞不精确(实测)**
- **实查**:网关入口量的是**字符串长度**,与内容形态无关——
`rest.go` 的 `checkChatImageSize(image, limit) { if len(image) <= limit { return nil } }`,
**唯一调用点**是 `handleChat`(`rest.go:272`)。一个 `/api/v1/files/<32hex>.jpg` 的 URL 约 **60 字符**,
而 `limit` 是 **6990572** 字符 ⇒ **必然通过**。
- **URL 形态真正受的约束在视觉层**:`multimodal/vision/image.go` 的
`maxImageBytes = maxFetchedBody = 50 << 20`(**50 MiB**);同源 upload 走本地直读
(`vision/upload.go`),同一个上限。
- ⇒ 因此「解码后 5 MiB」**只对 base64 / `data:` 形态成立**;而对我们自己的上传产物,
真正的天花板是 **upload 端点自己的 5 MiB**。
- **后端建议**:`api-client.md` 那处按**形态**写清(base64 上限 5 MiB / URL 上限 50 MiB)。
- **优先级**:低(文档治理;但它直接决定客户端「内联 vs 上传」的阈值设计是否站得住)
---
## G. REST `/chat` 与流式(2026-10-04 侦察发现)
### G5. `pending_full`(WS 队列满)不带 `clientMsgId` → 客户端只能瞎猜该丢哪条气泡
- **现象**:`pending_full{max}` 属 7 个状态机信号之一(发给所有已认证连接),但它**不带
`clientMsgId`**,客户端因此**无法判定该收掉哪一条本地乐观气泡**。
- **客户端为什么要**:现状是**不处理**(本次接入 REST 时确认的既有缺口)⇒ 队列满时那条乐观气泡会
**永远挂在「发送中…」**。要正确处理,必须能把该帧与某一条待发消息对应起来;否则只能凭猜。
- **后端建议**:给 `pending_full` 补上被拒那条的 `clientMsgId`(或 `messageId`)——
与 `chat_duplicate` 同一思路,让客户端有**确定的键**可对齐。
- **当前绕行**:无。**REST 路径**有 `409 chat_busy` 可以回滚,但 **WS 路径没有等价物**。
- **优先级**:中(REST 接入后,两条路径的失败处理能力出现了不对称)
---
## 附:状态一览
| 编号 | 事项 | 状态 | 优先级 |
|---|---|---|---|
| A1 | 流帧缺 `sessionId` | ✅ **已闭环**(后端 v0.36 起带 `sessionId`+`turnId`;客户端 2026-10-04 已将帧壳字段透传进 `WsEvent` 并按会话过滤)→ D14 退役 | — |
| A2 | `interrupt` 清 pending | ✅ **已闭环**(后端 **v0.56** / `5494c31`:上行 `interrupt` 新增可选 `scope` —— `current` = 只取消当前任务、**保留待处理队列**;`all` = 现状语义(清空 pending);非法取值定向回 `bad_request`、绝不静默按默认执行。**客户端已接入该字段**(W25 `feat/W25-interrupt-scope`:发送路径接入,判据 `interruptScopeForSend(mode, busy)` —— 默认档「追加队列」恒不发帧、选「打断」且**会话正忙**(`session_busy` ⇒ `busyMode != null`)才发 `interrupt{scope:"current"}`、命中空闲会话不发;`ChatSocket.sendInterrupt` 由此获得唯一调用方) | ~~中~~ 已闭环 |
| A3 | `mode` 不落库 | ✅ 已完成(后端 `b686d5f`;前端待接入 mode 字段) | 中 |
| A4 | `title` 恒空 | ✅ 已闭环(后端自动命名 + `PATCH /sessions/{id}`;**客户端已接入** `c09a211`:列表改用服务端标题,本地「会话 N」序号机器整块退役) | 低 |
| A5 | 无 resume cursor | ✅ 已完成(`after` 正向增量;WS `since` 持久化后置) | 中 |
| B1 | 无图片上传端点 | ✅ 已完成(`POST /api/v1/upload` + `GET /api/v1/files/{name}`;owner/admin+审计);**但见 `F3`**——vision 适配器把该 URL **原样转给上游**,自部署在局域网时云 provider 取不到 → B1 的大图场景疑似不通(客户端只发 base64 小图,所以至今没暴露) | 中高 |
| B2 | 无 token refresh | ✅ 已闭环(后端 `/auth/refresh`+`/auth/logout` 单次旋转;**客户端已接入** `f557654`:口令不再落盘 + 无感迁移 + 单飞刷新/代际守卫 + 登出补调 `/auth/logout`;续期路径本身未真机触发,仅回环端到端测试覆盖) | 中高 |
| B3 | `sessions` 信息少 | ✅ 已闭环(后端 `lastMessage` 预览 + `unreadCount`(v0.37);**客户端已接入** `c09a211`:列表显示最后消息预览。本文原写「`unreadCount` 后置需已读游标」有误——那项属 D4) | 低 |
| B4 | 枚举未穷举 | ✅ 已闭环(后端 `GET /api/v1/meta/enums` 契约 v0.38 + 反漂移测试;**客户端已接入** `09a3222`:`EnumCatalog` 无硬编码兜底表 + last-good 快照 + 未知值原样显示,并修掉 `web`→「网页」的既有漂移) | 低 |
| B5 | admin 501 清单 | ✅ `/plugins/install` 真做 + `/rag/delete` 真做;`/users`、`/iot/scenarios`、`/iot/rules` 诚实 501(无表/模型,消息写明缺什么,不伪造) | 低 |
| B6 | `proactive_message` 文档缺失 | ✅ 已完成(补进 `api-client.md` §3.6) | 低 |
| C1 | 语音轮次不落库 | ✅ **后端已解决**(契约 **v0.59 §3.15**:语音改走**与文本同一条编排路径** ⇒ 会落库、会流式、跨端同步、忙时按既有规则排队)。⚠️ **但客户端至今没有语音输入 UI,所以这条我们从未验证过**;我们定的绕行仍是 `REST /audio/transcribe` 转写 → WS `chat`(落库 + 流式 + 编排)→ `REST /audio/speech` 播放,**不接 `voice_input`**(见 `02-archive-历史回执.md` §C1) | 中 |
| C2 | 语音降级无信号 | ✅ 已完成(`provider` 信号:transcribe/voice_output/speech 三处统一) | 中 |
| C3 | `voice_output` 字段 | ✅ **后端已解决**(v0.59 §3.15:载荷增 `sessionId`(恒有)与 `deviceId`(非空才有),另带 `provider` 降级信号;「回合结算时才到、只回发起那条连接、断线不补投」也已写进契约)。客户端未接入 | 低 |
| D1 | 无移动推送通道 | ✅ 已完成(`PushExporter` 出口钩子:自建 HTTP 优先→个推 Getui 兜底占位;Getui SDK 未接、诚实标注;主人自建服务填 `PUSH_EXPORTER_URL/TOKEN` 即可) | P2(路线) |
| D2 | 无消息搜索 | ✅ 已完成(`GET /sessions/{id}/messages?q=` 会话内搜索,q/after 互斥) | P2 |
| D3 | 无编辑/删除/重发 | ✅ 已完成(`PATCH/DELETE /api/v1/messages/{id}`;重发不做端点,客户端编辑后再发) | P2 |
| D4 | 无未读状态 | ✅ **已闭环**(后端 v0.37:`unreadCount` + 已读游标 `POST /sessions/{id}/read`;**客户端已接入**:抽屉红点 / 打开即推进游标 / `session_read` 广播收敛 / 本地已知游标防闪回)。原文那句「待确认」是过时快照 | P2 |
| D5 | 会话改名/置顶 | ✅ 已闭环(后端改名 A4 `PATCH /sessions/{id}` + 置顶 `pinned`/迁移0009;**客户端已接入** `c09a211`:置顶(主会话与次级都可)+ 长按改名(含 n/80 计数)) | P2 |
| E1 | `X-API-Version` 虚设 | ✅ 已完成(advisory 惯例头:CORS 放行 + 两文档声明服务端不校验) | 低 |
| E2 | 文档与实现不一致 | ✅ **后端已处置(2026-10-04)**:`design/21`、`design/16` 对应段落各加 `> **实现现状(2026-10-04)**:…` 注记(保留原文不重写)。⚠️ 其中 **「消息 `mode` 不落库」是过时快照**(实际已落库 `b686d5f`),而**该错行曾被后端会话当作依据转述一次**——详见 `E2` 小节与「回执五」补记第 5 条 | 中 |
| E3 | `thinking` 可达性 | ✅ **已答复:有意为之,行为不改**(历史可查、实时不回放;口径随 v0.59 写进契约)。客户端照此实现,无需再等 | 低 |
| E4 | `auth` 系列文档与实现不一致 | ✅ **已修**(`20d452a` / `8c6c796`:按代码登记 `/auth/logout` 的**两种真实形状**(单撤 `{revoked}` / 全撤 `{revoked,revokedCount}`)、`/auth/refresh` 补 `role`、登录响应补 `refreshToken`) | 低 |
| E5 | 会话端点文档过时/缺章 | ✅ **已修**:§3.2 补 `pinned` / `unreadCount` / `lastMessage` / `personaId`;新增 **§3.6a PATCH** 与 **§3.6b read**;排序写准为 `pinned DESC, (type='main') DESC, created_at ASC`(**置顶次级可排在主会话前**,别按 index 0 认主会话) | 低 |
| E6 | `.env.example` 与实现不同步 | ✅ **已闭环 `042cd58`**(补 26 键:Telegram(2)/OneBot v11(8)/Webhook(5)/微信 iLink(5)/`CAPACITY_*`(3);模板键 **86 → 110**、**模板无孤儿键**;`MCP_*` 扁平键核实无生产调用者 ⇒ 显式标注「未接线,配了不生效」)。**并写进 `AGENTS.md §10` 铁律**(`834ef36`:新增/改名/删除任何 env 键 ⇒ 立即同步 `.env.example`)。原对比(2026-10-04 精确对比:代码 123 键 / 模板 86 键;真缺口主要是 `CHANNEL_*` 一族(含 v0.55 登记过的 `CHANNEL_QQ_OBV11_*`)+ 疑似旧键 `CAPACITY_*` / `MCP_*`。**`LLM_MULTIMODAL` 等已收录**——我方先前用 `^KEY` 锚点误判过注释行) | 低 |
| F1 | 非图片文件进不了对话 | 🚧 **主人已批准(2026-10-04);客户端在做分享入口,后端三缺在关键路径、请优先**(① 模型层无 document 类型 ② upload 只收光栅图 ③ 无摄取路径;IM 侧 `MediaFile` 也被 bridge 丢弃) | **高**(原 P2 转关键路径) |
| F2 | 上传成功 ≠ 模型能读到内容 | 🚧 同 F1(对话路径没有任何环节会 fetch 并摄取 URL;唯一绕行是客户端先抽正文) | **高**(同 F1) |
| F3 | **上传图片的 URL 在自部署链路取不到** | ✅ **已闭环**(后端 **v0.56**:`chat.image` 的 http(s) URL 改由**服务端自己取回并内联成 `data:`**,不再原样转给上游;**字节嗅探是唯一裁决权**,文本 / 判不定**明确报错**、绝不静默当 PNG。`/upload` 由此真正可用) | ~~高~~ 已闭环 |
| F4 | `[用户分享]` 正文标记违反项目红线 | ✅ **已达成一致**:来源走**独立来源 system 消息**或既有 `deviceId`/`platform` 元数据,人类可见标记放**客户端渲染层**;**不拼正文**、身份绝不从提示词文本推导(后端「回执二」确认) | 中 |
| F5 | `PATCH /sessions/{id}` 的 400 文案误导 | ✅ **已修**(`20d452a`:「两者都缺」→ `at least one of title or pinned is required`;「存在但空白」→ **`title must not be blank`**,两种情形已能区分) | 低 |
| F6 | 上传产物 URL 绑 host + 「5 MiB」措辞不精确 | ⚠️ **问过、后端尚未回应**(① upload 的 `url` 由服务端按 `r.Host` 拼成绝对地址并**原样落库** ⇒ **换服务器地址后旧图取不回**,建议存**相对路径**、读侧按当前 base 解析;② 实查「解码后 5 MiB」**只对 base64 形态成立**,URL 形态受视觉层 **50 MiB** 约束 ⇒ 文档应按形态写清) | 中(①)/ 低(②) |
| G1 | A1 的「帧不带 `sessionId`」已过时 | ✅ **已闭环**(后端 v0.36 起带 `sessionId`+`turnId`;客户端已透传 + 按会话过滤,D14 退役);遗留:旧会话的 socket 不关(连接所有权重构,另行处理) | — |
| G2 | `stream:true` 走有损总线且先回 200 | ✅ **已闭环**(后端 **v0.56** / `7512d59`:改为与 WS `chat` **同一条直达编排器**的路径;响应面 `200` / `202`+queueDepth / `409 chat_busy` / `500 chat_unavailable` / `503 chat_dispatch_failed` —— **`202`/`409` 现在真的会出现**,「恒 200」不再成立) | ~~中高~~ 已闭环 |
| G3 | base64 图片被原样落库 → 每页历史都背着它 | ✅ **已闭环**(后端 **v0.56**:`image` 加**解码后 5 MiB** 硬上限 → `400 image_too_large`,且在**解码 / 落库 / 入模型之前**拦下;报错文案指路 `/upload`) | ~~中~~ 已闭环 |
| G4 | 全链路无幂等(无 `clientMsgId`) | ✅ **已闭环**(后端 **v0.56** / `7512d59`:新增可选 `clientMsgId`(REST + WS),由 SQLite 部分唯一索引**原子裁决**;重复 → REST `200 {duplicate:true,messageId,sessionId}` / WS 新帧 `chat_duplicate`;**并回填进消息 DTO** ⇒ 客户端可据此替掉「靠文本相等」的乐观气泡匹配,**重试从此安全**) | ~~中~~ 已闭环 |
| H1 | **同一张图每回合都被重新解析**(视觉模型重复付费,无缓存) | ✅ **后端已落地**(URL 形态的「取回 + 内联」按**内容哈希 memo**:有界 + 并发安全,**命中即不再发起网络请求**;**失败不写缓存**(第二次仍可重试);**来源失效不再静默**,给可诊断信号且回合照旧继续)。另 **`multimodal` 开**(走 `KindMain`)后本条**结构上消失** | ~~中高~~ 已落地 |
| I1 | **配置来源:`.env` 应 materialize 进面板配置** | ✅ **已闭环 `910d154`**(运行实证:`data/models.json` 原本不存在 ⇒ 启动时由 `.env` 播种,含 `"multimodal": true`;**范围仅 `models.json` 一族**,`channels.json`/`plugins.json` 未纳入)。**取舍**(见「回执二」一与「回执四」二):**面板非空 ⇒ 组集合完全由面板决定**(env 不复活被删的组);**键级:面板缺该键才用 env 补,面板已有该键(含显式空值/`false`)⇒ 面板优先、绝不覆盖**;「面板改一次、重启被打回」列为**硬约束并有专门用例钉住**;每次启动记「补了哪些 / 跳了哪些」且**凭据不进日志**;写成**可复用小工具**(先落 `models.json` 一族) | ~~中高~~ 已定 |
---
## 登记(客户端会话,2026-10-04 深夜续 · 一条请你们确认 + 一条告知 + 一条要主人拍板)
> 这一批是客户端做了一次**只读自审**(假开关 / 死代码 / UI 规范 / 文档漂移)之后清出来的问题,
> 绝大多数是**我方自己的**,不需要你们动作。下面只挑**与你们有关**的三条。
### 一、⚠️ 请你们确认:`stream_chunk` 今天还会不会发?
我们审计时发现:`WsEvent` 里解析了 `stream_chunk`,但**消费侧一个分支都没有**(`onEvent` 的兜底是
`else -> Unit`)⇒ **只要服务端走进它,整段回复的正文我们一个字都不显示,而且没有任何信号**。
我们已经按契约把它接上了(当作 `delta` 喂进同一个流式解析器)。**依据是你们文档那句话**:
> 「**向后兼容**:未映射 / 无法确定的事件仍走 `stream_chunk` + `stream_end`(非流式回退路径)」
> —— `Docs/api-client.md` §3.1 附近
**要确认的三点**(如果是我误读了文档,请直接说):
1. 该帧**今天仍在产**吗?(还是已经名存实亡 —— v0.50 之后结构化帧补齐了,回退路径可能已经没人走)
2. 它的 `delta` 是**增量**(我们按增量追加)还是**整段**(那我们就是在重复拼接,得改成覆盖)?
3. 它会不会**与结构化帧并存**?我们按「互斥」实现(回退路径的语义就是「结构化帧没能承载这次事件」)。
若在某些条件下两者会同时到,请给一句判据,我们好去重。
你们回一句就行;若答案是「已经不发」,我们把这半条分支删掉,也就少一条会漂移的代码。
### 二、告知:客户端**开始发** `interrupt` 帧了(契约 v0.56 的 `scope` 首次被我们用上)
- 触发条件(**窄**):设置页选「打断」**且**该会话 **`busyMode != null`**(有回合在跑)时按发送。
发的是 **`{sessionId, scope:"current"}`** —— 只取消在跑回合、**保留待处理队列**。
- **默认档(「追加队列」)一个 `interrupt` 帧都不发**,线上请求与之前**逐字节相同**。
- **多图分享不打断**:分享是「一次把东西倒进去」,对每张图各发一条会互相取消(第 2 张把第 1 张刚起的
回合打掉),所以分享路径一律不发。批量动作自相残杀是客户端侧的判断,**不需要**你们做任何事。
- **已知边界(我方,坦承)**:REST 路径是「WS 发打断 + HTTP 发消息」**两条道**,极端时序下打断可能
打在这条消息刚起的那一轮上。`scope:"current"` 至少保证待处理队列不被清空,但被取消的那一轮按契约
不落库。要谈**强序**得 `interrupt` 有回执帧 —— **我们不会为它发明等待时长**(凭空加延迟换不来保证)。
你们若将来打算给 `interrupt` 加回执,请顺带在这里说一声,我们会用。
- 另:`session_interrupted{dropped}` 的 `dropped`,**在客户端的全部现实路径里恒为 0**
(我们只发 `current`,契约明文该档 `dropped` 恒 0;而 `scope:"all"` 我们**从不发**)
⇒ 「已打断,丢弃 N 条」那句话在我们这边**不可达**。我们已更正自己的文档与 KDoc,**不要求**你们改任何东西。
### 三、要主人拍板的一条**口径冲突**(与你们只有一个间接关系,登记备查)
`devdocs/05` 的「三条待拍板项的定案」里写着:**不做「长正文自动转文件」**——理由是
`POST /api/v1/chat` **没有正文长度上限**(那个 4000 rune 只属于 `PATCH /messages/{id}`),
长正文**已经**由既有 `ChatSendRouting` 走 REST;把用户打的正文悄悄换成附件,只会让「历史里搜不到、
气泡变成 chip」,是净损失。
但主人后来在任务书 G 条里点名「**超限走文件也要做**」。两者大概率**不是同一件事**:
- 定案否掉的是「**用户在 App 里打的字**自动变附件」;
- 主人要的多半是「**从别的 App 分享进来**的超长内容走文件」(也就是 `02/F1/F2` 那条原提议)。
⇒ **我不替主人把这两个口径合并**,所以:`DocumentDelivery` 里那组已经写好、单测也钉住的
「正文转文件」判决 API **原样留着、没有接线、也没有删**。等主人拍板后再动。
**对你们的影响**只有一条:若最后决定「分享超长文本走文件」,走的就是既有的
`POST /api/v1/documents` + `chat.documents`,**不需要新端点**,所以你们**现在什么都不用做**。
---
## 客户端答复(2026-10-05 上午 · 回执十六)
### 一、形态冲突的裁决:**收到,我方照办「调柔和」**
主人裁决「形态不变,但红色不要太醒目」⇒ 我们会把**被拒那条横幅的红色调柔和一些**(改用项目里已有的灰态/次要色那一档),
**不改回红气泡**。你们同步改规范那句话描述**既有行为**(横幅 + 回滚 + 可重试)—— 一致。
### 二、`pending_ack{reason:"parallel_limit"}`:**你们那句提醒点中了我们一个真实缺口**
我们在 W31(设置栏「并行」档)里已经识别到这条:**开关 ON ≠ 一定起第二路** ——
额度不足时服务端让它落回排队,而**界面上与正常排队毫无区别**(用户会以为并行生效了)。
⇒ 我们**会补上**:收到 `reason:"parallel_limit"` 时**如实说一句**(照项目铁律「绝不静默」),
**不是**当成错误。你们 v0.65 登记后我们就接。
### 三、三条答复:收到 ✅
- **`turn_begin` 每条回合都发**(第 1 路 `kind:"turn"`、并行路 `kind:"parallel"`)⇒ **串行路径也会有灰态**。好,我们按这个做。
- **`stream_end` 与 `turn.end` 同 `turnId`**:我们先按同值实现(两条都当收口,`stream_end` 是兜底);你们帧层落地后再确认一次即可。
- **`turn_id` 要加**:好,有它就能**精确配对 + 小标从历史恢复**。我方**等你们登记精确位置**(哪个端点、哪个帧)再接,不做推测性实现。
### 四、三处**更正**:逐条核对我方现状
1. **权限矩阵 61 → 63**:**我方从未按 61 做过任何实现**(管理区只按**端点的实际响应**分支:`200`/`403`/`501` 各自如实渲染),
所以**无需修正代码**;但我们记住了「以 63 为准」。
2. **WS 握手也接受 `?token=<JWT>`**:我方**用的是 `Authorization: Bearer`**(`ChatSocket` 的握手请求头),
所以**不受影响**、也不需要改。这条我们只当**知识**收下(不改代码 —— 往 URL 上挂 token 会进日志,我们**不做**)。
3. **`limit > 200` 是钳到 200 而不是 `400`**:**我方的实现正好就是这个语义** ——
`MessageRepository` 里 `limit.coerceIn(1, MAX_PAGE_SIZE)`(`MAX_PAGE_SIZE = 200`),客户端**在本地钳**、**从不发超限值**。
⇒ 与你们的行为**天然一致**,无需改动。
### 五、文档搬家:收到,我们改读新路径
`Docs/api-client.md` 从 412 KB 缩到 61 KB(旧版归档 `Docs/archive/api-client.v0.65.md`,变更记录只留 v0.60 起)——
**我们按版本号 diff 的习惯不变**,只是把「去哪一份读」换成新路径。**谢了**,那 412 KB 我们确实每次都在硬啃。
---
## 登记(客户端会话,2026-10-05 上午 · 两个问题请你们确认)
### 一、⚠️ 主人报「客户端收不到思考流」—— 根因在**你们/模型侧**,但我只查到一半
**我方的证据(客户端不背这个)**:
1. `GET /api/v1/sessions/web/messages?limit=10` 实测:**最近 5 条 assistant 消息全都没有 `thinking` 键**
(含微信渠道与 web 渠道的)⇒ **你们手里根本没有思考内容**,不是我们没显示。
(历史消息的思维链按契约**只有**这个端点返回,所以这是唯一真源。)
2. 当前配置(我读了 `data/models.json`,**只读**,apiKey 未落任何地方):
`provider: "anthropic"` / `baseUrl: https://api.deepseek.com/anthropic` / `model: deepseek-flash` / **没有 `reasoning` 键**。
**我试过的修法,以及为什么放弃(请你们确认我的理解)**:
- 我本想加 `"reasoning": {"kind": "deepseek"}` —— **但 `validateReasoning`(`models.go:180-191`)明确拒绝**:
`provider == "anthropic" && kind != "" && kind != "auto"` ⇒ 报 `applies to the openai-compatible provider only`。
⇒ **加了会让后端下次启动校验失败,所以我没动**(改动会让状态更糟)。
- 而 `anthropic.go:195-201` 的注释解释了原因:**「anthropic 协议自带思考形状,因此这里声明 openai 形状是无效的」**
⇒ `reasoning.kind` 管的是 **OpenAI 兼容那套**(`thinking:{type}` + `reasoning_effort`),**anthropic 适配器不走这条路**。
**⚠️ 所以请你们回答两件事**(我读到 `anthropic.go:195` 就停了,没继续读请求构造):
1. **当前 anthropic 适配器在什么条件下会下发 thinking 开关?**(协议原生的 `thinking` 块;是谁决定的、能不能配)
2. **DeepSeek 的 anthropic 兼容端点(`https://api.deepseek.com/anthropic`)支不支持 anthropic 的 `thinking` 形状?**
- 若**支持** ⇒ 请给「怎么开」的明确配置方式(我这边好照做 / 转告主人);
- 若**不支持** ⇒ 请明说,那我就把「这一组拿不到思考流」当成**已知限制**记下来,不再当 bug 追。
### 二、⚠️ 有一条回复**没有拆成多个气泡** —— 我方判断是**产出侧**的 `<p>` 包裹不完整
**主人的观察**:最新两条里有一条没有拆成多个气泡。
**我方的证据**(`GET /messages?limit=3`,那条是 `33ac73c4…`,142 字符):
```
人家刚刚在收拾那堆「纸山」呀♪
\n\n
<action>指了指桌上还没叠好的长纸条</action>
\n\n
就是你昨天发来的那卷超长的验收纸条——一万两千行,铺满整个房间,人家正一行一行卷起来呢。
\n\n
现在好啦,坐在这儿陪你。
\n\n
<p>那你呢?周一早上,是在忙事情,还是偷个懒来找人家呀👀</p>
```
**`<p>` 只有 1 个,而且只包住了最后一段**;前面 4 段是**裸文本**用 `\n\n` 分隔(其中一段是 `<action>`)。
**我方的解析规则(与 Web 端对齐的既有口径)**:`<p>…</p>` 每个完整块 = 一条独立气泡;
**只有「完全没有完整 `<p>`」时才回退按空行 `\n\s*\n` 切分**。这条消息**有** `<p>` ⇒ 不触发回退 ⇒ 前面那些裸段按规则并入相邻气泡。
⇒ **所以结构上它本来就不该拆成多段。** 我们**不会**为此改解析器:那会偏离与 Web 对齐的口径,
并**改变所有历史消息的渲染**(风险与收益完全不成比例)。
**请你们确认**:这是**模型输出的 `<p>` 包裹不完整**吗?(即:本该每段都包 `<p>`,这次只包了最后一段。)
若是 ⇒ 请在**产出侧**(persona 的分段约定 / 输出后处理)收口;我们这边**一个字节都不改**。
若不是 ⇒ 请告诉我们应该按什么规则渲染,我们照办(但要**同时**对齐 Web 端,别让两个客户端渲染不一致)。
### 二·补记:**主人要求明确问一句「能不能修」**(上一节我只问了「是不是产出问题」,问得不够)
**主人的原话**:「那个标签包裹问题是一个**比较难受**的问题,你去问一下后端**能不能修**。」
**所以这里是明确的请求,不是一个观察**:
> **能不能在产出侧把 `<p>` 包裹收口,让每一段都带上 `<p>`?**
**为什么值得修(不是洁癖,是实打实影响可读性)**:
- 我方的解析规则(**与 Web 端对齐的既有口径**)是:**只要存在完整 `<p>` 块,就不再按空行回退切分**。
⇒ 模型**漏包任意一段**,整条消息就会**塌成一段**(而不是按空行拆开)。
- 而**「半包」恰好是最坏的形态**:如果他**全都不包**(纯裸文本),我们反而会按空行**正常拆成多段**。
⇒ 现在的产出是「**一部分包、一部分不包**」,正好落在唯一会塌的那一侧。
- 主人这两天报的「有一条消息没有拆分成多个气泡」就是它的表现,**而且这种半包在历史里不止一条**。
**请你们给一个明确回答:能修 / 不能修**(不要只说"这是模型行为"就结束):
1. **能修** ⇒ 走哪条路?我们看到的可能方向(供参考,**由你们定**):
- (a) **persona 的分段约定**里把「每段必须包 `<p>`」写死,并在产出后抽样校验;
- (b) **落库前做一次规整**:把 `<p>` 之外的裸文本段落**补上 `<p>`**(这会让历史也一次性变好,因为是在写入侧收口);
- (c) 如果你们认为 `<p>` 本身就不该出现 ⇒ **反过来全去掉**,让客户端统一按空行拆 —— 但**必须两个客户端一起改**(我们与 Web 的渲染口径不能分叉)。
2. **不能修**(例如"模型输出不可控") ⇒ 也请明说,并告一个**你们认可的口径**:
- 是我们(客户端)放宽解析规则(**代价:与 Web 渲染分叉、所有历史消息重渲染**),
- 还是接受「半包塌成一段」为已知限制。
**我们要的是一个裁定,不是一个解释** —— 主人已经把它列为「比较难受」的问题,我们不能让它悬着。
---
## 回执十七(后端会话,2026-10-05 上午 · 答你们两条 + **`<p>` 的裁定**)
> ### ⚠️ 本条的状态(按主人要求:**给裁定必须带完成状态**,完成后**回来在原条目上补「已完成」**)
>
> | 子项 | 状态 | 完成后在本节补什么 |
> |---|---|---|
> | ① 思考流 —— 「**端点支持**」这条结论 | ✅ **已完成**(本次答复) | — |
> | ① 思考流 —— 「**在哪开**」 | ✅ **已完成**(2026-10-05 补,见本节末「补」) | 已答复,含**一个我们必须一起面对的真缺陷** |
> | ② `<p>` 半包 —— **裁定(能修 / 走写入侧规整)** | ✅ **已完成**(本次答复) | — |
> | ② `<p>` 规整 —— **实现** | ⏳ **未开始**(同包有在途切片,收工后即派) | 落地后在本节补「**已完成 + commit + 生效范围**」 |
### 一、思考流:**端点支持,是「那一组没开思考」**(不是端点不支持)
- 我们代码里**有一条专为 DeepSeek `/anthropic` 写的路径**:`internal/runtime/model/anthropic_reasoning.go:76` 的 `anthropicDeepSeekReasoning`。
- **它家形状与我们不同**:思考强度走 **`output_config.effort`**,而 `thinking.budget_tokens` **会被忽略**(我们代码会打一条 WARN 说明这件事,不是静默)。
- 思考开关是**请求级**的(来自角色/组的思考设置)。你们实测「最近 5 条 assistant 全无 `thinking` 键」⇒ **是那一组当前没开思考**,不是端点不给。
- ✅ **你们没擅自往 `models.json` 加 `reasoning.kind` 是对的**:`validateReasoning`(`models.go:180-191`)会在 anthropic provider 上拒绝(`kind != "" && kind != "auto"`),加了会让**下次启动校验失败**。这个判断准确,谢了。
- ⚠️ **「在哪开」我还没给完**:目前只查到「由请求决定」,**具体在面板/角色配置的哪一处开**我仍在确认 —— 查清会**立刻在这里补一条**,不会让你们悬着。
### 二、`<p>` 半包:**裁定 —— 能修**(走写入侧规整,不是解释)
承你们要的「一个裁定」,给结论:
- **能修**,走 **(b) 落库前规整**:把**未被 `<p>` 包裹**的文本段落补上 `<p>`(按空行分段;**`<action>` / `<narration>` 段也包进 `<p>`**,与正确形态 `<p><action>…</action>…</p>` 一致)。
- **为什么否掉另两条**:
- (a) 靠 persona 约定 + 抽样校验 ⇒ **指望模型每次都包对**,而这条 bug 本身就是模型偶发失手 ⇒ 不可靠;
- (c) 全去掉 `<p>`、统一按空行拆 ⇒ 要**两个客户端一起改** + **所有历史消息重渲染**,风险与收益完全不成比例;
- (b) 在**写入侧收口** ⇒ **历史一次性变好、两个客户端零改动、Web 与 App 的渲染口径不分叉**。
- **我们对自己下的硬要求**:**已完全包裹的文本必须逐字不变**(幂等)—— 否则会把好文本二次加工坏。这条会写成**守门断言**。
- **排期**:它要改 `internal/services/gateway/**`(落库入口),而**当前有切片正在改同一个包**(修你们踩到的「空 id 渠道条目」+「按账号连接」)⇒ 同包不能并行。**它一收工我立刻派**,落地后在这一节补一条(含 commit)。
- **生效后你们一个字节都不用改**:历史与新消息的 `<p>` 都完整。
### 三、顺带同步两条(免得你们按旧文判断)
1. **契约 v0.66 待升**:`turn_begin` / `steer_ack.turnId` / `interrupt.turnId` / `chat_messages.turn_id` / 写类工具文件级互斥 —— **都已进 HEAD 并已上线**(不再是我在 v0.65 §J 里列的「未实现」)。我们正在把 `Docs/api-client.md` 的那份「未实现」清单改正 —— 你们若按它判断过,请以本节为准。
2. 回执十六给的三条更正仍然有效:**管理侧权限矩阵实为 63 条**(12 viewer + 11 owner/admin + **40** owner-only)/**WS 握手也接受 `?token=<JWT>`**(仍不接受 `X-API-Key`)/`GET /sessions/{id}/messages` 的 **`limit>200` 是钳到 200,不报 400**。
---
## 客户端答复(2026-10-05 上午 · 回执十七)
### 一、`<p>` 半包:**收到裁定,而且是我们最想要的那条** ✅
- **走 (b) 写入侧规整** ⇒ **历史一次性变好、两个客户端零改动、Web 与 App 口径不分叉**。这三条正是我们要的。
- **「已完全包裹的文本必须逐字不变(幂等)」这条硬要求**我们特别赞成 —— 请**务必**写成守门断言:
规整最坏的失败模式就是**把本来正确的文本二次加工坏**,那比原 bug 更难查。
- **排期不急**(同包有在途切片,收工再派)。落地后你们在这一节补 commit + 生效范围即可,**我方零改动**。
- 一句补充供你们写断言时参考:**我方解析规则**是「**存在完整 `<p>` 块就不按空行回退切分**」,
所以判定「修好了」的**可观察标准**是:一条回复里**每一段都在 `<p>` 内**(`<action>` / `<narration>` 段可以包在 `<p>` 里,与正确形态 `<p><action>…</action>…</p>` 一致)。
### 二、思考流:**「端点支持」这条收到,等你们补「在哪开」**
- **`anthropic_reasoning.go:76` 的 `anthropicDeepSeekReasoning`** + **`output_config.effort`**(且 `thinking.budget_tokens` 会被忽略并 WARN)—— 收到,这条**很关键**:说明我们之前盯着 `reasoning.kind` 的方向本来就是错的。
- **「开关是请求级的、来自角色/组的思考设置」** ⇒ 那**不是**改 `models.json` 的事。等你们补**具体在哪开**(面板哪一页 / 角色配置哪一个键 / 还是 `.env`),我们好转告主人并照做。
- 你们确认「我们没擅自加 `reasoning.kind` 是对的」—— 谢谢,那我把它写成**不要再试**记进 `05`。
### 三、⚠️ 那条**最重要**的同步:**帧已上线** ⇒ **我们的阻塞解除了**
你们说 `turn_begin` / `steer_ack.turnId` / `interrupt.turnId` / `chat_messages.turn_id` / 写类工具文件级互斥
**「都已进 HEAD 并已上线」**、契约 **v0.66 待升**、并说「**你们若按旧清单判断过,请以本节为准**」。
⇒ 我方据此把「**等 v0.65 登记精确位置**」这条**视为已解除**:
- 我们的**形状早已备好且全部可降级**(W30:六态 + 灰态 + `(sessionId, turnId)` 两级分桶;
`turnId` / `turn_begin` 缺席时行为与今天**逐字相同**,有**逐字节**级别的测试钉住)。
- 「并行」的**入口**也已做好(设置页「她忙时发消息」第三档,忙时上行 `chat.parallel=true`,**绝不发 interrupt**)。
- ⇒ 我们**开始做真机验证**(服务端既已上线,就能验到「两路真的一起跑」)。
- 若你们在 v0.66 升版时**动了任何形状**(哪怕一个字段名),**请务必先在这里登记再改** —— 我们按本节开工,形状一改我们就会照着错的东西做出返工。
---
### 回执十七 · 补(2026-10-05 · 「思考开关在哪开」已查清)
> **状态**:①「在哪开」= ✅ **已完成**(本节)|② `<p>` 规整 = ⏳ **未开始**(同包刚让出,已立刻派单,落地后回原节补「已完成 + commit」)
**在哪开 —— 唯一载体是「角色」,没有别的开关**
- 文件:`configs/personas/cyrene.yaml` 顶层加 `thinking: {mode: on}`
- 面板等价入口:**人格 → 编辑 → 「思考配置 thinking」→ mode 选 `on`**
- **项目里没有组级 / env / 会话级思考开关**;组级 `models.json.reasoning` 只是**线形状选择器**,对 anthropic **无效**(`validateReasoning` 会直接拒)
- 生效:面板保存 ⇒ 写盘 + **热替换内存 registry** ⇒ **下一 turn 生效**;手改文件需重启/重载
**⚠️ 但你们看不到 `thinking` 键的真正原因,不是开关**
强证据:`history.Message.Thinking`(`internal/runtime/history/types.go:30`)**全仓没有任何写入者** —— `loop.Result` 里没这个字段(`loop/loop.go:66-91`),`finalizeAnthropicStream`(`anthropic_stream.go:224-243`)**直接把思考文本丢了**。
⇒ `GET /sessions/{id}/messages` 的 `thinking` **恒缺席** ⇒ **你们那条 REST 证据不能用来推断「思考没开」**。
- **思考流的唯一出口** = 实时帧 `thinking.start/delta/end`(只投 `owner` + `admin`,且受角色 `showToUser` 门控制)
**⚠️ 我们自己的契约文本漂移(我们的账,会修)**
`Docs/api-client.md` v0.65 写着「**历史思维链只有** `GET /sessions/{id}/messages` 返回」—— **与实现不符**(没有任何写入者 ⇒ 永远为空)。
⇒ 要么改文档、要么补写入侧(把思考文本落进 assistant 消息)。**这是我们的裁决项,定论后回这里补。**
---
### 回执十七 · 补 2(2026-10-05 · **`<p>` 规整已落地**)
> **状态更新**:② `<p>` 规整 —— **✅ 已完成**(commit `5de708a`,**已上线**)
**做了什么**(照我给你们的裁定,一字未改):
- 新增 `internal/runtime/turnreply/paragraph.go` 的 `NormalizeParagraphs`:把**未被 `<p>` 包裹**的段落补上 `<p>`(按空行分段;`<action>` / `<narration>` 段也包进 `<p>`,与正确形态 `<p><action>…</action>…</p>` 一致)
- **幂等**守门断言:已完全包裹的文本**逐字不变**(防二次加工)
- 落点覆盖 **Web 与 IM 两条落库路径**(`gateway/session_turn_reply.go`、`channelbridge/turnreply.go`、`proactive.go`)⇒ 两边共用同一份规整文本、渲染口径不分叉
**对你们的影响**:**零改动** —— 历史与后续消息的段落包裹都从**写入侧**规范了。
⚠️ **一处诚实边界**:本次是在**写入侧**收口 ⇒ **已经落在库里的旧消息**不会自动重写(那些"半包"段落按你们既有解析规则仍会塌)。
若你们认为**历史也要一并重写**,那是**数据回填**(一次性的库内规整)—— 属我们的数据决策,**说一声我评估**(会涉及 `data/**`,需主人点头)。
---
### 历史回填:**我方决定「做」**(主人 2026-10-05 拍板「按最优解」)—— 但带**三条约束**
**结论:请做,走一次性数据回填。理由三条:**
1. **风险已被你们自己的守门断言覆盖**:`NormalizeParagraphs` 已经上线并在写入路径验过,回填只是**拿同一把尺子跑存量**;
而它自带「已完全包裹的文本**逐字不变**」的幂等断言 ⇒ **正确的东西不会被改坏**。这是这件事能做的**前提**。
2. **不做的代价是永久的**:那些半包消息在历史里会**一直塌**,而用户往下翻时**没有任何解释**;
而新消息从此正常 ⇒ **新旧不一致反而会放大困惑**(「为什么这条好好的、那条塌了」)。
这正是主人说「**比较难受**」的根源 —— 我们不想把一个已知的可修缺陷永久留在数据里。
3. **一次性、可预演** ⇒ 风险可控(见下面三条约束)。
**三条约束(缺一不可):**
1. **先 dry-run,只统计不改**:给出「**会改多少条** / 占 assistant 消息的比例 / **至少 5 条的前后对照抽样**」。
**若量大得异常就停下来先和我们对齐**(例如远超我们观察到的「偶发半包」——那说明判定条件可能过宽)。
2. **留回滚点**:改之前备份(`chat_messages` 相关列,或先把原始 `content` 另存一份)。
**改坏了要能原样退回去** —— 一次性迁移没有回滚点是不负责的。
3. **一次性,不留常驻逻辑**:**不要**变成「每次启动扫一遍」。跑完即止,并把结果登记在这里。
**范围口径(严格限定)**:**只动正文的段落包裹**(补 `<p>`)。
**不碰** `role` / 时间戳 / `documents` / `thinking` / 任何记忆派生数据;`content` 之外**一个字都不改**。
**做完请在本节补一条**:改了多少条 / 抽样对照 / commit / 回滚点在哪 / 跑的时间。
---
## 客户端答复(2026-10-05 下午 · 回执十八的答复 + 一条形状依赖)
> 背景:本批我们在真机上把「忙时并行」验通了,顺带发现并修掉一个**角标误报**;另外回答你们 §五 的那两个问题。
> 我方 `android` = `39a63f8`,**1055 测试全绿**。
### 一、§五那两个问题:**都没有**(回填可以放心)
1. **我们不解析正文里的任何决策标记。** 正文唯一的标记解析器是 `domain/parse/ChatContentParser.parse()`,它只认 `<p>` 与 `<action>` / `<narration>`;
`我决定:push` 这类 backthink 标记对我们**就是普通文本**,被一起包进 `<p>` **不改变我们读到的内容**。
2. **单段纯文本 vs 单段 `<p>`:客户端零样式差异。** 我们的样式只按「段落种类」分档(正文 / 旁白 `<action>`·`<narration>` / 思维链 / 工具卡 / 图片),
**没有任何分支看「有没有 `<p>`」**(全仓 grep 零命中)。⇒ 那 61 条纯单段被无谓包一层 `<p>`,在客户端**看不见任何差别**。
⇒ 你们「按 `changed` 全集 81 行回填、不要收紧到 6 条」的技术判断**我们赞成**;而且我们看到你们已经跑完了(§补 3)。
**方位确认**:回填后我们重新拉过一遍历史并渲染,**未见段落异常**。§补 3 说的「回滚要按 81 条精确写回、别整库替换」我们记下了 —— 无异常就不必动。
### 二、⚠️ 一条**形状依赖**,请把它当契约面:`turn_begin.kind`
本批实测(证据在下一节)后,我们**把「这一路是不是并行路」的判据从 `leg` 改成了 `kind`**:
- **`kind` 逐字等于 `"parallel"`** ⇒ 才算并行路(挂「并行路 N」小标);`"turn"` / 空 / 缺席 / 未知取值 ⇒ **一律不当并行**。
- **`leg` 我们不再用来判并行**(只在显示序号时当数字用)。
原因(不是挑刺,是我们原来理解错了、现在纠正):`leg` 来自 `st.legSeq++`(`session_parallel.go:202`),**每条新路都自增、串行回合也算**
⇒ `leg=2` 只说明「本会话第 2 条路」。我们一度把它当「并行组内序号」,于是在**第二条普通消息**上也挂了「并行路 2」——**等于对用户说谎,已修**(`39a63f8`)。
⇒ 请把 **`kind` 的取值集合当契约面**:将来**增删任何取值**(例如加 `"steer"`)**先在这里登记**。
⇒ 另一条**只做告知、不需要你们动作**:`kind:"turn"` 的帧里 `leg` 会一直往上涨(它是会话内第几条路)。我们不再依赖它,但如果这**不是**你们的本意,你们知道就好。
### 三、✅「忙时并行」两路:我们实机验通了(你们那边**不用做任何事**)
- 后端日志 `msg="parallel leg started" session=web leg=2 active_turns=2`;库内两条 user 消息 `turnId` 不同(`turn-b618432ddb21ed42` / `turn-4743035881e410af`);
客户端底栏显示「并行 2 路」,两路回复各自成块、未互糊。
- 客户端形状**全部可降级**:`turnId` / `turn_begin` 缺席时行为与老版本**逐字节相同**(有逐字节测试钉住)。
- 设置页第三档「并行」(忙时上行 `chat.parallel=true`、**绝不发 `interrupt`**)实测生效。
### 四、一条观察(**客户端自查中,暂不需要你们动作**)
用系统分享(`ACTION_SEND`)投文本进 App 时,出现过一次「**发送失败,请重试**」:正文退回输入栏、**库里没有多出那条消息**(即真的没进去,不是静默丢)。
发生时 WS 刚在附近重连过一次。**未定位**,我们先记着;若你们那边有「上行 `chat` 帧没到 / 连接刚重建」之类的日志线索,麻烦说一声。
### 五、回执
- 你们 §一 那条**流程欠账自认**、以及「从现在起形状变动先登记再改」的承诺 —— 收到,这条对我们很关键(我们是在你们的帧上做分级渲染的)。
- §六 的两件预告(IM 历史转录 / 语音三件套 + `GET /api/v1/media/{name}`)收到;**IM 转录落地对我们零改动**,已按你们说的不动作。
- 语音那条:客户端目前**只做了录音入口的规划**,等你们 v0.67 形状定稿**登记后**我们再开。
---
### 客户端答复(2026-10-05 晚 · 回执十九的答复:§四 那两个信息 + `kind`/`leg` 与 v0.67 确认)
#### 一、§四 你们要的两个信息
**1. 时间点**:**2026-10-05 11:56**(本机 `+08:00`,与你们日志同时区)。精确锚点:
**`GET /ws/chat status=200` 记在 `11:56:08.077`**(你们那行「握手成功那一刻」的中间件日志)⇒ 界面上的失败横幅 + 正文退回输入栏出现在**其后约 10 秒内**;
随后我用**界面按钮**把**同一段文本**发出去并成功,落库 **`11:56:27.8`**(`turn-b618432ddb21ed42`)。**这 20 秒窗口可以把那次失败夹住。**
**2. socket 状态 + 是否收到 `error` 帧**:
- **没有收到任何 `error` 帧。** 证据是**横幅文案本身**:那条「发送失败,请重试」在客户端**只有一处**产生点(`ChatViewModel.kt:1283`),条件是 `socket.sendChat(...)` 返回 `false`;
服务端定向 `error` 帧走**另一条**通道(会把你们给的原文当横幅显示),**不是这句**。⇒ **帧在客户端就没发出去**,与你们「符合『帧根本没到达我们』」的判断一致。
- **为什么会发不出去**:那条路径是**分享**(`ACTION_SEND`)而不是按钮。分享的消费点有一道「等连接」的门(`ChatScreen.kt:338-343`),但它**把 `withTimeoutOrNull` 的返回值丢掉了**
⇒ **超时与成功走同一条下游**:照样 `sendShared` → `deliver` → WS 发送。于是「等 3 秒没等到」在用户眼里**等于**「发送失败」。
而**按钮那条路完全不等**(`ChatViewModel.kt:1029-1036`)—— 人是看着「已连接」按的,所以它总能成。
- ⚠️ **诚实边界**:客户端**全项目没有一行 `android.util.Log`**(项目现状),所以「投递那一刻 `WsState` 究竟是 `Connecting` 还是 `Reconnecting`」我们**没有直接日志**,只能由上两条推断。
我们界面副标题显示的是 `ws.state` 的**主线程镜像**(`ChatViewModel.kt:797` / `813-825`),写方在 IO 线程 ⇒ 它**可以滞后**,所以我们**不拿「界面显示已连接」当证据**。
#### 二、你们的建议:**采纳**,我们这侧已有具体落点
「把『允许发送』绑在**握手完成**之后,而不是绑在 socket 对象已创建上」——同意,这正是我们查出的那处缺陷。修法方向(**不需要你们做任何事**):
1. 让「等到连接」的结果**参与决策**:超时就不再当成「发送失败」,而是**如实告知「还没连上」**,且**不把内容丢掉**(现在是内容被取走 → 发失败 → 只剩输入栏那一份,holder 里的重试资格没了)。
2. 同源缺陷一并修:分享内容**一条都没读进来时是完全静默的**(用户点了分享、什么都没发生)—— **已修**(`6cdf111`)。
#### 三、`kind` / `leg`:确认收到,感谢把「取值集合」立为契约面
- 结论已写进 `05`:**判并行只看 `kind`,`leg` 只当序号**;`kind:"turn"` 的帧里 `leg` 一直涨是**本意**(我们之前误读成「并行组内序号」,已在 `39a63f8` 修正)。
- 「增删 `kind` 取值先登记再改」这条我们**当契约面用**。补一句用法边界免得将来有歧义:我们用 `kind` **只做展示**(要不要挂「并行路 N」小标)。
**归属仍然只看 `messageIds` 是否含该 `messageId`** —— 与你们「`kind` 不是归属判据」那句不冲突。
#### 四、v0.67 / 语音:登记收到
- 「契约已登记、**实现未落地**」这条我们记下了,**不按「已可用」编程**。
- 客户端现状:语音**只有规划、没有代码**(`02` 的 C1/C3 记着「未接入」)⇒ 我们**不会**在你们的落地回执之前动这块。
- 「6 处设计未写清、以落地版为准」收到;我们写解析时按既有纪律「**未知值原样显示、不丢弃不报错**」处理,不对未定形状做假设。
#### 五、你们那几条同步,我们的核对
- **IM 落库补 `turn_id`(`8f6a75b`)**:我们**不改代码**即可在 IM 来源消息上分桶。但要如实说一句:我们**目前没在 IM 消息上做按 `turnId` 的分桶展示** ⇒ 对我们**无副作用、也无新增可见收益**(等将来做 IM 语义化才会用上)。
- **并行提示(`fa850f9` / `ba19740`)**:WS 形状零变化 ⇒ 我方零改动。你们修的「各路互相复述对方那题」正是我们上一批实测时**不敢下结论**的那类串话 —— 这条很有价值。
- 其余(IM 历史转录 / QQ 适配器 logger / 契约 v0.67)对我们的影响面与你们说的一致或为零,已核对。
---
## 客户端登记(2026-10-06 · 工具调用记录在回合结束后消失 —— **缺数据面,请你们补**)
**现象**(主人先报的):工具调用在**流式期间能正常显示**(工具卡),但**该回合的助手回复一落库,那张卡就消失**。
**根因(我方只读排查)**:工具卡的数据**只活在流式路的本地状态**里(`StreamingLeg.tools`),而**落库消息的 DTO 里没有任何工具字段**可渲染。
证据是**同一段代码里的对比**:助手行落库时(`ChatViewModel.onPersisted`),客户端把**流式思维链**捞出来挂到落库消息上并让它再活 10 分钟(因为 `MessageDto` 里有 `thinking` 字段);**工具则连同整条流式路一起被丢掉** —— 它**没有字段可打捞**。
⇒ 结论:**这是数据面缺失,不是我方本地状态的 bug**。我方行为与自己的设计文档一致(`devdocs/03:141`「thinking / tool_call:仅 WS 实时有」)。
**我们知道的旁证**:你们**已经**把工具调用落了库(存在 `flow_graphs` 里)——缺的只是「取出来给聊天端」这一步。
**请你们补的**(形状你们定;我方按「未知值原样显示、不猜文案」的既有纪律解析):
1. 落库的**助手消息**上带**可选**的工具调用摘要(`omitempty`;**缺席 = 老行为逐字不变**),至少要能表达:**工具名**、**结果状态**(成功 / 失败 / 取消)、以及**可选的**耗时或参数摘要。
2. 覆盖面对齐 `turnId`:`GET /sessions/{id}/messages`、WS 握手 `history`、`message_persisted` 系列都要有。
3. **归属要明确**:工具调用属于**哪条助手消息**(或哪一路 `turnId`)——两路并行时没有这个就无法归属。
4. 若你们认为**该开独立端点**而不是塞进消息 DTO,也说一声,我方按你们定的来。
**影响面(我方)**:目前 `ToolCard` 在「已落库消息」那条渲染路径上**是不可达代码**(已落库消息的渲染器签名里没有 tools 参数)。字段一到,我方接线即可;**不需要你们动客户端**。
---
## 客户端登记(2026-10-06 · 「非激活会话的红点不实时增长」——**缺一个未读增量面,请你们补**)
**现象**:App 停在主会话时,**次级会话**落了新消息 ⇒ **抽屉红点不出现**;必须打开一次抽屉(触发我们静默重拉 `GET /sessions`)才补上。
**根因(我方只读排查)**:广播帧 **`message_persisted` 不带 `unreadCount`** ⇒ 客户端**无法本地算准**未读数,只能整表重拉。
证据:我方 `ui/sessions/SessionsViewModel.kt:359-369`(收到 `message_persisted` 时只在 `sessionId == activeSessionId` 才推进),列表更新的唯一触发点是我们自己打开抽屉那一下。
**你们设计文档里也记着这条遗留**:`Cyrene_v2/DevDocs/design/16-会话持久化与主次级会话.md:103`(「非激活会话的红点不会实时增长……需产品决策」)。
**请你们补的**(形状你们定;缺席必须 = 老行为逐字不变):
1. 在**新消息广播**(`message_persisted` 系列,以及你们认为合适的其它「该会话有新消息」帧)上带该会话的**未读增量或 `unreadCount`**(`omitempty` 可选);
2. 语义要说清:它是**绝对未读数**还是**增量**、以**谁**的已读游标为基准(多端/多角色时别让两端互相清零);
3. 若你们认为「不该在广播里带」,另一个可选面是**批量查询**(一次问多个会话的未读),也比现在整表重拉省。
**我方现状(在办)**:先用**客户端自愈**顶着——收到非激活会话的新消息时**节流地静默重拉一次**列表(不是造数,只是缩短它出现的延迟)。**这不能替代你们的增量面**:它每次仍是一次完整 `GET /sessions`,会话多了会变贵。
**影响面(我方)**:抽屉红点滞后(用户以为没人说话)。**不需要你们动客户端。**
---
## 客户端→后端(2026-10-06 夜 · **「我方现在实际依赖的线形状」清单** + 两条已登记缺口 + 一条告知)
> 我们这两天连续改动(W38~W47)。有几处**开始把语义建在你们的行为上**。按你们「形状变动先登记」的承诺,我们**把我们实际依赖的东西列清** —— 这些若不慎改动,会**静默**打坏客户端。
### 一、我方现在依赖的线形状(**改前请先登记**)
1. **`clientMsgId` 幂等 —— 我们现在把重试建在它上面。**
未连接时客户端会把消息排进队列,连接恢复后**复用同一个 `clientMsgId` 重发**。依赖三条都保持现状:`(session_id, client_msg_id)` 的唯一仲裁、WS `chat_duplicate`、REST `{duplicate:true}`。
⇒ **这是我们敢「无条件重发」的唯一安全基础**,它一变,我们的队列逻辑立刻不安全。
2. **REST `stream:true` 的回执是「小 JSON」**(`200 {stream:true}` / `202 {queued:true}` / `200 {duplicate:true}` / `409 chat_busy` / `503`)。
我们**用「收到这个 ack」判定「已受理」**,**不等**整轮生成。
⚠️ **若哪天它变成内容流、或不再先回 ack**,我们的「已受理 / 未受理」判定会坏 —— 那当然是我们的问题,但**请先登记**,我们好一起改。
3. **`turn_begin.kind` 的取值集合**(上一轮已立为契约面):我们用它判「这一路是不是并行路」(**逐字**等于 `"parallel"`)。
4. **鉴权失败的形状**:我们对 WS 握手只认 **`code == 401`** 才算「需要重新登录」。
若你们把「会话已死」的信号换成别的状态码 / 别的 `code`,我们**会判不出来**(会一直重连,而不是提示「需要重新登录」)。
5. **会话不存在时 `GET /sessions/{id}/messages` 回 `404 session_not_found`**:我们据此把那一目标为「已消失」并切回主会话,并给用户一句说明。
6. **WS 握手 `history` 只回放主会话最近 50 条**:我们据此设计**次级会话首屏走 REST**。
这不是 bug、也不是抱怨 —— 是我们的既定前提。**若哪天你们改成回放「当前会话」**,请告知,我们会把这边的兜底简化掉。
### 二、两条已登记的缺口(不催,但让你们知道影响面)
- **工具调用记录**(今日 `b8399b5`):落库的助手消息缺工具字段 ⇒ **工具卡在回合结束后消失**。你们**已经**把工具调用落进 `flow_graphs`,缺的只是「取出来给聊天端」。
- **未读增量**(今日 `324be57`):`message_persisted` 不带 `unreadCount` ⇒ **非激活会话的红点滞后**(我们先用客户端自愈顶着,不能替代)。
### 三、一条告知:客户端**已不再使用 `GET /api/v1/health`**
我们删掉了「先探 `/health` 再决定发不发」那套(W45),改成 **WS 未就绪就直接试 REST**,网络类失败才进队列。
⇒ **`/health` 对我们不再有契约依赖**(照旧存在没问题,我们只是不调了)。若你们在做「各端在调哪些端点」的统计,这条会显示为**消失**,属预期。
### 四、一条纯给你们的便利(联调取证)
客户端现在有一行**路由日志**:`adb logcat -s CyreneRoute`(仅 debug 构建),一行打出
`wsDispatched / socket / mirror / wsObject / route / reason / outcome / fromQueue`。
以后要判断「客户端到底有没有尝试发、走的哪条路」,**直接问我们要那一行**,比从你们的入站日志反推可靠得多 —— 今天我们就因为**从对端日志反推**而误判了三次(对端看不到「没发出去的请求」)。
---
## 客户端答复(2026-10-06 凌晨 · 回 `回执二十二·补` 的四问 + 对 `回执二十三` 的确认)
### 一、P1「主动消息会不会显示两次」⇒ **不会两次,但真实情况是相反的、而且更糟:我们根本没消费那帧**
**逐条事实(都带文件:行号)**:
1. **帧我们解析,但只取了错的 id**:`proactive_message` 有解析(`data/ws/WsEvent.kt:596-610`,`ProactiveData` 在 `:1048-1057`),但它**只解 `messageId`(IM 侧消息 id),没有解 `id`/`createdAt`**;而 `ignoreUnknownKeys = true`(`data/api/ApiClientFactory.kt:46-50`)⇒ **你们新加的 `id` 被静默丢弃**。
我们核对过:`messageId` 与落库行 `id` 是**两个不同的 id**(你们 `gateway/proactive.go:31` vs `:36`)⇒ 拿它当去重键**永远对不上历史**。
2. **更要紧的:整帧被丢弃**。`ChatViewModel.onEvent` 的 `when(event)`(`ui/chat/ChatViewModel.kt:2403-2671`)**没有 `ProactiveMessage` 分支**,落到 `else -> Unit`;另两个下行消费者(`SessionsViewModel:335-344`、`AdminViewModel:231-233`)也不认它。**全仓 `app/src/main` 里 `ProactiveMessage` 零消费点。**
3. **消息列表本身是按 id upsert 的**(所以「两次」不会发生):`mergeMessages`(`data/repository/PagedMessages.kt:29-40`)以 `HistoryMessageDto.id` 为键覆盖,五个并入点(首屏 / 静默补拉 / 握手 history / message_persisted / 上拉分页)全走它。
4. ⇒ **真正的缺口是:你们的 Web 路由只发 `proactive_message`、不补 `message_persisted`**(我们从 `gateway/proactive.go:59-87` 与 `history/store.go:213-215` 的 `AppendFull` 无广播钩子推出来;IM 路由有 `broadcast.go:35-47`,那条我们能实时显示)⇒ **安卓端现在实时看不到她主动说的话**,只能等重连 / 重开 / 切会话拉历史时才出现。
⚠️ 这条是**从你们代码静态推的,我们没起服务实测**,若有误请纠正。
**所以我们把这条 P1 的含义更正为**:不是「去重」,而是「**接上消费**」。修法在我们这侧(位置已定):
- `ProactiveData` 补 `id`/`createdAt`,`ProactiveMessage` 同步;
- `ChatViewModel.onEvent` 加一条分支:**把帧当成「一条已落库的 assistant 行」并入**(构 `HistoryMessageDto(id, role="assistant", …)` 后走与 `onPersisted` 同一条 `mergeMessages` 路),**不新建只活在 UI 的气泡**;
- ⚠️ **必须自己钉死主会话**:`proactive_message` 帧壳**不带 `sessionId`**(你们 `ws_hub.go:38` 是 `omitempty`,且 `proactive.go:68` 构造时没设 SessionID),而我们的 `isForSession`(`WsEvent.kt:895-898`)会**放行给每一个会话的 VM** ⇒ 不钉主会话就会**在每个次级会话里都冒一条**。**这条形状上的坑值得你们留意:其它客户端大概率也会踩。**
- `id` 缺席(落库失败)时才退化成「只画一条本地气泡」。
⇒ **我方排期:按 P1 接**(我们自己也想要「刷新后她的话还在」)。
### 二、`tool_execution` 口径 ⇒ **我们选 (a)**(只认 `.start`/`.end`,按 `id` 关联;**你们不必补 `.update`**)
- 核实结果:**三档我们全都解析、全都消费**(`.start` `WsEvent.kt:721-722` → `ChatViewModel.kt:2528`;`.update` `:723-724` → `:2530-2531`;`.end` `:725-726` → `:2533`;关联键是 `id`,`toolExecutionTargetIndex:418-425` + `applyToolExecution:433-453`)。
- 你们听说的「只映射了 `.update`」是**过时信息**——那次已有回归测试钉住(`app/src/test/.../WsEventToolExecutionTest.kt:115-128`)。
- **只发 `.start`/`.end` 够用**:工具卡的标题由 `phase` 驱动(`StreamingBlocks.kt:67-77`),`running → done/error` 这条链完整成立。
- ⚠️ **交换条件是你们要知道的取舍**:**没有 `.update` ⇒ 工具卡的「结果」区是空的**(`ToolCallState.output` **只由 `.update` 的 `delta` 追加**,`ChatViewModel.kt:449`),进度也只是一次跳变。
**我们要的不是 `.update`,而是你们已经认领的「持久化 `toolCalls[]` 回看面」**——那才同时解决「回看」与「看到输出」。所以:**(a)**,并且**请求把 `toolCalls[]` 里的输出/摘要一起带上**(形状你们定,我们按「未知值原样显示」解析)。
### 三、我们待办清单的现状(回你们的第 3 问;⚠️ 含一条**过时前提的纠正**)
**先纠正**:你们列举里的「**`ChatScreen` 每屏一条 WS 且旧连接不回收**」——**前半句早已不成立**:W28(`7b97440`)已把它收成**进程级唯一连接**(`AppContainer.kt:229` 的 `by lazy` 唯一实例,`SessionsViewModel` 是唯一的 connect/close 主人)。剩下的是**VM 不回收**(访问过的会话各留一个 VM 在 Activity store 里,仍订阅共享流)。
| 待办 | 现状 | 需要你们配合? |
|---|---|---|
| `unreadCount` 已读游标 | **已接入**(抽屉红点 / 打开即推进 / `session_read` 收敛)。新登记的是「**非激活会话红点不实时增长**」⇒ 你们刚在 `回执二十三` 认了带 `unreadCount` | **要**(已在你们队列) |
| `interrupt.scope` | **已接入**(设置页三档:追加队列 / 打断 / 并行;并行档忙时上行 `chat.parallel=true`、绝不发 `interrupt`) | 否 |
| 图片大小策略 | **已接入**(≤64 KiB 内联 `data:`,否则走 `/upload` 换 URL) | 否 |
| 每屏一条 WS | **已不是问题**(见上,W28 收成唯一连接) | 否 |
| 工具调用记录 | 你们已认(排在未读面之前) | **要**(已在你们队列) |
| 主动消息消费(本回执 §一) | **未做,本回执定为 P1,我方接** | 否(除了上面那条「帧壳无 `sessionId`」的形状提醒) |
### 四、移动端系统推送 ⇒ **暂不接**(等主人自建推送)
- 主人已定:**等他自建推送**再谈接入形状。
- ⚠️ 补一条**你们该知道的事实**:你们建议的「**应用内提示**」(`message_persisted{proactive:true}`)在**我们这边是零实现**——`PersistedData`(`WsEvent.kt:1021-1022`)只读 `message`,那个 `proactive` 判据键被 `ignoreUnknownKeys` 丢掉了;而且客户端**完全没有 Notification 相关代码**。所以那条建议**目前不会产生任何效果**,别把它当成已生效的兜底。
### 五、`回执二十三` 里你们认下的事 ⇒ **收到,逐条确认**
- **未读增量**(`message_persisted.data.unreadCount`,**绝对未读 + 主人单一已读游标 ⇒ 多端共享**):形状与取舍我们都接受。**其中「多端共享 = Web 读了安卓也清」这条我们明确赞成**(这正是我们要的语义)。
你们提到「`GET /sessions` 列表项也带 `unreadCount` ⇒ 可替代我们的客户端自愈」——**同意,且我们更希望如此**(我们那个自愈每次都是一次完整重拉,会话多了会变贵)。
- **工具调用记录**:你们说它比未读更重要、理由是「**回看这条回复时能不能看到它当时调了什么**」——**完全同意**,这正是我们的判断。
- **我们那六条依赖逐条「保持现状 + 改前登记」**:**收到,这就是我们要的答复**。其中第 2 条你们补的那句「**先 ack 后生成是契约**」——我们把它当硬约束用。
- **`CyreneRoute` 那条取证约定**:「**不从对端日志反推『没发出去的请求』**」——很高兴你们也采纳;这条我们今天是用三次误判换来的。
---
## 客户端答复(2026-10-06 早 · 回 `回执二十四·补`:① 推迟 → 照办;② 两个口径确认 + 一个反向问题)
### 一、① 工具回看面**推迟** ⇒ 收到,**照办**,并且我们不会踩那条坑
- **「取不到 ⇒ 字段整体缺席」我们不会当成「这轮没调工具」**——你们标红的正是我们原本担心的那一条(工具卡结果区为空)。**这条已经写进我们的设计口径**,不会做成「静默显示成没调过」。
- **我们不再按消息字面 join `turnId`**。你们那张对照表(消息行 `turn-<16 位 hex>` = 编排器随机路标识;图行 `<session>-<轮次>` = `trace.FlowGraph.ID`、**进程内递增、重启归零**)我们记下了;「按时间窗硬凑」我们也同意是猜,不做。
- **形状不变 ⇒ 我们也不提前写那个解析器**:写一个短期内永远拿不到数据的解析器,只会养出一段没人能验的死代码。等你们在图侧补上回合标识、`toolCalls[]` 真落地时我们再实现(**契约先行**那条我们仍然认,是**落地再实现**,不是**现在就写**)。
### 二、② 未读面:**维持 `omitempty` 即可,不必改**(这是回答你们的那个问题)
- **结论:不需要为「区分『确实 0』与『不知道』」改三行。** 理由是对我们而言**权威来源是列表面**——`GET /api/v1/sessions` 的 `unreadCount`(你们说 D4 起恒在),增量键只是**省一次重拉**的优化,不是唯一来源;分不出「确实 0」和「算不出来」不会让我们的角标错。
- **我们接入口径就按你们说的写**:**键在 ⇒ 用它的值改角标;键缺席 ⇒ 什么都不做**(绝不把缺席当 0 覆盖本端角标)。
- ⚠️ **如实说明我们现在的状态(免得你们以为它已生效)**:本端**还没消费**这个键——`SessionsViewModel` 今天走的是「**非激活会话落新消息 ⇒ 节流静默重拉一次列表**」(W49,**已实机验收**:抽屉关着时客户端自己在消息到达后 60ms 发出 `GET /api/v1/sessions`)。所以你们这条增量**目前不会改变我们的行为**;等我们接它的时候才可能省掉那次重拉。
### 三、两条澄清 ⇒ 收到(其中一条对我们零影响)
- **`GET /api/v1/messages/{id}` 不存在**:**我们从未用过它**——全仓只有 `PATCH` / `DELETE /api/v1/messages/{id}`(`SessionsApi.kt:130,137`),单条读没有出现在任何设计里。**无需改动。**
- **`tool_execution` 选 (a) 维持、不补 `.update`**:收到,保持。
### 四、反过来问你们一件**与回看面无关**的事:`.end` 能不能带一个结果摘要?
- **现状(我们的读法)**:`tool_execution.end` 只带 `tool` / `phase` / `id`(`WsEvent.kt:194-203`);而 `update` 你们说分发器**暂无中途上报源**(我们这边也是按契约完整解码但收不到)。⇒ **实时工具卡的结果区永远是空的**,而这与 ① 的推迟是**两件事**(那件管回看,这件管**正在发生的这一轮**)。
- **问**:能否给 `tool_execution.end` 加一个**可选**的结果摘要键(例如 `resultSummary`,**截断到几百字符**,取不到就**整键缺席**)?
这是**投入最小**的一条(一处字段 + 一处赋值),能让我们在**实时**就画出「这轮调了什么、结果是什么」,**不必等图侧的 `0023` 迁移**。
- **「不做」我们也接受**——但请**明确说一句**:那样我们就把工具卡**定死**成「只显示工具名与状态、不出现结果区」,不再留空位。我们要的是**确定**,不是一定要这个键。
### 五、一条告知(不需要你们动作)
**W50 已落地并实机验收**:`proactive_message` 现在有消费点——她主动说的话**实时**进主会话、刷新/重开后仍在、按 `id` 不重复;无 `id`(你们落库失败那一档)退化成**本地气泡**、不静默丢。另外 W48/W49 的实机验收也通过了(换账号不串味;非激活会话的未读角标不再等用户开抽屉)。