Files
Cyrene-For-Android/devdocs/02-前后端协同事项.md
T
AskaEth cb5fc0c605 docs(devdocs): 拆分 02/05 两份过长协作文档,历史部分归档,内容零丢失
02-前后端协同事项.md:2126 行 → 860 行
- 历史回执(回执一~十六及同时段已闭环的登记)整体搬到新建 devdocs/02-archive-历史回执.md(1274 行)
- 主文件保留:标题与用途、顶部「待后端处理的 P0」置顶块、登记规范、A~I 全部登记条目、附:状态一览表、仍未闭环的登记(2026-10-04 深夜续那条待主人拍板的口径冲突)、最近 5 条往来
- 置顶块之后新增 5 行归档说明:历史回执去向 / 新回执照旧写在本文末尾 / 已闭环的老登记会定期挪进归档 / 本文旧指针去归档里找对应小节
- 不动置顶块、不动状态一览表、不动未闭环的登记,后端会话仍可照旧在本文末尾追加回执

05-开发进度与交接.md:1035 行 → 601 行
- 历史批次细节与已过时的验收现场记录搬到新建 devdocs/05-archive-历史批次.md(437 行):本轮追加(W4–W7)、W8–W10、W11–W12、W13–W14、W15–W16、装机验收轮、分享到 App 验收轮、W27
- 主文件保留全部「★ 恢复锚点」性质的节(当前状态与待办、锚点补记一~五、纪律补记)与当下要用的节(当前状态、构建、验证方式、凭据处理红线、环境要点、后端、模拟器、易忘的实现约定、管理区约定、C-2 验证结论、坑与勘误、后端协同、实机验收方法、三条待拍板项定案)

零丢失的证据
- 全部搬运用 sed 按行区间原样切割,未改写任何既有文字;新增内容只有 02 主文件那 5 行归档说明与两份归档文件开头的 2 行说明
- 逐行多重集比对(awk 计数 + diff):除上述新增行与个别空行外,原文件与新「主文件+归档」的行集合完全一致,无增无删
- 8 个代表字符串的 grep -c 拆分前后相等:679cee0 3=3、18381f6 1=1、guardrail_input 3=3、internal/config/runtimeconfig/models.go:300-311 1=1、回执十七标题 1=1、devtools/fake-backend/ 4=4、W15-retry-2-EDITED 3=3、ChatImageBudget.SERVER_MAX_CHARS 1=1
- 行尾统一为 CRLF(与 devdocs 下其余文件一致)

未跑 Gradle(本次纯文档搬运),未 push。
2026-10-05 11:10:03 +08:00

861 lines
80 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);不做的标 `不采纳` 并写明理由。
**反馈节奏**:每轮开发结束时,在对话里列「本轮新增协同事项」(编号 + 一句话),主人直接照着修后端。
---
## A. 阻塞 P0 的(不解决就得改客户端设计)
### A1. 结构化流帧不带 `sessionId` → 多会话流式无法归属 —— ✅ **已闭环**(后端 v0.36 + 客户端 2026-10-04)
- **闭环**:后端 v0.36 起帧壳已带 `sessionId` + `turnId` + `streamId`(见 `G1`);客户端已把帧壳上的
`sessionId` 透传进 `WsEvent` 的每个流式事件类,并在 `ChatViewModel.onEvent` 里按会话过滤
(`WsEvent.isForSession`)。回归测试:`data/ws/WsEventSessionScopeTest.kt`。
**下面「后端建议 / 当前绕行」两段是历史记录,已不成立。**
- **现象(历史)**:`message.start/delta/end`、`thinking.*`、`tool_call.*`、`tool_execution.update` 的 payload 只有 `{index}` / `{id,index,delta}`,**没有 `sessionId`**(`model.StreamEvent` 无会话上下文)。
- **客户端为什么要**:WS 是全局一条连接,多会话同时流式时无法判断 delta 属于哪个会话,切会话会串台。
- **后端建议**:这些帧补 `sessionId`,或至少补 `turnId`(客户端用 turnId 反查会话)。
- **当前绕行**:客户端硬约束「同一时刻只有一个会话在流式」(已拍板接受)。
- **优先级**:中高(有绕行,不阻塞 P0)
### A2. `interrupt` 清空 pending → 「打断」模式连发丢消息
- **现象**:`interrupt {sessionId}` 取消在跑 turn **并清空 pending**,下行 `session_interrupted{dropped}`。
- **客户端为什么要**:S1.4 定了「追加队列 / 打断」两种模式;用户选「打断」时连发多条会被静默丢弃。
- **后端建议**:`interrupt` 增加 `scope`(`current` = 只取消当前 turn / `all` = 连 pending 一起清),默认维持现状。
- **当前绕行**:打断模式下 UI 限制一次只能发一条 + 明确提示。
- **优先级**:中
### A3. 消息 `mode` 不落库 → 渲染形态丢失
- **现象**:design/16 §5.1 写「消息体带 `mode`」,但 Web 路径(`session_persist.go`)与 IM 路径(`channelbridge/persist.go`)**都不写 `Mode`**,字段 `omitempty` → 实际恒缺。
- **客户端为什么要**:9 值渲染 mode(chat/code/home/work/docs/voice/vision/agents/settings)决定气泡/卡片形态,且「只有 chat 才解析 `<p>`」这条规则依赖它。
- **后端建议**:持久化时写入 `mode`(design/16 已约定,属实现缺失)。
- **当前绕行**:客户端一律回落 `chat`。
- **优先级**:中
### A4. 会话 `title` 恒空 → 列表没名字
- **现象**:`historySessionSummary.title` 有字段,但 `store.go` 恒写 `""`,无生成规则/接口。
- **后端建议**:后端生成(截首条用户消息),或提供 `PATCH /sessions/{id}` 让客户端改名。
- **当前绕行**:客户端本地生成「会话 N」+ 序号持久化。
- **优先级**:低
### A5. WS 断线无补发 / 无 resume cursor
- **现象**:无 `since` / `lastEventId`;`seq` 只用于排序,未规定可续传;重连仅有 `history` 回放主会话最近 50 条。
- **客户端为什么要**:断线期间超过 50 条就永久丢失,只能整页重拉。
- **后端建议**:`/ws/chat?since=<seq 或 createdAt>` 补发,或提供 `GET /sessions/{id}/messages?after=<ms>` 正序增量接口。
- **当前绕行**:重连后 REST 按 `before` 补差 + 按 `id` 去重。
- **优先级**:中
---
## B. 影响体验 / 客户端需绕行
### B1. 无图片上传端点 → 大图只能 base64 塞进 `chat.image`
- **现象**:无 `/upload`;`image` 只接受 base64 data URI 或 http(s) URL;WS 上行帧限 **4096 字节**。
- **客户端为什么要**:主人已定「发图先做着」,但没有上传端点就只能把压缩后的 base64 走 REST,大图会撑大请求体(WS 完全走不通)。
- **后端建议**:`POST /api/v1/upload`(multipart)→ 返回 URL;`chat.image` 收 URL。
- **当前绕行**:客户端压缩后再 base64,限制单图尺寸。
- **优先级**:中高(主人已要求做发图)
### B2. token 24h 且无 refresh 端点 —— ✅ 已闭环(后端 + 客户端)
- **原现象**:`tokenTTL=24h`,无 refresh/renew;`rotate-jwt` 让全部 token 立即失效。
- **✅ 后端已做**:`/auth/refresh` + `/auth/logout`(不透明 refresh 哈希 + **单次使用旋转、无宽限期**)。2026-10-04 实测 `POST /api/v1/auth/login` 的响应已带 `refreshToken` 字段。
- **客户端为什么要**:主人选了「静默重登」,但静默重登需要长期保存账号密码。
- **✅ 客户端已做(2026-10-04,提交 `f557654`)**:
- `LoginResponse` 补 `refreshToken`;新增 refresh / logout 端点与 DTO
- `SessionStore` 存 refresh token、**不再写入口令**,并对升级安装 `commit()` 抹掉历史口令
- 迁移**对用户无感、绝不强制重登**:启动时若「无 refresh token 但有旧口令」,用旧路径静默登录一次换到 refresh token 再抹口令;离线失败下次启动重试且不清会话
- `TokenRefresher`:单飞刷新 + 代际守卫 + 终态记忆(服务端旋转是原子且无宽限期,而本端并发 401 是常态:管理概览页并发两请求、聊天页 REST 与 WS 同时起步)
- 用应用层 Interceptor 而非 OkHttp `Authenticator`(后者对**任何** 401 都会被调用并被内部循环驱动,持续 401 的令牌会打出刷新风暴)
- 可恢复的 401(网络 / 5xx)**不再登出**;登出补调 `/auth/logout`(此前从不调用,服务端 grant 会白活 30 天)
- **未验证项**:主动 / 被动续期路径本身没能真机触发(唯一输入是加密存储里的 `expires_at`,shell 写不进去,且 `tokenTTL` 是 24h 常量),仅由回环端到端测试覆盖
- **优先级**:中高 —— **已闭环**(安全面:口令不再落盘)
### B3. `GET /sessions` 信息太少(无未读、无最后一条预览)
- **现象**:只有 `{id,type,title?,createdAt}`。
- **客户端为什么要**:会话列表做不了预览和未读红点;多端同步时无法标记已读。
- **后端建议**:补 `lastMessage{content,role,createdAt}`、`unreadCount`,或提供已读游标接口。
- **优先级**:低(P0 按序号展示即可)
### B4. `platform` / `mood` / `mode` 取值未穷举 —— ✅ 后端已实现,客户端已接入
- **原现象**:文档只给示例(`web`/`telegram`/`weixin-ilink`/`qq-obv11`/`kook`;`calm`;`normal`)。
- **✅ 后端已做(2026-10-03 核实)**:新增 `GET /api/v1/meta/enums`(`api-reference.md` §3.7 / `api-client.md` §1.1,契约 **v0.38**)→ `200 {data:{platforms,moods,modes,unknown}}`,每项 `{id,label}`;`unknown` 恒 `"display-raw"`。**任意已认证角色可读**(与 `GET /sessions` 同级)。文档里**明确标注这条就是「Android 协同 B4」**。单一事实来源在 Go 侧 `internal/contract/enums` + 反漂移测试(适配器 `Name()` / 情绪状态机 / 前端九模式漏登记即 CI FAIL)。
- **客户端为什么要**:来源角标和情绪标签需要**稳定映射**(显示「来自微信」而不是原始字符串)。
- **✅ 客户端已做(2026-10-04,提交 `09a3222`)**:`MetaApi` + `EnumCatalog`(**无任何硬编码兜底表**)+ `LocalEnumCatalog` 注入 + `EnumPreferences` last-good 快照(`baseUrl+username` 指纹闸门);删掉 `platformLabels` / `personaModeLabels` / `moodLabels`;未知值一律原样显示。
- **接入时实测修掉一处既有漂移**:客户端原本把 `web` 显示成「Web」,权威表里是「网页」。
- 未转换(且**不该**转换):trace `kind`/`status`、memory `scope`、`thinking.mode`、WS `session_busy` 的 `mode` 集合——它们都不在 `/meta/enums` 内。
- 真实响应体已作为夹具进 `MetaDtoRealPayloadTest`。
- **优先级**:低 —— **已闭环**(后端 + 客户端都完成了)
### B5. 管理员端点 5 个返回 501
- **清单**:`POST /admin/users`、`POST /admin/iot/scenarios`、`POST /admin/iot/rules`、`POST /admin/plugins/install`、`POST /admin/rag/delete`。
- **✅ 已做**:`POST /admin/plugins/install`(hot 装 MCP server,连接失败不落盘,已推库)。
- **仍 501**:`POST /admin/users`、`POST /admin/iot/scenarios`、`POST /admin/iot/rules`、`POST /admin/rag/delete`。
- **客户端为什么要**:管理员页面要决定这些入口做不做、做了怎么降级。
- **当前绕行**:客户端硬编码「已知未实现」清单,入口置灰(仅剩上面 4 个)。
- **优先级**:低
### B6. `proactive_message` 只在 `api-reference.md` 有,`api-client.md` 摘要缺失
- **后端建议**:补进 `Docs/api-client.md`。
- **优先级**:低(文档治理)
---
## C. P1 语音相关
### C1. `voice_input` 轮次不落库、不过编排器
- **现象**:`runVoiceTurn` 直调 `runner.Run`,无 `history.AppendFull`、不经 `session_orchestrator` → **无 `message_persisted`、无 busy/pending、不跨端同步**;与 `api-client.md` §3.1「随语音轮次落库」不符。
- **客户端为什么要**:语音问过的事必须进历史、跨端可见,否则在 Web 上查不到。
- **后端建议**:把语音轮次接入编排器与持久化(与文本轮次同一路径),或在文档里明确「语音不留痕」。
- **当前绕行(客户端已定)**:**不用 `voice_input` 提交对话**,改为 `REST /audio/transcribe` 转写 → **WS `chat`**(落库 + 流式 + 编排)→ `REST /audio/speech` 播放。
- **优先级**:中
### C2. 语音降级到 silent 时客户端无法感知
- **现状**:cloud 档已接阿里百炼(STT `qwen-audio-3.0-asr-flash` / TTS `cosyvoice-v3-flash`,需 `DASHSCOPE_API_KEY`);`edge-tts`/`espeak` 未实现;`local` 默认不注入;`silent` 兜底返回空音频。
- **客户端为什么要**:没有 key 时语音会「静默成功但没声音」,客户端无法提示。
- **后端建议**:降级到 silent 时给出可识别信号(响应头 `X-Voice-Provider: silent` 或专门错误码)。
- **优先级**:中
### C3. `voice_output` 是否带 `sessionId` / `deviceId` 未规定
- **客户端为什么要**:多端同时说话时无法区分归属。
- **优先级**:低
---
## D. P2 / 路线缺失(客户端想做,后端尚无能力)
### D1. 移动端推送通道(系统通知)—— 最大缺口
- **现象**:主动推送只有 WS `proactive_message`(仅 owner/admin 连接收到),**App 不在前台就收不到**。
- **客户端为什么要**:design/21 §5 的 P2 明确要「主动推送 → 系统通知」,目前没有任何移动推送通道。
- **后端建议**:**主人已定(2026-09-08)**:主通道 = 主人自建**统一推送服务**(后续用于主人自己的项目);**回退 = 个推(Getui)**,仅当主人的推送服务出问题时启用。Cyrene 侧预留「推送出口接口/上报钩子」,按「自建服务优先 → 个推兜底」两级投递,Cyrene 不直接接 FCM。
- **优先级**:P2,但属**路线级缺失**,建议早登记。
### D2. 消息搜索 / 会话搜索
- **现象**:无搜索接口(`/admin/memories/search` 是记忆,不是消息)。
- **优先级**:P2
### D3. 消息编辑 / 删除 / 重发
- **现象**:无接口。
- **优先级**:P2
### D4. 未读状态 / 已读游标
- 见 B3。
- **优先级**:P2
### D5. 会话改名 / 置顶 / 排序
- 见 A4;`GET /sessions` 次级按 `created_at ASC`,改名/置顶需要新接口。
- **优先级**:P2
---
## E. 文档与契约治理
### E1. `X-API-Version` 形同虚设
- **现象**:服务端不校验该头;CORS `Allow-Headers` 只有 `Authorization, Content-Type`(未放开 `X-API-Version`)。
- **建议**:要么真正校验,要么从契约里去掉,避免客户端误以为存在版本协商。
### E2. 文档与实现不一致(调研发现)
| 事项 | 文档说法 | 实现 |
|---|---|---|
| 真实 ASR/TTS | design/21 §4「未接通」 | 已接百炼并实测 |
| `voice_input.deviceId` | design/21「待补」 | 已有 |
| REST 语音端点 | design/21「待新增」 | 已有 |
| 语音轮次落库 | api-client「随轮次落库」 | 未落库(见 C1) |
| 消息 `mode` | design/16 §5.1「消息体带 mode」 | ✅ **已落库**(后端 `b686d5f`)——⚠️ 本行原写「不落库(见 A3)」是**过时快照**,与本文 `A3` 行的「已完成」自相矛盾,且**该错行曾被后端会话当作依据转述一次**(见「回执五」补记第 5 条)。已按代码更正 |
| 来源标识注入 | design/16 §4.6.1「文本前缀」 | 独立 `RoleSystem` 来源消息 + 结构化 `SenderName`;**把 `[来源: …]` / `[用户分享]` 拼进正文即违反红线**(与本文 `F4` 一致) |
- **建议**:以 `api-reference.md` + 代码为准,`api-client.md` 随实现同步(AGENTS.md 已有此红线)。
- **✅ 后端处置(2026-10-04)**:`E2` 已在其侧落地——在 `design/21`、`design/16` 对应段落各加一条
`> **实现现状(2026-10-04)**:…` 注记(**保留原文不重写**,便于对照「设计本意 vs 现实」)。
- **⚠️ 教训(后端会话提出,值得记牢)**:**文档漂移会被人当作依据再传播一次**——上面那行 `mode`
就被转述过一回,直到子代理核代码才反过来纠正。**所以发现漂移要当场改掉,只写一句
「以代码为准」是不够的。**
### E3. `thinking` 可达性确认 —— ✅ 已答复:**有意为之,不改行为**
- **现状**:WS 实时/回放已剥离,只有 `GET /sessions/{id}/messages`(owner/admin)返回。
- **客户端影响**:历史消息的思维链需要额外请求。
- **✅ 后端答复(2026-10-04)**:这是**有意的隐私默认**,**行为不改**;只把口径写进契约
(「历史可查、实时不回放」,随 **v0.59** 登记)。客户端照此实现即可,无需再等。
### E4. `auth` 系列文档与实现不一致(2026-10-04 核实,B2 接入前发现)
- **`/auth/logout` 的响应形状三处互相打架**:
- `api-reference.md:196` 写 `{"ok":true,"revokedCount":<n>}`
- `api-client.md:26` 写「返回 `{revokedCount}`」
- **代码实际**是两种形状:body 走 `{refreshToken}` 撤单个 → `{"revoked":<bool>}`(**既无 `ok` 也无 `revokedCount`**);
带 `Authorization: Bearer` 全撤 → `{"revoked":<bool>,"revokedCount":<n>}`
(`internal/services/gateway/auth_refresh.go:122` 与 `:141`)
- **`/auth/refresh` 的响应实际带 `role`**(`auth_refresh.go:29,90`),但两份文档的响应串都漏了它。
- **`api-reference.md:178` 已过时**:写「登录响应以代码为准 = `{token, expiresAt, role}`」,而代码早多出 `refreshToken`(`rest.go:99`);`api-client.md:419` 的一次性对接段落同样漏了。
- **客户端为什么要**:严格按文档解析 `/auth/logout` 会解不出来。客户端目前的绕行是**忽略响应体**,只看 2xx(客户端此前根本没调过该端点——登出只清本地,见 B2)。
- **后端建议**:以代码为准修这三处文档;`/auth/logout` 的响应形状要么统一成文档写的,要么把文档改成实际形状。
- **优先级**:低(文档治理;不影响功能,客户端按「忽略响应体」绕开)
### E5. `api-reference.md` 的会话端点章节已过时且缺章(2026-10-04 核实,会话列表升级前发现)
- **§3.2 已过时**:`api-reference.md:250-260` 仍写 `GET /api/v1/sessions` 返回 `{id,type,title?,createdAt}`
四字段,而**代码实际**还恒返回 `pinned`、`unreadCount`,并按需返回 `lastMessage{role,content,createdAt}`
与 `personaId`(`internal/services/gateway/history.go:18-50`)。
- **缺章节**:`PATCH /api/v1/sessions/{id}`、`POST /api/v1/sessions/{id}/read` **只在端点表里有一行**
(`api-reference.md:72`),**没有对应的 §3.x 详情章节**(请求体、响应形状、错误码、部分更新语义、
「title 无法清空」等都不在里面)。
- **权威形状只在 `api-client.md`**:`:29`(排序与字段)、`:35`(PATCH 语义)、`:36`(read)、`:488`(unreadCount)。
- **排序描述也有坑**:代码是 `pinned DESC, (type='main') DESC, created_at ASC`
(`internal/runtime/history/session_preview.go:44`)——**置顶的次级会话可以排在主会话前面**,
不是「主会话恒首位」。客户端若按「index 0 就是主会话」写会出错。
- **`02` 自身也需要更正**:本文 B3 行附近曾写「`unreadCount` 后置需已读游标」,实际 **v0.37 已实现**(D4 不再被后端阻塞)。
- **后端建议**:补 `PATCH`/`read` 的 §3.x 章节、把 §3.2 的响应字段补全、把排序规则写准(含「置顶次级可在主会话前」)。
- **优先级**:低(文档治理;客户端以代码 + `api-client.md` 为准)
### E6. `.env.example` 与实现不同步(2026-10-04 精确对比)
- **对比方法**:代码侧取 struct tag `env:"…"` + `os.Getenv/LookupEnv("…")` + 自定义 env helper;
模板侧取 `.env.example` 的**去注释行**(`# KEY=` 也算收录)。结果:**代码使用 123 个键,模板收录 86 个**。
- **差集里真正属于应用配置的是这几族**(请补进 `.env.example`):
- **`CHANNEL_*` 一族**(最多的缺口):
`CHANNEL_TELEGRAM_BOT_TOKEN` / `CHANNEL_TELEGRAM_WEBHOOK_URL`;
`CHANNEL_WEBHOOK_{SECRET, ENDPOINT_PATH, CHANNEL_ID, CHANNEL_NAME, SIGNATURE_HEADER}`;
`CHANNEL_WEIXIN_ILINK_{BOT_TOKEN, BOT_ID, OWNER_ID, BASE_URL, CDN_BASE_URL}`;
以及 **v0.55 changelog 明确登记过的** `CHANNEL_QQ_OBV11_{TRANSPORT, API_BASE_URL, REPORT_PATH}`
(同批的 `MODE` / `REMOTE_URL` / `LISTEN_ADDR` / `SELF_ID` / `ACCESS_TOKEN` 也缺)。
- `CAPACITY_ENABLED` / `CAPACITY_MIN_CONFIDENCE` / `CAPACITY_TOOL_SETS`(**疑似旧键**,请确认是否仍有效)。
- `MCP_{ENABLED, TRANSPORT, URL, COMMAND, ARGS, API_KEY, ENV, NAME}` / `MCP_HELPER_TOKEN`
(**疑似「单 server env 模式」**,请确认)。
- **差集里的噪声(不用追)**:`APPDATA` / `XDG_DATA_HOME`(OS 变量,用于数据目录兜底)、
`GO_WANT_MCP_STDIO_HELPER`(测试用的 re-exec helper)、`NAME` / `X` / `Y`(我方提取器误命中,非配置键)。
- **澄清(我方此前误报,已更正)**:`LLM_MULTIMODAL`、`LLM_REASONING_KIND`、`CHAT_IMAGE_MAX_BYTES`、
`GUARDRAIL_KEYWORDS`、`OWNER_IDENTITY_INJECTION`、`CONTEXT_MAX_TOKENS` **均已收录**——
我先前用 `^KEY` 锚点判定时,把**注释形式**(`# KEY=`)的条目误判成缺失了。
- **后端建议**:把「**`.env.example` 与实现同步**」列进**升版时的固定动作**,与「改帧 / 字段就同步
`api-client.md`」同一类;若 `CAPACITY_*` / `MCP_*` 那几族确已废弃,请**明确删除或标注为旧键**,
别让新部署照着漏配。
- **优先级**:低(文档治理;不影响功能,但会让照模板部署的人漏配)
---
## 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 同批)
### F3. 上传图片的 URL 在自部署链路里**取不到** —— B1 的端到端可用性存疑(含一处契约违背)
- **实测到的生效配置(2026-10-04,走 `GET /admin/llm`,密钥不回显)**:
`provider: anthropic` / `model: deepseek-flash` / **`multimodal: false`** / `enabled: true`
(`baseUrl: https://api.deepseek.com/anthropic`)。
- **先排除一个疑点**:`anthropic_encode.go:143-146` 要求图片是 `data:` URI,而
`internal/runtime/model/` 下**没有任何 URL→base64 转换**——所以「把 URL 形式的 `chat.image`
交给 Anthropic 主模型」会 400。**但当前 `multimodal: false`,主模型根本收不到图片**
(图片走 vision/ocr 工具那条路),**这条在主人这套配置上不会触发**。
将来若把生效组换成 `multimodal: true` 的 Anthropic 协议组,它立刻变成真 bug。
- **真正的 bug(契约违背):vision 适配器把 URL 转给上游,而不是自己取回。**
`vision/adapter.go:166-171` 的 `imageURL` 把 http(s) URL **原样转给 provider**,
而 `POST /upload` 返回的是**同源绝对 URL**(`upload.go:330-371`,形如
`http://<局域网IP>:10942/api/v1/files/<name>`)。自部署在局域网时,
**云 provider 根本 fetch 不到这个地址** → 上传成功、再把 URL 当 `chat.image` 发,视觉链路会失败。
**而 B1 存在的全部意义就是让大图绕开 WS 4096 字节帧上限**,所以这条直接决定 B1 是否真能用。
- **后端建议**:二选一——
① 服务端**自己取回**同源 URL 再转成 `data:` 交给 provider。注意现成的
`vision/image.go:74-99 fetchURL` 有个附属问题:**非图片响应会被静默改标成 `image/png`**
(`image.go:145-148`),取回路径要顺手修,别把一个错的内容类型喂给视觉模型;
② 或让 `chat.image` 支持「服务端文件引用」而非裸 URL,由服务端解析。
- **对照现状(最容易误判的地方)**:客户端目前发小图走 **base64 data URI**——`image` 字段本就
两种形式都收(`vision.go:57-79`),这条路**不经过 provider 取 URL,所以能work**。
于是形成「**小图能用、大图(正是 B1 要解决的场景)不能用**」的组合。B1 被标成「已完成」
容易让人以为图片上传整条链已经通了。
- **待实测确认**:真发一条 `chat.image = <upload 返回的 URL>`,看视觉链路是否失败。
客户端侧至今从未调用过 upload,所以这一层从来没有被端到端验证过。
- **文档/实现不一致**:`DevDocs/02-development-constraints.md:159` 写「文件上传限 50MB 且限类型」,
而实现是**硬 5 MiB**(`upload.go:20-28` 的 `maxUploadBytes = 5 << 20`,整体请求体 6 MiB)。
未找到任何可覆盖该常量的配置项——**50MB 今天不可达**。
- **安全/运维事实(设计使然,但要知道)**:
- `GET /api/v1/files/{name}` **完全不鉴权**(`routes.go:82` 没挂任何中间件)——128-bit 随机名
就是凭据,因为 `<img>` / `chat.image` 带不了 Bearer(`upload.go:133-137`、`api-client.md:113`)。
**分享出去的图片,谁拿到 URL 谁就能取。** 路径穿越防护是齐的(`upload.go:304-323` 等)。
- 上传**无 TTL / 无配额 / 无清理**(全仓仅 gateway 那几处提到 `uploads`),`<dataDir>/uploads/` 会无限增长。
- 上传鉴权为 `owner|admin`。
- **优先级**:**高**(原写「中」;若实测确认取不到,B1 在**主人的自部署形态下等于没做**)
### F4. 提议的 `[用户分享]` 正文前缀**违反项目自己的红线**,应改用既有约定
- **项目既有约定(已实现,不是设计稿)**:
- **来源**是**独立的 `RoleSystem` 消息**,不是正文前缀:`channelbridge/source.go:25-35` 生成
「当前会话来源:…」并由 `buildLoopMessages` 前插(`inbound.go:330-349`)。
⚠️ 注意这与 `design/16 §4.6.1` 的「文本前缀」写法**相悖**,本文 `E2` 已记录该分歧。
- **说话者身份**走**结构化字段** `model.Message.SenderName`(`model.go:77-83`、`inbound.go:323-328`),
源码注释写得很明确:*「文本标签既含糊,又构成提示词注入面」*。
`loop/attribution.go:17-27` 同理:*「归属元数据绝不进入消息正文」*。
- **红线**:`design/11 §10.2` / `AGENTS.md` ——**用户内容永远只进 user 角色消息**,
不把用户正文拼进任何 system 段。
- **结论**:把 `[用户分享]` 拼进 `content` 既违反上述红线,又与已实现的约定相悖。
- **建议**:来源信息走**独立来源 system 消息**(与 `originSystemText` 同形)或已有的
`deviceId` / `platform` 元数据(二者本就按消息持久化);**人类可见的**「来自分享」标记放在
**客户端渲染层**。另注:任何这类标记只是呈现,**身份/信任绝不从提示词文本推导**。
### F5. `PATCH /sessions/{id}` 的 400 文案会误导(小)
- **现象**:请求体里 `title` **存在但为空**(或全空白)时,服务端回 **400 `title is required`**
(`internal/services/gateway/history_write.go:82-86`)——文案读起来像「缺字段」,
实际是「字段在、但值非法」。客户端按文案排查会去找「我是不是没传 title」,方向就错了。
- **另一个后果(不是 bug,但客户端必须知道)**:因此**标题无法被清空**(`""` 一律 400),
也就是说「恢复自动命名」这个操作服务端表达不了——客户端**不得**提供该入口。
- **后端建议**:文案区分两种情况,例如字段存在但空白回 `title must not be blank`,
字段缺失仍回 `title or pinned is required`。
### 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 侦察发现)
### G1. A1 的「现象」**后端已修复**,约束如今只存在于客户端解析器
- **⚠️ 本条是 A1 的现状更新:A1 正文写的「这些帧没有 `sessionId`」已过时。**
- **后端现状(v0.36 起)**:结构化流帧 `message.*` / `thinking.*` / `tool_call.*` /
`tool_execution.*` **已带 `sessionId` + `turnId` + `streamId`**(打戳处 `internal/runtime/loop/stream.go:66-82`,
上帧处 `ws_hub_turn.go:136`);`stream_end` / `stream_chunk` / 状态帧同样带 `SessionID` + `TurnID`。
`api-client.md:525`(v0.36 变更条目)记录的就是这件事。
(顺带:`api-reference.md:1142` §6.6 仍写「OnStream 只填 `seq`、不带 turnId」,**也是错的**。)
- **客户端现状**:`WsEvent.parse`(`WsEvent.kt:332-342`)在构造这些事件时**把 shell 上的 `sessionId`
丢掉了**——数据类里根本没这个属性。所以 A1 的串台风险**现在纯粹是本端解析器造成的**。
- **加重项(已核实)**:`ChatScreen` 用 `viewModel(key = sessionId, …)` 建 VM,而**每个 VM 各自建一条
`ChatSocket`**;切会话后**旧会话的 socket 与 VM 仍在收全量广播**(广播是发给**每条连接**、不按会话
订阅,`ws_hub.go:167-182`),于是旧 VM 也会跑流式状态机——不修就是长期串台。
- **建议(客户端)**:把 `sessionId` 透传进这些事件类,并在 `onEvent` 里按会话过滤(与 `2a49c25`
修 `history` 同一手法)。顺带即可**退役 D14「同一时刻只有一个会话在流式」**。
- **✅ 已落地(2026-10-04)**:透传 + 过滤都已做;D14 退役。**D14 从来只是文档约定**——代码里
既没有阻止切会话时流式的闸,`ChatSocket.sendInterrupt` 也**没有任何调用方**(死 API)。
遗留:旧会话的 socket 仍随 VM 留着不关——每个 `ChatScreen` 的 VM 各建一条 `ChatSocket`,
而 VM 存在 Activity 级 `ViewModelStore` 里,**删会话也不回收**(本次刻意未改,属连接所有权重构)。
- **优先级**:~~中高~~ 已完成
### G2. `POST /chat` 的 `stream:true` 走**有损总线**且**先回 200** —— 可能静默什么都没发生
- **现象**:`stream:true` 的链路是 `publishChatStream` → **`bus.Publish`** → hub 订阅后 `streamChat`
→ orchestrator(`rest.go:181-185`、`ws_hub_turn.go:168-173,22-26,47-73`)。而 **WS 的 `chat` 帧刻意
绕开总线**正是为了避免丢事件(`ws_route.go:44-49,160-171`)。总线缓冲满时会**丢事件**
(`bus/impl.go:81-85`),且 `rest.go` **在询问 orchestrator 之前就回了 `200 {"stream":true}`**
(`rest.go:181-185`)——于是**客户端拿到 200,但不落库、不会有任何帧**。
另有:`bus.Publish` 在调用方 ctx 已取消时直接返回,而 `publishChatStream` 传的是 `r.Context()`
且**丢弃错误**(`ws_hub_turn.go:172`)。
- **客户端为什么要**:长文与图片**只能**走 REST(WS 帧硬上限 4096 字节,且**超限是直接断连**,
`ws_hub.go:25` + `ws_hub_conn.go:63-88`),所以必须依赖这条路径——但它的成功回执**不可信**。
- **客户端绕行(已定)**:REST 回 200 后**不当作已受理**;以「`message_persisted(user)` 或
`session_busy` 是否在若干秒内到达」为准,超时给重试入口,但**绝不自动重试**(见 G4)。
- **后端建议**:REST 的流式入口应与 WS 一样绕开总线(或 `Publish` 失败时回非 2xx),
别让「总线丢事件」表现为「静默成功」。顺带:`stream:true` 永远不会回
`202 {queued,queueDepth}` 或 `409 chat_busy`(`rest.go:181-185` 提前返回),
忙/满只能从 WS 的 `session_busy` / `pending_ack` / `pending_full` 观察。
- **优先级**:中高
### G3. base64 图片会被**原样落库**,于是每页历史都背着它
- **现象**:客户端发来的 `image` 字符串原样存进 `chat_messages.image`
(`session_persist.go:66-70`、`history/store.go:282-286`),`model.ImageMessage` 也原样保留
(`model/model.go:100-107`)。于是**每次 `GET /sessions/{id}/messages?limit=50` 与每次 WS
`history` 回放都会把完整 base64 再传一遍**——一张 5 MiB 的图 ≈ 每页 6.7 MB。
且 `image` 在 API 层**完全无长度上限**(`vision/image.go:58-72` 直接 base64 解码整个字符串)。
- **而 `POST /upload` 恰好能避免这个**(只存 URL)——但它今天不可用,见 **F3**。
- **客户端绕行(已定)**:**发送前先缩放 / 重编码再 base64**,把它当已知成本。
- **后端建议**:`image` 落库改为存引用而非整串;或加上限并回明确错误(而不是无声地吃下几 MB)。
- **优先级**:中
### G4. 全链路**没有任何幂等机制**(无 `clientMsgId`)
- **现象**:全仓 grep `clientMsgId` **零命中**;`history.insert` 每次新铸 id 并插入
(`history/store.go:265-291`)。同一段文本发两次 = 两条用户消息 + 两个回合。
- **客户端为什么要**:G2 的「静默未受理」与服务端**没有写超时**(`server.go:154`)+ 客户端 60s 读超时,
两者都会把人逼向重试;**没有幂等,重试就是重复对话**。
- **后端建议**:`chat` 增可选 `clientMsgId`,服务端据此去重并把它回填到消息上——**顺带能解掉
客户端「靠文本相等归一化乐观消息」这种脆弱匹配**(`ChatViewModel.kt:320-324`)。
- **客户端绕行(已定)**:不自动重试;重试前先用文本 + 时间与本地待发列表去重。
- **优先级**:中
### G5. `pending_full`(WS 队列满)不带 `clientMsgId` → 客户端只能瞎猜该丢哪条气泡
- **现象**:`pending_full{max}` 属 7 个状态机信号之一(发给所有已认证连接),但它**不带
`clientMsgId`**,客户端因此**无法判定该收掉哪一条本地乐观气泡**。
- **客户端为什么要**:现状是**不处理**(本次接入 REST 时确认的既有缺口)⇒ 队列满时那条乐观气泡会
**永远挂在「发送中…」**。要正确处理,必须能把该帧与某一条待发消息对应起来;否则只能凭猜。
- **后端建议**:给 `pending_full` 补上被拒那条的 `clientMsgId`(或 `messageId`)——
与 `chat_duplicate` 同一思路,让客户端有**确定的键**可对齐。
- **当前绕行**:无。**REST 路径**有 `409 chat_busy` 可以回滚,但 **WS 路径没有等价物**。
- **优先级**:中(REST 接入后,两条路径的失败处理能力出现了不对称)
---
## H. 成本 / 计费(2026-10-04 主人要求登记,供后端讨论)
### H1. 同一张图**每个回合都被重新解析一次** —— 视觉模型重复付费,且无缓存
- **现象(已核实到代码,非推测)**:
- `resolveImages` 在**每个回合**装配消息时运行:`internal/runtime/loop/run.go:70`
(回合内 steer 路径见 `internal/runtime/loop/steer.go:95`)。入参 `msgs` 来自
`l.loadCheckpoint(ctx, sessionID, messages)`——**每回合从 checkpoint 重建**,紧接着就解析图片。
- `MessageResolver`(`internal/runtime/multimodal/vision/resolver.go:25-29`)**没有任何缓存**:
结构体只有 `router` / `info` / `invoke`;在 vision 包与该文件内 grep
`cache|memo|once|sync.Map` **零命中**;构造点全仓只有一处(`cmd/cyrene/wire_runtime.go:215`)。
- 解析结果是**请求期改写、不落库**(库里存的仍是原图字节),所以下一回合无从复用。
- **后果**:在 `multimodal: false`(本人这套配置:`provider: anthropic` / `deepseek-flash` /
`multimodal: false`,2026-10-04 实测)下走 `KindVisionTool`(`resolver.go:86-90`),
**每个回合都对上下文里的每一张图重新调一次视觉模型**去产出同一段描述 ——
一张图在上下文窗口里留 N 个回合,就付 N 次视觉调用。放大项:`CONTEXT_MAX_TOKENS` 默认 32768,
图能留很久;图越多越线性放大。
- **⚠️ 两件事不要混**(本人此前也差点混了):
- **编码方式(base64 vs URL)只影响字节 / 库容量**(base64 约 +33%,见 G3);在 `multimodal: false`
下它**不影响主模型 token**——主模型收到的是**文字描述**,不是图本身。
- **本条讲的是重复调用**,与编码方式无关:URL 那条路同样会重新取回、重新描述。
- **后端建议(择一或组合,请后端裁量)**:
1. **按内容哈希缓存**解析结果(进程内 LRU 即可)——命中直接复用文字描述;
2. **把描述回写进上下文**(首次解析后即以文字部件替代图像部件),后续回合不再解析;
3. 或至少**按回合去重**:`resolveImages` 现在对整份 `msgs` 逐条无条件解析,同一 `Run` 内
同一张图没有 memo。
- **客户端为什么要**:这不是客户端能绕开的——重试、取消、切会话都不改变「同一张图被反复解析」。
且 `AdminMetrics` 明确声明**不统计、不返回 token / 成本**(「不伪造指标」),所以
**从 API 侧完全看不出来**,只能靠 provider 账单或另加埋点发现。
- **当前绕行**:无(客户端对该路径零影响力)。
- **未查清的边界**:只验证到 vision 包与 loop 这一层无缓存;其调用的工具实现(`ToolInvoker` 背后)
是否自带缓存**未查**——可能性低,但请后端一并确认。
- **⚠️ 重要更正(2026-10-04,据 DeepSeek 官方文档的本地副本
`DevDocs/reference/deepseek/guides-vision.md`,抓取于 2026-10-03)——本条的前提可能只是配置问题,
且图像 token 有硬上限**:
- **`deepseek-flash` 本身支持图片输入**(该文档首句:「`deepseek-flash` 模型支持在文本之外输入图片」)。
而本人这套配置是 `multimodal: false` —— **一个能识图的主模型被降级走视觉工具**,
这正是 H1「每回合重调视觉模型」的**根因**。**请后端确认该标志是否真为有意为之**;
若置为 `multimodal: true`(走 `KindMain`),**H1 在结构上直接消失**(`resolver.go:75-85` 不调任何工具)。
- **图像 token 有硬上限且与分辨率无关**:文档「Token 用量」节——每张图进模型前自动缩放
(小于约 544×544 放大,更大者缩到约相当于 **1300×1300** 的像素),**故每张图最多 1024 token**,
且「2000×2000 与 5000×5000 经缩放后消耗的 token 数相同」。⇒ 在 32768 默认预算里约占 **3%**。
**「一张大图会占掉可观比例」是我此前的夸大,此处更正。**
- **据此重估两条路**:`KindVisionTool` = 每回合**多一次完整模型调用**(那次调用自身还要带图 + prompt + 输出);
`KindMain` = 每回合多约 ≤1024 输入 token、**零额外调用** ⇒ **`KindMain` 更省**。
- **顺带(给后端的现成旋钮)**:文档还提供 `detail: low`(缩到 512×512、更省 token)——
但该字段由**服务端构造内容块时**决定,客户端无法设置。
- **仍成立的**:`data:` 与 URL 在**取回**上的差异(见下),以及库 / 历史字节成本。
- **客户端缩放的价值需下调**:既然 provider 自己会缩到 ~1300×1300 并把 token 封在 1024,
**客户端缩放主要省的是「库与历史字节、5 MiB 上限、上传带宽」,不再是为省 token**——
我此前称它是「降 token 的高杠杆点」,一并更正。
- **另请后端注意(同文档「限制 / 使用限制」节,与本链路的既有实现有交互)**:
- 图片**只能出现在 `user` 消息**里;`system` / `assistant` 带图会回 **400**
——历史重装配时若把图像部件落到非 user 角色,会直接把回合打挂。
- 请求体上限 **48 MiB**;单图 base64 / URL 路由上限 **32 MiB**;URL 长度 **≤8192 字符**;
单边最大 **8192 像素**(单请求 ≥15 张图时降为 4096)。这些都比 Cyrene 自己的 5 MiB 上限宽,
所以**当前真正的瓶颈是 Cyrene 的 5 MiB(G3),不是 provider**。
- **另请后端一并考虑**:① 把「取回 + 内联」的结果**按内容哈希 memo**,避免每回合重取;
② 若走 `KindMain`,确认**消息装配顺序能否命中 provider 的 prompt 前缀缓存**——
图像长期留在上下文时,前缀缓存是把它变便宜的主要手段(**此条未核实**,取决于装配顺序)。
- **优先级**:中高(若「`multimodal: false` 是误配」成立,则**改一个配置项即可消除整条重复付费**——
所以先请后端确认该标志的意图,再谈缓存与 memo)
---
## 附:状态一览
| 编号 | 事项 | 状态 | 优先级 |
|---|---|---|---|
| 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`**(见本文 §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 消息)。**这是我们的裁决项,定论后回这里补。**