4 Commits
Author SHA1 Message Date
AskaEth 681ae96a9c docs(02): 收下后端会话的追加(回执十七·补2:<p> 规整已落地 5de708a,写入侧收口、我方零改动)+ 合并 W34 的拆分 2026-10-05 11:14:22 +08:00
AskaEth 95e657e704 merge: W34 —— 拆分 02/05 两份过长协作文档(历史部分归档,内容零丢失) 2026-10-05 11:14:16 +08:00
AskaEth 98b2274e68 docs(devdocs): 二次瘦身——02 里已确认完结的登记条目整块归档,05 收回两段纪律
02-前后端协同事项.md:860 行 -> 454 行;02-archive-历史回执.md:1274 行 -> 1681 行
- 把已确认完结的登记条目整块搬进归档:A(A1~A5)、B(B1~B6)、C(C1~C3)、D(D1~D5)、E(E1~E6)、F3~F5、G1~G4、H1,共 8 块 30 条,按原顺序插在归档前言之后、回执一之前
- 主文件保留:标题与用途、顶部 P0 置顶块、归档说明、登记规范、仍未闭环的登记(F1/F2/F6 与 G5)、附:状态一览表、最近 5 条往来
- 唯一改动原文处:状态表 C1 行的指针「见本文 §C1」改成「见 02-archive-历史回执.md §C1」
- 归档前言新增一行,说明这批条目的来路,以及 F/G 两节的主标题与未闭环条目仍在主文件

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

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

未跑 Gradle(纯文档搬运),未 push。
2026-10-05 11:13:28 +08:00
AskaEth cb5fc0c605 docs(devdocs): 拆分 02/05 两份过长协作文档,历史部分归档,内容零丢失
02-前后端协同事项.md:2126 行 → 860 行
- 历史回执(回执一~十六及同时段已闭环的登记)整体搬到新建 devdocs/02-archive-历史回执.md(1274 行)
- 主文件保留:标题与用途、顶部「待后端处理的 P0」置顶块、登记规范、A~I 全部登记条目、附:状态一览表、仍未闭环的登记(2026-10-04 深夜续那条待主人拍板的口径冲突)、最近 5 条往来
- 置顶块之后新增 5 行归档说明:历史回执去向 / 新回执照旧写在本文末尾 / 已闭环的老登记会定期挪进归档 / 本文旧指针去归档里找对应小节
- 不动置顶块、不动状态一览表、不动未闭环的登记,后端会话仍可照旧在本文末尾追加回执

05-开发进度与交接.md:1035 行 → 601 行
- 历史批次细节与已过时的验收现场记录搬到新建 devdocs/05-archive-历史批次.md(437 行):本轮追加(W4–W7)、W8–W10、W11–W12、W13–W14、W15–W16、装机验收轮、分享到 App 验收轮、W27
- 主文件保留全部「★ 恢复锚点」性质的节(当前状态与待办、锚点补记一~五、纪律补记)与当下要用的节(当前状态、构建、验证方式、凭据处理红线、环境要点、后端、模拟器、易忘的实现约定、管理区约定、C-2 验证结论、坑与勘误、后端协同、实机验收方法、三条待拍板项定案)

零丢失的证据
- 全部搬运用 sed 按行区间原样切割,未改写任何既有文字;新增内容只有 02 主文件那 5 行归档说明与两份归档文件开头的 2 行说明
- 逐行多重集比对(awk 计数 + diff):除上述新增行与个别空行外,原文件与新「主文件+归档」的行集合完全一致,无增无删
- 8 个代表字符串的 grep -c 拆分前后相等:679cee0 3=3、18381f6 1=1、guardrail_input 3=3、internal/config/runtimeconfig/models.go:300-311 1=1、回执十七标题 1=1、devtools/fake-backend/ 4=4、W15-retry-2-EDITED 3=3、ChatImageBudget.SERVER_MAX_CHARS 1=1
- 行尾统一为 CRLF(与 devdocs 下其余文件一致)

未跑 Gradle(本次纯文档搬运),未 push。
2026-10-05 11:10:03 +08:00
4 changed files with 2147 additions and 2113 deletions

No files matched your search

File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+418
View File
@@ -0,0 +1,418 @@
> **归档**:本文件由 `05-开发进度与交接.md` 拆出,存放 **W4–W7 起的各历史批次详细记录**与已过时的验收现场记录(原样搬运、按原文顺序,只读追溯)。
> 当前状态、待办、家务、纪律与「★ 恢复锚点」各节都在 `05-开发进度与交接.md`;锚点里说的「上面各批次」即指本文件。
### 本轮追加(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`)。
**下一步(顺序已定)**:① ✅ **已完成**(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` 求和,**不采信子代理自述**。
- **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,验不过就换触发方案)
## ② 分享到 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 失败**
## 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`)。主人说清再清。
## W27(2026-10-04 深夜 · 死代码清理 + `03` 四处过时表述更正)
**两处清理(都不改行为)**
- **删 `AdminAccess.availablePages()`**(`domain/admin/AdminAccess.kt`):全仓(含 `app/src/test`)**调用点 0 处**——
`app/src/main` 里只有它自己的声明,测试里 2 处(`AdminAccessTest` 的那 1 条用例,一并删掉)。
它已被 UI 那套做法取代:`ui/admin/AdminHomeScreen.kt:101,116` 遍历 `AdminPage.entries` + 每行 `canAccess` 决定是否禁用。
- **`ChatImageBudget.SERVER_MAX_CHARS` 不再自己重写膨胀公式**:原来它把 `base64Chars` 的
`((n + 2) / 3) * 4` 又内联写了一遍(同一公式两份真相)⇒ 改为
`val SERVER_MAX_CHARS = base64Chars(SERVER_MAX_DECODED_BYTES) + PREFIX_ALLOWANCE_CHARS`。
⚠️ 代价:该常量由 `const val` 变 `val`(`const` 不允许调用函数)。**取值逐位不变 = `6990572`**
(5 MiB ⇒ base64 膨胀 6990508 字符 + 前缀余量 64),由 `ChatSendPolicyTest` 的「独立重算 → 相等」断言继续钉住。
**`devdocs/03` 四处就地更正**(一律 ~~删除线~~ 保留原文 + 「⚠️ 更正」括注,不动别人的回执段落)
1. §4「重连补全」——`02/A5` 的 `after` 正向增量**早已接入**(`loadAfter` + `ChatViewModel.catchUp`,`28c3142`)。
2. §4「忙碌/队列 UI 映射」——`session_interrupted{dropped}` 那句:客户端**只发** `scope:"current"`,
该档 `dropped` **恒为 0**、`all` **从不发** ⇒「丢弃 N 条」在今天的客户端里**不可达**。
3. §5「mode 回落」——`mode` **已落库**(后端 `b686d5f`),`MessageBubble.kt:110` 按 `mode` 分支。
4. §4「打断模式」——「限制一次只发一条」**已由 W25 修掉**(补记本就写在案,本轮补上依据 `cfdc094` / `feat/W25-interrupt-scope`)。
> ⚠️ **顺手查到但本轮没动**:`data/ws/WsEvent.kt:272` 的 `SessionInterrupted` KDoc 也写着
> 「当前任务取消、pending 清空,`dropped` 为丢弃条数」——与上面第 2 条同一个过时前提。
> 它不在本轮允许改动的文件清单里,留给下一个动那个文件的会话顺手更正。
+23 -434
View File
@@ -7,145 +7,6 @@
- 分支 **`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),**编译就在本地跑**:
@@ -346,6 +207,28 @@ ADB="C:/Users/Aska/AppData/Local/Android/Sdk/platform-tools/adb.exe"
| `Cyrene_v2/Docs/api-reference.md:1142`(§6.6) | OnStream 结构只填 `seq`、不带 turnId | 实际已打 `TurnID` 并上帧;`api-client.md:525`(v0.36)记的就是这事 |
| `Cyrene_v2/DevDocs/02-development-constraints.md:159` | 文件上传限 50MB | 实现是**硬 5 MiB**(`upload.go:24`),无配置项可覆盖(见 `02/F3`) |
### worktree 并行:约定与坑(下个会话必读)
**本轮新增约定 / 坑(下个会话必读)**
- **worktree 并行必须给绝对路径**:W1 曾用相对路径编辑,结果写进了**主树**(10 分钟),
靠「worktree 构建在改动后仍报 `UP-TO-DATE`」这个反常信号才发现。**派 agent 进 worktree 时,
任务书里必须写明「只用绝对路径」**。(当次已自检+窄回退,我复核主树逐字节等于 HEAD,无损伤。)
- **删 worktree 目录别用 git 自带的**:Windows 会报 `Filename too long`(`MAX_PATH`,Gradle 的
`build/` 路径太深)⇒ 用 **Git Bash 的 `rm -rf`**,它能处理。
- **`/upload` 产物的 URL 绑死当时的 host**(`r.Host` 拼绝对地址 + 原样落库)⇒ **换服务器地址后旧图取不回**。
已登记 `02/F6①`。**上传也不能突破 5 MiB**(`02/F6②`:那个上限只对 base64 形态成立,URL 受视觉层 50 MiB 约束)。
- **`intent` 共享 socket 债又加一条**:`SessionsViewModel` 现在**自己持有一条 socket**(登录连、登出/
`onCleared` 关)——因为 `AppContainer`/`ChatSocket` 归属别的 agent。收敛方向仍是**一条应用级共享 socket
+ 按会话路由**。
- **`chat.documents` 契约形状**(v0.59,别提前写死):**数组 ≤5**(不是单对象 —— 单对象会让 1 回合 5 个附件
里 4 个从历史消失);响应/历史 DTO 为 `"documents":[{name,mime,size,chars,truncated,status}]`;
`status:"unreadable"` 才是「昔涟读不到」的信号;**文档一律走 REST**;**只收服务端存储名、不收 URL**。
### 派 agent 进 worktree 的硬规矩
**派 agent 进 worktree 的硬规矩(沿用上轮教训)**:任务书必须写明「**只用绝对路径**」(上轮有 agent 用
相对路径把改动写进了主树);**不许**碰 adb/模拟器、**不许** push/merge、**不许**动构建文件
(`build.gradle.kts` / `settings.gradle.kts` / `libs.versions.toml` / `local.properties`)。
## 后端协同
见 `02-前后端协同事项.md`。开发中一旦遇到后端不支持或未实现,**当场写进该文件并反馈主人**。
@@ -404,54 +287,6 @@ curl -s -X PATCH "$BASE/api/v1/sessions/$SID" -H "Authorization: Bearer $TOK" \
- `-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 的默认展开;只有一条路径的组不出组头;路径等宽字体 + 段级中间省略。
@@ -462,231 +297,10 @@ APK = 主树 `a6b4bae` 的产物(`app/build/outputs/apk/debug/app-debug.apk`
- 接线时必须如实告知:`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 深夜)
> **压缩/换会话后先读这一节**,它是对「此刻到底在哪」的一句话答案;细节在上面各批次里。
> ⚠️ **历史批次的细节(W4–W7 起各批)已移到同目录 `05-archive-历史批次.md`** —— 这里说的「上面各批次」都在那份里,按原文顺序保留;本节的各锚点仍在主文件。
### 状态
@@ -815,31 +429,6 @@ PDF · 563 B · 24 字 · 已读入
---
## W27(2026-10-04 深夜 · 死代码清理 + `03` 四处过时表述更正)
**两处清理(都不改行为)**
- **删 `AdminAccess.availablePages()`**(`domain/admin/AdminAccess.kt`):全仓(含 `app/src/test`)**调用点 0 处**——
`app/src/main` 里只有它自己的声明,测试里 2 处(`AdminAccessTest` 的那 1 条用例,一并删掉)。
它已被 UI 那套做法取代:`ui/admin/AdminHomeScreen.kt:101,116` 遍历 `AdminPage.entries` + 每行 `canAccess` 决定是否禁用。
- **`ChatImageBudget.SERVER_MAX_CHARS` 不再自己重写膨胀公式**:原来它把 `base64Chars` 的
`((n + 2) / 3) * 4` 又内联写了一遍(同一公式两份真相)⇒ 改为
`val SERVER_MAX_CHARS = base64Chars(SERVER_MAX_DECODED_BYTES) + PREFIX_ALLOWANCE_CHARS`。
⚠️ 代价:该常量由 `const val` 变 `val`(`const` 不允许调用函数)。**取值逐位不变 = `6990572`**
(5 MiB ⇒ base64 膨胀 6990508 字符 + 前缀余量 64),由 `ChatSendPolicyTest` 的「独立重算 → 相等」断言继续钉住。
**`devdocs/03` 四处就地更正**(一律 ~~删除线~~ 保留原文 + 「⚠️ 更正」括注,不动别人的回执段落)
1. §4「重连补全」——`02/A5` 的 `after` 正向增量**早已接入**(`loadAfter` + `ChatViewModel.catchUp`,`28c3142`)。
2. §4「忙碌/队列 UI 映射」——`session_interrupted{dropped}` 那句:客户端**只发** `scope:"current"`,
该档 `dropped` **恒为 0**、`all` **从不发** ⇒「丢弃 N 条」在今天的客户端里**不可达**。
3. §5「mode 回落」——`mode` **已落库**(后端 `b686d5f`),`MessageBubble.kt:110` 按 `mode` 分支。
4. §4「打断模式」——「限制一次只发一条」**已由 W25 修掉**(补记本就写在案,本轮补上依据 `cfdc094` / `feat/W25-interrupt-scope`)。
> ⚠️ **顺手查到但本轮没动**:`data/ws/WsEvent.kt:272` 的 `SessionInterrupted` KDoc 也写着
> 「当前任务取消、pending 清空,`dropped` 为丢弃条数」——与上面第 2 条同一个过时前提。
> 它不在本轮允许改动的文件清单里,留给下一个动那个文件的会话顺手更正。
> **★ 锚点补记三(2026-10-04 深夜续二 · 自主推进一批)**:主人去睡了,我按主线往下推 + 做了一次只读审计。
> 这一批**全部是「界面上/文档上声称能做到、代码里其实没接线」类的问题**(主人最恨的一类)。
>