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

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

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

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

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

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

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

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

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

76 KiB
Raw Blame History

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. 图片 / 工具卡实机验证(后端暂未配置工具)
  5. 概览页「按路径」的可读性:26 条路径挤在一行右对齐文本里,手机上很密——是否改成分组/折叠, 待主人定
  6. 子会话流程(主人已确认搁置)
  7. 图片 / 工具卡实机验证(后端暂未配置工具)

接下来可做的大块(后端都已就绪,几乎全是纯客户端欠账):

  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),编译就在本地跑:

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 上,主人明确说可以直接抓模拟器窗口。

所以现在允许也应该自己截图验证,方法两条一起用:

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}。

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 撑住才收得到响应体,否则只出一堆空行:

"$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 深夜晚补,含本轮踩到的坑)

图片/大图路径的验收脚本(已跑通两遍,可直接复用):

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 一次):

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):

"$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 那条结构上消失。 我们还没做「如果主模型本身能识图」的客户端侧跟进,等主人指路。