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

75 KiB
Raw Blame History

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 的实机验收也通过了(换账号不串味;非激活会话的未读角标不再等用户开抽屉)。