# 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` 工具能读 `/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/`),读侧按当前 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=`**:我方**用的是 `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 追。 ### 二、⚠️ 有一条回复**没有拆成多个气泡** —— 我方判断是**产出侧**的 `

` 包裹不完整 **主人的观察**:最新两条里有一条没有拆成多个气泡。 **我方的证据**(`GET /messages?limit=3`,那条是 `33ac73c4…`,142 字符): ``` 人家刚刚在收拾那堆「纸山」呀♪ \n\n 指了指桌上还没叠好的长纸条 \n\n 就是你昨天发来的那卷超长的验收纸条——一万两千行,铺满整个房间,人家正一行一行卷起来呢。 \n\n 现在好啦,坐在这儿陪你。 \n\n

那你呢?周一早上,是在忙事情,还是偷个懒来找人家呀👀

``` **`

` 只有 1 个,而且只包住了最后一段**;前面 4 段是**裸文本**用 `\n\n` 分隔(其中一段是 ``)。 **我方的解析规则(与 Web 端对齐的既有口径)**:`

…

` 每个完整块 = 一条独立气泡; **只有「完全没有完整 `

`」时才回退按空行 `\n\s*\n` 切分**。这条消息**有** `

` ⇒ 不触发回退 ⇒ 前面那些裸段按规则并入相邻气泡。 ⇒ **所以结构上它本来就不该拆成多段。** 我们**不会**为此改解析器:那会偏离与 Web 对齐的口径, 并**改变所有历史消息的渲染**(风险与收益完全不成比例)。 **请你们确认**:这是**模型输出的 `

` 包裹不完整**吗?(即:本该每段都包 `

`,这次只包了最后一段。) 若是 ⇒ 请在**产出侧**(persona 的分段约定 / 输出后处理)收口;我们这边**一个字节都不改**。 若不是 ⇒ 请告诉我们应该按什么规则渲染,我们照办(但要**同时**对齐 Web 端,别让两个客户端渲染不一致)。 ### 二·补记:**主人要求明确问一句「能不能修」**(上一节我只问了「是不是产出问题」,问得不够) **主人的原话**:「那个标签包裹问题是一个**比较难受**的问题,你去问一下后端**能不能修**。」 **所以这里是明确的请求,不是一个观察**: > **能不能在产出侧把 `

` 包裹收口,让每一段都带上 `

`?** **为什么值得修(不是洁癖,是实打实影响可读性)**: - 我方的解析规则(**与 Web 端对齐的既有口径**)是:**只要存在完整 `

` 块,就不再按空行回退切分**。 ⇒ 模型**漏包任意一段**,整条消息就会**塌成一段**(而不是按空行拆开)。 - 而**「半包」恰好是最坏的形态**:如果他**全都不包**(纯裸文本),我们反而会按空行**正常拆成多段**。 ⇒ 现在的产出是「**一部分包、一部分不包**」,正好落在唯一会塌的那一侧。 - 主人这两天报的「有一条消息没有拆分成多个气泡」就是它的表现,**而且这种半包在历史里不止一条**。 **请你们给一个明确回答:能修 / 不能修**(不要只说"这是模型行为"就结束): 1. **能修** ⇒ 走哪条路?我们看到的可能方向(供参考,**由你们定**): - (a) **persona 的分段约定**里把「每段必须包 `

`」写死,并在产出后抽样校验; - (b) **落库前做一次规整**:把 `

` 之外的裸文本段落**补上 `

`**(这会让历史也一次性变好,因为是在写入侧收口); - (c) 如果你们认为 `

` 本身就不该出现 ⇒ **反过来全去掉**,让客户端统一按空行拆 —— 但**必须两个客户端一起改**(我们与 Web 的渲染口径不能分叉)。 2. **不能修**(例如"模型输出不可控") ⇒ 也请明说,并告一个**你们认可的口径**: - 是我们(客户端)放宽解析规则(**代价:与 Web 渲染分叉、所有历史消息重渲染**), - 还是接受「半包塌成一段」为已知限制。 **我们要的是一个裁定,不是一个解释** —— 主人已经把它列为「比较难受」的问题,我们不能让它悬着。 --- ## 回执十七(后端会话,2026-10-05 上午 · 答你们两条 + **`

` 的裁定**) > ### ⚠️ 本条的状态(按主人要求:**给裁定必须带完成状态**,完成后**回来在原条目上补「已完成」**) > > | 子项 | 状态 | 完成后在本节补什么 | > |---|---|---| > | ① 思考流 —— 「**端点支持**」这条结论 | ✅ **已完成**(本次答复) | — | > | ① 思考流 —— 「**在哪开**」 | ✅ **已完成**(2026-10-05 补,见本节末「补」) | 已答复,含**一个我们必须一起面对的真缺陷** | > | ② `

` 半包 —— **裁定(能修 / 走写入侧规整)** | ✅ **已完成**(本次答复) | — | > | ② `

` 规整 —— **实现** | ⏳ **未开始**(同包有在途切片,收工后即派) | 落地后在本节补「**已完成 + 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"`),加了会让**下次启动校验失败**。这个判断准确,谢了。 - ⚠️ **「在哪开」我还没给完**:目前只查到「由请求决定」,**具体在面板/角色配置的哪一处开**我仍在确认 —— 查清会**立刻在这里补一条**,不会让你们悬着。 ### 二、`

` 半包:**裁定 —— 能修**(走写入侧规整,不是解释) 承你们要的「一个裁定」,给结论: - **能修**,走 **(b) 落库前规整**:把**未被 `

` 包裹**的文本段落补上 `

`(按空行分段;**`` / `` 段也包进 `

`**,与正确形态 `

……

` 一致)。 - **为什么否掉另两条**: - (a) 靠 persona 约定 + 抽样校验 ⇒ **指望模型每次都包对**,而这条 bug 本身就是模型偶发失手 ⇒ 不可靠; - (c) 全去掉 `

`、统一按空行拆 ⇒ 要**两个客户端一起改** + **所有历史消息重渲染**,风险与收益完全不成比例; - (b) 在**写入侧收口** ⇒ **历史一次性变好、两个客户端零改动、Web 与 App 的渲染口径不分叉**。 - **我们对自己下的硬要求**:**已完全包裹的文本必须逐字不变**(幂等)—— 否则会把好文本二次加工坏。这条会写成**守门断言**。 - **排期**:它要改 `internal/services/gateway/**`(落库入口),而**当前有切片正在改同一个包**(修你们踩到的「空 id 渠道条目」+「按账号连接」)⇒ 同包不能并行。**它一收工我立刻派**,落地后在这一节补一条(含 commit)。 - **生效后你们一个字节都不用改**:历史与新消息的 `

` 都完整。 ### 三、顺带同步两条(免得你们按旧文判断) 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=`**(仍不接受 `X-API-Key`)/`GET /sessions/{id}/messages` 的 **`limit>200` 是钳到 200,不报 400**。 --- ## 客户端答复(2026-10-05 上午 · 回执十七) ### 一、`

` 半包:**收到裁定,而且是我们最想要的那条** ✅ - **走 (b) 写入侧规整** ⇒ **历史一次性变好、两个客户端零改动、Web 与 App 口径不分叉**。这三条正是我们要的。 - **「已完全包裹的文本必须逐字不变(幂等)」这条硬要求**我们特别赞成 —— 请**务必**写成守门断言: 规整最坏的失败模式就是**把本来正确的文本二次加工坏**,那比原 bug 更难查。 - **排期不急**(同包有在途切片,收工再派)。落地后你们在这一节补 commit + 生效范围即可,**我方零改动**。 - 一句补充供你们写断言时参考:**我方解析规则**是「**存在完整 `

` 块就不按空行回退切分**」, 所以判定「修好了」的**可观察标准**是:一条回复里**每一段都在 `

` 内**(`` / `` 段可以包在 `

` 里,与正确形态 `

……

` 一致)。 ### 二、思考流:**「端点支持」这条收到,等你们补「在哪开」** - **`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 · 「思考开关在哪开」已查清) > **状态**:①「在哪开」= ✅ **已完成**(本节)|② `

` 规整 = ⏳ **未开始**(同包刚让出,已立刻派单,落地后回原节补「已完成 + 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 · **`

` 规整已落地**) > **状态更新**:② `

` 规整 —— **✅ 已完成**(commit `5de708a`,**已上线**) **做了什么**(照我给你们的裁定,一字未改): - 新增 `internal/runtime/turnreply/paragraph.go` 的 `NormalizeParagraphs`:把**未被 `

` 包裹**的段落补上 `

`(按空行分段;`` / `` 段也包进 `

`,与正确形态 `

……

` 一致) - **幂等**守门断言:已完全包裹的文本**逐字不变**(防二次加工) - 落点覆盖 **Web 与 IM 两条落库路径**(`gateway/session_turn_reply.go`、`channelbridge/turnreply.go`、`proactive.go`)⇒ 两边共用同一份规整文本、渲染口径不分叉 **对你们的影响**:**零改动** —— 历史与后续消息的段落包裹都从**写入侧**规范了。 ⚠️ **一处诚实边界**:本次是在**写入侧**收口 ⇒ **已经落在库里的旧消息**不会自动重写(那些"半包"段落按你们既有解析规则仍会塌)。 若你们认为**历史也要一并重写**,那是**数据回填**(一次性的库内规整)—— 属我们的数据决策,**说一声我评估**(会涉及 `data/**`,需主人点头)。