# 05 — 开发进度与交接 > 本文用于跨会话交接:记录当前状态、构建管线、环境要点、易忘的实现约定。 > 最后更新:2026-10-04 ## 当前状态 - 分支 **`android`**,最新提交 **`664fd8d`**(= `origin/android`,全部已推) ## 构建(已全部在 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 端标记** (`

` / `` / ``,甚至有记录以一串空 `

` 开头)。 渲染前必须过 `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`) | ### worktree 并行:约定与坑(下个会话必读) **本轮新增约定 / 坑(下个会话必读)** - **worktree 并行必须给绝对路径**:W1 曾用相对路径编辑,结果写进了**主树**(10 分钟), 靠「worktree 构建在改动后仍报 `UP-TO-DATE`」这个反常信号才发现。**派 agent 进 worktree 时, 任务书里必须写明「只用绝对路径」**。(当次已自检+窄回退,我复核主树逐字节等于 HEAD,无损伤。) - **删 worktree 目录别用 git 自带的**:Windows 会报 `Filename too long`(`MAX_PATH`,Gradle 的 `build/` 路径太深)⇒ 用 **Git Bash 的 `rm -rf`**,它能处理。 - **`/upload` 产物的 URL 绑死当时的 host**(`r.Host` 拼绝对地址 + 原样落库)⇒ **换服务器地址后旧图取不回**。 已登记 `02/F6①`。**上传也不能突破 5 MiB**(`02/F6②`:那个上限只对 base64 形态成立,URL 受视觉层 50 MiB 约束)。 - **`intent` 共享 socket 债又加一条**:`SessionsViewModel` 现在**自己持有一条 socket**(登录连、登出/ `onCleared` 关)——因为 `AppContainer`/`ChatSocket` 归属别的 agent。收敛方向仍是**一条应用级共享 socket + 按会话路由**。 - **`chat.documents` 契约形状**(v0.59,别提前写死):**数组 ≤5**(不是单对象 —— 单对象会让 1 回合 5 个附件 里 4 个从历史消失);响应/历史 DTO 为 `"documents":[{name,mime,size,chars,truncated,status}]`; `status:"unreadable"` 才是「昔涟读不到」的信号;**文档一律走 REST**;**只收服务端存储名、不收 URL**。 ### 派 agent 进 worktree 的硬规矩 **派 agent 进 worktree 的硬规矩(沿用上轮教训)**:任务书必须写明「**只用绝对路径**」(上轮有 agent 用 相对路径把改动写进了主树);**不许**碰 adb/模拟器、**不许** push/merge、**不许**动构建文件 (`build.gradle.kts` / `settings.gradle.kts` / `libs.versions.toml` / `local.properties`)。 ## 后端协同 见 `02-前后端协同事项.md`。开发中一旦遇到后端不支持或未实现,**当场写进该文件并反馈主人**。 **当前状态(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 ``` - **改动前的基线**(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 里登出 → 分享 → 再登录 → 看内容是否落地。 ### 三条待拍板项的**定案**(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>."}]`,**≤5 条、只给服务端存储名**;带附件一律走 REST(客户端策略)。**`name` 是唯一上行字段**。 ## ★ 当前状态与待办(恢复锚点 · 2026-10-04 深夜) > **压缩/换会话后先读这一节**,它是对「此刻到底在哪」的一句话答案;细节在上面各批次里。 > ⚠️ **历史批次的细节(W4–W7 起各批)已移到同目录 `05-archive-历史批次.md`** —— 这里说的「上面各批次」都在那份里,按原文顺序保留;本节的各锚点仍在主文件。 ### 状态 - **`android` = `origin/android` = `85a922c`,工作区干净**,**891 测试 / 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 消息,**零条含标记**;所有 `

` / `` 都在 assistant 消息里(assistant 不可编辑)。所以 W24 的「剥标记」在今天是一条**防御性口径**,对真实数据是恒等变换;`hasMarkup` 的判据因此取的是「`text != content`」(= 保存真的会替换掉什么),而不是「出现过 `<`」。 2. **`after` 是升序、`before` 是倒序**,且 `after` 方向的下一条游标是**本页最新**那条(不是最旧的)。服务端按 `limit` 从最旧一端给,所以取最旧当游标等于原地不动。同毫秒并列超过一页时纯 `createdAt` 游标推不动(契约 §A5 明写的边界)⇒ 循环里必须有「游标严格前进」+「轮数上限」两道闸,否则无限请求。 ### 待办(按优先级) 1. **W19 剩下的那一处**:**删除后抽屉里的「最后消息预览」不刷新**(本端只改了消息列表,没触发 `GET /sessions`)。① 编辑口径已由主人拍板并落地(W24)。 2. ~~**遗留 ①:连接所有权**~~ ✅ **已闭环(W28,`7b97440`)**:现在**进程内共享一条 `ChatSocket`**(`AppContainer` 唯一持有、`SessionsViewModel` 是唯一 owner),连接数从 **N+2 降到 1**。装机验收见「补记四」。 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 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` 那条结构上消失。 > **我们还没做「如果主模型本身能识图」的客户端侧跟进**,等主人指路。 --- > **★ 锚点补记三(2026-10-04 深夜续二 · 自主推进一批)**:主人去睡了,我按主线往下推 + 做了一次只读审计。 > 这一批**全部是「界面上/文档上声称能做到、代码里其实没接线」类的问题**(主人最恨的一类)。 > > ### 本批落地(HEAD `85a922c`,891 测试 / 0 失败) > > | # | 内容 | 提交 | > |---|---|---| > | 审计 | **只读审计**(假开关 / 死代码 / UI 规范 / 文档漂移四张清单,每条带 `文件:行号`) | 无提交(产出清单) | > | W25 | **设置页的「打断模式」真接线** —— 它此前是个**假开关** | `e4c6c7c`(`cfdc094`) | > | W26 | **`stream_chunk` 不再静默丢正文** + **错误横幅可关闭** + **分享不再自相打断** + 两处死代码 | `b7ac6b8`(`eacc65a`) | > | W27 | 删只被测试养的 `availablePages` + **图片 base64 上限公式去重** + `03` 四处文档更正 | `b7ac6b8`(`805d8b5`) | > | — | `WsEvent.SessionInterrupted.dropped` 的过时 KDoc 更正 | `85a922c` | > > ### 三个「假开关 / 静默」的真相(这批最值钱的东西) > > 1. **设置页的「她正忙时按发送的行为」是个假开关**:`InterruptMode` 能读能写能显示, > **全仓没有任何发送路径读它**(连 `ChatSocket.sendInterrupt` 都是零调用方的死 API)。 > 用户切成「打断」,行为与「追加队列」一模一样。⇒ 现已接上:默认档**零行为变化**, > 「打断」档且会话正忙时先发 `interrupt{scope:"current"}`(**保留**待处理队列)。 > 判据抽成纯函数 `interruptScopeForSend(mode, busy)`,8 条测试钉住两档 × 忙闲穷举。 > 2. **`stream_chunk` 被静默丢掉**:契约明写它是**非流式回退路径**(「未映射/无法确定的事件」), > 而 `onEvent` 的兜底是 `else -> Unit` ⇒ **只要服务端走那条路,整段回复一个字都不显示、且没有任何信号**。 > 现已把它当 `delta` 喂进同一个流式解析器。(它与结构化帧**互斥**,依据是契约那句「回退路径」。) > 3. **分享多图会自相打断**:W25 接线后暴露 —— 分享 N 张图 = N 次投递,第 2 张起各发一条 `interrupt` > 把前一张刚起的回合取消掉。⇒ 分享路径一律 `allowInterrupt = false`(批量动作自相残杀一定是错的)。 > > ### 两条「查完发现不是 bug」的(**别再返工**) > > 1. **「删除后抽屉里的最后消息预览不刷新」——不是 bug**(W19 留下的待办)。 > 证据两面:① **服务端实时重算** —— 我用 REST 实测:删掉某会话最后一条 assistant 消息后, > `GET /sessions` 的 `lastMessage` **立刻**退回前一条 user 消息;② **客户端每次打开抽屉都会 > `refresh(silent = true)`**(`ChatHomeScreen` 的 `onOpenDrawer`)⇒ 用户看到的是新预览。 > 唯一剩下的极端场景是「抽屉**正开着**时别的端删了消息」——那属于「抽屉不订阅 `message_deleted`」, > 是**另一个**(更小的)话题,别和这条混起来。 > 2. **`session_interrupted{dropped}` 的「已打断,丢弃 N 条」在本端不可达**:契约 v0.56 明文 > `scope:"current"` 时 `dropped` 恒为 0,而本端**从不发 `scope:"all"`**(见上 W25)。 > ⇒ 只更正了文档与 KDoc(`03` §4 与 `WsEvent.kt`),**没有**去实现一个用不上的计数。 > > ### 装机验证(有设备证据的) > > - **错误横幅的关闭叉:验通**。造错手法:**撤掉 `adb reverse` 后重开 App**(老教训:`--remove` > 断不开已建立的连接,得让 App 重开会话)⇒ `ConnectException` ⇒ 抽屉底部出现红字「无法连接到服务器」 > + 右侧 ✕(`app/build/e2e-shots/x2-drawer-error.png`);点 ✕ 后那条消失、 > 而「网络异常,显示 刚刚 的结果」**正确保留**(`x3-dismissed.png`)—— 两件事本来就是两个信号。 > - **`stream_chunk` 的端到端仍未验**(要服务端真走进回退路径;本端没法自己造), > 收益侧是「与 `MessageDelta` 逐字同路」,风险侧只有契约那两句话作依据。 > - **W25 的打断帧未在真机上看过发出**(要「偏好切成打断 + 会话正忙 + 发一条」三条同时满足; > 后端在另一个会话手里,我没有主动去占它的回合)。逻辑侧由 8 条纯函数测试钉住。 > - **登录页的输入框是否被 IME 遮挡:未验**。审计把它标成「存疑」(字面违反「输入框在 `bottomBar` + > `imePadding()`」这条约定,但 `LoginScreen` 有 `verticalScroll` + Manifest 是 `adjustResize`, > `TextField` 通常自己会把光标带进可视区)。**要验就得清空 App 数据回到登录页**—— > 风险是万一登不回去,今晚所有装机验证能力一起没了,所以我**没做**,如实留在这里。 > 判断:**风险低但不是零**;主人下次在干净设备上装一次就能一眼定论。 > > ### 本批**没做**的(含一个我刻意不做的) > > - **连接所有权(共享 socket + 按会话路由)**:**没有动手**。它是架构级改动, > 动到四条 VM 的帧路由,而今晚模拟器状态不稳、后端在另一个会话手里 —— > 一旦引入回归,我**未必能当场发现**(这比不做更糟)。已写设计要点与验收清单(见本节末)。 > - **`DocumentDelivery` 里那组「正文转文件」的判决 API**(`plan` / `textAsFile` / `textNeedsRest` > + `AsDocuments.textAsDocument` 字段)**没有删**,也**没有接线**:所有权归属有**口径冲突**—— > `05` 的三条定案写着「**不做**长正文自动转文件」(理由:`POST /chat` 没有正文长度上限,长正文 > 已经走 REST 了),而主人后来在 G 条里点名「**超限走文件也要做**」。两者多半指的不是同一件事 > (前者 = 用户**在 App 里打的字**自动变附件;后者 = 从别的 App **分享**进来的超长内容走文件), > 但我**不替主人合并这两个口径**。已登记进 `02` 请他拍板。 > - 另有一条 W26 留下的边界记在这里备查:**回退路径没有 `message.start`** ⇒ > `MessageStart(index==0)` 里那句 `freshTurnIfFinished()` 在回退轮次不触发;日常看不见问题 > (落库/`stream_end` 各自会收尾),只有「上一轮被取消且没落库 + 紧接着来一轮回退流」才会让 > 上一轮的思维链/工具卡滞留。加它属于行为增强,当时按「只加消费分支」停手了。 > > ### 连接所有权的设计要点(**下一批做它之前先读这段**) > > - 现状:`ChatViewModel`(**每个会话一个 VM**)、`SessionsViewModel`、`AdminViewModel` **各建一条** > `ChatSocket`,而 chat 的 VM 活在 Activity 级 `ViewModelStore` 里 ⇒ **访问过的每个会话都永久留一条 > 连接**(各自带 `OkHttpClient` + 连接池 + 重连循环),删会话也不回收。 > - 自然解法:**共享一条 socket + 按 `sessionId` 路由帧**(`WsEvent.isForSession` 已经是现成的判据)。 > - 验收清单(缺一不可):① 主会话流式正常;② 切到次级会话时**不串台**(旧连接收到的别的会话帧必须被丢); > ③ 管理页三帧(metrics/trace/log)仍到;④ 抽屉未读角标仍对;⑤ 断线重连后 `after` 补洞仍跑; > ⑥ 登出时要断开且重登要接上(现有 `authManager.state` 那条 `connect()/close()` 逻辑要跟着搬); > ⑦ 删会话后那条连接不再留着。 > **★ 锚点补记四(2026-10-05 上午 · 主人给了明确指示后的一批)** > > ### 状态 > - **`android` = `origin/android` = `7b97440`**,工作区干净,**891 测试 / 0 失败 / 0 错误**。 > - APK:`app/build/outputs/apk/debug/app-debug.apk`(就是 `7b97440` 这棵树)。 > > ### 本批落地 > > | # | 内容 | 提交 | > |---|---|---| > | W28 | **进程内共享一条 `ChatSocket`**:连接数 **N+2 → 1**,`SessionsViewModel` 为唯一 owner | `7b97440`(`276b011`) | > | FB | **可编程假后端测试台**(`devtools/fake-backend/`,纯 Python 标准库、零依赖) | `27d5680` | > | — | `02` 三处登记:回应回执十三 / 置顶「微信文件看不到」P0 / IM 语音消息的新需求 | `691b073` `ae49853` `a008580` | > > ### W28 的关键取舍(**别退回**) > - **共享的 socket 谁都不许 close**:`onCleared` 里的 `close()` 全删了(否则切个会话就把全 App 的连接掐断)。 > 现在全仓 `connect()` / `close()` **各只剩一处**,都在 `SessionsViewModel`(它由 `ChatHomeScreen` 在进登录态时创建、活在 Activity 级 store 里,存活期正好覆盖「登录→登出→重登」)。 > - **`_events` 的 `replay` 必须恒为 0**:共享后新建的 `ChatViewModel` 会立刻订阅同一个 SharedFlow,replay > 0 就会把历史帧重放给它。 > - **两个已知的固有代价**(都在 W28 的 commit 里写明):① 晚于连接建立才创建的 VM **不再收到 `history` 帧** —— 实测无影响,新 VM 的正文由 REST `loadLatest` 给(设备上验证过:次级会话首次访问 `GET …/messages?limit=50 → 200`);② **Activity 结束不再断开连接**(进程级共享的必然结果),只在登出 / 令牌失效时断。 > > ### W28 装机验收(**有设备证据**) > - **连接数不随访问会话增长**:`awk` 数 `/proc/net/tcp{,6}` 里 App uid 的已建立连接 —— 只访问主会话时 **4**,**切到第二个会话后仍然 4**(改前这里会 +1)。这是 W28 全部意义所在。 > - 切会话 → 切回主会话,渲染**完全正常**(消息、来源角标、旁白都在)。 > - 管理页三个端点全到(`admin/observability/{logs,metrics,trace}`)并渲染出真实数据(总请求 205 / 平均延迟 20ms)。 > - **未验**:重连补洞(`after`)在共享 socket 下的表现、登出/重登的 owner 逻辑、viewer 分支 —— 都还没有设备证据。 > > ### 假后端(FB)—— 它是「验不了」这个借口的解药 > `devtools/fake-backend/`(`python fake_backend.py --port 18080` + `ws_probe.py`)。它解锁三件**真后端做不了**的验证: > 1. **`after` 补洞**:`POST /__control/messages?count=120` 免费灌 120 条,握手只回放 50 条 ⇒ 精确造出那个洞。 > (**这解释了为什么之前说「验不了」是错的**:我当时只想着用真后端灌 51 条 = 51 次模型回合。**该用假后端。**) > 2. **打断帧**:`out/upstream.jsonl` 记录**所有上行帧** —— 这是「W25 那帧到底发没发」的**唯一直接证据**。已实跑抓到 `{"type":"interrupt","data":{"sessionId":"web","scope":"current"}}`。 > 3. **`stream_chunk` 回退路径**:能指定走旧形状,正好用来验 W29 要修的那条分支。 > - App 指过来:`adb reverse tcp:18080 tcp:18080` + 登录页服务器地址填 `http://127.0.0.1:18080`(账号随便填)。 > - ⚠️ **它的事实来源是客户端代码与协作文档,不是真后端** —— 别拿它当后端行为的依据。 > > ### 本批的一条硬教训:**问一句,比自己猜一整天都值** > `stream_chunk` 是我昨夜「按增量」接的(把它当 `MessageDelta` 追加)。今早问后端,他们回复:**`delta` 是整段 `res.Text`**(而且生产里已无触发源)。 > ⇒ **真到达会追加成重影**。**我昨夜那版是错的,而且如果没有问,它会以「已修好」的样子留在仓库里。** > 同类:`viewer` 凭据我一直挂着当红验收项,问了才知**全仓没有任何铸造路径**,应当直接摘掉。 > > ### 待办(更新) > 1. **W29(在跑)**:`stream_chunk` 改成「整段替换 + 有结构化帧则忽略」/ 空壳消息如实说一句 / > 分享超长内容走文件 / `F6①` 图片 URL 兜 host。 > 2. **等后端**:把 IM 入站的文件挂到消息的 `documents` 上(P0,`02` 已置顶);IM 渠道语音消息的入站 ASR。 > 3. **用假后端补三条设备验收**:`after` 补洞 / 打断帧 / `stream_chunk`(等 W29 合完)。 > 4. 子会话流程(主人说搁置);图片/工具卡实机验证(后端未配工具)。 > 5. **已结案(别再返工)**:登录页输入框被 IME 遮挡 —— **不成立**,2026-10-05 装机实测(密码框聚焦、键盘弹出时完整可见,截图 `app/build/e2e-shots/y3-ime.png`)。 > **★ 锚点补记五(2026-10-05 上午 · 忙时并行批次 + 思考流排查)** > > ### 状态 > - **`android` = `origin/android` = `c3cfef7`,工作区干净,1033 测试 / 0 失败 / 0 错误**。 > > ### 本批落地(一行一个) > | # | 内容 | 提交 | > |---|---|---| > | W28 | **进程内共享一条 `ChatSocket`**(连接数 N+2 → 1;`SessionsViewModel` 是唯一 owner);装机验过「连第二个会话连接数不涨」 | `7b97440` | > | FB | **可编程假后端**(`devtools/fake-backend/`,纯 Python 标准库) | `27d5680` | > | W29 | `stream_chunk` 改**整段**语义 / 空壳消息如实说一句 / 分享超长走文件 / 图片 URL 兜 host | `ac3f63f` | > | W30 | **忙时并行 UI 半边**:六态 + 灰态 + `(sessionId, turnId)` 两级分桶,**帧侧全部可降级** | `0057205` | > | W31 | 设置栏「她忙时发消息」**追加第三档「并行」**(忙时上行 `chat.parallel=true`,绝不发 interrupt) | `4847d74` | > | W32 | 忙时被拒那条横幅**调柔和**(只那一档,真错误仍红)+ 并行额度落回排队如实说一句 | `79fcd80` | > | W33 | `parallel_limit` 提示加「**本端确在并行档**」的闸 + 查证横幅(结论:代码无缺) | `1f6c16c` | > | FB2 | 假后端加 **`--hold-replies`**(收到消息不自动落库)⇒「待发气泡还挂着」可稳定复现 | `c3cfef7` | > > ### 装机验过的(有设备证据) > - **`after` 正向增量补洞**:重连后她自己发出 `messages?limit=50&after=…`(这是之前两天判定「验不了」的那条 —— **错在我想窄了,该用假后端**) > - **打断帧**:上行帧原文 `{"type":"interrupt","data":{"sessionId":"web","scope":"current"}}`,**先于** `chat` 帧 > - **`stream_chunk`**:假后端发**一帧整段** → 渲染 `CHUNKONE` **一次**,无重影 > - **W28 连接数**、**错误横幅关闭叉**、**登录页 IME(不成立)** > > ### ⚠️ 思考流(主人 2026-10-05 报的)—— **根因在后端/模型侧,已查到一半** > 1. **证据**:真后端最近 5 条 assistant 消息**全都没有 `thinking` 键**(`GET /messages` 是 owner 可见的唯一历史思维链来源)⇒ **后端手里根本没有思考内容**,不是客户端不显示。 > 2. **配置**(`Cyrene_v2/data/models.json`):`provider: "anthropic"`、`baseUrl: https://api.deepseek.com/anthropic`、`model: deepseek-flash`、**没有 `reasoning` 键**。 > 3. **`reasoning` 键加不了**(**别再试**):`models.go:180-191` 的 `validateReasoning` 明确规定 —— **anthropic 组上除 `auto`/空之外任何 kind 都非法**,写了会让后端**下次启动校验失败**。 > 4. **原因在代码里写着**:`anthropic.go:195-201`「**anthropic 协议自带思考形状**,因此这里声明 openai 形状是无效的」⇒ `reasoning.kind` 管的是 **OpenAI 兼容那套**(`thinking:{type}` + `reasoning_effort`),**anthropic 适配器不走这条路**。 > 5. **所以开关在别处**:在 **anthropic 适配器自己的请求构造**里(协议原生的 `thinking` 块)。 > **⚠️ 待查(我读到 `anthropic.go:195` 就停了)**:它到底发不发 `thinking`、什么条件下发; > 以及 **DeepSeek 的 anthropic 兼容端点支不支持 anthropic 的 `thinking` 形状**。 > ⇒ 最省的办法:**直接问后端会话**(他们写的,一句话就够)。 > > ### 待办(按优先级) > 1. **用假后端把「忙时被拒」那条横幅端到端验一次**(上次没验成:当时待发气泡是 0 条 ⇒ 落 `Ignore` ⇒ **刻意什么都不写**,那是设计不是 bug)。 > 做法已在 `devtools/fake-backend/README.md` **第 6 节**(`--hold-replies` → 发 chat 挂住 → `persist` 补落库 → 注入 `pending_full`),**逐条实跑过**。 > 2. **思考流那条的后续**(见上):问后端 anthropic 适配器的 thinking 下发条件。 > 3. **等后端 v0.65**:`chat.parallel` / `turn_begin` / `turn_id` 的**精确位置**登记后接帧(我们形状已备好,全可降级)。 > 4. 旧的:子会话流程(主人说搁置)、图片/工具卡实机验证(后端未配工具)。 > 5. **作废的说法**(别再引用):曾记「假后端 `--stream-chunk` 建模与契约不符」——**错**,它的 `chunks=1` 就是契约的「一帧整段」。 > > ### 环境与纪律(这次踩到的) > - **模拟器今天很脆**:连 launcher 都 ANR 过,App 进程被系统重建。**新会话开工前先重启模拟器**。 > - **假后端进程还占着 18080 / 18081 / 18083**(`taskkill` 对它们不太好使,重启机器最干净)。 > - **我这次又犯了「先怀疑代码、而不是先怀疑自己的测量」**:截图上没看到横幅时,我先去查代码,其实当时只是**待发气泡为 0**(设计如此)。这条纪律写在上一节,**要一直记着**。 > ### ⚠️ 纪律补记(2026-10-05 上午 · 主人问「为什么项目文件夹里那么多本项目的文件夹」) > > **那些是 git worktree** —— 给并行子代理各开一个独立工作区用(避免两个代理同时改同一棵树)。 > **问题不是它们存在,而是我合并完忘了清**:这一批(W28–W33 + FB/FB2)**8 个全都残留下来了**, > 主人先在文件管理器里发现的。上一次(W4–W24)我做过一轮「家务清理」,之后就再没做。 > > **⇒ 从今往后写成硬规则:** > 1. **每合并一个 worktree,就 `git worktree remove` / `rm -rf` 它**(别攒着)——**同一次操作里做完**, > 不要留到「以后一起清」。 > 2. 清理用 **Git Bash 的 `rm -rf`**(Windows 直接用 git 自带的删会撞 `MAX_PATH`),然后 `git worktree prune`。 > 3. **删之前先验**:`git merge-base --is-ancestor android` —— 只有已并入的才删。 > 4. **分支引用保留**(方便回退),只删目录。 > 5. 收尾自检:`git worktree list` 应当**只剩主树**、`ls -d /d/Project/Code/Uni/Cyrene-Android-*` 应当**为空**。 > > 本次已清:**8 个全删,`git worktree list` 只剩主树**;`feat/W4-*` … `feat/W33-*` 分支全部保留。 > > **另一条同源的提醒**:我给子代理开的「假后端」进程(python,占 18080/18081/18083)**也是我留下的** —— > `taskkill` 对它们不太好使,**重启机器最干净**。以后用完就该关。 > ## ★★ 锚点补记六(2026-10-05 上午 · **文档已拆分** + `

` 规整上线 + 三件待办) > > ### ⚠️ 先看这条:`devdocs/` 的结构变了(新会话别找错文件) > > | 想找什么 | 读哪一份 | > |---|---| > | **当前状态 / 待办 / 锚点 / 纪律** | `05-开发进度与交接.md`(本文,624 行) | > | **历史批次的详细记录**(W4–W7 等) | **`05-archive-历史批次.md`**(418 行,只读追溯) | > | **当前开放的登记 + 状态一览 + 置顶 P0 + 最近往来** | `02-前后端协同事项.md`(472 行) | > | **历史回执(一~十七)与已闭环的登记** | **`02-archive-历史回执.md`**(1681 行,只读追溯) | > > **规则**:新回执写在 `02` 的**末尾**;闭环的登记由我方**定期**挪进归档。`02` 是**与后端会话共用**的文件 —— > 他们也会往里写,**改动前先看 `git status`**(这次就撞上过一次:他们未提交的追加把我的 merge 挡了, > 处理方式见下)。 > > ### 状态 > - **`android` = `origin/android` = `681ae96`**,工作区干净,**1033 测试 / 0 失败 / 0 错误**。 > - 本批**只动文档**,没有代码改动。 > > ### 本批发生的事 > 1. **拆分过长文档**(W34,两轮):`02` 2143 → **472 行**主 + 1681 归档;`05` 1035 → **624 行**主 + 418 归档。 > **零丢失有两层证明**:代表性字符串 `grep -c` 前后相等 + **逐行多重集 `diff`**(差异只有新增说明行)。 > 第二轮按主人要求把 `A~E/G/H` 那批**已闭环的登记**(约 430 行)也归档,并修了状态表那处会断的指针。 > 2. **撞上一次协作竞态**(值得记):后端会话**在我拆的期间往 `02` 追加了内容**(未提交)⇒ merge `Aborting`。 > **处理**:先把他们的补丁 `git diff > /tmp`,`git checkout --` 清工作区,合并拆分,**再把他们的内容原样追加回新主文件末尾**。 > ⇒ 两边都没丢。**教训:动 `02` 之前先 `git status`。** > 3. **`

` 规整已落地并上线**(后端 `5de708a`):照我们的裁定走 —— **写入侧**收口、**幂等守门断言**、 > 覆盖 **Web 与 IM 两条落库路径**(渲染口径不分叉)。**我方零改动。** > > ### ✅ 已拍板(主人 2026-10-05):历史回填 **做** > **决定:做**(主人「按最优解」)——三条约束见 `02-前后端协同事项.md` 末尾「历史回填」一节: > **① 先 dry-run 只统计不改(量大异常先对齐)② 留回滚点 ③ 一次性、不留常驻逻辑**。 > 范围严格限定为「只补 `

`」,不碰 role/时间戳/documents/thinking/记忆派生数据。 > > > 原问题描述(保留备查):**历史消息要不要一并回填重写?** 后端的原话:本次是**写入侧**收口 ⇒ **已落库的旧「半包」消息不会自动重写**(按既有解析规则仍会塌)。 > 要重写就是**一次性数据回填**(涉及 `data/**`,**需主人点头**)。**不做的话**:新消息从此正常、老消息照旧塌。 > > ### 待办(按优先级) > 1. **真机验证「并行」两路**:后端已确认 `turn_begin` / `steer_ack.turnId` / `interrupt.turnId` / `chat_messages.turn_id` > **都已进 HEAD 并已上线**(契约 v0.66 待升)⇒ 「等登记」这个阻塞**已解除**。我方形状早备好且**全部可降级** > (W30),入口也在(W31:设置页「她忙时发消息」第三档)。**装机步骤**:先重启模拟器(见下)→ 装 APK → > 设置切「并行」→ 在**她正忙**时发一条 → 看是否真开第二路 + 灰态 + 两级分桶。 > 2. **用假后端验「忙时被拒」横幅**(上次没验成的那笔):`devtools/fake-backend/README.md` **第 6 节**有**逐条跑过**的命令序列 > (`--hold-replies` → 发 chat 挂住 → `persist` 补落库 → 注入 `pending_full`)。**关键**:必须让待发气泡 **≥1 条** > (0 条会落 `Ignore`,**刻意什么都不显示**,那是设计不是 bug——我上次就栽在这)。 > 3. **等后端补「思考开关在哪开」**:已确认**端点支持**(`anthropic_reasoning.go:76` 的 `anthropicDeepSeekReasoning`, > 走 `output_config.effort`,`thinking.budget_tokens` 会被忽略并 WARN),且**开关是请求级的、来自角色/组的思考设置** > ⇒ **不是**改 `models.json`。**⚠️ 绝对不要再试 `reasoning.kind`**:`validateReasoning`(`models.go:180-191`) > 在 anthropic 组上会**拒绝**它,加了会让**后端下次启动校验失败**。 > 4. 旧的:子会话流程(主人说搁置)、图片/工具卡实机验证(后端未配工具)。 > > ### 环境与纪律 > - **模拟器今天极不稳**(连 launcher 都 ANR 过、App 进程被系统重建)⇒ **开工前先重启模拟器**。 > - **假后端进程还占着 18080 / 18081 / 18083**(`taskkill` 不太好使,**重启机器最干净**)。 > - **worktree 纪律(上一轮主人要求写死的)**:每合并一个**当场 `rm -rf` + `git worktree prune`**, > 删前先 `merge-base --is-ancestor` 验已并入,只删目录保留分支,收尾自检 `git worktree list` 只剩主树。 > **本批已按此执行**(W34 合并后立刻清掉)。 > - **我又犯了一次「先怀疑代码、而不是先怀疑自己的测量」**(截图看不到横幅时)。这条已经写过两次了, > 是第三次 —— **下次第一反应必须是"我的验证条件对不对"**。 > ## ★★ 锚点补记七(2026-10-05 下午 · 并行两路**实机验通** + 一个误标 bug 已修 + 假后端横幅两档已验 + 模拟器调参) > > ### 状态 > - `android` = `origin/android` = **`39a63f8`**,主树全量 `assembleDebug` + `testDebugUnitTest` 跑过: > **1055 测试 / 0 失败 / 0 错误 / 0 跳过**。 > - 本批代码两个都**已合并、已推送、worktree 已清**:**W35** `6e00697`(分享来源角标)、**W36** `39a63f8`(并行路角标改以 `kind` 为准)。 > - ⚠️ **工作区有 `02` 的在途改动 —— 那是后端会话的追加(回执十八等),不是我们的,别提交它。** 处理手法见本文末。 > > ### ✅ ①「并行」两路:实机验通(这次是真证据,不是看角标猜的) > 三条互相独立的判据: > 1. **后端日志**(`%USERPROFILE%\cy.log`):`msg="parallel leg started" session=web turn=turn-4743035881e410af leg=2 active_turns=2` > —— 这行**只在并行启动路径**打(`session_parallel.go:175`),`active_turns=2` 按定义就是「起第二路时第一路仍在飞」。 > 2. **库内 `turnId`**(`GET /sessions/web/messages`):`write 2000 words about the ocean` = `turn-b618432ddb21ed42`(11:56:27.8), > `para2` = `turn-4743035881e410af`(11:56:39.4)⇒ **两条 user 消息不同 turnId**;leg1 的 assistant 行 **11:56:41.8** 才落库 ⇒ 与 leg2 真重叠。 > 3. **客户端画面**:底栏显示「正在思考… **并行 2 路**」(= `activeTurns=2`),两路回复各自成块、没互糊。 > > ⚠️ **差点被自己骗过去的坑(写死它)**:第一轮我把「角标显示并行路 2」当通过 —— **是假阳性**。 > 后端 `leg` 来自 `st.legSeq++`(`session_parallel.go:202`,**每条新路都自增,串行回合也算**)⇒ `leg=2` 只说明「本会话第 2 条路」。 > **判并行只能看 `turn_begin.kind` / `active_turns`,或后端那行 `parallel leg started` 日志。** > > ### 🐞 副产物 bug(已修 W36):`leg >= 2` 就挂「并行路 N」 > 复现:会话里发**第二条普通消息**,气泡下就出现「并行路 2」。根因是客户端**把 `turn_begin.kind` 丢掉了**,角标只认 `leg`。 > W36 把 `kind` 带进 `TurnBinding.parallel`,判据改成 `parallel && leg >= 2`;**`kind` 缺席(老服务端)⇒ 一枚都不挂**(宁可少挂,不说假话)。 > > ### ✅ ②「忙时被拒」横幅:假后端两档都验过 > 手法(比改 App 服务器地址干净):**不动 App 配置**,把 `adb reverse` 的 10942 转到假后端端口 —— > `adb reverse --remove-all; adb reverse tcp:10942 tcp:18099`,App 照旧访问 `http://127.0.0.1:10942`;验完还原 `adb reverse tcp:10942 tcp:10942`。 > - 待发 **1** 条 ⇒ 横幅「**她正忙且队列已满,这条没发出去,请稍后手动重试**」+ 气泡摘掉、**正文退回输入栏**(`e2e-shots/fb3-banner.png`) > - 待发 **≥2** 条 ⇒ 横幅「**她忙不过来,这条可能没发出去**」+ **一个气泡都不动**(`e2e-shots/fb5-unknown.png`) > - 关键前置:**待发气泡必须 ≥1 条**(用 `--hold-replies` 挂住,别让它落库) > > ### 🔧 模拟器调参(今天那串 ANR 的真因不是代码) > 宿主 Ryzen 5 5600G(6C/12T)/ 28G;AVD 原本 **4 vCPU + 6G**(`config.ini` 里 `hw.ramSize=2048`,靠 `-memory 6144` 顶)。 > 客户机 `top` 显示 **iowait 196% + swap 已用 457MB** ⇒ 瓶颈是 **IO + 内存**,不是核数。 > 已改 `C:\Users\Aska\.android\avd\Pixel_10_Pro_XL.avd\config.ini`:`hw.ramSize=8192` / `hw.cpu.ncore=6` / `vm.heapSize=512`。 > 效果:**iowait 196% → 14%,swap 457MB → 0**。 > 另外**别再每次 `-no-snapshot-save`** —— 冷启动那一遍 IO 最贵;现在让它存快照,下次热启动。 > > ### ⚠️ 新踩的两个坑 > 1. **`settings put global hide_error_dialogs 1` 会让 ANR 直接杀掉 App**(日志 `reason=6 (ANR)` + `Killing … user request after error`)。 > 它对自动化友好,但代价是丢状态 —— 配合「不在负载高峰驱动 UI」一起用。 > 2. **别一边跑 Gradle 一边驱动模拟器 UI**(今天就这么把 App 饿到 ANR)。 > 判断「是不是 App 的 bug」的正确做法:**拉 ANR trace 看主线程栈** —— 在 `/data/system/procexitstore/`,`adb root` 后可 pull。 > 那次主线程是 `__epoll_pwait`(**空闲**),且 **launcher 同时段以同样理由 ANR** ⇒ **模拟器被饿死,不是 App 的问题**。 > 3. `uiautomator dump` 失败会**留着上一次的快照**:每次用**唯一文件名**并检查输出行 `UI hierchary dumped to:`,否则会对着旧快照做判断(本批栽过一次)。 > > ### 📦 分享接入:**其实早就做完了** > 子代理查实:Manifest 两个 intent-filter、`app/ShareIntake.kt`、`domain/share/{SharePayload,PendingShareHolder}.kt`、`ChatViewModel.sendShared` 全在, > 且 `05-archive-历史批次.md:124-139` 有 2026-10-04 的装机验收(文本 / 单图 / 未登录暂存 / 登录后落地四条)。 > 本次补的是**「可识别」那一半**(W35):分享进来的消息 `clientMsgId` 用 `share-` 前缀,渲染层挂 **`[用户分享]`** 角标,**正文零改动**(守 `02` 的 F4 红线)。 > ⚠️ **一次观察(未结案)**:用 `am start -a SEND --es …TEXT` 投分享时,客户端出现「**发送失败,请重试**」并把正文退回输入栏,库里**没有**多出那条消息; > 当时 WS 刚在附近重连过一次。**未定位**,先记着。 > > ### 待办(按优先级) > 1. **分享路径剩下三条实机验证**(超长正文走文件 4000 rune / `SEND_MULTIPLE` 多图 / 冷启动)—— 命令见 README 与子代理报告。 > 2. **W36 的真机目视**:单测钉住了,但「第二条普通消息不再冒角标」没在设备上目视过。 > 3. 旧账:子会话流程(主人说搁置)、图片/工具卡实机验证(后端未配工具)。 > > ### 环境与纪律(更新) > - 假后端**用完即关**(本次已关,18099 已释放);别长期占 18080/18081/18083/18099。 > - worktree 纪律照旧(合并当场 `rm -rf` + `prune`);本批 W35/W36 都已执行,`git worktree list` 只剩主树。 > - **动 `02` 之前先 `git status`**:本批 `02` 一直带着后端会话的在途追加,处理手法 = 先存他们的补丁 → `git checkout --` 还原 → 只提交我的 → 再把他们的贴回末尾。两边都没丢。 > ## ★★ 锚点补记八(2026-10-05 傍晚 · 分享路径设备验证的成绩单 + W37 + 三条新环境坑) > > ### 状态 > - `android` = `origin/android` = **`a2350a6`**(含 W37 的合并提交),**1084 测试 / 0 失败 / 0 错误 / 0 跳过**(主树实测)。 > - W37 `6cdf111`(分享:认下字符串形态的 `EXTRA_STREAM` + 消灭静默失败)已合并、已推送、**worktree 已清**。 > - `02` 里已回后端**回执十九**(`0764bc2`):给出那次分享失败的**精确时间锚点**与「无 `error` 帧 ⇒ 帧根本没发出」的证据链。 > > ### ✅ 分享路径:三条通过 > | 场景 | 结果 | 证据 | > |---|---|---| > | 纯文本・**热启动**(连接正常) | ✅ | 库内 `clientMsgId=share-5f65cb44…` + 正文 `share one`;气泡挂 **`[用户分享]`** 角标 | > | 纯文本・**冷启动** | ✅ | App 被 ANR 杀掉后由分享冷启动,`share two` 照样落库(`share-ea81310c…`) | > | **超长正文(4200 rune)→ 文件** | ✅ | 后端 `POST /api/v1/documents 201`;库内 `content_len=0`、`documents=[{…txt, size:4200, chars:4200, status:"extracted"}]`;她回「人家打开你发来的那个文件了」 | > > ### ⛔ 分享路径:**图片(单图/多图)没拿到设备证据** —— 原因是工具,不是产品判定 > **`adb shell am` 造不出「带授权的 URI 分享」**,两条都试过: > - `--esa`(`String[]`):类型不对 ⇒ `Cannot cast [Ljava.lang.String; to android.net.Uri`(**W37 已让它不再静默**); > - `--eu`(单 `Uri`)+ `-f 0x00000001`:**没有 ClipData ⇒ 授权传不到目标应用** ⇒ App 打不开那个 `content://` ⇒ 静默无反应(同一处缺陷); > - **`am` 帮助里确认没有 ClipData 选项**(只有 `--eu/--esa/--grant-*`),所以这条路在 adb 上封死。 > **真路径**是:相册(或任何真实发送方)→ 分享面板 → Cyrene。我试过,但今天 UI 驱动不可靠(见下面三条坑 + 模拟器 `system_server` 崩过一次)。 > ⇒ **要么主人在真机上装最新 debug APK 试一次「相册多选 → 分享」,要么等下次环境稳定再做**。**不要再花时间用 adb 造 URI intent。** > > ### 🔧 W37 修了什么(都是今天实机暴露的) > 1. **字符串形态的 `EXTRA_STREAM` 现在认了**(`String[]` / `ArrayList`,历史遗留与跨壳应用常见),**只接受 `content://`**(`file://`/`http(s)://` 一律拒,理由写在 `stringStreamUri` 的 KDoc);非法项逐条跳过,不整体失败。 > 2. **消灭静默失败**:`EXTRA_STREAM`/`ClipData` 里**确实有内容迹象**却一条都读不进来 ⇒ 给一条**柔和告知**「这次分享的内容没能读进来,没有发送任何内容…」(刻意不写「发送失败」)。 > **边界**:`EXTRA_TEXT` 与 `EXTRA_STREAM` 都为空 ⇒ **仍然静默**(两条判据同源)。 > > ### ⚠️ 今天新踩的三条环境坑(比代码更耽误时间) > 1. **模拟器会自己黑屏**,而 `svc power stayon true` **不一定挡得住**。后果极坏:黑屏下的 `input tap` 全部落空 ⇒ 我会误判成「点了没反应 / 长按不生效 / 焦点乱跑」。**开测前先 `input keyevent 224` + `82` 唤醒并截图确认屏幕是亮的**,另加 `settings put system screen_off_timeout 1800000`。 > 2. **`adb root` 会重启 adbd,把 `adb reverse` 全部冲掉**。之后 App 就「无法连接到服务器」。**`adb root` 用完必须补回 `adb reverse tcp:10942 tcp:10942`。** > 3. **模拟器的 `system_server` 会崩**(症状:`Can't find service: window`、`am` 报 `Failure calling service activity: Broken pipe`、负载飙到 11)。**此时除了重启模拟器没有别的办法**;别把这类现象当 App 的问题。 > > ### 📌 主人问的「开了并行之后连串行消息都成了并行处理?」—— 结论:**不会**,今天的数据里也没有发生过 > 三条证据: > 1. **服务端有硬门**:`session_orchestrator.go:300` 的 `if st.mode != ModeIdle` —— **会话空闲时根本不进并行分支**,无论客户端声明什么都不可能开第二路(空闲只有一条串行路)。 > 2. **客户端只在忙时声明**:`parallelForSend(mode, busy) = busy && mode.wireParallel`(`InterruptScope.kt:65`),空闲时三档**逐字节相同**。 > 3. **今天全部日志(含轮转备份)里 `parallel leg started` 只有 1 条**:`turn-4743035881e410af leg=2 active_turns=2`(`11:56:39`)—— 正是我做并行实测时**故意**制造的。 > ⇒ **最可能的真相**:主人手机上装的是 **W36 之前**的 APK,于是**任何会话的第 2 条及以后的消息**都会被误挂「并行路 N」(`leg` 是「本会话第几条路」,不是并行与否)——**已修为 `39a63f8`,请装最新 debug APK 再看**。 > ⇒ 若看到的其实是**底栏那句**「正在思考… 并行 N 路」:那句来自 `ChatUiState.activeTurns > 1`(`ChatScreen.kt:863`),是**另一个信号**;服务端 `session_idle` 用 `busyPayload(…, ModeIdle, 0)` 发 0(`session_orchestrator.go:455`)⇒ 正常情况下会清掉。**若仍能看到,请告诉我,我按 activeTurns 陈旧这条线单独查。** > > ### 最新 debug APK > `app/build/outputs/apk/debug/app-debug.apk`(**由 `d8228fb` 构建**;含 W35 分享角标 / W36 角标闸 / W37 字符串形态与告知 / **W38 分享等连接**)。 > ## ★★ 锚点补记九(2026-10-05 夜 · 分享「等连接」缺陷**已修**(W38)+ W37 设备验证通过 + 「并行」误标的设备证据) > > ### 状态 > - `android` = `origin/android` = **`d8228fb`**(W38 合并提交;分支提交 `aeb3745`);主树 **1091 测试 / 0 失败 / 0 错误**。 > - W38 worktree 已清,`git worktree list` 只剩主树。`02` 工作区仍是**后端会话的在途追加**(本次没动它)。 > > ### ✅ W38:分享「等连接」缺陷已修 —— 这处是今天唯一确认的真缺陷 > **缺陷**(实机证据链见 `02` 回执十九与补记八):分享消费点把 `withTimeoutOrNull` 的**返回值丢掉** ⇒「等 3 秒没等到」与「等到了」走**同一条下游**: > 照样 `take()` + `sendShared` → WS 未就绪 ⇒ `sendChat` 回 false ⇒ `rollback(localId, "发送失败,请重试")`。 > 三个后果:**① 硬错误横幅把「一次都没发出去」说成「发送失败」;② `take()` 原子出队且无归还路径 ⇒ 内容既不在 holder 也不在库里,重试资格丢了;③ effect 键不含连接状态 ⇒ 连接后来恢复也不会补发。** > > **修法**(`aeb3745`,4 files / +277 −20): > - 新增 `ui/chat/ShareLanding.kt`:纯函数 `shareLanding(hasPending, connection, waitedLong, alreadyTold)` → `Nothing / KeepWaiting / TellWaiting / Send`(**返回单值动作 ⇒ 发送与告知在类型上不可能同时发生**)。 > - 落地 effect 的键加了**连接状态**(连接一变就重跑判据,**无轮询**);**只在 `Send` 分支才 `take()`**。 > - 等太久(3 秒)⇒ 走既有柔和通道 `showShareNotice` 说**一次**「还没连上、会等连上后自动发出」,**不是** error 横幅、**不含**「失败/重试」字样,**抖动不刷屏**。 > - `SHARE_CONNECT_TIMEOUT_MILLIS` **改名**为 `SHARE_WAIT_NOTICE_MILLIS`:含义从「到点就照发的发送时限」变成「等多久才值得说一句」,KDoc 明写**不是发送时限**。 > - 新增 `ShareLandingTest` 7 例(穷举 `WsState.entries` × 两个标志);**未动**普通发送路径(输入框/图片/附件)、未动 `PendingShareHolder.take()` 的原子语义。 > > ✅ **真机回归已做(2026-10-05 夜,设备:模拟器 Pixel_10_Pro_XL / 包 `f15f0bf` 构建)**: > 1. **断线时分享 ⇒ 不再报「发送失败」**:`adb kill-server` 真断线(reverse 清空)后投分享 —— 顶栏「重连中…」, > 横幅是**「还没连上,分享的内容会留着,连上后自动发出」**(柔和),**没有** error 横幅;**库里查不到那条消息** ⇒ 内容没丢、也没假装发出去。 > 2. **连接恢复 ⇒ 自动补发,且只发一次**:`adb reverse tcp:10942 tcp:10942` 恢复后(等 ~50 秒,含退避)库里出现**恰好一条** user 消息 > `cli=share-914435c5…`、`turn-8cbf94e7`(同 turn 的另一条是她的回复,不是重复发送)。 > ⇒ 三条回归里 **① 断网分享后恢复 ② 横幅不是 error** 都拿到了;**冷启动分享**未单独重跑(补记八里冷启动路径已验过一次,且那条路径的差异只在入口)。 > 截图留档:`app/build/e2e-shots/w38-offline.png`(柔和告知)/ `w38-recovered.png`。 > > ### ✅ W37 设备验证通过(补记八里说它「只有单测背书」) > 装 `a2350a6` 的包,用 `am start -a SEND_MULTIPLE -t image/png -f 0x00000001 --esa android.intent.extra.STREAM ,`: > - 界面出现告知 **「有 2 个文件读不到,未发送」** ⇒ **App 真的认下了这 2 个字符串形态的流**(W37 生效),只是 adb 给不出读授权所以读不到; > - **App 侧不再有 `Cannot cast [Ljava.lang.String; to android.net.Uri`**(logcat 里剩下的那几条来自 **system_server 的 `ActivityCallerState`**,pid 1315,不是我们的进程)。 > > 🐞 **顺带发现一处文案瑕疵(未修,登记)**:这次告知显示成**两句叠在一起** ——「有 2 个文件读不到,未发送**;**分享的内容是空的,没有可发送的内容」。 > 前者是「认下了但读不到」,后者是「一条都没认下」,**同时出现读起来像两个不同的问题**(`ChatScreen.kt:905-911` 的拼接顺序)。 > 建议:`failedImages/failedDocuments > 0` 时**不再追加**「内容是空的」那句。 > > ### ✅ 主人问的「并行档下串行消息也变并行」——**设备证据到手,不会** > 步骤(可复现):设置页切**并行** → 她**空闲** → 用分享投一条普通消息 `serial gate check`,然后看两处: > - **后端日志新增 `parallel leg started` = 0 条**(那条消息只有一个 `turnId`:`turn-780c3aa2`); > - **界面助手气泡 footer 只有「发送至 安卓」,没有「并行路 N」**(含 W36 的包)。 > ⇒ 并行档只在**她真的在忙**时才另起一路;空闲时就是串行。**主人若仍看到角标,几乎确定是旧包**(W36 之前 `leg>=2` 就挂标)。 > > ### ⚠️ 一条今天的测量教训(差点又误判) > **`adb reverse --remove-all` 不会杀掉已经建立的连接** —— 我用它模拟「断线」,结果那条探测消息**照样落库**(`share-f7cd2374` 的 `offline share probe`)。 > ⇒ 想真的把 App 与后端切断,要 **`adb kill-server`**(再 `start-server` + 补回 `adb reverse`)。**别拿「移除转发」当断线。** > > ### 待办(更新) > 1. **W38 的三条真机回归**(冷启动分享 / 断网分享后恢复 / 横幅不是 error)—— 真机或下次环境稳定时做。 > 2. **文案瑕疵**(`failed* > 0` 时不再追加「内容是空的」)—— 小改动,与下面两条同区,建议合成一批。 > 3. W38 子代理提的三条后续(**未做,待主人定**):① `deliver` 在 WS 未就绪时对**输入栏那条路**仍一律 `rollback(…, "发送失败,请重试")`(「未就绪」与「发失败」仍是两件事);② `WsState.Offline`(不可恢复)目前也走「等 + 连上后自动发出」,会一直等一句永不到来的「连上」,可改成「需要重新登录 / 检查服务器地址」;③ 待发内容只在进程内存里,一直连不上且进程被杀就丢(既有语义)。 > 4. **`02` 的 §四/回执十九 仍以「修法方向」口吻描述这处缺陷** —— 下次动 `02` 时补一句「已修 `aeb3745`」(本次没动它,避免与后端会话的在途追加打架)。 > 5. 旧账:子会话流程(主人搁置)、图片/工具卡实机验证(后端未配工具)、**相册多选→分享**(图片路径仍需真机)。 > ## ★★ 锚点补记十(2026-10-05 夜 · W39 三处「措辞不诚实」已修) > > ### 状态 > - `android` = `origin/android` = **`b55d220`**(W39 合并提交;分支提交 `a763592`);主树 **1101 测试 / 0 失败 / 0 错误**。W39 worktree 已清。 > - ⚠️ **本批只有单测与编译证据,没有真机回归**(真机那三条留给下次或主人)。 > > ### ① 分享告知「两句叠加」已消 > **改前实机原文**:「有 2 个文件读不到,未发送**;**分享的内容是空的,没有可发送的内容」——读起来像两个不同的问题。 > **改法**:`shareNoticeFor` 里新增 `hasFailedPieces = failedImages > 0 || failedDocuments > 0`,`isEmpty` 那档改成 `isEmpty && !hasFailedPieces`(`ChatScreen.kt:997` / `1002`)。丢件那句**逐字未改**。 > ⚠️ **子代理发现的事实(值得记)**:加了这个前提之后,「分享的内容是空的」这句**一次都不会再出现**——因为 `worthDelivering` 要求 `isEmpty` 的分享必须带丢件计数或 `contentUnreadable`,而后者已被「读不进来」那档接走。分支**保留并在 KDoc 里写明它已无出口**(没删,避免猜错路径时反而丢告知)。 > > ### ② `deliver`:未连接与发送失败分开说(**只改文案,行为不变**) > - 新常量:`SEND_FAILED_MESSAGE = "发送失败,请重试"`(逐字不变)、`SEND_NOT_CONNECTED_MESSAGE = "还没连上,这条没发出去,请稍后手动重试"`。 > - 新纯函数 `sendFailureMessage(connection: WsState)`:`Connected` 一句、其余四档另一句;`SendFailureMessageTest` 覆盖 `WsState.entries` 全部。 > - 接线处取的是 **socket 自己的 `state`**(正是 `sendChat` 判的那个值),**不用界面镜像那格**,避免晚一帧读成 `Connected`。回滚、幂等键、**无自动重发**全部照旧。 > - ⚠️ **没做**(单列,等主人定案):让「未就绪」像 W38 的分享那样**不取走内容、等连上再发**。那会改变普通发送的重试语义(未连接时是留在输入栏,还是自动排队重发),属产品定案。 > > ### ③ `WsState.Offline` 不再承诺「连上后自动发出」 > - `ShareLanding` 加 `TellOffline`(**时机一字未改**:仍是等够久才说、同一轮只说一次,只换措辞);新纯函数 `shareWaitingNotice(landing)` 决定哪档说哪句,Compose effect 只剩一行接线。 > - 文案:`SHARE_OFFLINE_NOTICE = "现在处于离线,需要重新登录或检查服务器地址;分享的内容一个字节都没发出去,不会丢"` —— **不含**「连上」「自动发」「失败」。 > - 仍**不 `take()`、不发送**;恢复 `Connected` 后照旧自动补发(W38 的硬要求未动)。 > - ⚠️ **没做**:`Offline` 时**立刻**告知(现在仍等 `SHARE_WAIT_NOTICE_MILLIS`)。属独立改动。 > > ### 待办(更新) > 1. **主人定案项**:普通发送在「未连接」时该不该也自动排队重发(现在是留在输入栏 + 诚实文案)。 > 2. **未纳入新判据的一处字面量**:`ChatViewModel.kt:1552`(REST 回执被拒那条路)仍是 `rejected.message.ifBlank { "发送失败,请重试" }`,刻意未动。 > 3. `Offline` 立刻告知(不等 3 秒)。 > 4. **W38/W39 的真机回归**:W38 已验(补记九);W39 这三条只在真机上扫一眼即可(触发条件都少见)。 > 5. 旧账:子会话流程(主人搁置)、图片/工具卡实机验证(后端未配工具)、**相册多选→分享**(图片路径仍需真机;App 一个权限都没声明 ⇒ adb 造不出可读 URI,这条**只能**走真机或驱动相册 UI)。 > ## ★★ 锚点补记十一(2026-10-05 · W40:普通发送「未连接 ⇒ 进队列」,不再回滚) > > ### 状态 > - worktree 分支 `feat/W40-send-queue-offline`(基线 `e60ae7a`),分支提交 **`df9fc85`**;**未合并、未推送**(主树与 `origin/android` 都没动)。 > - 本批证据:`assembleDebug` 成功;`testDebugUnitTest` **1118 测试 / 0 失败 / 0 错误 / 0 跳过**(83 套件)。**没有真机回归**(这一批只有单测与编译证据)。 > - 净变化:W39 加的 `SendFailureMessageTest`(4 条)**删掉**,其断言改成新事实后并入新的 `SendQueueTest`(21 条)⇒ 1101 − 4 + 21 = 1118。 > > ### ① 主人的裁决落地:未连接 ⇒ 进队列(上一批的「待办 1」已定案并做完) > - 新纯函数 `wsSendOutcome(sent, connection)`(`ui/chat/SendQueue.kt`)三档:`Dispatched`(帧交给 socket;**成功路径一个字没动**)/ `Queued`(未就绪四档:Idle / Connecting / Reconnecting / Offline)/ `Failed`(连接就绪却仍发不出去 ⇒ 今天那条 `rollback` + `SEND_FAILED_MESSAGE`)。 > - `deliver` 的 WS 分支**只改「未就绪」这一支**:不回滚、不进 `error` 横幅,改由 `queueForConnection` 留在待发态(`ChatUiState.queuedForConnection`:条目只有键与并行声明,正文仍从气泡上现取)。 > - **触发点**:`onConnectionChanged` 里 `connection == WsState.Connected` 那一格(由 `socket.state` 驱动,**无轮询**)+入队后的竞态兜底。 > - **顺序**:FIFO = 用户发送的先后顺序;补发只看队头。**只发一次**:队头 + 出队恰好一次(`dropQueuedSend`);第二道是补发复用同一个 `clientMsgId`(服务端 `chat_duplicate` 判重)。`flushQueuedSends` 是同步函数(无挂起点),两个触发点不会交错。 > - ⚠️ **已知边界**:打断声明(`interrupt{scope:"current"}`)不随补发重放(补发时在跑的那一轮早已不是用户按发送时看到的那一个);内容仍只在**进程内存**里,一直连不上又杀进程就丢(既有语义)。 > > ### ② 状态表达:③ 排队中 + 小标「还没连上」,**不新增第七态** > - `phaseOf(待发气泡)` 的 ③ 判据扩为「收到过入队回执 **或** 本端因未连接留住」;气泡小标由新纯函数 `pendingBubbleFooter` 给:`排队中 · 还没连上`(服务端忙时排队仍是 `排队中`)——挂在气泡上而不是只靠告知,因为告知 8 秒就收走,而这是会持续几十秒的状态。 > - 柔和告知走**新通道** `ChatUiState.sendQueueNotice`(与 `shareNotice` 分开:两条说不同的动作):`SEND_QUEUED_NOTICE = "还没连上,这条排队等着,连上后自动发出"`;`Offline` 那一档换 `SEND_QUEUED_OFFLINE_NOTICE = "现在处于离线,需要重新登录或检查服务器地址;这条还没发出去,已排队不会丢"`(**不提**「连上」「自动发」)。**不刷屏**:一轮「队列从空到空」只说一次。 > > ### ③ REST 通道(长文 / 图片 / 附件)**未覆盖**,仍是回滚 + 网络错误文案 > 理由:它给不出「未连接」这个信号(只有一次网络错误,请求可能已被服务端收到),且补发要重跑图片重编码与附件上传(本地 URI 授权可能已失效)。影响面仅这一条通道。 > > ### 待办(更新) > 1. **W40 的真机回归**:断网发普通消息 ⇒ 应看到「排队中 · 还没连上」+ 柔和告知(**没有**红色横幅、输入栏不退回)⇒ 恢复连接 ⇒ 自动补发且只落库一条。 > 2. REST 通道(长文 / 图片 / 附件)未连接时要不要也排队——需主人定案(代价见 ③)。 > 3. `Offline` 立刻告知(不等 3 秒)——W39 遗留,未动。 > 4. **未纳入新判据的一处字面量**:REST 回执被拒那条路仍是 `rejected.message.ifBlank { "发送失败,请重试" }`,刻意未动。 > 5. 旧账:子会话流程(主人搁置)、图片/工具卡实机验证(后端未配工具)、**相册多选→分享**(只能走真机或驱动相册 UI)。 > ## ★★ 锚点补记十二(2026-10-05 深夜 · AVD 调参第二轮:尺寸/核数/无窗口) > > ### 状态 > `android` = `origin/android` = **`31e8e31`**(W40 合并提交);主树 **1118 测试 / 0 失败 / 0 错误**;worktree 只剩主树。 > > ### 先纠正一条容易走错的路:**硬件加速本来就是开着的** > 模拟器日志:`WHPX on Windows 10.0.26100 detected` + `Windows Hypervisor Platform accelerator is operational`;CPU `VirtualizationFirmwareEnabled=True`。 > ⇒ **慢不是「没吃到加速」**,别往那个方向修。 > > ### 本轮改了什么(`Pixel_10_Pro_XL.avd/config.ini`) > | 项 | 改前 | 改后 | 理由 | > |---|---|---|---| > | `hw.lcd.width/height/density` | 1344×2992 @480 | **1080×2340 @440** | 像素少 ~37%,Compose 每帧重绘直接变轻 | > | `hw.cpu.ncore` | 6 | **5** | 宿主只有 6 个物理核;**给满会与宿主机互相抢**(WHPX 下超额分配会互相等) | > | `skin.name` / `skin.path` / `skin.dynamic` | 指向 `pixel_10_pro_xl` 皮肤 | **整三行删除** | **皮肤会把屏幕尺寸钉死**,`hw.lcd.*` 完全不生效(实测 `wm size` 仍是 1344×2992);删掉后 `wm size` 才是 1080×2340 | > | `showDeviceFrame` | yes | **no** | 无皮肤时没必要画外框 | > 备份留在 `app/build/e2e-shots/config.ini.bak`(改坏了可原样还回去)。 > `hw.ramSize=8192` / `vm.heapSize=512` 沿用上一轮(那一轮把 **iowait 196%→14%、swap 457MB→0**,是本项目至今最大的一次性能收益)。 > > ### ❌ `-no-window` **不要用**(这条与直觉相反,实测证据) > 无窗口启动时模拟器把渲染降级成软件 GL:日志 `emuglConfig_init: vulkan_mode_selected:lapvaipe gles_mode_selected:swiftshader` > ⇒ **省了宿主窗口开销,却丢了宿主 GPU**(原先是有窗口时的 `vulkan:host / gles:host`,用的是 AMD RX 6600 XT)。 > 对以 Compose 重绘为主的负载,这是**倒退**。⇒ **保留窗口跑**。 > > ### 结论:**真机做验证、模拟器做冒烟与看日志** > 全系统模拟本来就比真机慢 5~10 倍,宿主是 6 核 Zen3,再压也压不出真机的手感。其余可做的:测试期间别同时跑 Gradle / 别开 Docker 与浏览器(今天就有一次构建把 App 饿到 ANR);保持快照启动(已开)。 > 「换别的虚拟机」这条路**对这个项目基本走不通**:WSA 微软 2025 已停服;MuMu/LDPlayer/BlueStacks 多是 Android 9~12,而我们 `minSdk 33` **至少要 Android 13 才装得上**;Genymotion 个人版付费且镜像滞后;Waydroid 只有 Linux。**官方模拟器 + 真机仍是最优组合。** > > ### 待办(本节的) > - 新尺寸下**没重跑**任何设备验证(App 已装、登录态在;下次做设备测试时留意**旧的点击坐标全部作废**,要按 1080×2340 重新取样)。 > ### ✅ 补:W40 的真机回归已做(2026-10-05 深夜,1080×2340 新 AVD,包 `31e8e31` 构建) > 手法(可复现):`adb kill-server` + `start-server` 真断线(`adb reverse` 被清空)→ 在**输入栏**打 `q1` 并点发送 → 再 `adb reverse tcp:10942 tcp:10942` 恢复。 > | 阶段 | 结果 | > |---|---| > | 断线时 | 顶栏「重连中…」;柔和横幅 **「还没连上,这条排队等着,连上后自动发出」**;**没有红横幅**、**输入栏没有退回**;库里 `q1` **0 条** | > | 恢复后(等 ~55 秒,含退避) | 库里 `q1` **恰好 1 条**(`cli=local-bccbb362-7…`、`turn-d2b6457a`)⇒ **自动补发、顺序 FIFO、只发一次** | > ⇒ W40 主人定案的「未连接进队列」在设备上成立。 > > ### 📍 新 AVD(1080×2340 @440dpi)的实测点击坐标(省得下次重新取样) > | 目标 | 坐标 | > |---|---| > | 输入框 | **(560, 2170)** | > | 发送按钮 | **(950, 2170)** | > | 抽屉(左上菜单)| 按 1080 宽重算,约为 **(86, 188)**(未实测,下次用前先截图确认) | > 注意:**软键盘仍是禁用状态**(`ime disable` 写在 userdata 里,跨重启有效),所以布局不会因 IME 位移;这是刻意的(见补记八第 1 条坑)。