Files
Cyrene-For-Android/devdocs/05-archive-历史批次.md
T
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

42 KiB
Raw Blame History

归档:本文件由 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. 图片 / 工具卡实机验证(后端暂未配置工具)
  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,验不过就换触发方案)

② 分享到 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 条同一个过时前提。 它不在本轮允许改动的文件清单里,留给下一个动那个文件的会话顺手更正。