Files
Cyrene-For-Android/devdocs/05-开发进度与交接.md
T
AskaEth cfdc094c30 fix(chat): 设置项「她正忙时按发送」不再空转 —— 发送前按偏好决定是否先打断
设置页那个开关此前没有任何发送路径读它:interruptMode 全仓只出现在 AppSettings
(定义 / 读写)与 SettingsScreen(渲染文案),于是把它切成「打断」后行为与「追加队列」
一模一样,ChatSocket.sendInterrupt 更是零调用方(死 API)。这正是一直挂在文档
devdocs/02 协同项 A2 上的欠账(后端 v0.56 已支持 scope,客户端没接)。

现在按契约 v0.56 §3.1 接上,判据抽成与发送路由同类位置的纯函数
interruptScopeForSend(mode, busy: Boolean):String?

1. 追加队列(默认档)⇒ 恒定返回 null,一个 interrupt 帧都不发,线上请求与今天逐字节相同;
2. 打断(wireScope = "current")且会话正忙 ⇒ 这条消息之前先发 interrupt{scope:"current"}
   (只取消在跑回合、保留待处理队列,队列里的消息按编排器续跑,一条都不丢);
3. 打断但会话空闲 ⇒ 不发(命中空闲会话是 no-op,白发一条帧只多一次往返,还会在 logcat
   里制造「这里打断过」的假象)。

「正忙」只认服务端 session_busy 帧给的 busyMode != null,不用 queueDepth——队列深度
非零不等于有回合在跑(pending_ack / pending_drain 也会动它)。

接线点在 ChatViewModel.deliver() 的通道分支之前(app/src/main/kotlin/top/yeij/cyrene/
ui/chat/ChatViewModel.kt)。放在那里而不是 emitAndDeliver 开头是刻意的:上面每一段本端
拒绝(附件超限 / 图片处理失败 / 附件上传失败 / 引用不合法)都会「回滚、什么都不发」,
先打断她的回合再什么都不发是凭空的破坏。每次投递只经过那里一次,所以一次投递最多一条
打断帧,任何后续分支与重试都不会重复发。

偏好每次发送现读(不缓存、不订阅 Flow):「在设置页切成打断、然后立刻发的那一条」正是
本次要修的场景,缓存值要等 Flow 推到才更新,冷启动那一下必然没到——那就等于留了一个
静默不生效的窗口。读失败(IO / 损坏)回落默认档,也就是最少动作的那一档,与 AppSettings
把未知取值解成默认档是同一条纪律。

打断帧发不出去(只有「WS 未连接」一种原因)静默、不阻断本次发送:本次消息的成败与它
无关(REST 通道不看 WS 连接),且一直有既有的回执 / 回滚路径如实上报;反过来「打断失败」
没有对应得上的动作(重试等于把消息再发一遍,正是幂等键在防的事),喊一声只会误导。
于是打断没生效 = 行为退回默认档,是用户看得懂的状态,不是被藏起来的事。

测试:新增 InterruptScopeTest 8 条(两档 × 忙闲四格穷举 + 默认档零行为变化 + 偏好回落
方向),全仓 884 → 892。assembleDebug 与 testDebugUnitTest 全绿(0 失败 0 错误)。
设置页那两条文案原本就与 scope:current 的真实语义相符(「已排队的消息保留,继续依次
回答」),未改一个字。

文档:02 的 A2 状态表、05 的 A2 条目都写着「客户端尚未接入该字段」;03 里「打断模式 →
UI 限制一次只发一条」的前提(interrupt 会清空 pending)也随之失效,三处一并补记更正。
2026-10-04 22:29:57 +08:00

815 lines
76 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 05 — 开发进度与交接
> 本文用于跨会话交接:记录当前状态、构建管线、环境要点、易忘的实现约定。
> 最后更新:2026-10-04
## 当前状态
- 分支 **`android`**,最新提交 **`664fd8d`**(= `origin/android`,全部已推)
### 本轮追加(2026-10-04 深夜 · 三个 worktree 并行 + 合并验收)
**合并后 353 测试全绿**,正好 `272+11+33+37`——加法和实际一致,就是「文件集互不相交」成立的证据。合并**零冲突**(merge commit:`51ea890`/`b44e8d9`/`664fd8d`)。
- **D4 未读**(`f413f8d`):抽屉角标(Miuix `Badge`,放**标题行内**、带 `content-desc="未读 N"`、溢出 `99+`)
+ 已读游标。**已实机端到端验证(服务端证据)**:API `unread=6` 且角标「未读 6」→ 点该行 →
logcat `POST /sessions/web/read (2-byte body)` → `200` → API `unread=0`、角标消失。
读标记策略:**只在两处**推进——`select()`(进入会话)与「`message_persisted` 命中当前会话」;
**不在 `refresh()` 里做**(会把设置/管理页打开期间到达的消息静默吞掉)。
- **A2 `interrupt scope`**(`b780695`):`WsOutbound.Interrupt` 增可选 `scope`;设置项「她忙时发消息」=
**追加队列**(默认档,**根本不发 `interrupt`**)/ **打断**(`scope:"current"`:只取消在跑回合、**保留队列**)。
默认档**逐字节等于旧帧**,有测试钉住。**设置项渲染已实机验证**。
⚠️ 契约里 `all` 是「**清空 pending**」,所以「追加队列」**不能**映射到 `all`(那样自相矛盾)。
> ⚠️ **补记(W25,`feat/W25-interrupt-scope`)**:`b780695` 只做了「**设置项 + 帧形状**」——
> **没有任何发送路径读这个偏好**,所以那之后「打断」与「追加队列」行为完全一样(开关空转),
> `ChatSocket.sendInterrupt` 也一条帧都没发过。现已接上:判据 `interruptScopeForSend(mode, busy)`,
> **默认档恒不发帧**(线上逐字节不变)、选「打断」且**会话正忙**才发 `scope:"current"`、
> **命中空闲会话不发**(服务端 no-op,白发只多一次往返);「正忙」只认服务端 `session_busy` 的
> `busyMode != null`,**不用** `queueDepth`(队列非零 ≠ 有回合在跑)。接线点在
> `ChatViewModel.deliver()` 的通道分支之前,每次投递**最多一条**打断帧。
- **B1 `/upload`**(`cc2f266`):图片分流——**重编码后 >64 KiB 走上传(历史只留 URL),否则内联 base64**。
阈值理由是「内联的成本是**发多少次**而非发一次」:服务端把 `image` 原样落库 ⇒ 每页历史/每次 WS 回放
都重传。✅ **已实机端到端验证**(2026-10-04 深夜):209 KB 图经 Photo Picker 发出 → logcat
`POST /api/v1/upload (96764B)` → `201 Created` → `POST /api/v1/chat (243B body)`(243 字节 ⇒ 带的是
**URL 不是 base64**)→ 落库 `image` = `…/api/v1/files/<32hex>.png` → **冷启动 force-stop 重开后仍渲染**
(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)。
> ⚠️ **更正上文「当前状态」**:`664fd8d` 已过时。最新提交 **`a6b4bae`**(= `origin/android`,四个模块全部已推)。
**并行批次 W4–W7 —— ✅ 全部完成、已合并、已推送**(2026-10-04 深夜)。按开发纲要「多个并行子代理协同」
切分:四个 worktree、**文件集互不相交**(`D:/Project/Code/Uni/Cyrene-Android-W{4,5,6,7}`,分支
`feat/W4-overview` / `feat/W5-persona` / `feat/W6-documents` / `feat/W7-share`,全部从 `664fd8d` 切出):
| 模块 | 内容 | 测试增量 | 合并点 |
|---|---|---|---|
| W4 | 概览页「按路径」26 条的可读性重做(分组/折叠,Miuix) | 353 → 370(+17) | `f91a855` → `a6b4bae` |
| W5 | 新建会话时**选角色**(`POST /sessions {personaId}`,v0.42) | 376 → 408(+32) | `217cfe3` → `557cdb2` |
| W6 | 文档/文件进对话(`documents`)**数据层 + 纯逻辑**,不碰 UI/VM | 408 → 468(+60) | `095d6e7` → `a80dac7` |
| W7 | **分享到 App**(`ACTION_SEND(_MULTIPLE)` + `singleTop`/`onNewIntent` + 进程级暂存) | 353 → 376(+23) | `ae4e4e5` → `4834905` |
**四轮合并在主树跑的都是 `assembleDebug` + `testDebugUnitTest --rerun-tasks`**(不是 FROM-CACHE),
**最终 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)。
**D14「同一时刻只有一个会话在流式」退役**;核实结论:它**从来只是文档约定**,代码里从未强制
(`ChatSocket.sendInterrupt` 至今零调用方)
- **M0–M5 全部完成**,M4 完整(多气泡 / 旁白 / 思维链 / 工具卡 / 图片)
- 交互打磨 B 完成(回车发送、文本选择模式、自动跟随底部、IME 修复)
- 设置页 C-1 完成(主题 / 旁白 / 思维链默认展开 / 关于)
- **C-2 管理员只读监控完成并已实跑验证**:概览(metrics + trace)/ 人格 / 记忆 / 后台思考
四页只读,按 RBAC 分档自动降级(viewer 只能进概览 + 人格),管理区为可加页的壳 + 独立 `Scaffold`
- **B4 完成**(`09a3222`):接入 `GET /api/v1/meta/enums` 权威枚举表,删掉客户端硬编码的
`platform` / `mood` / `mode` 映射表——顺带修掉一处既有漂移(客户端把 `web` 显示成「Web」,
权威表是「网页」)
- **B2 完成**(`f557654`):接入 `/auth/refresh`,**登录口令不再落盘**(迁移对用户无感);登出补调
`/auth/logout`;可恢复的 401 不再登出;并发 401 由单飞刷新 + 代际守卫兜住
- **A4 / D5 / B3 完成**(`c09a211`):会话列表改用**服务端标题**(本地「会话 N」序号机器整块退役)、
置顶(主会话与次级都可)、最后消息预览;并补上**抽屉打开时静默刷新**——此前只在初始化 /
新建 / 删除时拉取,所以这些内容要重启 App 才可见;顺带修掉「快照指纹闸门没实装」与
「预览剥标签导致旁白与正文粘连」两处既有缺陷
- **`history` 串台已修**(`2a49c25`):WS 首帧 `history` 是**主会话**的回放,此前被无过滤地并进
每一个 `ChatViewModel` → 进次级会话会看到主会话的消息。现按**消息自身的 `sessionId`** 过滤
(`historyForSession`,空值放行)
- **工具执行三帧已接**(2026-10-04):后端契约 v0.50 把 `tool_execution` 拆成
`start` / `update` / `end`(载荷 `{tool, phase, id?, delta?}`,**无 `index`**,不属于模型流)。
此前本端只映射 `.update` 且读的是 `tool_call.*` 的 DTO(`start` / `end` 落到未知类型分支被
**静默丢弃**,而 `.update` 后端**没有生产者**)→ 工具执行状态根本到不了 UI。现三帧各有事件类
与解码 DTO,`ChatViewModel` 按 **`id`** 把它们关联到工具卡(`toolExecutionTargetIndex`;`id`
缺席时只在**无歧义**时才落位,绝不默认落第一张),工具卡标题/配色由 `phase` 驱动
`running → 执行中` / `done → 完成` / `error → 失败`。回归测试
`data/ws/WsEventToolExecutionTest.kt` + `ui/chat/ToolExecutionCorrelationTest.kt`
**待做**
1. **遗留(本端,均为既有问题)**:
- **① 连接所有权**:每个 `ChatScreen` 的 VM 各建一条 `ChatSocket`,而 VM 存在 **Activity 级
`ViewModelStore`** 里,所以**访问过的每个会话都永久留一条连接**(含各自的 `OkHttpClient`
与连接池),**删会话也不回收**;后端还会把每帧广播给每条连接。**已不再串台**(`d12a920`
加了会话闸),现在纯粹是资源 / 生命周期成本。自然解法:**共享一条 socket + 按会话路由**。
- **② `ChatSocket.sendInterrupt` 零调用方**——「切会话时打断」这个出口从未接线,是死 API。
(原 ② 的工具执行帧解析缺口已修,见「当前状态」最后一条。)
2. **概览页「按路径」的可读性**:26 条路径挤在一行右对齐文本里,手机上很密——是否改成分组/折叠,
待主人定
3. 子会话流程(主人已确认搁置)
4. 图片 / 工具卡实机验证(后端暂未配置工具)
2. **概览页「按路径」的可读性**:26 条路径挤在一行右对齐文本里,手机上很密——是否改成分组/折叠,
待主人定
3. 子会话流程(主人已确认搁置)
4. 图片 / 工具卡实机验证(后端暂未配置工具)
**接下来可做的大块**(后端都已就绪,几乎全是纯客户端欠账):
1. **REST `POST /api/v1/chat`(`stream:true`)** —— 当前性价比最高的一步:
- 解决「**长文发不出去**」:WS 帧硬上限 4096 字节,且**超限是直接断连**(不是报个错),
而客户端**只有 WS**
- 解决「**图片完全发不了**」:大图走 REST + base64(**先缩放 / 重编码**,见 `02/G3`)
- 是「分享到 App」的前置(`02/F1–F4`)
- **已侦察确认**:REST 发起的回合**会**在已连接的 WS 上流式回来(`stream:true` 与
`stream:false` 都会,同一个 orchestrator + 广播路径),所以能做到「**REST 发、WS 收**」,
**不牺牲流式体验**
- ⚠️ 两个坑必须一起处理:`stream:true` 走**有损总线**且**先回 200**,可能静默什么都没发生
(`02/G2`)→ 以 `message_persisted` 为准 + **绝不自动重试**;全链路**无幂等**(`02/G4`)
2. ~~**顺带解除 A1 / D14**:后端 v0.36 起流帧**已带 `sessionId` + `turnId`**,是本端 `WsEvent`
把字段丢了(`02/G1`)→ 透传并按会话过滤,即可退役「同一时刻只有一个会话在流式」这条长期约束~~
→ ✅ **已完成**(2026-10-04):`WsEvent` 全部流式事件类透传 `sessionId`,`ChatViewModel.onEvent`
按 `isForSession` 过滤(`null` 仍放行);回归测试 `data/ws/WsEventSessionScopeTest.kt`。
D14 **确认只是文档约定**,代码里从未强制。**未做**:连接所有权(每 VM 一条 socket、旧 socket 不关)
仍是遗留问题
3. 其余纯客户端欠账:A3 `mode` / D2 会话内搜索 / D3 消息编辑删除 / A5 正向增量 /
D4 未读(后端 v0.37 已实现)
4. **M6 打磨**(空态错误态、字体放大、冷启动 <1.5s、内存泄漏)与 **P1 语音**
(先真机 spike,验不过就换触发方案)
## 构建(已全部在 PC 上)
开发已迁到 PC(Meowhome),**编译就在本地跑**:
```bat
cd /d D:\Project\Code\Uni\Cyrene-For-Android
gradlew.bat assembleDebug
gradlew.bat testDebugUnitTest
```
> 后端(Cyrene v2)也已迁到同一台 PC,App 的服务器地址指向它即可(见「后端」一节)。
增量编译约 20–40 秒。**每做完一个模块就 push 一次**(主人 2026-10-04 定的默认动作)。
## 验证方式(2026-10-04 主人修订)
**旧的「UI 改动不要自己截图验证」规矩已作废。** 那条规矩的前提是「项目跑在手机上、
主人当时在看别的屏幕,截图也截不到」。**现在开发在 PC 上,主人明确说可以直接抓模拟器窗口。**
所以现在**允许也应该**自己截图验证,方法两条一起用:
```bash
ADB="C:/Users/Aska/AppData/Local/Android/Sdk/platform-tools/adb.exe"
"$ADB" exec-out screencap -p > app/build/e2e-shots/xx.png # 像素
MSYS_NO_PATHCONV=1 "$ADB" shell uiautomator dump /sdcard/ui.xml # 结构化布局(含 bounds)
MSYS_NO_PATHCONV=1 "$ADB" shell cat /sdcard/ui.xml
```
- `uiautomator dump` 拿坐标最可靠(`bounds="[x1,y1][x2,y2]"`),驱动用 `input tap` / `input text`。
它读的是**结构化语义**而不是像素,所以定位点用它、判断观感用截图。
- 截图只落 `app/build/e2e-shots/`(该路径 gitignored),别散落到别处。
- **观感与设计取舍仍归主人拍板**——截图能让你把问题指清楚,但别替主人做审美结论。
### 凭据处理红线
- 真实账号口令**只许运行时用**,**绝不写进任何文件**(源码 / 测试 / 夹具 / 文档 / 临时文件 / commit)。
- **打印任何服务端响应时,`"token"` 和 `"refreshToken"` 都要盖掉**——
曾经只盖了 `token`,导致一个真实 refresh token 明文进了对话记录。
- **`adb logcat` 会记下 `input text` 的命令行本身**,所以用 `input text` 输入过凭据之后,
必须 `"$ADB" logcat -c` 清缓冲,且不要把含凭据的 logcat 存盘。
## 环境要点
| 角色 | 位置 / 说明 |
|---|---|
| 本机 SDK | `~/Android/Sdk`(`platforms/android-37.0` 手工装 + 手写 `package.xml`;`build-tools/37.0.0` 是 ARM64 原生版) |
| 本机 Gradle 配置 | `~/.gradle/gradle.properties`:`aapt2FromMavenOverride`、`sdkDownload=false`、`daemon=false` |
| PC SDK | `C:\Users\Aska\AppData\Local\Android\Sdk`(已补 `android-37.0`),`JAVA_HOME` = JDK 21 |
| PC 项目 | `D:\Project\Code\Uni\Cyrene-For-Android`(`local.properties` 指向 SDK) |
| 后端源码 | `D:\Project\Code\Uni\Cyrene_v2`(**开发期间只读,不得修改**) |
| 镜像 | Maven→阿里云、Gradle 发行包→腾讯、Android SDK 包→腾讯 |
## 后端(2026-10-04 实测确认在线)
| 项 | 值 |
|---|---|
| 端口 | **10942**。已实测确认:`cyrene` 监听 `0.0.0.0:10942`;`GET /api/v1/meta/enums` 无 token 回契约里的 `401 {"error":{"code":"unauthorized","message":"auth: unauthorized"}}` |
| 启停 / 状态 | `Cyrene_v2\scripts\ctrl.bat start|stop|status`(日志 `%USERPROFILE%\cy.log`,pid 文件 `%USERPROFILE%\.cyrene.pid`) |
| 局域网 / 真机 | `http://10.0.0.10:10942`(以太网,网关 `10.0.0.1`)|
| 其他可达地址 | ZeroTier `192.168.0.100` / `10.144.0.100`;Tailscale `100.99.64.15` |
| 登录凭据 | **不写进文档**(`.env` 属敏感,不读不记),问主人 |
**⚠️ 四个坑**:
1. **不要用 8080**。`.env.example` 的 `PORT` 默认值是 8080,但实际配置是 10942,而且
**这台机器的 8080 被一个 PHP 应用占着**(`X-Powered-By: PHP/8.4.1`,对 `/api/v1/*` 回 HTML 404)。
往 8080 打会得到一个「看起来像后端」的 HTML 404,很容易误判成后端起不来。
2. **后端不一定在跑**。2026-10-03 两次实测都是 down(无 cyrene 进程 / 10942 无人监听 /
`curl 127.0.0.1:10942` 连接被拒),2026-10-04 主人说「开了」之后确实在线。
用之前先 `scripts\ctrl.bat status` 确认,**别默认它在跑**。
3. **另一个会话正在实时开发后端**(主人 2026-10-03 说明),且后端仓库**开发期间只读、不得修改**。
因此上表的端口与 `02` 里记的端点形状**都可能随时漂移**——联调前重新核对一次
`Cyrene_v2/Docs/api-client.md` 的契约版本号,别拿旧版本号当依据。
4. **`.env.example` 里的账号只是模板**(`admin` / `admin12345`),2026-10-04 实测登录回
`401 invalid username or password`。
## 模拟器(2026-10-04 实测)
AVD:**`Pixel_10_Pro_XL`**(`emulator -list-avds` 里唯一一个)。SDK 工具在
`C:\Users\Aska\AppData\Local\Android\Sdk\{platform-tools\adb.exe, emulator\emulator.exe}`。
```bash
ADB="C:/Users/Aska/AppData/Local/Android/Sdk/platform-tools/adb.exe"
"$ADB" reverse tcp:10942 tcp:10942 # ← 必须先做,见下
"$ADB" install -r app/build/outputs/apk/debug/app-debug.apk
"$ADB" shell monkey -p top.yeij.cyrene -c android.intent.category.LAUNCHER 1
```
**⚠️ 这个 AVD 没有网络接口**:`ip -o addr` 只有 `lo` 和 `dummy0`,`ip route` 全空,
`ping 10.0.2.2` / `ping 10.0.2.1` 都是 `Network is unreachable`。
**所以模拟器里填 `http://10.0.2.2:10942` 是走不通的。**
绕法:用 **`adb reverse`**,然后把 App 的服务器地址填 **`http://127.0.0.1:10942`**。
已实测通过——设备内 `toybox nc 127.0.0.1 10942` 拿到了后端真实的
`HTTP/1.1 401 Unauthorized {"error":{"code":"unauthorized"}}`。
**设备内没有 `curl` / `wget` / `javac`**(`toybox` 无 `wget` 子命令),探活只能用 `nc`;
且 `nc` 要**把 stdin 撑住**才收得到响应体,否则只出一堆空行:
```bash
"$ADB" shell "(printf 'GET /api/v1/meta/enums HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n'; sleep 4) | toybox nc 127.0.0.1 10942"
```
**签名坑**:模拟器里原本装着一个 2026-05-26 的旧 `top.yeij.cyrene`,与本机 debug keystore
签名不匹配 → `INSTALL_FAILED_UPDATE_INCOMPATIBLE`。必须先 `adb uninstall top.yeij.cyrene` 再装。
**时钟提醒**:模拟器时钟比宿主机慢约 8 小时(约等于 UTC)。界面里 `at` 显示的「本地时间」
是模拟器时区格式化的结果,不是转换错误。
## 易忘的实现约定
- 包名 `top.yeij.cyrene`,应用名 **Cyrene**
- 版本:AGP **9.2.1**(**内置 Kotlin,不能再应用 `org.jetbrains.kotlin.android`**)/ Kotlin 2.4.0 / Compose BOM **2026.05.01** / Miuix **0.9.3** / Gradle 9.7.1 / compileSdk 37 / minSdk 33 / `buildToolsVersion = "37.0.0"`
- **基础图标是 `MiuixIcons.Basic.Xxx`**(文档写 `MiuixIcons.Xxx` 是错的,以 AAR 为准);部分图标在 `icon.extended.*` 下、按 `MiuixIcons.Xxx` 访问(如 `MiuixIcons.Add` / `MiuixIcons.Delete`)——加图标前先看现有 import,别猜
- 列表项一律用 **`BasicComponent`**(Miuix 所有 Preference 的底座,MD3 圆角卡片)——但**它本身没有背景和圆角**,必须外面包一层 `ui/common/PreferenceCard.kt`(`surfaceContainer` + 16dp 圆角);页面底色是 `surface`,两者在浅/深色下都能区分
- **本项目没有导航库**(无 NavHost、无 Navigation Compose)。路由是 `ChatHomeScreen.kt` 里手写的布尔状态 + `BackHandler` 分层。加新页面照这个模式来,别引入导航库
- 输入框在 `Scaffold.bottomBar` + `imePadding()`;**Manifest 必须 `windowSoftInputMode="adjustResize"`**(否则 IME 高度被算两次,输入框会被顶到顶部)
- 系统栏图标明暗在 `app/CyreneRoot.kt` 里随主题切换(`WindowInsetsControllerCompat`)
- 返回层级:管理页→聊天、设置页→聊天、抽屉打开→先关抽屉(`ChatHomeScreen` 里的 `BackHandler`),都关着才回桌面
- 聊天列表 `reverseLayout` + `followBottom` 逻辑(只在滚动结束时更新跟随状态)才能「新消息自动置底、用户上滑时不打扰」
- 文本选择用 `SelectionContainer` 逐块包裹(气泡/旁白/思维卡/工具卡各自独立,「全选」不跨块)
- Retrofit 3 的 kotlinx converter:Kotlin 侧必须用扩展函数 `json.asConverterFactory(mediaType)`(那个 `KotlinSerializationConverterFactory` 只是 JVM facade)
- 明文 HTTP 已放行(`res/xml/network_security_config.xml`),否则自部署地址会报「网络不可用」
- **DTO 不用 enum**:`mode` / `scope` / `kind` / `status` / `role` / `platform` / `mood` 在后端都是**开放集合**,一律 `String` + 客户端映射,未知值**原样显示**(不丢弃/不报错/不占位符)
- **服务端时间有两种形状**,不能共用一个解析器:**RFC3339 字符串**(trace / audit)与 **Unix 毫秒**(`backthink.at`)
- **`auth` 的 `expiresAt` 是 Unix 秒**(不是毫秒)——`AuthManager` 里已经 `* 1000` 再比,别改错方向
- **别假设「文本字段就是纯文本」**:后端的 `backthink.conclusion` 存的是**Web 端标记**
(`<p>` / `<action>` / `<narration>`,甚至有记录以一串空 `<p></p>` 开头)。
渲染前必须过 `domain/parse/ChatContentParser.parse()`——项目里就这么一处标记解析器,
**不要另写正则**。判断依据:只要字段可能来自 Web 渲染链路,就先 `parse()` 再渲染
### 管理区(C-2)的约定
- **权限真源是 `domain/admin/AdminAccess.kt` 的纯函数**(`AdminRole` / `AdminTier` / `AdminPage` /
`canAccess`)。**加一个管理页 = 在 `AdminPage` 里加一条枚举**,入口页、ViewModel、标题都按它驱动
- 角色取自 `container.authManager.state` 的 `SessionState.LoggedIn(baseUrl, role)`
- **后端明确要求客户端自行降级**(`Cyrene_v2/Docs/api-client.md`:「客户端需按本矩阵自行降级」)。
本端做三层 fail-closed:入口页禁用 / ViewModel 短路(**不发请求**)/ 服务端 403 也渲染「无权限」态。
**任何失败都不得渲染成「暂无数据」**——空态只在请求真的成功且列表为空时出现
- RBAC 分档(后端 `api-client.md` §2.0,与 `internal/services/gateway/routes.go` 逐条一致):
- **viewer 可读**(`viewerAuth`):`GET /admin/personas`、`/admin/observability/metrics`、`/admin/observability/trace` 等 12 条只读监控档
- **仅 owner/admin**:`/admin/memories`、`/admin/backthink`、`/admin/users`、`/admin/llm`、`/admin/observability/audit` 等 9 条
- **仅 owner**:35 条写操作 + 凭据路径
- **未知角色按 viewer 降级**(fail-closed),角色名显示原始字符串
- 后端路线:**手机端后续要做「全功能管理」**(`Cyrene_v2/DevDocs/工作小结.md` 主线②),
其凭据前置(`viewerAuth` 接线、契约 v0.40)**已完成**——所以管理区要做成**可加页的壳**,别写成只读死胡同
## C-2 的验证结论(2026-10-04)
**已实跑通过,不再是「只编译过」**。证据:
- 4 页均由 App 真实取数成功(logcat 里 `GET /api/v1/admin/...` 全 200,含响应字节数),
`FATAL EXCEPTION` 计数 0
- 5 个端点的**真实响应体**已作为夹具进 `AdminDtoRealPayloadTest`,全部一次解出,无需改动生产代码
- 截图留档(可自行复核):`app/build/e2e-shots/`
- 未覆盖:**角色降级路径**(手上只有 owner 账号,viewer/admin 的 403 分支、禁用行、
`AdminFailure.Forbidden` 只被单测覆盖);刷新、错误重试态、空列表态;系统返回键
## 坑与勘误(2026-10-04 汇总)
### 客户端结构陷阱(改代码前先知道)
- **每个 `ChatScreen` 的 VM 各建一条 `ChatSocket`**(`ChatScreen` 用 `viewModel(key = sessionId, …)`,
VM 内 `AppContainer.createChatSocket()`)。切会话后**旧 socket 与旧 VM 仍然活着、照收全量广播**
——而广播是发给**每条连接**、**没有按会话订阅**。所以任何「只看帧内容就渲染」的代码都会串台。
**凡流式帧,一律先按 `sessionId` 过滤。**(`2a49c25` 修的是 `history` 那一条)
- **结构化流帧本身就带 `sessionId` + `turnId`**(后端 v0.36 起)。历史上本端 `WsEvent.parse`
把这两个字段丢了,才让 A1/D14 挂了很久——**别再把「流式帧不带 sessionId」当事实**。
- **乐观消息归一化的真实规则是「待发列表里第一条文本完全相等」**(`ChatViewModel.onPersisted`),
**没有** role 判断、**没有**时间窗(`03-客户端细化开发方案.md:57` 的描述与代码不符,以代码为准)。
- **WS 帧超过 4096 字节不是报错,是直接断连**(`ReadMessage` 出错 → `readPump` 退出 → 关连接重连)。
所以**永远不要**把长文本 / base64 图片塞进 WS 帧。
- **`POST /chat` 的 `stream:true` 走有损总线、且先回 200 再问 orchestrator** →
**200 不等于已受理**,要以 `message_persisted` / `session_busy` 是否到达为准;全链路**无幂等**,
**绝不自动重试**。见 `02/G2`、`02/G4`。
- **base64 图片会被原样落库**,于是每页历史都把它再传一遍 → **发图前先缩放 / 重编码**。见 `02/G3`。
- **本地快照一律带 `baseUrl|username` 指纹闸门**:读侧过滤、登出清空(`EnumPreferences` /
`SessionPreferences` 两个实现已对齐)。新加 last-good 快照时照这个来,否则换账号后会闪上个账号的数据。
### 工作流教训
- **子代理的自述不是证据,必须自己复核。** 实例:一个子代理报「没有任何凭据写进文件」,
而 `EnumPrefsTest.kt` 里有真实账号名(已改成中性夹具)。**每个模块都要自己 grep + 直接读 JUnit XML。**
- **编译 + 单测全绿也可能功能静默失效,截图能抓到。** 实例:抽屉「打开时刷新」第一次错误地挂在
`onOpenChange` 上,而 `MiuixDrawer` **只在关闭时**回调它——功能完全没生效,是截图发现的。
这正是 2026-10-04 放开「允许自己截图验证」的直接价值。
- **看像素能抓到断言抓不到的排版问题。** 实例:预览剥标签时把旁白与正文粘成一个词
(`…凑近了一点今天下午…`),单测全绿,是看截图才发现的。
- **别给要常驻的进程设超时。** 实例:模拟器以 1800s 上限后台启动,到点被宿主收割,
连带 `adb reverse` 隧道一起没了。**模拟器不加超时。**
### 文档勘误(已被实测推翻,别再引用)
| 文档 | 写的 | 实际 |
|---|---|---|
| `02/A1` 正文、`03:114-115`(D14) | 流式帧**不带** `sessionId` | 后端 v0.36 起**带** `sessionId`+`turnId`;是本端解析器丢了字段(见上「结构陷阱」) |
| `03:57` | 乐观消息按「同 role + 时间窗 + 文本匹配」归一 | 实际只在待发列表里按**文本完全相等**匹配,无 role、无时间窗 |
| `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`) |
## 后端协同
见 `02-前后端协同事项.md`。开发中一旦遇到后端不支持或未实现,**当场写进该文件并反馈主人**。
**当前状态(2026-10-04)**:`02` 的 A/B 两档绝大多数已闭环——B4(枚举权威表)与 B2(token refresh)
本轮由客户端接入,A4/D5/B3(会话标题 / 置顶 / 预览)也已接入,**A1 已由后端修复、约束只剩客户端一份**
(见上方「文档勘误」)。**尚未闭环的高优先项**:`F3`(上传图片的 URL 在自部署链路取不到 →
B1 的大图场景疑似不通)、`G2`(`stream:true` 可能静默不生效)、`G4`(全链路无幂等)。
## 实机验收方法(2026-10-04 深夜晚补,含本轮踩到的坑)
**图片/大图路径的验收脚本**(已跑通两遍,可直接复用):
```bash
ADB="C:/Users/Aska/AppData/Local/Android/Sdk/platform-tools/adb.exe"
"$ADB" exec-out screencap -p > app/build/e2e-shots/bigtest.png # 天然 >64 KiB,正好走上传
MSYS_NO_PATHCONV=1 "$ADB" push app/build/e2e-shots/bigtest.png /sdcard/Pictures/cyrene-bigtest.png
MSYS_NO_PATHCONV=1 "$ADB" shell am broadcast -a android.intent.action.MEDIA_SCANNER_SCAN_FILE \
-d file:///sdcard/Pictures/cyrene-bigtest.png
# 然后:抽屉 → 进临时会话 → 点「发送图片」(content-desc) → Photo Picker 选图 → 点「发送」
```
**⚠️ Photo Picker 的坐标每轮都不同**(同一个 picker,缩略图两次分别在 `[0,1709][329,2038]` 与
`[0,743][329,1072]`)。**必须每轮重新 `uiautomator dump` 取 `content-desc="Photo taken on …"` 的
bounds 再点**,别复用上一轮坐标(本轮第一次就是按旧坐标点空、白等一轮)。同理,`uiautomator dump`
在**转场动画期间会拿到旧内容**——出手前先睡 3 秒并确认顶部是 `MainActivity`。
**给临时会话起唯一标题**(`POST /sessions` **不收** title,要再 `PATCH` 一次):
```bash
SID=$(curl -s -X POST $BASE/api/v1/sessions -H "Authorization: Bearer $TOK" \
-H 'Content-Type: application/json' -d '{}' \
| /c/Python312/python -c "import sys,json;print(json.load(sys.stdin)['data']['sessionId'])") # ← 键是 sessionId
curl -s -X PATCH "$BASE/api/v1/sessions/$SID" -H "Authorization: Bearer $TOK" \
-H 'Content-Type: application/json' -d '{"title":"E2E-XXX"}' # 抽屉里就能按标题定位行
```
- 本机**没有 `jq`**(`which jq` 为空)→ 解析 JSON 用 **`/c/Python312/python`**。
- 驱动 UI 用 `uiautomator dump` + `tr -d '\r' | sed 's/></>\n</g'` 抽 `text` / `content-desc` 与 `bounds`;
**像素截图只用来判断观感**(本轮就是靠截图发现「缩略图里其实是『App 设置』页」这个关键事实的)。
**从「另一个 App」触发分享的验收手段**(不需要装第二个 App):
```bash
"$ADB" shell am start -a android.intent.action.SEND -t text/plain \
--es android.intent.extra.TEXT "分享测试"
"$ADB" shell content query --uri content://media/external/images/media \
--projection _id:_display_name --where "_display_name='cyrene-bigtest.png'" # 取 media id
"$ADB" shell am start -a android.intent.action.SEND -t image/png -f 0x00000001 \
--eu android.intent.extra.STREAM content://media/external/images/media/<id>
```
- **改动前的基线**(2026-10-04 实测):`am start -a …SEND -t text/plain` 起的是系统 resolver,
Cyrene **不在候选里**(`pm query-activities -a android.intent.action.SEND -t text/plain` 只有 Chrome /
Drive 等)。分享实现后这条命令应当能选中 Cyrene。
- `-f 0x00000001` = `FLAG_GRANT_READ_URI_PERMISSION`,**必须带**,否则 App 读不到那个 `content://`。
- 「未登录先暂存」的验法:先在 App 里登出 → 分享 → 再登录 → 看内容是否落地。
## ② 分享到 App —— 三条路径全部实机通过(2026-10-04 深夜)
APK = 主树 `a6b4bae` 的产物(`app/build/outputs/apk/debug/app-debug.apk`,46 MB),模拟器 `Pixel_10_Pro_XL`,
后端 `adb reverse` 到 `127.0.0.1:10942`。
| 路径 | 证据 |
|---|---|
| **装的 APK 真的在分享候选里** | `pm query-activities -a android.intent.action.SEND -t text/plain` 回 `top.yeij.cyrene.app.MainActivity` 且 `launchMode=LAUNCH_SINGLE_TOP`;`-t image/png` 同样在 |
| 分享菜单出现 Cyrene | `am start -a android.intent.action.SEND …` → 系统 sharesheet 列出 **Cyrene**(第一条),点中即投递 |
| **热启动 · 文本** | 落进会话的是**原样**的 user 气泡(正文一个标记都没加),她读懂了内容;走的 **WS**(logcat 里没有 `POST /chat`,符合「短文本走 WS」) |
| **热启动 · 图片** | `POST /api/v1/upload (96764B)` → `201` → `POST /api/v1/chat (243B)` → `200`(243 字节 = **URL 形态**);落库 `image` = `http://127.0.0.1:10942/api/v1/files/6b442b20….png`;气泡**渲染出了图**,且她**读懂了图里的内容** |
| **未登录态先暂存** | 登出 → 把分享 intent 投给 App → 界面**仍是登录页**、logcat **零 POST/GET**(只暂存、不发) |
| **登录后自动落地** | 登录(`POST /auth/login` → `200`)后**无需任何手动操作**,那条分享文本自动落进会话(DB 实查),她还回了话 |
**测试痕迹已清理**:临时会话已删;误落进主会话 `web` 的两条测试消息用 `DELETE /api/v1/messages/{id}` 删除
(各回 200),主会话已复原。**服务端另有 5 个孤儿上传文件**(`/api/v1/files/{name}`,无删除端点,见 `02/F7`)。
### 本轮的坑(下个会话直接用)
- **`am start` 的 extra 值里不能带空格**:`--es k "a b"` 会被**远端 shell 再拆一次**,`am` 把第二个词当成
`pkg`,报 `unable to resolve Intent … pkg=…`。写成无空格字符串,或整体加引号。
- **`uiautomator dump` 会静默失败并留下旧文件**:转场/动画期间它可能 dump 不出来,这时 `cat /sdcard/ui.xml`
拿到的是**上一次的快照**(本轮据此误判过「还停在分享菜单上」)。对策:**每次用唯一文件名**
(`/sdcard/ui$(date +%s%N).xml`)并检查输出是否为 `UI hierchary dumped to: …`。
- **sharesheet 里的「Direct Share」卡片(`Share with Cyrene` 那种圆角推荐行)对 `input tap` 无反应**:
本轮三次点击都没用,而同一菜单里的**普通应用行**(`Cyrene`)一点就中。⇒ 验收时优先点普通应用行;
或直接 `am start -n top.yeij.cyrene/.app.MainActivity -a …`,输出里的
`intent has been delivered to currently running top-most instance` 就说明走的是 `onNewIntent`(热启动)。
- 抽 `uiautomator` 属性时别用 `s/.*(text|content-desc)="([^"]*)"…/`:`.*` 贪婪,会匹配到后面那个
`content-desc` 并把值抽成空。**只匹配 ` text="…"`**(带前导空格)才稳。
### 本轮之后的新欠账(客户端侧,按优先级)
1. **可观测性下行帧没接**:契约 v0.55 有 `observability_metrics` / `observability_trace` / `observability_log`
(owner/admin,订阅 = 读一次对应 REST),客户端**完全没有 handler**,概览页仍是「进入 / 手动刷新各拉一次
REST」。接上才能实时刷新(要动 `WsEvent`)。
2. **文档附件的 VM/UI 接线**(W6 只做到数据层):清单在 `02` 的「接线时要改哪几处」一节
(`AppContainer` → `ChatViewModel` 的待发附件与回滚 → `sendViaRest` 带 `documents` → `MessageBubble` 渲染 chips)。
**两个产品决定要主人拍板**:正文转文件前要不要先问用户;转成文件后正文还发不发。
3. **分享的已知边界**:进程被杀会丢待落地内容(持有者在内存里);未登录期间界面**不提示**「有一条分享在等登录」;
分享一律落在**当前会话**(不能选会话/角色);多图 = 多回合(4 张 = 4 条消息)。
4. **搜索 / 编辑删除 / `after`** 仍是欠账(`mode` 已随轮次落库,客户端 `A3` 已完成)。
### 交付物
- 可安装 debug APK:`app/build/outputs/apk/debug/app-debug.apk`(主树 `a6b4bae` 构建,**已实机跑过上面全部路径**)
- 仓库:`android` @ `a6b4bae` = `origin/android`,工作区干净,**485 测试 / 0 失败**
### 三条待拍板项的**定案**(2026-10-04,主人:「按你推荐的来」)
1. **概览页「按路径」呈现** —— **保持 W4 的实现,不再改**:分组键 = 剥掉 `/api/v1` 后的首段;条数 >4 的组默认收起、≤4 的默认展开;只有一条路径的组不出组头;路径等宽字体 + 段级中间省略。
2. **新建会话选角色的交互** —— **保持 W5 的实现,不再改**:抽屉「+」→ 弹层(点行选中 → 底部「创建」);角色名挂在**会话行摘要前**(未绑 = 不显示;角色表里查不到该 id = 原样显示 id,不猜、无硬编码映射)。
3. **文档 / 「超限走文件」的两个产品决定**(W6 只做了数据层,接线按这两条来):
- **不做「长正文自动转文件」。** 理由:`POST /api/v1/chat` **没有正文长度上限**(`02` 已核:那个 4000 rune 只属于 `PATCH /api/v1/messages/{id}`),长正文**已经**由既有 `ChatSendRouting` 走 REST;把用户打的正文悄悄换成附件,只会让「历史里搜不到、气泡变成 chip」,是净损失。⇒ **转文件只用于真正的文件附件**(从别的 App 分享进来的文件、或用户自己选的文件)。
- **附件与正文各自独立存在**:用户自己打的字照发,附件随 `chat.documents` 一起发(服务端两种都支持,**纯附件回合也合法**)。**绝不把正文搬进附件**,所以也就没有「转文件后正文还发不发」这个问题。
- 接线时必须如实告知:`document_unreadable`(400) 客户端**基本触发不到**(那要显式索取正文,而 `document-read` 是服务端工具);客户端实际能看到的信号是 DTO 里的 `status:"unreadable"`。`truncated` 只表示**注入被预算截断**,**不是**「读不到」——两个信号不许互相顶替。
- 附件上行载荷:`"documents":[{"name":"<32hex>.<ext>"}]`,**≤5 条、只给服务端存储名**;带附件一律走 REST(客户端策略)。**`name` 是唯一上行字段**。
## W8–W10 批次 + 芯片修复(2026-10-04 深夜)
**四个模块全部合并、已推 `origin/android`,主树每一轮都跑 `assembleDebug` + `testDebugUnitTest --rerun-tasks`**(不是 FROM-CACHE),逐轮测试数我自己读 JUnit XML 求和:
| 模块 | 内容 | 测试 | 提交 |
|---|---|---|---|
| W8 | 设置页「版本」行可点 → **关于页**(应用名 / 版本取自 `BuildConfig` / 简介 / 作者 `AskaEth`) | 485 → 487 | `3ed2377` → `f24877d` |
| W10 | 接上**可观测性三帧**(`observability_metrics/trace/log`),概览页实时刷新 + 「最近日志」区块 | 487 → 516 | `b5e6f78` → `93941cd` |
| W9 | **文档附件全链路接线**:选文件入口(SAF)/ 待发芯片 / 上传 → `chat.documents` / 气泡芯片 / 分享进来的**文件**走文档通道 | 516 → 567 | `30180ab` → `ab76798` |
| — | 契约 v0.59 已发布 → 更正 W6 那批「未发布」注释(纯注释) | 567 | `2973391` |
| — | `devdocs/` **纳入版本库**(`.gitignore` 移除该条) | — | `2369dd2` |
| — | **附件芯片改换行排布**(修主人发现的「多附件被裁到屏幕外」) | 567 | `ca5596a` |
**已实机验证**:关于页(点版本进 → 返回一次到设置、再一次到聊天,层级正确);附件芯片换行(主会话那条**三附件**消息现在渲染成三行、三枚芯片与各自的状态行全部可见)。
### 踩到的坑(重要,别再重复)
- **模拟器「未响应」弹窗的根因是 AVD 内存配小了**:`hw.ramSize=2048` / `vm.heapSize=256`,而镜像是 **API 37 / 16k page / 1344×2992@480dpi**。冷启 2 分钟后 guest `load average` 冲到 **52.96**,`system_server` / `SystemUI` 被反复挤压 → 不停弹 `isn't responding`。**修法(不动 AVD 配置)**:启动时加 `-memory 6144`;另把闲置的 Gradle daemon 停掉(`gradlew --stop`,两个 daemon 白占 4 GB)→ 宿主空闲内存 5 GB → 13.9 GB,6 GB 后 guest 负载降到 ~20、**ANR 归零**。若要固化,改 `config.ini` 的 `hw.ramSize`(那文件在项目外,需主人点头)。
- **在跑构建时 `adb reboot` 会把模拟器卡死在开机中途**:`sys.boot_completed` 永远空、`dev.bootcomplete` 空、logcat 里 init 反复报 `Could not find 'aidl/activity' for ctl.interface_start`(= `system_server` 没起来),此时 `adb install` 会**永远挂住**。**修法:`adb emu kill` 后冷启**,别指望它自己好。
- **`uiautomator dump` 会静默失败并留下旧文件** → 每次用唯一文件名;**`am start` 的 extra 值不能带空格**(远端 shell 会再拆一次);sharesheet 的 **Direct Share 卡片点不动 `input tap`**,点普通应用行或用 `-n` 直投。
- **双击坐标复用会点错**:本轮「记住了」的坐标在两次会话里分别落到了「添加文件附件」(意外开了 DocumentsUI,反而算半个正面证据)和「与昔涟」顶栏标题上。**每次都从新 dump 取 bounds**,并按 x/y 区间消歧(抽屉行 vs 顶栏标题同名)。
- **提交信息里别用反引号**:`git commit -m "…`文件名`…"` 在双引号里会被 bash 当命令替换执行,信息被吃掉一段(本轮真踩了,已用 `-F 消息文件` amend 修回)。**含反引号的提交信息一律走 `-F`。**
### 契约侧(已登记 `02`)
- **`api-client.md` 已发布 v0.59**(§1.2 文档存储与摄取契约 + 端点表 16/17)。我们实现的形状**逐条一致**(`file` 部件名、201/200+`deduped`、20 MiB、≤5、只收 `name`、相对 `url`、嗅探基类型、`documents` 数组含 `chars`/`truncated`/`status`、0 个附件时该键省略、4 个错误码);白名单在契约里也已更正为 **22 项**。
- 新登记的 `02` 项:`F7`(上传产物无删除端点 ⇒ 客户端无法清理孤儿)、可观测性订阅**会被「没有在线 WS 连接」剪掉**(客户端必须持连接)、日志**没有历史引导**(本端还没接 `GET /admin/observability/logs`,所以「最近日志」只显示进页面之后产生的)。
- **`G5` 已由后端落地**(`pending_full` 带 `clientMsgId`)⇒ 队列满时客户端从此能**精确定位是哪条气泡被丢**;本端还没接(现状仍会让那条气泡永远挂「发送中…」)——**这是下一批的候选**。
### 主人给了一个调试用 `X-API-Key`(**owner 级**,值不写进任何文件)
`GET /api/v1/sessions`、`/api/v1/admin/observability/{metrics,logs}` 全部 200 —— 联调可以**不再用口令登录**(更省事也更干净)。用法:`curl -H "X-API-Key: <值>"`。
### 新欠账(按优先级)
1. **附件预览 / 打开**(主人 2026-10-04 问的):`DocumentRepository.download(name)` 在 W6 就写好了、**零调用方**;消息 DTO 只带引用与统计(契约刻意),所以预览要**按需拉**。要做三件:① `FileProvider`(**manifest 现在没有任何 provider**,`file://` 交给别的 App 会抛 `FileUriExposedException`)② 下载到 `cacheDir/documents/<name>`(名字是内容寻址 + 服务端 `immutable` ⇒ 缓存永不失效)③ 预览要**截断并如实说明**(20 MiB 文本不能一把塞进 `Text`)。**两个决定待主人拍**:点芯片的默认动作(App 内预览 / 交给别的 App / 两者都给)与截断阈值。⚠️ `/documents/{name}` **要 Bearer**,所以**不能**照图片那样让 Coil 直接啃(Coil 用的不是挂了 `AuthInterceptor` 的那个 client)。
2. **`pending_full` 的 `clientMsgId` 接线**(后端已就绪,见上)。
3. **`GET /admin/observability/logs` 接线**(给「最近日志」补历史引导)。
4. 搜索 / 编辑删除 / `after` 仍未动;可观测性 `observability_audit` 后端仍未做(审计仍是 REST)。
## W11–W12 批次 + 一个实机抓到的真 bug(2026-10-04 深夜)
| 模块 | 内容 | 测试 | 提交 |
|---|---|---|---|
| W12 | 「最近日志」接上 `GET /admin/observability/logs` 引导读取(历史 + 帧增量;**viewer 有历史、无增量**) | 567 → 591 | `642bdb1` → `a7dbee8` |
| W11 | **附件预览**:点芯片开弹层(云端按需拉、256 KB / 5000 行先到者为准、分块渲染)+「用其它应用打开」「保存到下载目录」(FileProvider) | 567 → 610 | `27b6910` → `60b0eac` |
| — | **修**:保存附件被 SAF 补成双扩展名(`.md` → `.md.txt`) | 634 | `ffa7ed2` |
**合并后 634 测试 / 0 失败**(= 567 + 24 + 43,加法逐轮吻合)。
### 这两块的实机验证(这次抓得很细)
- **W12**:概览页三路并发 `GET /admin/observability/{logs?limit=200, metrics, trace?limit=100}` **全 200** —— logs 的 REST 引导**确实发出去了**,这就是 W10 那个「只显示进页面之后」缺口的闭环证据。
- **W11 附件预览**(用的就是 `E2E-DOC` 会话里那条真实附件):
- 点芯片 → 弹层里**显示的正是从服务端取回的内容**(我那张纸条的正文);logcat 硬证据:
`GET /api/v1/documents/809320….md` → **200(95 字节)**;
- **零 `FileUriExposedException`**;
- 「用其它应用打开」在这台**裸模拟器没有能读 `text/plain` 的应用**时,如实回了一句「没有应用能打开……可以『保存到下载目录』后再处理」,**没有静默失败**;
- 「保存到下载目录」→ SAF 的建议名 = **内容寻址存储名**(界面里同时如实写明「原始文件名不会返回给客户端」)→ 落盘 95 字节、内容与弹层一致。
- **抓到的真 bug(已修)**:保存那条一开始落盘成 `…e.md.txt` —— `CreateDocument` 传的是**嗅探基类型**(`.md` 也报 `text/plain`),SAF 就按它补了 `.txt`。**修法:保存这条路传 `*/*`**(没有可补的扩展名),而**打开那条路继续用 mime**(那里要的正是「能读懂这段内容的类型」)。重测落盘为 `…e.md`。
### 新坑
- **GMS 自己会 ANR**:模拟器上 `Google Play services isn't responding` 会挡在界面前面(**与我们的 App 无关**,6 GB 内存也照样偶发)。对策:`adb shell am force-stop com.google.android.gms`,立刻清掉接着干活。
- **`uiautomator dump` 的静默失败要包重试**:本轮把它包成「重试 3 次 + 检查文件非空」才稳定下来。
- **「预览 / 打开 / 保存」三条出口的类型选择是不一样的**(见上),别互相套用。
### 仍欠(按顺序)
1. **`F7` / 原始文件名**:契约不给 `originalName` ⇒ 保存只能用内容寻址的存储名;已把这件事**写在界面上**(不编假名)。若后端补 `originalName`,客户端只需改一处。
2. **`pending_full` 的 `clientMsgId` 接线**(后端已就绪 —— `05` 旧条目里的 G5)。
3. 搜索 / 消息编辑删除 / `after`。
4. `observability_audit` 帧(后端未做,审计仍是 REST)。
> 另:`E2E-DOC` 测试会话**暂时保留**当后端异常的现场证据(纯附件回合没有助手消息 + 两次 `knowledge` 工具失败,见 `02` 的最新登记);对面回执说可删就删。
## W13–W14 批次(2026-10-04 深夜)
| 模块 | 内容 | 测试 | 提交 |
|---|---|---|---|
| W13 | 队列满时**定位并处置那条待发气泡**(`pending_full` / 定向 `chat_busy` 的 `clientMsgId`)——修「气泡永远挂发送中…」 | 634 → 664 | `3b4a790` → `9854cc4` |
| W14 | 点气泡里的图片进**全屏查看**(双击/捏合缩放 + 拖动 + 边界钳制,返回键只关弹层)+「用其它应用打开 / 保存到下载目录」 | 664 → 706 | `557ef3f` → `f109846` |
**合并后 706 测试 / 0 失败**(= 634 + 30 + 42,加法吻合)。`android` = `origin/android`。
### W13 的判定顺序(**证据强度优先,改之前先读**)
1. `clientMsgId` 命中某条待发气泡:`messageId` **非空 ⇒ 收编**(那条其实早就落库/被认领了,不能当失败);**缺席 ⇒ 回滚**(正文/图片/附件一起还给输入栏 + 一句人话)。
2. `clientMsgId` **有值但不命中 ⇒ Ignore**:带键的帧只属于某一次具体发送,键对不上就不是本端这条。**同一帧到两次也落这一档** —— 这是「广播 + 定向双到达」的幂等来源(不靠去重表,靠状态本身:气泡已经不在待发列表里了)。
3. `clientMsgId` **缺席 ⇒ 不猜**(老服务端 / 那条消息本就没带键):待发为空 → Ignore;**恰好一条 → 回滚它**;**两条及以上 → 只提示、一个气泡都不动**。沿用 `toolExecutionTargetIndex` 那条先例。
### ⚠️ W13 顺手查出的两条「文档与实现不符」
- `PendingMessage` 的 KDoc 写着「手动重试可以复用同一个 id」,但 `emitAndDeliver` **每次发送都新铸 `localId`** ⇒ 若某次失败其实是「服务端已收下、客户端判成失败」,用户手动重试**会变成第二条消息**(重复)。**要不要做「回滚后按内容指纹复用原键」是产品决定,本模块没擅自改**(改了才对得上 KDoc,但那是行为变更)。
- REST `409 chat_busy` 的旧文案已与 WS 侧**统一成同一句**(同一个 code 不该有两种说法)。
- 另:`WsEvent` 里**没有新增独立事件类**,而是扩了既有 `Error` 并保留 `code` 原文——因为定向 `error{code:"chat_busy"}` 与既有 `error` 是**同一个 `type`**,一个 type 无法用两个事件类表达。
### W14 实机验收(都在这台模拟器上点过)
- 弹层渲染正常(图片本体 + 两条出口 + 「原始文件名不会返回给客户端」那句诚实说明);**返回键只关弹层**,回到聊天、没退到抽屉/桌面。
- 保存落盘 `748a6a73….png`(**96,585 字节,没有被补成 `.png.png`**)—— W11 那条「保存一律传 `*/*`」的结论在图片上同样成立。
- ⚠️ **取字节那条路 logcat 看不到**:W14 对能力 URL(`/api/v1/files/{name}`,免鉴权)用的是**裸 OkHttp 客户端**(不发 Bearer、**也不挂 logging**)⇒ 证据改为**设备上的缓存文件**:`cache/documents/748a6a73….png`(96,585 字节,与保存出来的一致)。
- **未验**:手势手感(捏合焦点 / 双击判定 / touch slop)只有单测保证;Coil 的 `File` 分支没在真机确认;**待发图**有 20 MiB 上限(发送时 `ChatImagePreparer` 会缩到 ≤5 MiB,所以**落库后**的同一张图正常 —— 只有待发气泡那一段是死胡同)。
### 后端回执七要点(已入库 `9e03986`)
- **大附件那道坎修好了**(`51c1672`):输入护栏上限不再硬编码 262144,改为按推导预算(1M 窗口 ⇒ **699,050 字符**)+新 env `GUARDRAIL_MAX_INPUT_CHARS` ⇒ **可以恢复大文件路径**;「多份各自撞注入预算」还差一刀(G2b 在跑)。**这也解释了主会话那条 726 KB 的 `.txt` 回合为什么回 `input_length_exceeded`**。
- **`X-API-Key` 是纯内存表**:进程重启即全部失效 —— **实测已经 `401`**(后端在这期间重启过)。要长期用 key 需等 K1 落 SQLite,或请主人再签发一个。
- IM 侧:bridge 摄取已通,但适配器只上报 URL/ID 不带字节 ⇒ 真机目前只会得到「无法读取:渠道只给了远程引用」。
- 面板将新增只读 `GET /api/v1/admin/system/storage`,契约升 **v0.60**(纯新增,客户端忽略未知字段即可)。
## W15–W16 批次(2026-10-04 深夜)
| 模块 | 内容 | 测试 | 提交 |
|---|---|---|---|
| W15 | 手动重试同一条内容时**复用原幂等键**(改过的内容照旧铸新键) | 706 → 729 | `af11a94` → `8b90703` |
| W16 | **会话内搜索 D2**(顶栏入口 + 搜索面板 + 点结果定位) | 729 → 737 | `23b46f9` → 见 merge commit |
**合并后 737 测试 / 0 失败**(= 706 + 23 + 31,加法吻合)。`android` = `origin/android`。
### W15:三条必须记住的取舍(**改之前先读**)
1. **指纹是「值比较」不是哈希**:摘要一旦碰撞,两条**不同**的消息会共用一个键、后一条被服务端判重**静默丢掉**。比较只发生在进程内,值比较碰撞面为零、也没有收益可图。
2. **正文一个字都不归一**:只多敲一个空格也算「改过」。反过来(归一后再比)会让**用户真打算发的两条消息并成一条**,而「改了却复用旧键 ⇒ 服务端判重 ⇒ 编辑被静默丢掉」才是真正要防的那一半。
3. **晚到的 `message_persisted` 刻意不清键**:那条帧恰恰是「服务端其实收下了、客户端当时判成失败」的**证据**;键留着,用户的重试才会被判重而不是落成第二条。**谁按「成功即清」的字面去那里加一句 `forget`,就把这个 case 打回原样。**
4. 附件只看**本地 URI(有序)**、不看 `displayName`/`sizeBytes`:那两项是元数据,同一份文件两次查询完全可能一次报得出、一次报不出,不影响「是不是同一份文件」。
**待主人确认的产品取舍**:记忆是**单槽**(只记最近一次失败那条)。正常流程下更早失败那条的内容早已不在输入栏,所以单槽够用;要改成多槽说一声。
### W16:搜索的落地形状
- 入口在**聊天页顶栏**;面板**叠在整页之上**(自带顶栏 + 输入框自动聚焦 + IME 搜索键 + 旁边的放大镜按钮);结果 = 角色 / 时间 / 命中片段(命中子串加底色);**四态分明**(未搜 / 搜索中 / **空结果** / **失败**,失败**绝不**画成「没找到」)。
- 片段先过 `domain/parse` 的 `parse()` 再拼回**纯文本**(正文/旁白/动作全保留),**绝不把 `<p>`/`<action>` 当富文本**;只在纯文本上加底色。
- **分页**:滚到末尾自动 `before + limit`;翻页失败**不清空已有结果**、尾部红字 + 重试、**不自动重试**。
- **点结果**:在已加载窗口内 → 关面板 + 滚动定位 + 横幅;**不在窗口内 → 如实说**(按时间分三种措辞)。**没有插临时气泡**——列表是 `reverseLayout`,凭空插一条几千条前的消息会出现在「刚发出的位置」、与自己的时间戳自相矛盾,还会带偏 `followBottom`。要真正并进消息窗得改 `ChatViewModel`(本模块不许碰)⇒ **列为下一批**。
- **状态归属**:`SessionSearchViewModel`,按会话各一份(`viewModel(key = "session-search:<id>")`),随 Activity 存活(关面板不丢结果)。
- **返回键**:`ChatScreen` 内新增的 `BackHandler(enabled = searchOpen)` 注册在 `ChatHomeScreen` 那几条之后 ⇒ 优先级更高,**先关搜索**;搜索没开时 disabled,既有抽屉/管理页/设置页层级**一个字节没动**。
- **未验**:UI 全部没实机跑过(自动聚焦 + IME、高亮对比度、滚动落点、图标长相都只有编译 + 推演);`q` 只验到 Retrofit 层(对本地假服务端断言原始 URI),**服务端是否真按契约实现 `q` 没验**。
### 新登记(见 `02`)
W16 报上来六条**契约没写透**的口径,其中两条值得扫一眼:**`before` 是含还是不含游标**(契约只对 `after` 明说)、**`q` 匹配的是含标记的原始 `content`**(搜 `action` 会命中、但我们**不画高亮**,不假装命中)。
## 装机验收轮(2026-10-04 深夜 · 设备证据存档)
主人要求「每项都要**设备证据**,不能只看编译过了」。设备:模拟器 `Pixel_10_Pro_XL`(`-memory 6144`),App 的服务器地址 `http://127.0.0.1:10942`,`adb reverse tcp:10942 tcp:10942`。
### ② W18 `.pdf` 非文本拒收 —— ✅ 通过
用 API 造了一份**带文本层的真 PDF**(自拼对象 + 正确 xref,563 字节,文本 `Cyrene PDF e2e: meow-314`)→ `POST /documents` → `POST /chat` 挂进会话 `E2E-PDF`。落库形状:
`documents: [{"name":"0efe0a2a….pdf","mime":"application/pdf","size":563,"chars":24,"status":"extracted"}]`
(**`chars:24` 正好等于那份 PDF 里那句文本的长度** ⇒ 文本层真被抽取;也顺带证明我拼的 PDF 是合法的。)
设备上点那枚芯片,弹层显示:
```
附件预览
0efe0a2a13c2583568904105946f91a6.pdf
PDF · 563 B · 24 字 · 已读入
这是 PDF,App 内不预览这类内容,请用「用其它应用打开」或「保存到下载目录」
用其它应用打开 / 保存到下载目录 / 关闭
服务端只保留了内容寻址的存储名…原始文件名不会返回给客户端
```
⇒ **正文区是那句人话,不是一屏二进制** —— 洞堵住了。截图 `app/build/e2e-shots/w18-pdf-preview.png`。
(同时印证了登记进 `02` 的那条「措辞别扭」:芯片说**已读入**、弹层说**不预览**,两句都真。)
### ③ W15 重试复用幂等键 —— ✅ **可验部分**通过(另半边由单测背书)
会话 `E2E-RETRY`:
| 步骤 | 结果 |
|---|---|
| **硬断开**(`adb kill-server` 掐断 reverse 上的在途连接)→ 点发送 | **失败回滚**:正文 `W15-retry-2` **被还回输入框**,没有变成卡死的「发送中…」气泡 |
| 恢复隧道 → 等 WS 重连 → **原样重发** | 服务端**只有 1 条**该内容的消息(**不重复**) |
| 落库 | `clientMsgId: local-7c99433b-…` —— 幂等键确实在链路上 |
⚠️ **如实标明边界**:「复用**同一个** id」那半边在本机**证不出来** —— 那次失败根本没到服务端,所以 id 从未被服务端见过,重试是「铸新键 + 正常收下」,结果同样是**一条**。那半边的证据只有单测(W15 的 13+10、W17 的 16、W21 的 +7,其中一条专门钉住「失败记键 → 重发复用 → duplicate → 屏幕上只剩一条」)。**不许把它说成「设备上验过了」。**
### ④ W19 消息编辑删除 —— ✅ 通过
| 环节 | 证据 |
|---|---|
| 角色门 | 同一条 `⋯`(`content-desc="消息操作"`):**user** 消息 → 「编辑 + 删除」;**assistant** 消息 → **只有「删除」** |
| 编辑弹层 | 标题「编辑这条消息」、内容框里是**原始 content**、右下角**码点计数 `11 / 4000`** |
| 编辑执行 | logcat **`PATCH /api/v1/messages/696cdf76…`(32 字节)→ `200`**,恰好一次;服务端同一条 id 内容变成 `W15-retry-2-EDITED`(**原位改、无第二条气泡**) |
| 二次确认 | 「删除这条消息?」/「它会从所有端移除,且不可恢复。」 |
| 删除执行 | logcat **`DELETE /api/v1/messages/f9c53d22…` → `200`**(56 字节);服务端那条 assistant **已消失**、界面气泡消失 |
### ① W16 会话内搜索 —— ✅ **通过**(⚠️ 并附一条我自己制造的假警报与教训)
**结论先改**:这一项**本来就是好的**。下文保留那条「不可用」的原委,以免重犯。
真正的设备证据(都取到了):
| 环节 | 证据 |
|---|---|
| 入口 | 顶栏右上角放大镜 `content-desc="搜索会话里的消息"`;它的**可点父节点**是 `[1164,162][1308,306]`(144px = 48dp 标准触控区) |
| 打开面板 | **像素确认**:不透明盖满整页、顶栏「搜索会话」+ ✕、输入框**自动聚焦**(蓝色边框)且 IME 弹起、中间「输入关键词,搜索这个会话里的消息」 |
| 搜索 | 输入 `W15` 提交 → logcat **`GET …/messages?limit=30&q=W15` → `200`(583 字节)**;顶栏「**2 条命中**」+ 两行结果(`我 / W15-retry-2-EDITED / 10-04 13:32`、`我 / W15-key-test / 10-04 13:30`) |
| 点结果定位 | 面板关闭 + 顶部横幅「**已定位到这条消息**」 |
| 返回键层级 | 再开面板(dump 数到「搜索会话」= 1)→ 按返回 → **像素确认回到聊天页**(面板关、IME 收起、消息与输入栏都在),`topResumedActivity` 仍 `MainActivity`(没退到抽屉/桌面) |
**⚠️ 那条假警报的原委(值得记,别再犯)**:我先前连点两次入口后 dump,都「看到聊天页、且多出一个 `EditText [36,339][1176,509]`」,于是判「面板没渲染、尺寸塌了」。**真相是 `uiautomator dump` 在「面板弹出 + IME 动画」期间会静默失败,而我读到了上一次的旧文件**——把「一个正常的输入框」读成了「面板塌掉的残骸」。改用**像素**(`screencap`)一看,面板好端端盖着整页。
**代价与教训**:我为此派了两个代理去修「非 bug」(W20 零输出挂死 14 分钟、W22 被停掉),**都零改动、没污染仓库**。两条纪律:
1. **动代码之前先用像素确认症状**(`uiautomator dump` 只用来取坐标,不用来判定「界面有没有变化」);
2. 当**症状与读出来的结构矛盾**时(根容器 `fillMaxSize()` + 不透明底色 ⇒ 结构上不可能「看不见」),**先怀疑自己的测量**,再怀疑代码。
**另记一处「不是错但不该这么写」**:`SessionSearchPanel.kt:105` 在 `Column` 里给子项用了 `Modifier.fillMaxSize()`(正确写法是 `weight(1f)`)。实测**当前不影响观感**(面板内容居中正常),所以**我没有改**——没有证据的改动只会引入风险,留在这里备案。
### 本轮新增的坑(客户端侧方法论,值得单独记)
1. **`adb reverse --remove tcp:10942` 断不开已经建立的连接**(reverse 规则只影响**新建**连接)⇒ 拿它造「发送失败」是**无效**的(那次发送照样成功落库)。要真断得用 `adb kill-server` / `start-server`。
2. **两个子代理同时跑 Gradle 会抢 `~/.gradle/caches` 的文件锁**,表现就是**零输出挂死**(本次 W20 静默 14 分钟就是它)。⇒ 给子代理加「**某条命令 3 分钟无输出就报告**」的约束;也别让它们跑多轮 `--rerun-tasks`。
3. **「文件还在」≠「消息还在」**(后端已确认:他们现在有 GC,未引用且超 7 天的文档会被删;`GET /api/v1/files/<name>` 只证明字节在)。⇒ 别拿它当存活证据。
4. **`mysql`/`uiautomator dump` 在转场期会静默失败并留下旧文件** ⇒ 唯一文件名 + 重试 3 次 + 检查非空(这条早就记过,本轮又踩到一次)。
### 验收用的会话(**都留着,未清理**)
`E2E-DOC`(纯附件回合没助手消息的现场)、`E2E-PDF`(带 PDF 附件、回合正常)、`E2E-RETRY`(含一条**被编辑过**的消息 `W15-retry-2-EDITED`)。主人说清再清。
## ★ 当前状态与待办(恢复锚点 · 2026-10-04 深夜)
> **压缩/换会话后先读这一节**,它是对「此刻到底在哪」的一句话答案;细节在上面各批次里。
### 状态
- **`android` = `origin/android` = `1794e53`,工作区干净**,**884 测试 / 0 失败 / 0 错误**(`--rerun-tasks` 全量重跑后自己读 JUnit XML 求和)。
- 可安装包:`app/build/outputs/apk/debug/app-debug.apk`。
- 那轮「装机验收」的**四项全部有设备证据**(含 ① 的更正:本来就是好的,是我测量假象):见上一节「装机验收轮」。
- **联调凭据(压缩后最容易丢,务必先看这条)**:后端调试用的 `X-API-Key` **已由主人授权落地到本机 `dev-secrets.local.md`**(仓库根目录,**已 gitignore、不在仓库里**——已用 `git check-ignore` / `git ls-files` 双重核对)。用法:`curl -H "X-API-Key: <该文件里的值>" $BASE/api/v1/…`,**owner 级**(`/sessions`、`/admin/observability/{metrics,logs}`、`/admin/system/storage`、`/admin/personas` 全 `200`;不带 key 回 `401`)。**上一个 key(`rG14nMNX…`)已失效(实测 `401`),不要再试。** 轮换只改那个文件,代码/文档里没有任何硬编码。
- **`devdocs/` 本身是入库的**(主人 2026-10-04 授权),所以本锚点跟着仓库走(`git log devdocs/` 可查);但**凭据绝不进 `devdocs/`**,只在那份 gitignore 的文件里。
- 模拟器:AVD `Pixel_10_Pro_XL`,**启动需加 `-memory 6144`**(否则 2 GB 会自己 ANR,见「踩到的坑」),`adb reverse tcp:10942 tcp:10942`,App 服务器地址填 `http://127.0.0.1:10942`。
### 本轮(W8→W22)已落地并推送的模块(一行一个)
| # | 内容 | 提交 |
|---|---|---|
| W8 | 设置页「版本」行 → **关于页** | `3ed2377`→`f24877d` |
| W10 | **可观测性三帧**(metrics/trace/log)→ 概览实时刷新 + 最近日志 | `b5e6f78`→`93941cd` |
| W9 | **文档附件全链路**(选文件 / 待发芯片 / 上传 / `chat.documents` / 气泡芯片 / 分享文件走文档) | `30180ab`→`ab76798` |
| — | 契约 v0.59 已发布 → 更正旧注释;`devdocs/` **入库** | `2973391` `2369dd2` |
| W13 | **队列满处置那条气泡**(`pending_full` / 定向 `chat_busy` 的 `clientMsgId`) | `3b4a790`→`9854cc4` |
| W14 | **图片全屏查看** + 打开/保存 | `557ef3f`→`f109846` |
| W15 | **重试复用幂等键**(按内容指纹) | `af11a94`→`8b90703` |
| W16 | **会话内搜索**(顶栏入口 / 面板 / 点结果定位) | `23b46f9`→`46ac15a` |
| W17 | 重试记忆改**多槽** | `d3fc0c8`→`440e441` |
| W19 | **单条消息编辑与删除** | `3518917`→`ea16f48` |
| W18 | **跟上 v0.61 的 `.pdf`**(App 内预览对非文本拒收,不再出乱码) | `48e4188`→`1f9d1dd` |
| W21 | 重试记忆加 **10 分钟时间上界** | `8938fff`→`df63783` |
(W20/W22 是**修「非 bug」的空转**,零改动,见上一节的教训。)
### ★ 本轮(压缩恢复后 · 2026-10-04 深夜续)已落地并推送
| # | 内容 | 提交 |
|---|---|---|
| — | 收下后端**回执十一**(四点确认 + `knowledge` 已立案 + IM-1/U1 在实施) | `e4915a6` |
| A5 | **`after` 正向增量**(断线补洞):掉线记游标 / 重连正向追 / 补拉循环抽成纯函数 | `28c3142` |
| — | **`404 session_not_found` 兜底**:非主会话读不到 ⇒ 如实说一句 + 自动退回主会话 | `28c3142` |
| W23 | 接上契约 v0.63 的 **`documents[].reason`**(只在 `unreadable` 时下发,原样显示) | `2d382b1`(`e5fa1b8`) |
| W24 | **编辑弹层只显示纯文本**(真有标记时才剥;弹层明说会替换掉旁白) | `1794e53`(`66c2291`) |
**这一轮的两条关键事实(复用价值高)**
1. **可编辑的只有 `user` 消息,而 user 消息存的是用户原文、从不带标记** —— 我扫过线上主会话 64 条 user 消息,**零条含标记**;所有 `<p>` / `<action>` 都在 assistant 消息里(assistant 不可编辑)。所以 W24 的「剥标记」在今天是一条**防御性口径**,对真实数据是恒等变换;`hasMarkup` 的判据因此取的是「`text != content`」(= 保存真的会替换掉什么),而不是「出现过 `<`」。
2. **`after` 是升序、`before` 是倒序**,且 `after` 方向的下一条游标是**本页最新**那条(不是最旧的)。服务端按 `limit` 从最旧一端给,所以取最旧当游标等于原地不动。同毫秒并列超过一页时纯 `createdAt` 游标推不动(契约 §A5 明写的边界)⇒ 循环里必须有「游标严格前进」+「轮数上限」两道闸,否则无限请求。
### 待办(按优先级)
1. **W19 剩下的那一处**:**删除后抽屉里的「最后消息预览」不刷新**(本端只改了消息列表,没触发 `GET /sessions`)。① 编辑口径已由主人拍板并落地(W24)。
2. **遗留 ①:连接所有权** —— 每个会话一个 `ChatScreen` 的 VM 各建一条 `ChatSocket`,而 VM 活在 Activity 级 `ViewModelStore` 里 ⇒ 访问过的每个会话都**永久留一条连接**(含各自的 `OkHttpClient` 与连接池),**删会话也不回收**。自然解法:**共享一条 socket + 按会话路由**。
3. **`ChatSocket.sendInterrupt` 零调用方**(「切会话时打断」这个出口从未接线,是死 API)。
4. `observability_audit` 帧(**后端未做**,审计仍是 REST);`POST /admin/users` 仍是 `501` ⇒ 铸不出 `viewer` 账号,viewer 降级只能单测背书。
5. 子会话流程(主人已确认搁置)。
### 本轮**未验证**的(别当成已验证)
- **`after` 的设备侧验收**:真机上**确实打出过**真请求 —— `GET /sessions/web/messages?limit=50&after=1791121966668` → `200`(说明「重连补洞」那条路真的在跑)。但**「断线超过 50 条」那个洞没造出来**(要造得让后端不可达再灌消息,而后端当时正被另一个会话实时开发,我实测到一次 `HTTP 000`)⇒ **补洞的端到端仍是未验证**。逻辑侧由 `ChatRecoveryTest`(循环两道闸)与 `MessageAfterRepositoryTest`(真 OkHttp 断言线上 URI)钉住。
- **`404 session_not_found` 兜底:已装机端到端验通**(`b5c0b13`)。证据:logcat `GET /sessions/web:{已删}/messages?limit=50` → **404** →(0.9 s 后)`GET /sessions` → 界面回主会话;红字横幅「这个会话在服务端已经不存在了(可能已在别处删除),已切回主会话」可见(`app/build/e2e-shots/w7-gone-2.png`)。**第一次验收它是失败的** —— 切换对了、横幅一个字没显示(原因见下面那条新教训),修完才通过。
- **W24 的编辑弹层:弹层本身在设备上开过**(user 消息 → ⋯ → 编辑;弹层正常,计数 `0 / 4000`、保存键按预期禁用)。**但没验到「非空初值」**:主会话里那一片 user 消息**全是纯附件回合、`content` 长度为 0**(已对着 `GET /messages` 逐条核过),初值本来就是空串。**也没验到「原文带标记」那句告知**(线上 64 条 user 消息零条含标记,见上)。⇒ 「剥标记」那条路径仍**只有单测背书**。
- **W23 的 `reason` 弹层显示**:纯逻辑链(DTO → chips → previewPlan → 弹层文案)由 17 条单测打通,**没有在设备上点开过一个真正 `unreadable` 的附件**。
- **已用设备证据确证的那一条**:待发附件的**小预览框 + 叉**(主人点名要的那个)**本来就存在且可用** —— 截图 `app/build/e2e-shots/v4-draft.png`(`attach-demo.txt / TXT · 45 B` + 叉,挂在输入框上方)与 `v5-removed.png`(点叉后预览框消失、发送键复灰)。**零代码改动**。
### 家务(**2026-10-04 深夜续已办完**)
- **三条验收会话已删**(主人放行后执行,带证据):`E2E-DOC`(1 条消息)/ `E2E-PDF`(2 条)/
`E2E-RETRY`(2 条)三条 `DELETE /api/v1/sessions/{id}` 全 `200`;现在服务端只剩主会话 `web`。
- **18 个 worktree 已全部清除**(W4–W19 + W23/W24),用的是 **Git Bash 的 `rm -rf`**(Windows 直接用
git 自带的删会撞 `MAX_PATH`),随后 `git worktree prune`;`git worktree list` 现在只剩主树。
**分支引用全部保留**(每个都 `git merge-base --is-ancestor <branch> android` 验过已并入,
留着方便回退)。
- 清理手法留在下面的「构建与环境」一节:这两个动作都是**不可逆**的,删之前先跑一次
「每个分支是否已并入」的检查。
### 与本轮配套的两条新纪律(已进上面的教训段,这里再点一次)
1. **动代码之前先用像素确认症状**;`uiautomator dump` 只用来取坐标。
2. **症状与代码结构矛盾时,先怀疑自己的测量**。
### 还有一条:那个「未响应弹窗」的真相(主人问过两次)
- 现象:模拟器隔一阵弹 `Cyrene isn't responding` / `Process system isn't responding`。
- **证据**:我在 40 秒内连跑 3 次 `uiautomator dump`,紧接着一次 `adb shell input tap` **48 秒才返回**;
WindowManager 报 `ANR in top.yeij.cyrene … Waited 5121ms for MotionEvent`,**而同一段日志里还有
`PointerEventDispatcher0 is not responding. Waited 37729ms`** —— 系统自己的输入分发器都卡了
37 秒,那不是 App 的 bug。紧接着就弹了 `Process system isn't responding`。
- ⇒ **`uiautomator dump` 要省着用**(它是整棵视图树的重量级遍历),一次够用就够;一次交互超过
几秒没回来就当**环境卡了**,别记成 App 的 bug。真卡住了就 `adb emu kill` 重启(本次重启后
同一操作的延迟从 48 s 回到 0.27 s)。
> **★ 锚点补记(2026-10-04 深夜 · 压缩前落)**:后端回了 **回执十**,契约升到 **v0.63**,其中两条**直接冲我们**:
>
> 1. **`documents[].reason`(可选)** —— 只在 `status:"unreadable"` 时下发;文案是**他们注入模型清单里的同一份字符串**(10 条,例如「无法抽取正文:这是图片型/无文本层 PDF,需要 OCR 才能读(当前未做)」)。⚠️ **别做整句相等判断**(真实字符串比登记里给我看的更完整),要分支就用稳定子串(如「需要 OCR」),**默认原样显示**。
> ⇒ **新待办**:客户端接上它(现在我们只能显示通用的「读不到」,W18 那条缺口就是它)。
> 2. **`GET /sessions/{id}/messages` 对不存在/已删除会话改为 `404 session_not_found`**(行为变更;**存在但空**仍是 `200 {"data":[]}`)。**隔离会话 `sess-*` 也回 404**,而**我们的 App 不会按 `sess-*` 拉消息**(已核代码:id 只来自 `GET/POST /sessions`;`sess-` 只在两处注释里出现)⇒ 已答复他们「不必加例外」。
> ⇒ **新待办**:用这个 404 做「**当前会话已被删除 ⇒ 自动退回主会话 + 如实说一句**」的兜底(就是主人那次「图片凭空消失」的体验问题)。
>
> 另有 **v0.62** 的 `POST /admin/llm/reset-from-env`(owner-only 写、要 `confirm:true`)——客户端暂无 LLM 管理页,**纯增量、不用管**。
### 又一条硬教训:**一次性告知不能挂在「会被销毁的状态」上**
404 兜底的第一版把「会话已删」那句横幅放在 `ChatUiState.sessionGone` 上。逻辑看着完全对,真机上
**用户一个字都看不到**:
`onSessionGone` 一被调用 ⇒ `activeSessionId` 收敛回主会话 ⇒ `ChatScreen` 的 `key` 变化 ⇒
**承载那面横幅的 VM 连同状态立刻被销毁**。
⇒ 规矩:**告知要挂到活得比它久的那一层**(这里是 `ChatHomeScreen` 的 `remember`),并且
**先立告知、再触发刷新** —— 顺序反了同样看不到。
这类 bug 单测抓不到、编译也不报,只有**真机看一眼**才会发现。→ 又一个「UI 改动必须真的看一遍」的证据。
> **★ 锚点补记二(2026-10-04 深夜续 · 压缩前落)**:本轮把**三条待办做完了两条半**,另收了后端
> **回执十二**(`knowledge` 根因 + 并行 `tool_use` 配对的新 P0 + **契约 v0.64 的 `GET /admin/llm/usage`**)。
>
> 1. **`after` 正向增量已接**(`28c3142`):掉线记游标 / 重连正向追 / 循环抽成纯函数 `loadForwardPages`
> (两道闸:游标严格前进 + 20 轮上限)。**真机打出过真请求**,但「断线超 50 条」的洞没造出来。
> 2. **`documents[].reason` 已接**(W23 / `2d382b1`):弹层里**原样显示**他们那句,芯片仍是「读不到」。
> ⚠️ 一处**我们主动选的口径**:`unreadable` 且 `mime` 是 `text/` 时让 `reason` **压过**正文渲染
> (理由与代价都写进了 `02` 的登记,等他们确认)。
> 3. **`404 session_not_found` 兜底已装机验通**(`28c3142` + 修复 `b5c0b13`)。
> 4. **W24 编辑弹层只显示纯文本**(`1796e53`→`66c2291` 合并为 `1794e53`):真有标记才剥;
> `hasMarkup` 取「`text != content`」(= 保存真的会替换掉什么),弹层明说会替换掉旁白/动作。
> 5. **家务已办完**(3 条验收会话 + 18 个 worktree,见上一节)。
> 6. **后端那边与我们有关的两条**:`knowledge` 的修法已经落地(**历史里会落一条诚实说明**,我们在
> `89776fe3…` 那条会话里亲眼看到);并行 `tool_use` 配对的 P0 在修,**客户端不需要适配**。
> 7. **`.env` 新增 `LLM_MULTIMODAL`**(主人加的)⇒ 主模型能识图,`H1` 那条结构上消失。
> **我们还没做「如果主模型本身能识图」的客户端侧跟进**,等主人指路。