From 98b2274e684b5f253e20dc3c64678868f1a0eae0 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 5 Oct 2026 11:13:28 +0800 Subject: [PATCH] =?UTF-8?q?docs(devdocs):=20=E4=BA=8C=E6=AC=A1=E7=98=A6?= =?UTF-8?q?=E8=BA=AB=E2=80=94=E2=80=9402=20=E9=87=8C=E5=B7=B2=E7=A1=AE?= =?UTF-8?q?=E8=AE=A4=E5=AE=8C=E7=BB=93=E7=9A=84=E7=99=BB=E8=AE=B0=E6=9D=A1?= =?UTF-8?q?=E7=9B=AE=E6=95=B4=E5=9D=97=E5=BD=92=E6=A1=A3=EF=BC=8C05=20?= =?UTF-8?q?=E6=94=B6=E5=9B=9E=E4=B8=A4=E6=AE=B5=E7=BA=AA=E5=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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。 --- devdocs/02-archive-历史回执.md | 407 ++++++++++++++++++++++++++++++++ devdocs/02-前后端协同事项.md | 408 +-------------------------------- devdocs/05-archive-历史批次.md | 19 -- devdocs/05-开发进度与交接.md | 23 ++ 4 files changed, 431 insertions(+), 426 deletions(-) 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`** —— 这里说的「上面各批次」都在那份里,按原文顺序保留;本节的各锚点仍在主文件。 ### 状态