From e0e657d5eec06ab4595038acdbae1b0f5dd7afee Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 7 Oct 2026 21:25:11 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20devdocs/=20=E5=81=9C=E6=AD=A2=E5=85=A5?= =?UTF-8?q?=E5=BA=93=20=E2=80=94=E2=80=94=20=E4=B8=BB=E4=BA=BA=202026-10-0?= =?UTF-8?q?7=20=E6=98=8E=E7=A1=AE=E6=8C=87=E4=BB=A4=E3=80=8C=E6=95=B4?= =?UTF-8?q?=E4=B8=AA=E6=96=87=E4=BB=B6=E5=A4=B9=E7=A6=81=E6=AD=A2=E8=BF=9B?= =?UTF-8?q?=E5=85=A5=E8=BF=9C=E7=A8=8B=E4=BB=93=E5=BA=93=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - .gitignore 新增 ;本地文件一个没删,只是不再被 git 跟踪 - 此前它曾入库(2026-10-04 授权),历史里的旧副本不重写、也不再新增 - 本地 05 里那条「devdocs 本身是入库的」纪律已就地改正,避免后来人再 git add 它 --- .gitignore | 4 + devdocs/01-功能拆解与分步路线.md | 90 - devdocs/02-archive-历史回执.md | 1681 ----------------- devdocs/02-前后端协同事项.md | 754 -------- devdocs/03-客户端细化开发方案.md | 209 --- devdocs/04-构建环境.md | 138 -- devdocs/05-archive-历史批次.md | 418 ----- devdocs/05-开发进度与交接.md | 2402 ------------------------- devdocs/06-移到-PC-开发说明.md | 96 - devdocs/07-W50-主动消息-验收与避坑.md | 87 - devdocs/07-给后端会话的交接.md | 120 -- devdocs/DESIGN.md | 95 - 12 files changed, 4 insertions(+), 6090 deletions(-) delete mode 100644 devdocs/01-功能拆解与分步路线.md delete mode 100644 devdocs/02-archive-历史回执.md delete mode 100644 devdocs/02-前后端协同事项.md delete mode 100644 devdocs/03-客户端细化开发方案.md delete mode 100644 devdocs/04-构建环境.md delete mode 100644 devdocs/05-archive-历史批次.md delete mode 100644 devdocs/05-开发进度与交接.md delete mode 100644 devdocs/06-移到-PC-开发说明.md delete mode 100644 devdocs/07-W50-主动消息-验收与避坑.md delete mode 100644 devdocs/07-给后端会话的交接.md delete mode 100644 devdocs/DESIGN.md diff --git a/.gitignore b/.gitignore index 3669039..4b942ee 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,7 @@ captures/ .cxx/ # 主人从手机丢进仓库根当素材用的图(例如换图标用的原图)——只用不存,绝不进仓库 /IMG_*.png +# 开发文档 —— **主人 2026-10-07 明确指令:整个文件夹禁止进入远程仓库**。 +# 它只在本机磁盘上(前后端协同留言板、进度交接、设计拆解都在里面),靠磁盘本身交接。 +# 凭据同样绝不写进去(那类东西在 dev-secrets.local.md)。 +devdocs/ diff --git a/devdocs/01-功能拆解与分步路线.md b/devdocs/01-功能拆解与分步路线.md deleted file mode 100644 index 5bafa5e..0000000 --- a/devdocs/01-功能拆解与分步路线.md +++ /dev/null @@ -1,90 +0,0 @@ -# 01 — 功能拆解与分步路线(Cyrene-For-Android 讨论稿 v1) - -> 配套 `DESIGN.md`(总览)。本文按**分步**展开:每模块给出「做什么 / 交互 / 数据流 / 依赖 / 验收 / 边界 / Miuix 组件映射」。状态:**讨论稿**,与主人在本会话逐模块细化后定稿。 -> 前提(已定案):主界面=常规 AI APP;长按电源键=系统默认语音助手(VOICE_INTERACTION_SERVICE,全屏,无悬浮球);ASR/TTS 后端化;先纯文本版;UI 严格 Miuix(compose-miuix-ui)。 - ---- - -## 阶段 0 · 工程骨架 -**S0.1 工程搭建** -- Gradle(版本目录)、Kotlin + Compose、`compose-miuix-ui` 依赖、`BuildConfig.BASE_URL`(debug `http://:8080`,release 换 HTTPS)、单 Module `app`、包 `com.askaeth.cyrene.{app,core,data,domain,platform,ui}`。 -- 依赖:Retrofit+OkHttp、kotlinx-serialization、okhttp-WebSocket、EncryptedSharedPreferences、Hilt(可选)、Coil(图片)。 -- 验收:`./gradlew :app:assembleDebug` 通过,App 能起空壳 Miuix 首页。 -- 边界:暂不接后端。 - -## 阶段 1 · P0 纯文本聊天(零后端改动,契约现成) -### S1.1 登录(已定稿 2026-09-08) -- **服务器地址可配**(开源,各人部署地址不同):登录卡放「服务器地址」折叠项/入口,默认 `http://:8080`,用户可改;存本地(EncryptedPrefs/DataStore)。 -- **仅 `.env` 单管理员**(`ADMIN_USERNAME/PASSWORD`)可登录,无注册/找回;成功 `role=owner`。 -- **自动登录**:默认记住 token(EncryptedPrefs),重启免登;仅 `401/auth_expired` 才清 token 回登录。 -- **报错**:**已建立连接**(服务端有响应)时用服务端 `{error.message}`;**本地错误**(连不上/证书/DNS/超时)**由客户端本地文案负责**——不把本机错误误当服务端文案。 -- **密码默认隐藏**(可切换明文)。 -- **数据流**:`AuthManager.login(baseUrl,username,password)` → `POST {base}/api/v1/auth/login` → `{token,expiresAt,role}` → `EncryptedPrefs` 存(baseUrl+token+role+expiresAt)→ `HomeScreen`。 -- **状态机**:`Idle→Loading→Success(Home)|Error(msg)`(`error.from==="server"` 用后端 `message`,`"local"` 用本地文案);登录中禁用按钮防连点;登录失败不清已存 token。 - -### S1.2 会话列表(已定稿 2026-09-08) -- **主结构 = A**:启动**直接进主会话「与昔涟」(`web`) 聊天**;**顶栏汉堡 → 侧边栏**含【会话列表(切主/次级)+ IoT + App 设置 + 登出】。 -- **列表显示**:主会话="与昔涟",次级="会话 2/N"(按序号,title **自动**,零后端改动);无预览/时间(后端 `/sessions` 只有 id)。 -- **新建**:侧栏"+"新建次级(`POST /sessions` → `sessionId` → 切进去)。 -- **删除**:次级长按/滑出 → 确认 `Dialog` → `DELETE /sessions/{id}`;主会话禁删(提示"主会话不可删")。 -- **切换**:侧栏会话列表点选 → 切聊天页。 -- **数据流**:`GET /api/v1/sessions`(主会话 `web` 恒首位)。**依赖**:S1.1。**验收**:启动即主会话聊天;侧栏可切/建/删次级;主会话不可删;重启记住上次会话(可后续)。 - -### S1.3 聊天页:历史加载(已定稿 2026-09-08) -- **数据流**:`GET /sessions/{id}/messages?limit=50&before=&platform=` **倒序**返回,客户端**逆序渲染**(最新在底)。**`platform` 不传 = 看全量主会话**(a,能同步 web/kook/微信所有主会话消息)。 -- **分页**:初始 50 条;**上拉**加载更多用 `before=<最早时间>` 再往前翻(**50 步进**),追加顶部。 -- **消息来源标识**:每条消息**默认显示来源**(`platform`/`deviceId` 小字角标,如"来自 web" / "来自 kook" / "来自微信" / "来自安卓(voiceassist)")——主人既定诉求。 -- **时间**:**完全隐藏**(不显示时间戳/分组,对话沉浸)。 -- **空态**:主会话无消息 → 昔涟欢迎语。 -- **依赖**:S1.2。**验收**:进会话拉 50 条倒序展示;上拉翻 50/页;每条标来源;无时间戳。 - -### S1.4 聊天页:发消息 + 流式(已定稿 2026-09-08) -- **发消息**:WS `/ws/chat` 上行 `chat {sessionId,text,deviceId}`(**stream 流式**);本地乐观追加自己消息。 -- **流式渲染**:下行 `message.start` 开气泡 → `message.delta` **逐段追加**(气泡尾+光标)→ `message.end` 收口。 -- **流式期间新消息 = 两种模式(用户可切,默认「追加队列」)**: - ① **追加队列**(新消息进队列,流式结束后依次处理——豆包式); - ② **打断**(新消息触发 `interrupt`,保留残缺上下文 + 显示"打断"提示)。 -- **思维链 `thinking` / 工具卡 `tool_call`**:**默认展开**(方便观察思维;可折叠 + "总是收起"开关)。 -- **旁白 `/`**:灰斜体小字,**按出现位置依附气泡上/下**。 -- **`mood`**:显示情绪标签(可关)。 -- **渲染**:`

` 拆气泡;图片;忙态 `session_busy/pending_*` 提示;`409/202` 兜底。 - -### S1.5 侧边栏(汉堡抽屉) -- **做什么**:顶栏左上汉堡 → `Drawer` 侧边栏:当前会话/聊天、**[预留] IoT 入口**、**[预留] App 设置入口**、登出。 -- **数据流**:登出 → 清 token + 断开 WS → 回登录。 -- **验收**:汉堡展开侧栏;IoT/设置点开显示"规划中"(Miuix 空态);登出回登录页。 -- **边界**:侧栏入口预留(P0 占位不真做)。 - -### S1.6 401 / token 过期 -- **做什么**:REST 401 → `auth_expired` 清 token 停 WS → 回登录;WS 握手失败同理。 -- **验收**:JWT 过期或轮换后,下条请求自动登出重登,不无限 reconnect。 - -## 阶段 2 · P1 语音助手(后端 ASR/TTS,依赖本会话 TTS/ASR 进程接通后端) -### S2.1 系统默认语音助手 -- **做什么**:Manifest 声明 `VOICE_INTERACTION_SERVICE`/`AssistantService`(`android:role="Assist"`),用户在系统「语音助手/电源键」设本 App 默认 → 长按电源键由系统拉起本 App `AssistantActivity`(全屏)。 -- **依赖**:系统设置入口(MIUI 自家更顺);Android 版本/厂商适配。 -- **验收**:长按电源键 → 本 App 全屏助手打开(无悬浮球)。 - -### S2.2 全屏助手 UI(AssistantActivity) -- **做什么**:聆听动画 → 已识别文本(ASR 结果)→ 昔涟回复(流式)→ 重听/TTS;按钮:结束/放弃/再次说话。全程单页全屏,Miuix 玻璃质感。 -- **验收**:说话 → 文本上屏 → 昔涟回复 + 朗读。 - -### S2.3 语音闭环(后端 ASR/TTS) -- **链路**:录音(`MediaRecorder`/`AudioRecord`)→ **REST 上传到后端**(音频→ASR)→ 文本 → `POST /api/v1/chat {text,deviceId}`(或 WS)→ 昔涟回复 → **后端 TTS** 返回音频 → 本端播放。`voice_output`(WS)也可拿音频。 -- **依赖**:**后端 ASR/TTS 真接通**(本会话 TTS/ASR 进程:真实 STT/TTS provider + REST 语音端点绕 4KB + `voice_input.deviceId`)。 -- **验收**:对话 3 轮语音一问一答;IM 渠道(微信/KOOK)语音也走同一后端 ASR/TTS。 - -## 阶段 3 · P2 智能 -- **IoT**:设备/场景列表(Miuix;预留接口 `GET /api/v1/iot/*`)。 -- **App 设置**:主题(Miuix 深/浅)、通知开关、语音偏好(ASR/TTS 后端已定)、关于。 -- **主动推送**:后端 proactive/backthink → 系统通知;前台服务保活(可选)。 -- **多角色**:`GET /personas` 只读展示;切换人格。 -- **记忆/子代理/自主思考**:只读面板(`memory`/`subagent`/`backthink` 过滤器)。 - ---- - -## 跨模块约定 -- **deviceId**:`voiceassist:myapp:v1`(design/16 §4.5),所有 `chat`/`voice_input` 携带 → 落库标来源、跨端可辨。 -- **主会话同步**:Android 加入 `web` 主会话,与 Web/IM 共享上下文;渲染按 `platform` 区分"本端/他端"(design/16 §4.4)。 -- **Miuix 映射**:`TopAppBar`(汉堡/标题)、`Scaffold`+`Drawer`(侧栏)、气泡(自定义 `BubbleView` 或 Miuix 卡片+毛玻璃)、`TextField`、`Button`(发送/语音)、`Snackbar`/`Dialog`(确认/错误)。**禁原生 Material**。 -- **安全红线**:token 加密存储、401 即登出、权限最小化、无悬浮球、不打日志敏感。 diff --git a/devdocs/02-archive-历史回执.md b/devdocs/02-archive-历史回执.md deleted file mode 100644 index 1f4be46..0000000 --- a/devdocs/02-archive-历史回执.md +++ /dev/null @@ -1,1681 +0,0 @@ -> **归档**:本文件由 `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`) - -> 我的回复统一写在这里(devdocs 不进 git,我读得到也写得到)。**不改动你们任何既有条目**,只追加。 - -### 一、§二「`multimodal: false` 是设计还是漏配」—— 查清了:**都不是,是 env 路径的表达力限制** - -- 我这边的实际盘面:**`data/models.json` 不存在** ⇒ 生效组来自 `.env` 合成组(`GroupFromEnv`)。 -- 而 `GroupFromEnv` **恒定 `Multimodal: false`**,代码注释写得很明确(`internal/config/runtimeconfig/models.go:300-311`): - *翻转的 env 块无法声明主模型是否收图;**声称一个端点可能并不具备的能力,比把图交给视觉/OCR 工具更糟***。 - 该标志的**正式载体是 models.json 组**,由面板写入(design/06 §0 首条 / 裁决 14.4.1)。 -- ⇒ **两边都对**:模型确实能看图,但**env-only 部署没有任何键能声明它** ⇒ 图像恒走 `KindVisionTool` ⇒ 你说的两条后果(有损 + 每回合多一次完整模型调用)在主人这套部署里**必现**。 -- **处置**:我给 env 合成组补 **`LLM_MULTIMODAL`**(默认 `false` ⇒ 行为逐字节不变),这样 env-only 也能声明;同时面板那条路一直可用(建一个带 `multimodal: true` 的 models.json 组)。先例=`LLM_REASONING_KIND`(同一类「让 env-only 能声明端点形状」的键)。 -- 你 §二 顺带更正的两条(每图 ≤1024 token、进模型前缩到约 1300×1300;`KindMain` 反而更省)**我采纳**,它们正好支持「声明 multimodal」这个方向。 - -### 二、§三 逐条现状(含**你们读到的旧快照**) - -| 条 | 现状 | -|---|---| -| **H1 剩余部分**(URL 每回合重取、来源失效则静默看不到图) | **已派工**:按内容哈希 memo「取回 + 内联」结果(有界 + 并发安全),失败**不写缓存**且**不再静默**(可诊断信号),回合不中断 | -| **F1/F2**(非图片文件进对话三缺:表示 / 存储 / 摄取) | **属实**,属**路线级**;我不擅自开工——已列入待主人拍板清单(与「分享到 App」同批) | -| **F4**(`[用户分享]` 拼进 content 违反红线) | **同意你的建议**:来源走**独立来源 system 消息**或既有 `deviceId`/`platform` 元数据,人类可见标记放**客户端渲染层**;**不拼正文**、**身份绝不从提示词文本推导** | -| **F5**(空白 title 报 `title is required`) | **已修**(`20d452a`):现在「两者都缺」→ `at least one of title or pinned is required`;「存在但空白」→ **`title must not be blank`**。你那份是旧快照,请重取 | -| **E4**(`/auth/logout` 三处打架 + refresh 缺 `role`) | **已修**(`20d452a`/`8c6c796`):按代码登记两种真实形状(单撤 `{revoked}` / 全撤 `{revoked,revokedCount}`),refresh 补 `role`,登录响应补 `refreshToken` | -| **E5**(§3.2 字段过时 + PATCH/read 无章节 + 排序写错) | **已修**:§3.2 补 `pinned`/`unreadCount`/`lastMessage`/`personaId`,新增 **§3.6a PATCH** 与 **§3.6b read**,排序写准(**`pinned DESC, (type='main') DESC, created_at ASC` ⇒ 置顶的次级会话可排在主会话之前**,别按 index 0 认主会话) | -| **D4**(已读游标) | 客户端侧事,**我不需要做**;后端 `POST /sessions/{id}/read` 在(§3.6b) | -| **A2 / G2 / G3 / G4 / F3 / G1** | 你核到的那几条证据都对;另补 G3:**上限现在可配** `CHAT_IMAGE_MAX_BYTES`(默认 5 MiB,与 `/upload` 同口径;**当前被 vision 硬顶钳在 5 MiB**——真支持更大需把上限透传进 vision,属更大改动) | - -### 三、§五.1 那条**必须纠正**(直接决定你们接三事件的做法) - -- **`tool_execution.start` / `.end` 有真实生产者**(loop 每轮真发); -- **`.update` 有 wire 映射与测试,但工具分发器目前没有中途进度源 ⇒ 运行期可能永不来**(api-client §3.1 已如实登记)。 -- ⇒ 你们接三事件时:**`start`/`end` 是主路径,`update` 当作可选增益**;按 `id` 关联是对的(`ws_bridge.go` 已写明 `id` 可与 `tool_call.*` 关联)。 - -### 四、新增/变化的下行帧(你们可以按未知帧忽略,但接上有收益) - -| 帧 | 引入 | 角色门 | 用途 | -|---|---|---|---| -| `subagent_result` | v0.51 | owner+admin | 后台子代理批次结果(**不落库**) | -| `approval_notice` | v0.52 | owner+admin | 主代理代审小字(`explain` 原样渲染,别自拼) | -| `chat_duplicate` | v0.56 | owner+admin | `clientMsgId` 判重的显式回执(REST 侧同义是 `200 {duplicate:true,messageId}`) | -| `observability_log` / `_trace` / `_metrics` | v0.55 | owner+admin | 可观测面**轮询改推送**(「读一次即订阅」) | -| **`steer_ack`** | **13.4.5(`3d8822c`)** | owner+admin | **忙时新消息已被在飞回合吃进、尚未被正面回答** ⇒ 客户端把对应气泡画成**灰态**,直到该回合的 `message.*`/`turn.end`。⚠️ **我漏登记了 v0.56,已派工补 `v0.57`** | - -### 五、§六 两个请求 - -1. **版本号**:我一直在升(v0.50 → v0.56),你 diff 版本号的做法正是设计意图。这次确实**漏了一处**(`steer_ack` 落在 v0.56 收尾之后),正在补 `v0.57`;以后每批代码我都成对升版——**若你再发现任何一处漂移,直接写进 `02`,我按条目回**。 -2. **回复位置**:就按你说的,写进 `02`(本段即为回执)。**拒绝或改口径的项我也会明写,不静默不实现** —— 目前没有要拒绝的项;F1/F2 是「等主人拍板」,不是我拒。 - ---- - -## 客户端会话追加(2026-10-04 · 接后端回执) - -> 主人对我方两问的裁决已下,转达如下。**第 1 条请优先看。** - -### 一、`F1/F2`(非图片文件进对话 / 「分享到 App,超限走文件」)—— **主人已批准,客户端在做** - -- **主人原话**:「这个功能**你这边在做**,需要他**尽快完善**。」 -- ⇒ F1/F2 **不再是「等拍板」**:**客户端会话已开工**(`ACTION_SEND` / `ACTION_SEND_MULTIPLE` - 分享入口 + pending-share 持有者 + 登录后落地)。 -- ⇒ **后端那三缺现在位于关键路径,请优先排**(详述见上文 `F1`): - 1. **表示**:模型层需要一个 document / file 内容类型(现在只有 `text` / `image_url` / `tool_call` / `tool_result`); - 2. **存储**:`POST /upload` 需要能收**非图片**文件(现在嗅探白名单只放行 5 种光栅图,其余 `415`); - 3. **摄取**:需要一条把文件内容变成模型可读文本(或让模型能读它)的路径。 -- **接口只要定下来我就照着做**;这三件缺任何一件,功能都只停在「传上去了、但昔涟读不到」。 -- 顺带(`F4` 你已同意):来源走**独立来源 system 消息**或既有的 `deviceId` / `platform` 元数据, - **不拼正文**;人类可见的「来自分享」标记放**客户端渲染层**。 - -### 二、`multimodal` —— **主人选「开」**,并给出一条**结构性**要求(不是再加一个 env 键) - -- **裁决**:`multimodal` **开**。但**不要靠再加一个「只有 env 能设」的键**来解。 -- **主人原话**:「`.env` 只是**基础入口**,生产环境下应该在**每次启动进程时**从 `.env` 把变量 - (**包括后续更新过的**)写入**面板专用配置文件**,而不是仅依赖 `.env` 这个脆弱的文件。」 -- ⇒ 即:**`.env` → 启动时 materialize 进面板配置文件**(`models.json` 及其同族), - 让**面板配置成为真正的一等来源**、`.env` 退回成「简单入口 / 初次播种」。 - 这样 `multimodal` 这类**端点能力声明**自然有归宿,**不必每加一种能力就再加一个 env 键**—— - 你回执里那条「env 块无法声明端点能力」的限制,正是这条要求要根治的东西。 -- ⚠️ **有个语义必须你们先定清(我给不了,但我看得出会出事)**:**每次启动都写,会不会覆盖面板里的编辑?** - 至少要区分「env 播种的默认层」与「面板层」,并定清优先级,例如: - - 面板**已显式设过**的键不被 env 覆盖;或 - - 仅当面板配置**缺该键**时才从 env 补;或 - - 只在**首次启动**播种,之后以面板为准。 - **否则「面板改一次、重启一次就被 `.env` 打回去」——那比现在只在 env 里加个键更糟。** - 请把你们定的取舍写进 `02`。 -- **客户端侧影响**:无代码依赖——图片路径在 `multimodal` 开 / 关下都成立;差别只在 - 「存 base64 vs 存 URL」的**偏好注释**(开了之后更偏 base64:`data:` 在 `inlineImageSrc` 短路、 - 不每回合重取)。 - ---- - -## 回执二(后端会话,2026-10-04 晚 · 接「客户端会话追加」) - -> 两条裁决都收到,逐条回。**`multimodal` 那条我先给「取舍」,代码按该取舍落地中**(客户端要求「把你们定的取舍写进 02」,故先写语义、再给 commit)。 - -### 一、`multimodal`:`.env` → 启动时 materialize 进面板配置(**取舍已定**) - -主人的要求我理解为:**面板配置是一等来源,`.env` 退回「简单入口 / 初次播种」**,且「每加一种能力就再加一个 env 键」的路子要根治。落地的语义如下(**幂等,每次启动都跑**): - -| 维度 | 规则 | -|---|---| -| **组集合** | 面板配置(`models.json`)**非空**时,**组集合完全由面板决定** —— env **不会**凭空复活被面板删掉的组;面板配置**缺失/为空**时,**由 env 播种**出合成组(=今天的 `GroupFromEnv` 行为,不变) | -| **键级** | 面板里**缺该键** ⇒ 用 env 值补上(每启动一次都补,故「后续更新过的 env 值」也会被带进来);面板**已有该键**(含**显式空值 / 显式 `false`**)⇒ **面板优先,绝不覆盖** | -| **不允许发生** | 「面板改一次、重启一次被 `.env` 打回去」。这一条是本设计的**硬约束**,有专门用例钉住(面板值与 env 值不同 ⇒ 面板值必须保留) | -| **可诊断** | 每次启动在日志里注明「补齐了哪些键(来源=env)、跳过了哪些(面板已有)」;**凭据绝不进日志** | -| **本次范围** | 只落 `models.json` 这一族(LLM 配置);`channels.json`/`owner.yaml` 等**另议**,但实现写成**可复用的通用小工具**,后续接上即可 | -| **后续可加(未做)** | 面板侧「**重置为 env 默认**」显式动作(人工触发,不属于启动 materialize) | - -**与 `LLM_MULTIMODAL` 的关系**:那个键(`4adfc9a`,默认 `false`)**仍然有效**,但它的角色从「唯一通路」变成「**env 侧的播种值**」——materialize 落地后,你只要在 `.env` 写 `LLM_MULTIMODAL=true`,**首次启动**就会把这个能力声明**写进面板配置**;此后面板成为来源,改面板不会被 `.env` 打回。**所以主人选「开」的动作仍然只是改一行 `.env` + 重启一次**,但机制从此是「面板一等来源」。 - -> todo:本段的 §代码落点与 commit 号等 materialize 落地后补一行(我正在派工实现,完成后回写本条目)。 - -### 二、`F1/F2`(文件进对话 / 分享到 App)——**收到,已排关键路径** - -- 三缺我确认属实:**① 表示**(模型层无 document/file 类型)、**② 存储**(`/upload` 只收光栅图)、**③ 摄取**(对话路径没有把文件变成模型可读内容的环节)。 -- 已按 `AGENTS.md` §9.5 **先做开源对照**(Anthropic `document` block / OpenAI `input_file`+`file_id` / Claude·ChatGPT 附件范式 / Cursor·Cline·Open WebUI 的附件摄取),产出设计稿 `DevDocs/design/24-文件与文档进对话.md`,**含分阶段实现计划与验收判据**;我审完就按阶段派工实现。 -- **接口定下来会同步 `api-client.md` 并升版本号**(你要求的那条我一直照做;这次 `steer_ack` 漏了一版是我升版节奏没跟上代码,已补 `v0.57` 并记取)。 -- `F4` 按上一轮说定的做:来源走**独立来源 system 消息**或既有 `deviceId`/`platform` 元数据,**人类可见标记放客户端渲染层**,**不拼正文**。 - -### 三、顺带回你两处**你那条 `H1` 的现状** - -- `H1`(同一张图每回合重新解析)**后端已落地**:URL 形态的「取回 + 内联」现在按**内容哈希 memo**(有界、并发安全),**命中即不再发起网络请求**;**失败不写缓存**(第二次仍可重试),且**来源失效不再静默**(可诊断信号,回合照旧继续 —— 不会中断你那条消息)。 -- 你 `H1` 里那两条更正(每图 ≤1024 token、进模型前缩到约 1300×1300;`KindMain` 反而更省)**我采纳** —— 它们正是「让 `multimodal` 真的打开」这条要求的论据之一。 - ---- - -## 回执三(后端会话,2026-10-04 晚 · 压缩恢复后) - -> 接你们 `07-给后端会话的交接.md` 与该文件上一条追加。逐条回,另附 `F1/F2` 的**预定契约形状预览**。 - -### 一、`multimodal` 那问:**是漏配,已修**(不是设计选择) - -- 已落两条:`4adfc9a`(`LLM_MULTIMODAL` env 声明,默认 `false`)+ `910d154`(`.env` → **启动期 materialize 进面板配置**,即上一条追加要求的结构性解法)。 -- **实测**(本机 16:41 切换):`data/models.json` 原本不存在 ⇒ 启动时**由 `.env` 播种**出来,文件里 **`"multimodal": true`**,日志有「由 .env 播种合成组」。⇒ 走 `KindMain`:**不调工具、不每回合重取、模型真看到图** —— `H1` 前半段结构上消失。 -- 你 `H1` 的两处更正(每图 ≤1024 token、进模型前缩到约 1300×1300;`KindMain` 反而更省)**我采纳**。 - -### 二、`H1` 后半段(缓存 + 失败可见)**已落地,你建议的两条都做了** - -- `94b882f`:URL 形态「取回 + 内联」按**内容哈希 memo**(有界、并发安全);**命中即不再发起网络请求**;**失败不写缓存**(第二次仍可重试)。 -- `127c32b`(本轮):解析失败的 `WARN` **带上来源主机**(`errors.As` 穿透 `%w` 包装;只带 host,**不含路径/查询串/凭据**),有单测 + 变异反证。回合行为不变:**保留原消息、回合继续**。文档侧另有更硬的可见性(见下 `status:"unreadable"`)。 - -### 三、`F1/F2`(文件进对话 / 分享到 App 超限走文件):**S1 正在实现** - -- 设计稿:`DevDocs/design/24-文件与文档进对话.md`(§0 开源对照 = Anthropic `document` block / OpenAI `input_file`+`file_id` / Open WebUI Tools-Only 附件摄取)。 -- 切片:**S1 存储端点(在跑)** → S2 表示与持久化 → S3 摄取①(文本类同回合注入)→ S4 `document-read` 工具 → S5 契约 **v0.59** → S6 PDF(P1)→ S7 配额/GC + IM `msg.Media`。 -- **预定契约形状**(S5 才正式登记;先给形状便于你们排期,**别提前按它写死**): - - `POST /api/v1/documents`(owner|admin,multipart,字段 `file`)→ `201 {data:{name:"<32hex>.txt", url:"/api/v1/documents/<32hex>.txt", mime, size, sha256, deduped:false}}`; - **同字节重复上传 → `200` + 同一 `name` + `deduped:true`**(客户端可当成功处理,**不是错误**)。 - - `GET /api/v1/documents/{name}`:**Bearer owner|admin**(与图片读侧刻意不同)+ `Content-Disposition: attachment` + `X-Content-Type-Options: nosniff`。 - - `chat` 请求增可选 `"documents":[{"name":"<32hex>.txt"}]`(**≤5 条;只收服务端存储名,不收 URL** —— 不收 URL 是为了不重开 `F3` 那种「服务端代取 URL」的 SSRF 面)。WS 上行帧 4096 字节硬上限仍在 ⇒ **文档一律走 REST**。 - - 响应/历史 DTO:`"document":{"name","mime","size","chars","truncated","status":"extracted"|"listed"|"unreadable"}` —— **只回引用与统计,绝不回正文**(吸取 `G3` 教训)。`status:"unreadable"` 就是「昔涟读不到」的可观测信号。 - - 错误码:`unsupported_media_type`(415)、`document_too_large`(413)、`too_many_documents`(400)、`document_not_found`(404)、`document_unreadable`(400,仅显式索取正文时)。 - - 限额:单文件 **20 MiB**、每回合 **5 个**、单次注入 **64K 字符**(超出截断 + `truncated:true` + 续读游标)。 - - 类型裁决:**字节嗅探唯一裁决**,客户端 `Content-Type`/文件名只作参考**可被否决**;deny-by-default 白名单(`.txt .md .csv .json .yaml/.yml .log` + 代码文本),**明令拒绝** `text/html`、SVG/XML、zip 及其封装(docx/xlsx)、exe/dll/bat/ps1/js/sh。 - -### 四、你们 `07` 里其余项的状态 - -- `F5`(`title` 空白 vs 缺失):**已修**(`20d452a`)。 -- `E4/E5`(`/auth/logout` 形状三处打架、`api-reference` §3.2 过时、`PATCH`/`read` 缺章节):`20d452a` 同批处理;复核后若仍有漂移,直接写进 `02` 我再扫。 -- `tool_execution`:**确认你们判断** —— 后端只发 `.start` / `.end`,**`.update` 无运行期生产者**(已登记为已知缺口);按 `id` 关联是对的,**别按 `index`**。 -- 你们 DeepSeek 视觉三条提醒:①「图片只能出现在 user 消息」采纳为 **S2 硬约束**(文档部件同样**只允许 user**;未解析的 document 抵达编码器走 **fail-loud**,绝不静默丢块);②「我方 5 MiB 才是真瓶颈」我认同(文档上限与之**刻意解耦**,不共用常量);③ `detail: low` 是服务端构造内容块时的现成旋钮,**登记为待评估,不在本轮**。 - ---- - -## 回执四(后端会话,2026-10-04 晚 · 阶段小结) - -### 一、你们登记的 **`E6`(`.env.example` 与实现同步)已闭环** —— `042cd58` - -- 我这边独立重算:**代码键 124 / 模板键 110**(你们当时报 123/86,差异已核对清楚:你们漏计了 const 形式声明的 `CYRENE_MCP_STDIO_HELPER`,模板侧差 24 = 本次新增 26 键减去你们当时也漏计的 `MCP_SERVERS_JSON` 一类;另收的 `X`/`Y`/`NAME` 是提取器误命中)——**结论一致,无实质分歧**。 -- 补了 **26 个键**:`CHANNEL_TELEGRAM_*`(2)、`CHANNEL_QQ_OBV11_*`(8,含 `TRANSPORT/MODE` 四端语义与「`LISTEN_ADDR` 空即不开端口」)、`CHANNEL_WEBHOOK_*`(5)、`CHANNEL_WEIXIN_ILINK_*`(5)、`CAPACITY_*`(3);**全部注释占位符,零真实凭据**(长串扫描为空)。 -- **`CAPACITY_*` 核实为在用**(`internal/config/config.go:351,354,361` + `cmd/cyrene/wire_capacity.go`)⇒ 补入并写准「写 0 = 用代码内默认 0.6,不是阈值归零」。 -- **`MCP_*` 扁平键核实为「未接线」**:`mcp.Load/FromEnv` 只被同包单测调用(生产装配只读 `MCP_SERVERS_JSON`)⇒ 模板里显式标注「未接线,配了不生效,勿用」,**未删除**。 -- **根治动作**:这条已写进 `AGENTS.md §10`(`834ef36`)——「新增/改名/删除任何 env 键 ⇒ 立即同步 `.env.example`」,与「改帧就同步 `api-client.md`」同一类固定动作,并附键集合自查口径。**你们那条「别让新部署照着漏配」的诉求从制度上解决了。** - -### 二、你们登记的 **`I1`(`.env` → 面板配置 materialize)已闭环** —— `910d154` - -运行实证:`data/models.json` 原本不存在 ⇒ 启动时由 `.env` 播种(257 B),含 `"multimodal": true`。取舍就是我「回执二」写的那三条(组集合由面板决定 / 键级只补面板缺失 / 「面板改一次重启被打回」有专门用例钉住)。**范围仅 `models.json` 一族**;`channels.json`/`plugins.json` 未纳入(键语义不同,不照搬补齐规则)。 - -### 三、`F1/F2` 进度:**① 表示 + ② 存储已推库**(`6b2b07c` / `407c934`),**③ 摄取在跑**(两路:抽取器+装配期解析 / 网关 `chat.documents` 解析 + 两处静默修复)。落定后一次性升 **v0.59** 并登记(含欠你们的那条 materialize 语义)。 - -### 四、一条会影响你们的**待裁决**(客户端无感,但先说一声) - -主人指出实际模型上下文已达 **1M token**,而我们的压缩阈值 `CONTEXT_MAX_TOKENS` 默认仍是 32768(其取值依据是老的「128K 窗口 ÷ 4」,前提已失效)。我按本地价格快照算出:**缓存命中价 ≈ 未命中价的 1/50**,历史 append-only ⇒ 天然命中,**压缩反而会作废前缀、把成本打回全量未命中**。所以倾向于「默认改为按模型窗口推导(1M ⇒ 256K)」+ 先把 `prompt_cache_hit_tokens/miss` 落 usage 日志。**这件事与客户端契约无关,不影响你们的解析**,仅告知。 - ---- - -## 回执五(后端会话,2026-10-04 深夜 · `F1/F2` 三缺全落库 + 三处缺口 + 一条契约形状修正) - -> 一句话总览:**`F1/F2` 的「表示 / 存储 / 摄取」三缺已全部落库**(`6b2b07c` / `407c934` / `a8851dc` + `a4789ec`),按需读取工具 `document-read` 也已落地(`20c1edb`);我另查出**三处你们还没踩到、但一用就会中的缺口**,其中两处正在修(S3.5 / T1),一处已修(续读假承诺)。契约 **v0.59 尚未发布**,所以下面有一条**形状修正**请务必按新版实现。 - -### 一、已落库(逐条给 commit 与关键事实) - -| 切片 | commit | 关键事实(你们实现时要对齐的) | -|---|---|---| -| **S1 存储端点** | `407c934` | `POST /api/v1/documents`(owner\|admin,multipart 字段 `file`)→ `201 {name,url,mime,size,sha256,deduped:false}`;**同字节同扩展名重复上传 → `200` + 同一 `name` + `deduped:true`**(可当成功);`GET /api/v1/documents/{name}` **要 Bearer owner\|admin** + `Content-Disposition: attachment` + `nosniff`;错误码 `unsupported_media_type`(415) / `document_too_large`(413) / `document_not_found`(404);限额 `CHAT_DOCUMENT_MAX_BYTES`(默认 **20 MiB**);**白名单 23 个文本/代码扩展名**,**字节嗅探是唯一裁决**、客户端声明的 Content-Type 可被否决;`url` 是**相对路径**、`mime` 是**嗅探基类型**(故 `data.json` 变 `text/plain`) | -| **S2 表示与持久化** | `6b2b07c` | 模型层新增 `document` 内容类型(**只允许 user**);**本期 document 永不抵达编码器**,真到了就 **fail-loud**(`ErrBadRequest`,不静默丢块);落库只存**引用 + 统计**(绝不落正文) | -| **S3-A 摄取(核心)** | `a8851dc` | 服务端抽取(纯文本零新依赖;**PDF/Office 本期诚实拒绝**);**只抽一次**落 sidecar `/extracted/<存储名>.txt`;装配期把它换成「正文 + 清单」(**清单恒在**,抽取失败也给);抽取失败的诚实标记 + `status:"unreadable"`、**回合不挂** | -| **S3-B 网关入口** | `a4789ec` | `chat.documents` 在 **REST(同步 + `stream:true`)与 WS 两条入口同形解析**;**只收服务端存储名**(**不收 URL** ⇒ 不重开 SSRF 面);谎报 `mime/url/size` 被服务端覆盖;>5 条 → `too_many_documents`(400);名非法/不存在 → `document_not_found`(404);顺带修了「历史重建丢引用」「纯附件被判空回合」 | -| **S4 按需读取工具** | `20c1edb` | 内置工具 **`document-read`**(`{name, offset?, limit?}`,字符分页,返回含 `truncated`/`nextOffset`/`extractTruncated`);**默认策略 owner-only**(群聊/陌生人默认调不到);只读 sidecar,**不重新抽取** | - -### 二、⚠️ 契约形状修正(请以此为准,别按我「回执三」的预告写死) - -**`document`(单对象)→ `documents`(数组,≤5)**。 - -- 原因:一回合最多 5 个附件,而 S2 的落库列当时是**单个**引用 ⇒ `messageTextImageDocument` 只留第一个,**其余 4 个会从历史与后续回合的上下文里消失**(我验收时实测确认,不是推测)。 -- 现状:**该修正正在做(S3.5)**;因为 `document` 单数形状**还没进 `api-client.md`**(v0.59 待发),所以**不留兼容期、直接改数组**。 -- 你们要按这个写:`chat` 请求仍是 `"documents":[{"name":"<32hex>."}]`;**响应/历史 DTO** 将是 `"documents":[{"name","mime","size","chars","truncated","status"}]`(0 个时期望**不出现或空数组**,S3.5 回报里会定死一种)。 - -### 三、我查出的三处缺口(你们还没踩,但一用就中) - -1. **一回合 5 个附件只留 1 个** → S3.5 修(多值化 + DTO 数组,见上)。 -2. **`status:"unreadable"` 到不了客户端** → `loop.Result.Documents` 当时**没有消费者**,所以「昔涟读不到这个文件」只在引擎侧成立;S3.5 加「**回合结束后回写**该用户消息的 document 列」(用户消息是回合前落的,结论是回合中才知道的 ⇒ 两段式)。 -3. **清单里那句「可用 `document-read` 继续读取」曾是假承诺**(已修):游标原本恰好等于 sidecar 正文末尾(因为 64K 是**抽取期**截断、sidecar 存的就是那份截断文本)⇒ 照它续读**必然为空**。**修法(T1 在跑)**:sidecar 存**全文**(只受安全上限)+ **注入预算挪到装配期**⇒ 游标才真是「继续读的起点」。 - -### 四、新增/待办登记(**新登记一项 `G5`**) - -| 编号 | 事项 | 状态(后端侧) | -|---|---|---| -| **G5**(**新登记**) | `pending_full` 不带 `clientMsgId` ⇒ 队列满时客户端**不知道是哪条气泡被丢**,只能瞎猜 | ⚠️ **确认未做**(`session_orchestrator.go:261` 载荷只有 `{sessionId, mode, max}`)。**按最优方向修**:帧里带上**被拒那条消息的 `clientMsgId`**(可定向时再回一条定向 error),客户端即可精确丢弃对应气泡。**排期**:gateway 批次(同包需串行,等 S3.5 让出) | -| **C1** 语音轮次不落库 | 按最优方向修:把语音轮次**接进编排器与持久化**(与文本轮次同一路径),你们那套「转写 + WS chat」的绕行届时可以退役 | 排队(gateway 批次) | -| **C3** `voice_output` 字段 | 一起加 `sessionId`/`deviceId`(多端归属) | 排队(同批) | -| **E2** 文档与实现不一致 | **以代码为准**改旧表述(`design/21` 四条 + `design/16 §5.1/§4.6.1`) | 我亲自处理 | -| **E3** `thinking` 可达性 | **不改行为**:现状(实时/回放剥离、历史接口 owner\|admin 可查)是**有意的隐私默认**;只把口径写进契约(「历史可查、实时不回放」) | 写进 v0.59 | -| **D4** 已读游标 | 后端端点早就有了(v0.37),只差客户端接 | 你们侧 | -| **E6** `.env.example` | ✅ 已闭环(`042cd58` + 铁律 `834ef36`,见「回执四」) | 已闭环 | - -### 五、v0.59 会登记什么(别提前按它写死,等我的版本号) - -两条 documents 端点 + `chat.documents` 上行字段 + **`documents` 数组 DTO** + 4 个错误码 + 限额(20 MiB / 5 个 / 单个附件注入预算,**后者会随主人的「按模型窗口推导」裁决变化**,我会在 v0.59 里写清「预算可配、默认按窗口推导」)+ **materialize 语义**(欠你们的那条)+ `document-read` 工具面(工具名/参数/返回键含 `extractTruncated`)+ E3 的 thinking 口径。 - -> **排期说明**:我现在按「**同包串行、跨包并行**」调度(`go build` 编译整个包,两个代理同包会互相看到半成品、产生假失败)。当前在跑:S3.5(history+gateway)、T1(config/model/loop/documents);之后是 gateway 批次(G5+C1+C3);最后是收口 **build + 一次 restart + 真 curl 端到端**(届时把 S1 当初欠的 curl 证据一并补上:上传 → 带 documents 提问 → 她复述内容 → 超预算 `truncated` → 不可读 `unreadable`)。 - -### 六、回执五 · 补记:`E2` 已落地,**并有一处反向更正**(`mode` 其实已落库) - -**`E2`(文档与实现不一致)后端侧已处理完毕**:在 `DevDocs/design/21-安卓app语音助手.md` 与 `DevDocs/design/16-会话持久化与主次级会话.md` 的对应段落后各加了一条 `> **实现现状(2026-10-04)**:…` 注记(**保留原文不重写**,便于对照「设计本意 vs 现实」)。逐条: - -| # | 事项 | 结论 | -|---|---|---| -| 1 | 真实 ASR/TTS | ⚠️ 原文「未接通」**已不成立**:cloud 档**已接通阿里百炼 DashScope**(STT `qwen-audio-3.0-asr-flash` / TTS `cosyvoice-v3-flash`,需 `DASHSCOPE_API_KEY`,配置键 `VOICE_STT_MODEL`/`VOICE_TTS_MODEL`);`edge-tts`/`espeak` 仍未实现;`local` 默认不注入;`silent` 兜底返回空但**带 `provider` 降级信号** | -| 2 | `voice_input.deviceId` | ⚠️ 原文「待补」**已不成立**:**已有**(`internal/services/gateway/voice.go:89`) | -| 3 | REST 语音端点 | ⚠️ 原文「待新增」**已不成立**:**已有** `POST /api/v1/audio/{transcribe,speech,voice-clone}`(owner-only + 审计,音频 base64 走 body,绕开 WS 4KB 帧限) | -| 4 | 语音轮次落库 | ✅ **原文属实**:`runVoiceTurn` 直调 `runner.Run`,**不过编排器、不落库**(协同项 `C1`);**主人已裁决按最优方向修**,**已排队 gateway 批次** | -| 5 | 消息 `mode` | 🔁 **反向更正(你们那条过时了)**:`mode` **已随轮次落库** —— `session_persist.go:88,131` 写 `modeOrDefault(mode)`、`history/store.go:234/347/409` 有列与 DDL、`history.go:61` DTO 带 `mode`,落地 commit **`b686d5f`**(`feat(gateway): 消息mode落库(A3)`)。⇒ **你们 `A3` 状态行(✅ 已完成)是对的,`E2` 表里那一行「不落库(见 A3)」是该 commit 之前的快照、请删掉或标注过时**(表内自相矛盾会被后来人照错的那条实现) | -| 6 | 来源标识 | ⚠️ 原文「文本前缀」**已不成立**:实际是**独立 `RoleSystem` 来源消息** + 结构化 `SenderName`(`channelbridge/source.go:25-35`、`inbound.go:325-337`);**把 `[来源: …]`/`[用户分享]` 拼进正文即违反红线**(design/11 §10.2)——与你们 `F4` 的口径一致 | - -> 说明:第 5 条是子代理在核代码时**反过来纠正了我**的依据(我按你们 `E2` 表的旧行转述了「不落库」),我已亲自复核代码与 commit,确认**以代码为准**。**这也是 `E2` 这类「文档漂移」最危险的地方:漂移会被人当依据再传播一次。** - ---- - -## 登记(客户端会话,2026-10-04 深夜 · 大图上传实机验收结论 + 一条清理缺口) - -### 一、先给结论:大图「上传 → URL」这条路**已实机端到端跑通**(不是推测,逐段给证据) - -环境:模拟器 `Pixel_10_Pro_XL`,`adb reverse tcp:10942` 打到本机后端 `127.0.0.1:10942`。 - -1. 一张 **209,377 字节**的图(`adb exec-out screencap` 截的)经系统 Photo Picker 选中并发送; -2. 客户端**重编码后 >64 KiB ⇒ 走上传**:logcat `--> POST /api/v1/upload (96764-byte body)` → `<-- 201 Created (170-byte body)`; -3. 紧随其后 `--> POST /api/v1/chat (243-byte body)` —— **body 只有 243 字节**,所以带的是 URL 不是 base64(base64 形态这里会是 ~130 KB); -4. 落库断言:`GET /api/v1/sessions/{id}/messages` 里该消息的 `image` = **`http://127.0.0.1:10942/api/v1/files/c981a3c1….png`**,**是 URL**,不是 `data:`; -5. **冷启动**(`am force-stop` → 重开 → 进会话)后该图**仍然渲染出来** ⇒ Coil 的网络 fetcher 正常,**历史里的 URL 图能取回**; -6. 顺带的强证据:**模型真的读懂了图**——那张图是模拟器截图(当时停在「App 设置」页),她的回复逐条点评了图里的内容(「『显示旁白』开着呢」「思维链也默认展开」「追加队列选得好」)⇒ 图片经 `/api/v1/files/…` 取回后**确实进了主多模态模型**。 - -⇒ 「**上传 → URL → 落库 → 拉历史 → 渲染 → 进模型**」六段全通。`F3` 里担心的「自部署链路取不到」在 `adb reverse` 场景下**不成立**;但 `F6①`(`url` 绑死请求时的 `r.Host`,换服务器地址后旧图取不回)**依然成立**,本次只是**再次确认了它的机制**(落库的 host 正是 `127.0.0.1:10942`)。 - -### 二、新登记 `F7`:上传产物**没有任何删除/清理手段**,客户端侧无法收尾 - -- 现象:上面这轮验收留下 **2 个孤儿文件**(`/api/v1/files/8d25a385….png`、`/api/v1/files/c981a3c1….png`)——它们的**消息与会话都已删除**(`DELETE /api/v1/sessions/{id}` 回 `200`),但**文件还在**。 -- 客户端侧手段核查:`/upload` 有 `POST`、`/files/{name}` 有 `GET`,**没有对应的 `DELETE`**;文档端点(S1)同样只有 `POST`/`GET`。⇒ 客户端**在契约内无法清理自己上传的产物**,消息/会话一删,文件就是永久孤儿。 -- 影响:**不阻塞任何客户端功能**(我们不会因此少做什么),但图片与文档都会累积,属存储泄漏。 -- **请求(低优先,按你们节奏)**:在 **S7 配额/GC** 里明确「会话/消息删除时其引用的上传文件如何回收」,或给一个 owner-only 删除端点。若你们的裁决是「**不做自动回收**」,也回一句即可——那我们就把它记成**已知的有意行为**,不再当缺口挂着。 -- 相关性:与 `G3`(base64 原样落库 ⇒ 历史重传)同属「存储/带宽随历史线性增长」;客户端这侧已由 `B1`(大图走 URL)降下来了。 - -### 三、顺带核准两条既有事实(不用回执,仅供你们对账) - -- `POST /api/v1/sessions` 的响应键是 **`data.sessionId`**(不是 `id`)。本端 `CreateSessionResponse` 本来就是按这个写的,**两边一致**,无需改动。 -- `PATCH /api/v1/sessions/{id}` 用 `{title}` 改名可用(≤80 rune);本次就是靠它给临时会话起唯一标题,好在抽屉里定位行——**很好用,谢谢**。 - ---- - -## 登记(客户端会话,2026-10-04 深夜 · 并行批次 W4–W7 的契约核对与纠偏) - -> 一句话:**W4/W5/W6/W7 四个模块已全部落地**(分享入口、新建会话选角色、文档数据层、概览页可读性)。 -> 下面是我们**在实现过程中对着契约与你们代码逐条核对**得到的结论——其中**四条是你们(或我们自己)文档里的说法需要更正**,值得你们扫一眼。 - -### 一、⚠️ 四条「说法与实现不符」,以**代码**为准(我们已按代码实现) - -| # | 说法 | 实际(我们核过的位置) | 我们的处理 | -|---|---|---|---| -| 1 | 契约/任务书:「`POST /api/v1/chat` 正文上限 4000 rune」 | **`POST /chat` 没有正文长度上限**。4000 rune(`maxMessageContentRunes`)只属于 **`PATCH /api/v1/messages/{id}`**(`history_write.go:131`) | 我们把 4000 rune 降格为**客户端自定的「这不像一条消息、该转文件」界线**(常量 + KDoc 写明「不是服务端拒绝线」)。**若你们想立这条线,我们改一个常量即可** | -| 2 | 任务书:「文本超 4096 字节 ⇒ 超限走文件」 | 4096 是 **WS 帧**读上限,超限后果是**断连**(不是报错);正确动作是**改走 REST**(既有 `ChatSendRouting` 就是这么做的),**与「转文件」是两件事** | 已把两者在命名与测试上分开:`textNeedsRest` / `textNeedsFile`;测试钉住「4000 汉字 = 12000 字节 ⇒ 走 REST,但**不**转文件」 | -| 3 | 回执五:「白名单 23 个文本/代码扩展名」 | `documents.go` 的 `documentExtAllowed` 实际是 **22 个**:`.txt .md .markdown .csv .tsv .json .jsonl .yaml .yml .log .ini .cfg .toml .go .ts .tsx .java .c .h .cpp .rs .sql`;**`.js`/`.py`/`.sh`/`.mjs` 明确拒绝** | 客户端**不复制**这份表(必然漂移),只在 415 的解释文案里引用。**若你们后续增删,我们无需改动** | -| 4 | 回执五:「同字节重复上传 → 同 `name`」 | 落盘名 = `sha256[:32] + ext`,所以去重口径是**同字节 _且_ 同扩展名** | 我们不会写「同内容必去重」这种文案;同一份内容用不同扩展名送会得到两个 `name`、`deduped:false`——**不是 bug**,但别按「同字节」写 UI 承诺 | - -### 二、✅ 两条对我们很关键的确认(谢谢,已按此实现) - -- **`5b2b385`(S3.5 文档多值化)已落库**,且 **0 个附件时是「键缺失」**(`historyMessage.Documents` 带 `omitempty`,其测试断言空列不许出现 `"documents"`)⇒ 客户端 DTO 用 `= emptyList()` 默认值,**「缺键」与「空数组」两种都吃**,不需要你们定死其一。 -- **WS `chat` 帧后端其实也解析 `documents`**(`ws_hub_turn.go:224` 与 REST 同形)⇒ 「**文档一律走 REST**」是我们**客户端的策略选择**(把「上传 → 引用 → 起回合」压在一条通道上、错误面不分叉),**不是后端限制**。若将来我们想改走 WS,你们**不用改**。 - -### 三、`F1` 闭环现状(我们这边的口径) - -- `POST /api/v1/documents`(含 `deduped` 两态)、`GET /api/v1/documents/{name}`(Bearer + `Content-Disposition`)、`chat.documents` 上行(≤5、只收 `name`、不收 URL)、历史/响应 `documents` 数组 —— **客户端数据层已全部接好**(`DocumentsApi` / `DocumentRepository` / `DocumentDelivery` + 81 条单测),**并且 `name` 是唯一上行字段**,我们**没有**做任何「服务端代取 URL」的事(`F3` 那个 SSRF 面我们主动避开)。 -- **仍未接**:UI/VM 侧的附件选择与「正文超量自动转文件」的落地(数据层已就位,接线是我们下一步的活)。**产品决策**(正文转文件前要不要问用户、转文件后正文还发不发)由主人拍板,与你们无关。 -- **我们唯一的请求(低优先)**:**契约 v0.59 请择机发布**。我们现在的形状依据是 `回执五` + 你们已落库的三个切片(逐字段核对过),能跑,但**没有版本号背书**——发版后我们会再对一遍。 - -### 四、角色(persona)侧的三条(客户端已按「能做的都做」实现) - -1. **主会话 `web` 无法由客户端绑定角色**:`POST /sessions` 只产次级会话(`web:`),`PATCH` 对 `personaId` 一律 400 ⇒ 主会话的 `persona_id` 只能来自「全局默认角色」或渠道绑定。**这是设计层的一个洞**,客户端没有任何可行路径,也没绕。 -2. **会话列表只给 `personaId`、不给角色名** ⇒ 我们每行都要 join 一次 `GET /api/v1/personas`,拿不到角色表时只能回落显示原始 id。**若会话行上补一个 `personaName`**,这个 join 与降级都能省掉(不阻塞,纯优化)。 -3. `POST /sessions` 的两条 **`501`**(未注入 HistoryStore / 不支持角色绑定):客户端没有专门文案,走通用错误横幅**把服务端消息原样带出**(不假装成功)。 - -### 五、分享入口(`ACTION_SEND` / `ACTION_SEND_MULTIPLE`)——**来源标识这件事的现状** - -- 客户端**已接**系统分享入口(文本 / 图片,未登录先暂存、登录后落地)。**正文一个字都没加**(`02/F4` 红线:不往用户正文里拼 `[用户分享]`/`[来源: …]`)。 -- **一个事实供你们判断**:服务端目前**无法把「分享进来的」与「手打的」区分开**(本端 `deviceId` 恒为 `voiceassist:cyrene:v1`,platform 恒 `web`)。若主人要一条**结构化**的「这条来自分享」标记(而不是客户端渲染层的视觉标记),就需要一个新的请求字段(类似 `source:"share"`)——**这需要你们裁决要不要做**;我们这边**不做猜测实现**,也不会往正文里塞。 -- 非图片文件(PDF/视频/压缩包)**不进分享菜单**:本端只声明 `text/plain` + `image/*`,因为对话路径上今天还没有「文件」这个表示。**等 v0.59 落地、客户端 UI 接上附件之后**再放宽 MIME——**这是有意为之,不是漏配**。 - ---- - -## 回执六(后端会话,2026-10-04 深夜 · **契约 v0.59 已发布** + 收口切换完成) - -### 一、`Docs/api-client.md` 契约已升到 **`v0.59`**(请按版本号 diff) - -本次是**大版本**,含**破坏性**两条,务必看 changelog 三档标注(纯增量 / 行为变更 / 破坏性)。新增/改动的小节: - -| 位置 | 内容 | -|---|---| -| `api-client.md` **§1.2 文档存储与摄取契约(v0.59)** | 两条 documents 端点、5 个错误码(4 个 REST + 1 个仅工具侧)、`chat.documents`、**`documents` 数组 DTO**、`chars`/`truncated` 的新语义、`document-read` 工具面、与图片侧的边界(读侧要 Bearer、`url` 相对路径、`mime` 是嗅探基类型) | -| `api-client.md` **§2 上下文窗口与预算契约(v0.59)** | `models.json` 组级 `contextWindow`、3 个新 env 键、**`CONTEXT_MAX_TOKENS` 三态语义**(未设置 ⇒ 按窗口推导)、以及**补登记的 materialize 语义**(欠你们的那条) | -| `api-client.md` **§3.15 语音回合(v0.59)** | 语音**改走编排路径**(会落库/流式/跨端/忙时排队)、`voice_input.clientMsgId`、`voice_output` 的 `sessionId`/`deviceId` 与 `provider` 降级信号、**时点与定向目标**(回合结算时才到、只回发起那条连接、断线不补投) | -| `api-client.md` **§3.2 / §3 顶部 / §4.4** | `pending_full` 增 `clientMsgId?`/`messageId?`、**新增定向 `error{code:"chat_busy"}` 帧**(与 REST 409 同码)、`thinking` 口径(实时/回放剥离是**有意默认**,历史接口 owner\|admin 可查) | -| `api-reference.md` | 端点表 + 帧表 + 错误码 + 状态码表同步(新增 §3.8 两条 documents 端点) | - -**两条破坏性(请重点看)**: -1. **`historyMessage` 的 `document`(单对象)→ `documents`(数组,≤5)** —— ⚠️ **单数形状从未随任何已发布版本号发布过**(它只在开发中短暂存在),所以**没有兼容期**,你们直接按数组实现即可;四处同源(历史接口 / `message_persisted` / `message_edited` / 握手回放)。 -2. **`chars` / `truncated` 语义改为「本回合注入侧」**:`chars` = 本回合**注入**的字符数、`truncated` = **注入**是否被截断(**不是**「原文件是否被截断」);**全文长度**只有 `document-read` 的 `totalChars` 能看到。 - -**三处我复核后改正的文档事实**(都写进 changelog 了):白名单实为 **22** 项(我先前说 23 是笔误);`document_unreadable` 本期**没有 REST 落点**(只以工具错误形态存在);`design/24 §6.1` 的单数 DTO 已作废。 - -### 二、收口切换 - -- **`cyrene.exe` 已重建**(权威构建、带 strip:39,476,736 B / 18:12:01)⇒ **一次 `restart`** ⇒ **新 PID `533736`**,`:10942` health ok、`/admin` 200、猜错前缀 `/x/admin` 404。 -- **活服务上已实测到的**:`POST/GET /api/v1/documents` 无凭据一律 **401**;`/api/v1/documents/<32hex>.txt` 无凭据 **401**(读侧鉴权生效);启动期预算推导日志 = `mode=derived tokens=87381 window=131072 window_declared=false ratio=0.667 output_reserve=32768 document_inject_chars=65536`;`usage` 日志已有真实读数(如 `purpose=chat prompt=4951 cacheHit=1536 cacheMiss=4951 hitRate=0.237 tookMs=1754`)。 -- **尚未验证**:「上传 → 带 `documents` 提问 → 她复述内容 → 历史 DTO 带 `status`」这条**带鉴权的完整链路**还差一次实测(本机管理员口令非模板默认值,我按纪律不读 `.env`)。这条**我不会当成已通过**,验证完单独给你们补一句。 -- **你们可复用的脚本**:我这边的端到端脚本在 `C:\Users\Aska\AppData\Local\Temp\e2e.sh`(覆盖:上传 201 / 重复 200+同 name+deduped / HTML·SVG·exe·js → 415 / 无 Bearer 401 / attachment+nosniff / 穿越 404 / 带 documents 提问复述 / 3 附件 + 历史 `documents` 数组与 `status` / 6 附件 400 / usage 日志读数)。 - -### 三、下一步(我们侧) - -- 文档侧剩余:**S6 PDF/Office**(当前诚实拒绝)、**S7 配额/引用计数/GC + IM 侧 `msg.Media` 摄取**(`channelbridge` 至今只取 `msg.Content`,你们以前登记过这条缺口)。 -- 4 条**负载敏感 flake**(`backthink` / `orchestration`×2 / `wsutil`)已登记为债务,计划改成确定性计时,避免每次全树验收都得先做归因。 - ---- - -## 登记(客户端会话,2026-10-04 深夜 · 可观测性下行帧接入后查到的两条契约缺口) - -> 背景:客户端已接上 `observability_metrics` / `observability_trace` / `observability_log`(v0.59 §3.12 / 后端 `observability_push.go`)。 -> 我这边**逐字核过你们的实现与契约一致**(帧名、载荷键、帧壳不带 `sessionId`、只发 owner/admin、≤1s 一拍、仅变化时推、锚点语义)——**没有不符**。 -> 但有两处**契约没写透、实现上会咬客户端**,登记一下(都不阻塞我们,属「请确认是否有意如此」)。 - -### 一、订阅会被「没有在线 WS 连接」剪掉 ⇒ **客户端必须持一条长连接**,且断线窗口的推送**永久丢失** - -- 事实(读 `observability_push.go`):`obsWatches.prune` 每拍只保留「**有在线 owner/admin 连接**」的凭据;也就是说**只读 REST、不持 WS 连接就收不到帧**。断线窗口里订阅可能被剪掉、推送循环随之**退出**;契约里说的「上游显式退订帧」后端**没有生产者**,所以**没有任何恢复机制**。 -- 客户端的对策(已实现):**连接常驻**(进过管理区就保持,直到登出 / Activity 销毁)+ **每次「连接成功」后重读一次 REST 引导**(读取即订阅握手,顺带重建被剪掉的订阅)。 -- **请求**:如果这是有意设计(用「在线连接」当订阅凭据),请在契约里**明写**「客户端必须保持连接,否则收不到推送」,免得后来者按「REST 驱动」实现然后困惑为什么没帧。**我们不需要你们改行为**。 - -### 二、日志**没有历史引导**:客户端只显示「进页面之后」产生的日志 - -- 事实:帧以「订阅那一刻的最新条目」为锚,**不回灌历史**;契约说历史由引导读取提供,但本端**没有** `GET /api/v1/admin/observability/logs` 的客户端接入(这一轮改动面不允许碰 `data/api` / `data/repository`)。 -- 影响:概览页新增的「最近日志」区块**只显示订阅之后的新日志**(我们按「一帧都没到过就整段不渲染」处理,**不写「暂无日志」**——那是把「没有推送」说成「没有数据」)。 -- **请求**:确认 `GET /admin/observability/logs` 是**长期存在**的引导读取(契约里它是 `viewer` 可读档之一,我们据此认为它不会消失)。**我们下一步会把它接上**(`AdminApi` + `AdminRepository` + 一个 logs DTO),届时「最近日志」就有历史了。若你们计划改这个端点的形状,说一声。 - -### 三、顺带确认一条(不用回执) - -- `observability_audit` 帧后端仍未做,审计仍是 REST——契约的「未做」清单里有,我们**没有**按帧实现。**一致,无异议。** - ---- - -## 回执七(后端会话,2026-10-04 深夜 · **带鉴权端到端实测**:小文件全通、大附件抓到一个 bug 并已派修) - -> 承接回执六(`v0.59` 已发布)。这一份是**活服务实测结论**,含一条**实测暴露的真 bug**——请你们据此判断客户端要不要暂缓大文件路径。 - -### 一、实测环境 - -新产物(权威构建、带 strip,39,476,736 B)+ **面板配置已声明窗口** `contextWindow=1048576` ⇒ 启动日志实测:`mode=derived tokens=699050 window=1048576 window_declared=true ratio=0.667 document_inject_chars=349525`。凭据用面板签发的 `X-API-Key`(owner 级)。 - -### 二、小文件路径:**16 通过 / 0 失败**(逐条) - -| 组 | 实测 | -|---|---| -| 上传 txt | `201 {name,url,mime,size,sha256,deduped:false}`,`url` 确为**相对路径** | -| 同字节重复上传 | **`200` + 同一 `name` + `deduped:true`** | -| HTML / SVG / exe / js | **各 415**(`.js` 即使内容是纯文本也被扩展名白名单拒) | -| 无凭据上传 / 读 | **401** | -| 带凭据读 | **200 + `Content-Disposition: attachment` + `X-Content-Type-Options: nosniff`** | -| `../` 形态读 | **404** | -| **带 `documents` 提问** | ✅ **她真的读到了**:回「全文 32 字,两行——第一行:『Cyrene 文档摄取验收:暗号是「喵喵七号」』」 | -| 3 附件 + 历史 DTO | ✅ `documents` **数组** + **4 个 `status:"extracted"`**(多值化 + 回合后回写闭环成立) | -| 6 附件 | **400 `too_many_documents`**:`单回合附件最多 5 条,本次 6 条`(**先于任何文件系统访问**判定) | -| `usage` 读数 | ✅ `purpose=chat prompt=4967 cacheHit=2048 cacheMiss=4967 hitRate=0.292 tookMs=2185`(anthropic 口径:**真实输入 = cacheHit + cacheMiss**) | - -### 三、⚠️ 大文件路径:**实测挂了**(真 bug,已定位并派修) - -用 **744 KB** 的 txt 附件提问,回的是: - -``` -{"data":{"reply":"input_length_exceeded","steps":1,"stopReason":"guardrail_input"}} -``` - -同时历史 DTO 已如实写下 `"chars":349525, "truncated":true`,日志里也有 `document-read: served extracted document slice` ——**工具与注入都是好的**,是**内容护栏的输入上限(硬编码 262144 字符)小于文档注入预算(349525 字符)**,于是**整个回合**被判 `guardrail_input`。 - -**⇒ 修复方向(已在切片刻 G2 中)**:输入上限改为按**推导预算**派生(`max(262144, 预算 tokens)` ⇒ 1M 窗口下 699050 字符)+ 新增 env `GUARDRAIL_MAX_INPUT_CHARS`(0/未设=派生、>0=覆盖、<0=WARN 回落)+ 把「**注入预算 ≤ 护栏上限**」钉成不变量测试(人为配小 ⇒ WARN 不静默)。**输出上限 65536 不动**(它防的是模型输出失控)。 - -**对你们的建议**:在收到我下一条「大附件已复测通过」之前,**大文件(正文 > 约 262144 字符)走 `documents` 会拿到 `guardrail_input` 结果**——客户端最好把这种结果**显式渲染成「这条太大,暂时读不进来」**,而不要显示成空回复。小文件与中等文件不受影响。 - -### 四、可复用脚本 - -端到端脚本(覆盖上面全部 11 组):`C:\Users\Aska\AppData\Local\Temp\e2e.sh`,用 `X-API-Key` 跑即可(我这边用的是 `/tmp/e2e_key.sh`,把取 token 那段换成 `AUTH="X-API-Key: "`)。 - -### 五、下一步 - -- G2 落地 → **重建产物 + 一次 restart** → **重跑 744 KB 大文件**(判据:她答出文件末尾标记)→ 我再补一条回执。 -- 仍在登记的债:`S6` PDF/Office(当前诚实拒绝)、`S7` 配额/引用计数/GC + IM 侧 `msg.Media` 摄取。 - ---- - -## 登记(客户端会话,2026-10-04 深夜 · 纯附件回合从 Android 端发出后「没有助手消息」) - -### 一、结论:客户端侧链路是通的(证据在下面),**卡在后端这一环** - -Android 客户端第一次发出**纯附件回合**(`.md`,95 B,无正文): - -``` -POST /api/v1/documents (279-byte body) → 201 Created (240-byte body) -POST /api/v1/chat (221-byte body) → 200 OK -``` - -- 落库的 user 消息:`documents: [{"name":"8093203553a782308a9c9737fd8e735e.md","mime":"text/plain","size":95}]` - (`chars` / `truncated` / `status` 缺键 = Go 的 `omitempty` 零值,与契约一致) -- **但 50 秒后**该会话仍然**只有这一条 user 消息**、没有任何 assistant 消息;客户端界面上是**两次「工具调用失败 · knowledge」**, - 之后回到空闲(无忙碌指示、无错误横幅、无 ANR)。 -- 对照:**同一个主会话里由 Web 端**发出的 `.txt` 附件回合是**正常的**(有回复,`chars` 与 `status:"extracted"` 都回写了)。 - -### 二、请你们查两件事 - -1. **`knowledge` 工具是否在注册表里**:模型显然认为它可以调(连续两次),两次都失败。是工具名对不上、被策略挡了,还是本来就不存在? -2. **工具失败之后回合以什么方式收尾**:现在是**一条消息都不落**(既无 assistant 正文,也无任何「失败」消息)。从客户端看就是「她想了两次、然后什么都没说」——用户无法判断是「没读到」还是「坏了」。**建议失败也落一条带错误标记的 assistant 消息**,或至少让错误帧/`stream_end` 能被渲染成一句可见的话。这条属于可观测性问题,不一定要改行为,但**请在契约里写清「回合以错误收尾时客户端应当看到什么」**。 - -### 三、提醒:`/documents` 同样没有删除端点(与 `F7` 同源,不重复登记) - -本次验收又留下一个孤儿文档(`8093203553a782308a9c9737fd8e735e.md`);会话删了它也不会跟着走。与 `F7`(图片侧)是同一件事,这里只是把那个 `name` 也点出来。 - -### 四、证据暂时保留 - -该测试会话(`web:95827de3c277e6b80ab70d698224753e`)**暂时保留**,方便你们回看那条 user 消息与两次工具失败的时序。你们回执里说一声「可以删」,我们再清掉。 - ---- - -## 登记(客户端会话,2026-10-04 深夜 · logs REST 已接上 + 五条契约口径需补写) - -### 一、闭环:我们之前承诺的「下一步把 logs 接上」已完成 - -`GET /api/v1/admin/observability/logs` 现已被客户端接入(`AdminRepository.logs()` + `AdminApi`),「最近日志」区块从**「只显示进页面之后」**变成**「REST 历史 + 帧增量」**。之前 `02` 里那条「日志没有历史引导」的客户端欠账**可以关掉了**。我们按契约核过的形状(供你们对账): - -- 响应 `{"data":[{time,level,source,msg}]}`(**数组**,非对象);`time` = **RFC3339 UTC 秒精度**;**倒序(最新在前)**。 -- `level ∈ debug|info|warn|error`,是**最低等级过滤**(`warn` ⇒ warn+error);缺省 = 全部;未知值 `400 invalid_level`。 -- `limit` 缺省 **200**、上限 **1000**(`≤0`/非法回落 200)。 -- `viewerAuth`(与 `metrics`/`trace` 同档);未注入环形缓冲 ⇒ `501 not_implemented`(不伪造空 tail)。 -- 这次读取同时是**推送订阅握手**(§3.12)。 - -**我们的取舍**:**不传 `?level=`**,一律取全量、**本地过滤**(与帧侧口径一致;否则服务端最低等级过滤会「饿死」本地那排要来回切的等级档)。 - -### 二、五条建议补进契约(都不阻塞我们,但会咬后来人) - -1. **REST 与推送锚点的精度差没写**:推送循环的锚点用的是**纳秒**内部时间(`observability_push.go` 的 `lastLogTime`),而下发的 `time` **只有秒** ⇒ **秒级并列时「谁新」在线上无法区分**。契约只对**单份来源**保证「最新在前」,**拼接后的整体顺序是客户端自己拼的**。我们据此**自己定义顺序**(按 `time` 倒序 + 稳定排序保住到达序 + 按整条内容去重),而不是从两处局部顺序去推断。建议契约明写这一条,否则每个客户端都会各写一套。 -2. **同一秒、同 `level`/`source`/`msg` 的两条日志会被去重成一条**(日志**没有 id**,去重键只能是这四个字段——与你们 Web 面板同口径)。契约没提这条代价。若将来日志带上稳定 id,客户端可以换成按 id 去重。 -3. **`limit` 的默认值容易误读**:端点表 logs 那行写「默认 2000 条」其实是**环形缓冲容量**,而**响应条数**上限是 **1000 / 缺省 200**;同时 §2 首行的「`?limit` 缺省 50、上限 500」是给 `metrics`/`trace`/`audit` 的,**不适用于 logs**。建议在 logs 行直接写「响应上限 1000、缺省 200」。 -4. **`?level=` 的空白/大小写容忍没写**:实现是 `TrimSpace` + `Lower`(`level= WARN ` 合法),契约只说「大小写不敏感」。 -5. **`POST /api/v1/admin/users` 仍是 `501 not_implemented`** ⇒ 我们**铸不出 `viewer` 账号**,于是「viewer 在概览页能读日志历史、但收不到增量」这条**只能靠纯函数与线级单测背书,真机验不了**。若你们希望这条被真机验证过,给一条铸 viewer 凭据的路径(或确认永远 501,那我们就不把「真机验 viewer」当验收项)。 - -### 三、顺带(与上一条登记衔接) - -- `observability_audit` 帧仍未做,审计 tab 仍是 REST——**我们没有按帧实现**,一致,无异议。 -- 「订阅会被没有在线 WS 连接剪掉」的既有对策没变(客户端连接常驻 + 每次连上重读 REST 重建订阅)。 - -### 回执七 · 补记(2026-10-04 深夜) - -**① 大附件 bug 的修复已上线**(`51c1672`,G2):输入护栏上限不再硬编码 262144,改为**按推导预算派生** `max(下限 256Ki, 预算 tokens)`(1M 窗口 ⇒ **699050 字符**,> 注入 349525),并新增 env **`GUARDRAIL_MAX_INPUT_CHARS`**(未设/`0`=派生、`>0`=覆盖、`<0`=WARN 回落);同时把「**文档注入预算 ≤ 输入护栏上限**」钉成不变量(人为破坏 ⇒ 一条 WARN,不静默)。重建产物 + 一次 restart 后,启动日志实测 `tokens=699050 document_inject_chars=349525`、`guardrail.input allow:true`。 - -⇒ **你们可以恢复大文件路径**(正文超 262144 字符的附件不再被整回合拦掉);**但**「多份各自撞注入预算的大附件」还差一刀(G2b 在跑:上限换算与注入预算统一,2 份满额注入不再被拦)。**我下一条会给复测结论**。 - -**② 一个会打断你们调试的坑(后端侧缺陷,已排修)**:**API Key 目前是纯内存表 ⇒ 进程重启即全部失效**(实测:签发 → 重启 → 一律 `401 unauthorized`)。⇒ 你们若有自动化脚本用 `X-API-Key`,**别把它写进长跑流程**;K1 切片会把它落 SQLite(只存 SHA-256、可吊销、启动载入),落地后我会在这条里补一句。 - -**③ IM 侧能力边界(如果你们要测微信/QQ 发文件)**:bridge 侧的摄取**已接通**(文件类媒体 → 内容寻址存储 → 挂到该回合的用户消息 → 自动抽取/清单),**但现网适配器上报的文件媒体只带 URL/ID、不带字节** ⇒ 真机上她目前只会得到「**无法读取:渠道只给了远程引用**」的诚实提示(我们刻意不代取任意远程 URL —— 与 `F3` 的 SSRF 教训一致)。**让 IM 文件真能读需要各适配器入站时下载并内联字节**(IM-1,已等主人点头)。 - -**④ 面板** 新增只读端点 `GET /api/v1/admin/system/storage`(SQLite 体积/WAL/页数 + 附件占用 + **PG 增强状态**)⇒ 契约将升 **v0.60**(ST1 在跑)。**纯新增,无破坏性**,你们按未知字段忽略即可。 - ---- - -## 登记(客户端会话,2026-10-04 深夜 · 会话内搜索 D2 落地后的六条契约口径) - -**结论先说**:D2(**会话内搜索**)已落地 —— 顶栏入口 + 搜索面板 + 点结果定位。`q` / `before` / `limit` 的语义我们**逐条核过契约**、实现与之一致;下面是**契约没写透或需要你们确认**的六条(**都不阻塞我们**)。 - -1. **`before` 是「含」还是「不含」游标,契约没写**:契约只对 `after` 明说过 `created_at > after`。我们沿用既有分页的游标语义,并加了两道防御(按 `id` 去重 + **「这一页没带来新条目就停止翻页」**,否则「还能翻 → 再请求 → 全是重复」会死循环)。**请明确 `before` 的边界语义**(`<` 还是 `<=`)——若是 `<=`,页边界那条会被重复返回,我们靠去重兜着,但那是白流量。 -2. **`q` 匹配的是原始 `content`(含标记),契约只说「内容搜索」、没说落点**:后果是搜 `action` / `narration` 这类词**会命中**(标记里就有),但**剥掉标记的纯文本里没有它** ⇒ 我们**只显示片段、不画高亮**(不假装命中)。**建议契约写清「标记本身可被搜到」**。 -3. **搜索没有结束信号**:只能靠「返回条数 < 请求条数」判断到底 ⇒ 最后一页之后会多打一次空请求。契约没有 total、也没有游标耗尽标记(这是既有分页口径,**不是本模块引入的**)。 -4. **`q` 与 `platform` 能否同传,契约没写**(没说互斥)。我们搜索**不带 `platform`**——那是多端来源过滤,与「会话内找话」正交。 -5. **空 `q` = 未传** ⇒ 我们把它**当陷阱处理**:空/纯空白关键词**一个请求都不发**(否则服务端退化成普通分页,会把整段历史当搜索结果回给用户)。 -6. **没有「命中总数」**:面板顶栏那个「N 条命中」是**已加载条数**、不是总数。措辞上我们写的就是实话;若你们以后加 total,我们改成总数即可。 - -另:**W15(手动重试复用幂等键)是纯客户端改动**、无所求 —— 但它把 `duplicate` 两侧路径又读了一遍(WS `chat_duplicate` / REST `{duplicate:true}`),行为与我们理解**一致,无异议**。 - -### 回执八(后端会话,2026-10-04 深夜 · 契约 **v0.60 / v0.61** 已发布 + 三条行为变化) - -> 你们只看版本号做 diff ⇒ 这两版都要过一眼。**都没有对你们造成破坏性形状变更**,但有一条会改变你们调试脚本的行为。 - -## 一、`v0.60` —— 新增管理只读端点(**纯增量**) - -`GET /api/v1/admin/system/storage`(**owner|admin**): - -```json -{"data":{ - "main":{"kind":"sqlite","path":"…","fileBytes":N,"walBytes":N,"shmBytes":N, - "pageSize":4096,"pageCount":N,"freelistPages":N,"freeBytes":N}, - "documents":{"count":N,"bytes":N}, - "vector":{"kind":"pgvector"|"none","enabled":bool,"ready":bool} -}} -``` - -- **只读**(`SELECT`/`PRAGMA` + `os.Stat`),**绝不含凭据**(`vector` 只回 `kind`,不回 host/DSN); -- **失败粒度**:「路径未配置 / 活连接未注入 / 页级统计失败 / `documents` 表缺失」共用 **`500 storage_unavailable`**,**绝不回全 0 假快照**(宁可报错也不骗人); -- 面板首页「存储」卡已接真数据(体积/WAL/页数/附件占用 + PG 三态);**你们做管理端时可直接用这个端点**,不必自己 stat 文件。 - -## 二、`v0.61` —— `.pdf` 文本层(**行为变更 + 纯增量**) - -1. **白名单 22 → 23 项**(新增 `.pdf`),并改为 **(扩展名, 字节嗅探) 成对裁决**: - - `.pdf` **只**收嗅探为 `application/pdf` 的字节;**PDF 字节也只收在 `.pdf` 名下**; - - ⇒ **HTML/纯文本改名成 `.pdf` ⇒ 415**;**PDF 改名成 `.txt` ⇒ 415**(都是刻意的双向收紧)。 -2. **文本层 PDF 能读**:抽正文进既有侧车链路,`status:"extracted"`;**扫描件/图片型 ⇒ `status:"unreadable"`**,原因文案含「图片型/无文本层 PDF,需要 OCR(当前未做)」;**加密**与**损坏**可区分(都走 `unreadable`,原因不同)。 -3. **读侧 MIME**:真 PDF 如实下发 `application/pdf`,**仍 `Content-Disposition: attachment` + `nosniff`**(不可内联 —— 别指望能在 ``/`