diff --git a/devdocs/02-archive-历史回执.md b/devdocs/02-archive-历史回执.md
index ae8c54c..1f4be46 100644
--- a/devdocs/02-archive-历史回执.md
+++ b/devdocs/02-archive-历史回执.md
@@ -1,5 +1,412 @@
> **归档**:本文件由 `02-前后端协同事项.md` 拆出,存放 2026-10-04 ~ 10-05 的**历史回执**与同时段**已闭环的登记**(原样搬运、按时间顺序,只读追溯)。
> 当前状态、状态一览表、仍未闭环的登记与最近几条往来,都在 `02-前后端协同事项.md`。
+> 另:2026-10-05 二次瘦身时,主文件里**已确认完结的登记条目**(A~E、F3~F5、G1~G4、H)已整块搬来,按原顺序排在下文最前面;`## F.` / `## G.` 两个主标题与仍未闭环的条目(F1/F2/F6、G5)仍留在主文件。
+
+## 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 才解析 `
`」这条规则依赖它。
+- **后端建议**:持久化时写入 `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=` 补发,或提供 `GET /sessions/{id}/messages?after=` 正序增量接口。
+- **当前绕行**:重连后 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":}`
+ - `api-client.md:26` 写「返回 `{revokedCount}`」
+ - **代码实际**是两种形状:body 走 `{refreshToken}` 撤单个 → `{"revoked":}`(**既无 `ok` 也无 `revokedCount`**);
+ 带 `Authorization: Bearer` 全撤 → `{"revoked":,"revokedCount":}`
+ (`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_*` 那几族确已废弃,请**明确删除或标注为旧键**,
+ 别让新部署照着漏配。
+- **优先级**:低(文档治理;不影响功能,但会让照模板部署的人漏配)
+
+---
+
+### 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/`)。自部署在局域网时,
+ **云 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,所以这一层从来没有被端到端验证过。
+- **文档/实现不一致**:`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 随机名
+ 就是凭据,因为 `
` / `chat.image` 带不了 Bearer(`upload.go:133-137`、`api-client.md:113`)。
+ **分享出去的图片,谁拿到 URL 谁就能取。** 路径穿越防护是齐的(`upload.go:304-323` 等)。
+ - 上传**无 TTL / 无配额 / 无清理**(全仓仅 gateway 那几处提到 `uploads`),`/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`。
+
+### 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`)。
+- **客户端绕行(已定)**:不自动重试;重试前先用文本 + 时间与本地待发列表去重。
+- **优先级**:中
+
+## 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)
+
+---
## 回执(后端会话,2026-10-04 · 接 `07-给后端会话的交接.md`)
diff --git a/devdocs/02-前后端协同事项.md b/devdocs/02-前后端协同事项.md
index 82bdc0e..8b05f04 100644
--- a/devdocs/02-前后端协同事项.md
+++ b/devdocs/02-前后端协同事项.md
@@ -58,229 +58,6 @@
---
-## 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 才解析 ``」这条规则依赖它。
-- **后端建议**:持久化时写入 `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=` 补发,或提供 `GET /sessions/{id}/messages?after=` 正序增量接口。
-- **当前绕行**:重连后 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":}`
- - `api-client.md:26` 写「返回 `{revokedCount}`」
- - **代码实际**是两种形状:body 走 `{refreshToken}` 撤单个 → `{"revoked":}`(**既无 `ok` 也无 `revokedCount`**);
- 带 `Authorization: Bearer` 全撤 → `{"revoked":,"revokedCount":}`
- (`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「分享 → 昔涟」,带分享标记(如 `[用户分享]`),不超限走文本/图像,
@@ -320,66 +97,6 @@
- **唯一可行绕行**:**客户端先抽正文,再当文本发**。
- **优先级**: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/`)。自部署在局域网时,
- **云 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,所以这一层从来没有被端到端验证过。
-- **文档/实现不一致**:`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 随机名
- 就是凭据,因为 `
` / `chat.image` 带不了 Bearer(`upload.go:133-137`、`api-client.md:113`)。
- **分享出去的图片,谁拿到 URL 谁就能取。** 路径穿越防护是齐的(`upload.go:304-323` 等)。
- - 上传**无 TTL / 无配额 / 无清理**(全仓仅 gateway 那几处提到 `uploads`),`/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 ⇒ 换服务器地址后旧图取不回**
@@ -407,65 +124,6 @@
## 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`**,客户端因此**无法判定该收掉哪一条本地乐观气泡**。
@@ -478,70 +136,6 @@
---
-## 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)
-
----
-
## 附:状态一览
| 编号 | 事项 | 状态 | 优先级 |
@@ -557,7 +151,7 @@
| 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) | 中 |
+| 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(路线) |
diff --git a/devdocs/05-archive-历史批次.md b/devdocs/05-archive-历史批次.md
index c408702..098c345 100644
--- a/devdocs/05-archive-历史批次.md
+++ b/devdocs/05-archive-历史批次.md
@@ -29,22 +29,6 @@
(Coil 网络 fetcher 正常)→ 且**模型真的读懂了图**(那张图是模拟器截图,她逐条点评了图里的设置项)。
⚠️ 未变:`url` 绑死请求时的 `r.Host`(换服务器地址后旧图取不回,`02/F6①`);上传产物**无删除端点**(`02/F7`)。
-**本轮新增约定 / 坑(下个会话必读)**
-
-- **worktree 并行必须给绝对路径**:W1 曾用相对路径编辑,结果写进了**主树**(10 分钟),
- 靠「worktree 构建在改动后仍报 `UP-TO-DATE`」这个反常信号才发现。**派 agent 进 worktree 时,
- 任务书里必须写明「只用绝对路径」**。(当次已自检+窄回退,我复核主树逐字节等于 HEAD,无损伤。)
-- **删 worktree 目录别用 git 自带的**:Windows 会报 `Filename too long`(`MAX_PATH`,Gradle 的
- `build/` 路径太深)⇒ 用 **Git Bash 的 `rm -rf`**,它能处理。
-- **`/upload` 产物的 URL 绑死当时的 host**(`r.Host` 拼绝对地址 + 原样落库)⇒ **换服务器地址后旧图取不回**。
- 已登记 `02/F6①`。**上传也不能突破 5 MiB**(`02/F6②`:那个上限只对 base64 形态成立,URL 受视觉层 50 MiB 约束)。
-- **`intent` 共享 socket 债又加一条**:`SessionsViewModel` 现在**自己持有一条 socket**(登录连、登出/
- `onCleared` 关)——因为 `AppContainer`/`ChatSocket` 归属别的 agent。收敛方向仍是**一条应用级共享 socket
- + 按会话路由**。
-- **`chat.documents` 契约形状**(v0.59,别提前写死):**数组 ≤5**(不是单对象 —— 单对象会让 1 回合 5 个附件
- 里 4 个从历史消失);响应/历史 DTO 为 `"documents":[{name,mime,size,chars,truncated,status}]`;
- `status:"unreadable"` 才是「昔涟读不到」的信号;**文档一律走 REST**;**只收服务端存储名、不收 URL**。
-
**下一步(顺序已定)**:① ✅ **已完成**(W3 上传路径实机验收,见上)→ ② **分享入口**(`ACTION_SEND` /
`ACTION_SEND_MULTIPLE` + `singleTop`/`onNewIntent` + pending-share 持有者 + 登录后落地)→
③ 搜索 / 编辑删除 / `mode` / `after` → ④ 概览「按路径」可读性 → ⑤ 超限走文件(按 v0.59)。
@@ -66,9 +50,6 @@
**最终 485 测试 / 0 失败 / 0 错误**(= 353 + 17 + 32 + 60 + 23,加法逐轮吻合——这正是「文件集互不相交」
成立的证据)。每轮我都自己读了 `app/build/test-results/testDebugUnitTest/*.xml` 求和,**不采信子代理自述**。
-**派 agent 进 worktree 的硬规矩(沿用上轮教训)**:任务书必须写明「**只用绝对路径**」(上轮有 agent 用
-相对路径把改动写进了主树);**不许**碰 adb/模拟器、**不许** push/merge、**不许**动构建文件
-(`build.gradle.kts` / `settings.gradle.kts` / `libs.versions.toml` / `local.properties`)。
- **A1 / D14 完成**(`d12a920`,2026-10-04):`WsEvent` 的所有结构化流式事件类**透传帧壳上的 `sessionId`**
(此前解析器把该字段丢掉),`ChatViewModel.onEvent` 用 `WsEvent.isForSession` **按会话过滤**——
`sessionId` 为空仍放行(行为与修复前一致)。回归测试 `data/ws/WsEventSessionScopeTest.kt`(+16)。
diff --git a/devdocs/05-开发进度与交接.md b/devdocs/05-开发进度与交接.md
index fd23547..0f4a9d2 100644
--- a/devdocs/05-开发进度与交接.md
+++ b/devdocs/05-开发进度与交接.md
@@ -207,6 +207,28 @@ ADB="C:/Users/Aska/AppData/Local/Android/Sdk/platform-tools/adb.exe"
| `Cyrene_v2/Docs/api-reference.md:1142`(§6.6) | OnStream 结构只填 `seq`、不带 turnId | 实际已打 `TurnID` 并上帧;`api-client.md:525`(v0.36)记的就是这事 |
| `Cyrene_v2/DevDocs/02-development-constraints.md:159` | 文件上传限 50MB | 实现是**硬 5 MiB**(`upload.go:24`),无配置项可覆盖(见 `02/F3`) |
+### worktree 并行:约定与坑(下个会话必读)
+**本轮新增约定 / 坑(下个会话必读)**
+
+- **worktree 并行必须给绝对路径**:W1 曾用相对路径编辑,结果写进了**主树**(10 分钟),
+ 靠「worktree 构建在改动后仍报 `UP-TO-DATE`」这个反常信号才发现。**派 agent 进 worktree 时,
+ 任务书里必须写明「只用绝对路径」**。(当次已自检+窄回退,我复核主树逐字节等于 HEAD,无损伤。)
+- **删 worktree 目录别用 git 自带的**:Windows 会报 `Filename too long`(`MAX_PATH`,Gradle 的
+ `build/` 路径太深)⇒ 用 **Git Bash 的 `rm -rf`**,它能处理。
+- **`/upload` 产物的 URL 绑死当时的 host**(`r.Host` 拼绝对地址 + 原样落库)⇒ **换服务器地址后旧图取不回**。
+ 已登记 `02/F6①`。**上传也不能突破 5 MiB**(`02/F6②`:那个上限只对 base64 形态成立,URL 受视觉层 50 MiB 约束)。
+- **`intent` 共享 socket 债又加一条**:`SessionsViewModel` 现在**自己持有一条 socket**(登录连、登出/
+ `onCleared` 关)——因为 `AppContainer`/`ChatSocket` 归属别的 agent。收敛方向仍是**一条应用级共享 socket
+ + 按会话路由**。
+- **`chat.documents` 契约形状**(v0.59,别提前写死):**数组 ≤5**(不是单对象 —— 单对象会让 1 回合 5 个附件
+ 里 4 个从历史消失);响应/历史 DTO 为 `"documents":[{name,mime,size,chars,truncated,status}]`;
+ `status:"unreadable"` 才是「昔涟读不到」的信号;**文档一律走 REST**;**只收服务端存储名、不收 URL**。
+
+### 派 agent 进 worktree 的硬规矩
+**派 agent 进 worktree 的硬规矩(沿用上轮教训)**:任务书必须写明「**只用绝对路径**」(上轮有 agent 用
+相对路径把改动写进了主树);**不许**碰 adb/模拟器、**不许** push/merge、**不许**动构建文件
+(`build.gradle.kts` / `settings.gradle.kts` / `libs.versions.toml` / `local.properties`)。
+
## 后端协同
见 `02-前后端协同事项.md`。开发中一旦遇到后端不支持或未实现,**当场写进该文件并反馈主人**。
@@ -278,6 +300,7 @@ curl -s -X PATCH "$BASE/api/v1/sessions/$SID" -H "Authorization: Bearer $TOK" \
## ★ 当前状态与待办(恢复锚点 · 2026-10-04 深夜)
> **压缩/换会话后先读这一节**,它是对「此刻到底在哪」的一句话答案;细节在上面各批次里。
+> ⚠️ **历史批次的细节(W4–W7 起各批)已移到同目录 `05-archive-历史批次.md`** —— 这里说的「上面各批次」都在那份里,按原文顺序保留;本节的各锚点仍在主文件。
### 状态