docs(devdocs): 二次瘦身——02 里已确认完结的登记条目整块归档,05 收回两段纪律

02-前后端协同事项.md:860 行 -> 454 行;02-archive-历史回执.md:1274 行 -> 1681 行
- 把已确认完结的登记条目整块搬进归档:A(A1~A5)、B(B1~B6)、C(C1~C3)、D(D1~D5)、E(E1~E6)、F3~F5、G1~G4、H1,共 8 块 30 条,按原顺序插在归档前言之后、回执一之前
- 主文件保留:标题与用途、顶部 P0 置顶块、归档说明、登记规范、仍未闭环的登记(F1/F2/F6 与 G5)、附:状态一览表、最近 5 条往来
- 唯一改动原文处:状态表 C1 行的指针「见本文 §C1」改成「见 02-archive-历史回执.md §C1」
- 归档前言新增一行,说明这批条目的来路,以及 F/G 两节的主标题与未闭环条目仍在主文件

05-开发进度与交接.md:601 行 -> 624 行;05-archive-历史批次.md:437 行 -> 418 行
- 从归档里捞回两段纪律(本轮新增约定/坑、派 agent 进 worktree 的硬规矩),放进「坑与勘误」节末尾,各加一个小标题
- 锚点节里加一行指针:历史批次的细节已移到 05-archive-历史批次.md

零丢失的证据
- 逐行多重集比对(对基线 b0b73d0):02 的非空行 1560 -> 1568,正好 +8,即新增的说明行;C1 那处是同一行整行替换,一删一增净零
- 05 的非空行 846 -> 851,正好 +5,即 2 个小标题 + 1 行锚点指针 + 2 行归档前言
- 除上述新增行与空行计数漂移外,行集合与原文件完全一致,无任何内容行丢失或重复
- 12 个代表字符串的 grep -c 拆分前后相等,含跨主文件与归档的 pinned DESC 排序串、834ef36、title must not be blank 三条
- 搬迁全程用 sed 按行区间原样切割、未改写文字;行尾统一 CRLF

未跑 Gradle(纯文档搬运),未 push。
This commit is contained in:
AskaEth committed 2026-10-05 11:13:28 +08:00
1 parent cb5fc0c605
commit 98b2274e68
4 files changed
+431 -426

No files matched your search

+407
View File
@@ -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 才解析 `<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_*` 那几族确已废弃,请**明确删除或标注为旧键**,
别让新部署照着漏配。
- **优先级**:低(文档治理;不影响功能,但会让照模板部署的人漏配)
---
### 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`。
### 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`)
+1 -407
View File
@@ -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 才解析 `<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「分享 → 昔涟」,带分享标记(如 `[用户分享]`),不超限走文本/图像,
@@ -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/<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 ⇒ 换服务器地址后旧图取不回**
@@ -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(路线) |
-19
View File
@@ -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)。
+23
View File
@@ -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`** —— 这里说的「上面各批次」都在那份里,按原文顺序保留;本节的各锚点仍在主文件。
### 状态