设置页那个开关此前没有任何发送路径读它: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)也随之失效,三处一并补记更正。
815 lines
76 KiB
Markdown
815 lines
76 KiB
Markdown
# 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` 那条结构上消失。
|
||
> **我们还没做「如果主模型本身能识图」的客户端侧跟进**,等主人指路。
|