# 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`(那样自相矛盾)。 - **B1 `/upload`**(`cc2f266`):图片分流——**重编码后 >64 KiB 走上传(历史只留 URL),否则内联 base64**。 阈值理由是「内联的成本是**发多少次**而非发一次」:服务端把 `image` 原样落库 ⇒ 每页历史/每次 WS 回放 都重传。✅ **已实机端到端验证**(2026-10-04 深夜):209 KB 图经 Photo Picker 发出 → logcat `POST /api/v1/upload (96764B)` → `201 Created` → `POST /api/v1/chat (243B body)`(243 字节 ⇒ 带的是 **URL 不是 base64**)→ 落库 `image` = `…/api/v1/files/<32hex>.png` → **冷启动 force-stop 重开后仍渲染** (Coil 网络 fetcher 正常)→ 且**模型真的读懂了图**(那张图是模拟器截图,她逐条点评了图里的设置项)。 ⚠️ 未变:`url` 绑死请求时的 `r.Host`(换服务器地址后旧图取不回,`02/F6①`);上传产物**无删除端点**(`02/F7`)。 **本轮新增约定 / 坑(下个会话必读)** - **worktree 并行必须给绝对路径**:W1 曾用相对路径编辑,结果写进了**主树**(10 分钟), 靠「worktree 构建在改动后仍报 `UP-TO-DATE`」这个反常信号才发现。**派 agent 进 worktree 时, 任务书里必须写明「只用绝对路径」**。(当次已自检+窄回退,我复核主树逐字节等于 HEAD,无损伤。) - **删 worktree 目录别用 git 自带的**:Windows 会报 `Filename too long`(`MAX_PATH`,Gradle 的 `build/` 路径太深)⇒ 用 **Git Bash 的 `rm -rf`**,它能处理。 - **`/upload` 产物的 URL 绑死当时的 host**(`r.Host` 拼绝对地址 + 原样落库)⇒ **换服务器地址后旧图取不回**。 已登记 `02/F6①`。**上传也不能突破 5 MiB**(`02/F6②`:那个上限只对 base64 形态成立,URL 受视觉层 50 MiB 约束)。 - **`intent` 共享 socket 债又加一条**:`SessionsViewModel` 现在**自己持有一条 socket**(登录连、登出/ `onCleared` 关)——因为 `AppContainer`/`ChatSocket` 归属别的 agent。收敛方向仍是**一条应用级共享 socket + 按会话路由**。 - **`chat.documents` 契约形状**(v0.59,别提前写死):**数组 ≤5**(不是单对象 —— 单对象会让 1 回合 5 个附件 里 4 个从历史消失);响应/历史 DTO 为 `"documents":[{name,mime,size,chars,truncated,status}]`; `status:"unreadable"` 才是「昔涟读不到」的信号;**文档一律走 REST**;**只收服务端存储名、不收 URL**。 **下一步(顺序已定)**:① ✅ **已完成**(W3 上传路径实机验收,见上)→ ② **分享入口**(`ACTION_SEND` / `ACTION_SEND_MULTIPLE` + `singleTop`/`onNewIntent` + pending-share 持有者 + 登录后落地)→ ③ 搜索 / 编辑删除 / `mode` / `after` → ④ 概览「按路径」可读性 → ⑤ 超限走文件(按 v0.59)。 > ⚠️ **更正上文「当前状态」**:`664fd8d` 已过时。最新提交 **`a6b4bae`**(= `origin/android`,四个模块全部已推)。 **并行批次 W4–W7 —— ✅ 全部完成、已合并、已推送**(2026-10-04 深夜)。按开发纲要「多个并行子代理协同」 切分:四个 worktree、**文件集互不相交**(`D:/Project/Code/Uni/Cyrene-Android-W{4,5,6,7}`,分支 `feat/W4-overview` / `feat/W5-persona` / `feat/W6-documents` / `feat/W7-share`,全部从 `664fd8d` 切出): | 模块 | 内容 | 测试增量 | 合并点 | |---|---|---|---| | W4 | 概览页「按路径」26 条的可读性重做(分组/折叠,Miuix) | 353 → 370(+17) | `f91a855` → `a6b4bae` | | W5 | 新建会话时**选角色**(`POST /sessions {personaId}`,v0.42) | 376 → 408(+32) | `217cfe3` → `557cdb2` | | W6 | 文档/文件进对话(`documents`)**数据层 + 纯逻辑**,不碰 UI/VM | 408 → 468(+60) | `095d6e7` → `a80dac7` | | W7 | **分享到 App**(`ACTION_SEND(_MULTIPLE)` + `singleTop`/`onNewIntent` + 进程级暂存) | 353 → 376(+23) | `ae4e4e5` → `4834905` | **四轮合并在主树跑的都是 `assembleDebug` + `testDebugUnitTest --rerun-tasks`**(不是 FROM-CACHE), **最终 485 测试 / 0 失败 / 0 错误**(= 353 + 17 + 32 + 60 + 23,加法逐轮吻合——这正是「文件集互不相交」 成立的证据)。每轮我都自己读了 `app/build/test-results/testDebugUnitTest/*.xml` 求和,**不采信子代理自述**。 **派 agent 进 worktree 的硬规矩(沿用上轮教训)**:任务书必须写明「**只用绝对路径**」(上轮有 agent 用 相对路径把改动写进了主树);**不许**碰 adb/模拟器、**不许** push/merge、**不许**动构建文件 (`build.gradle.kts` / `settings.gradle.kts` / `libs.versions.toml` / `local.properties`)。 - **A1 / D14 完成**(`d12a920`,2026-10-04):`WsEvent` 的所有结构化流式事件类**透传帧壳上的 `sessionId`** (此前解析器把该字段丢掉),`ChatViewModel.onEvent` 用 `WsEvent.isForSession` **按会话过滤**—— `sessionId` 为空仍放行(行为与修复前一致)。回归测试 `data/ws/WsEventSessionScopeTest.kt`(+16)。 **D14「同一时刻只有一个会话在流式」退役**;核实结论:它**从来只是文档约定**,代码里从未强制 (`ChatSocket.sendInterrupt` 至今零调用方) - **M0–M5 全部完成**,M4 完整(多气泡 / 旁白 / 思维链 / 工具卡 / 图片) - 交互打磨 B 完成(回车发送、文本选择模式、自动跟随底部、IME 修复) - 设置页 C-1 完成(主题 / 旁白 / 思维链默认展开 / 关于) - **C-2 管理员只读监控完成并已实跑验证**:概览(metrics + trace)/ 人格 / 记忆 / 后台思考 四页只读,按 RBAC 分档自动降级(viewer 只能进概览 + 人格),管理区为可加页的壳 + 独立 `Scaffold` - **B4 完成**(`09a3222`):接入 `GET /api/v1/meta/enums` 权威枚举表,删掉客户端硬编码的 `platform` / `mood` / `mode` 映射表——顺带修掉一处既有漂移(客户端把 `web` 显示成「Web」, 权威表是「网页」) - **B2 完成**(`f557654`):接入 `/auth/refresh`,**登录口令不再落盘**(迁移对用户无感);登出补调 `/auth/logout`;可恢复的 401 不再登出;并发 401 由单飞刷新 + 代际守卫兜住 - **A4 / D5 / B3 完成**(`c09a211`):会话列表改用**服务端标题**(本地「会话 N」序号机器整块退役)、 置顶(主会话与次级都可)、最后消息预览;并补上**抽屉打开时静默刷新**——此前只在初始化 / 新建 / 删除时拉取,所以这些内容要重启 App 才可见;顺带修掉「快照指纹闸门没实装」与 「预览剥标签导致旁白与正文粘连」两处既有缺陷 - **`history` 串台已修**(`2a49c25`):WS 首帧 `history` 是**主会话**的回放,此前被无过滤地并进 每一个 `ChatViewModel` → 进次级会话会看到主会话的消息。现按**消息自身的 `sessionId`** 过滤 (`historyForSession`,空值放行) - **工具执行三帧已接**(2026-10-04):后端契约 v0.50 把 `tool_execution` 拆成 `start` / `update` / `end`(载荷 `{tool, phase, id?, delta?}`,**无 `index`**,不属于模型流)。 此前本端只映射 `.update` 且读的是 `tool_call.*` 的 DTO(`start` / `end` 落到未知类型分支被 **静默丢弃**,而 `.update` 后端**没有生产者**)→ 工具执行状态根本到不了 UI。现三帧各有事件类 与解码 DTO,`ChatViewModel` 按 **`id`** 把它们关联到工具卡(`toolExecutionTargetIndex`;`id` 缺席时只在**无歧义**时才落位,绝不默认落第一张),工具卡标题/配色由 `phase` 驱动 `running → 执行中` / `done → 完成` / `error → 失败`。回归测试 `data/ws/WsEventToolExecutionTest.kt` + `ui/chat/ToolExecutionCorrelationTest.kt` **待做** 1. **遗留(本端,均为既有问题)**: - **① 连接所有权**:每个 `ChatScreen` 的 VM 各建一条 `ChatSocket`,而 VM 存在 **Activity 级 `ViewModelStore`** 里,所以**访问过的每个会话都永久留一条连接**(含各自的 `OkHttpClient` 与连接池),**删会话也不回收**;后端还会把每帧广播给每条连接。**已不再串台**(`d12a920` 加了会话闸),现在纯粹是资源 / 生命周期成本。自然解法:**共享一条 socket + 按会话路由**。 - **② `ChatSocket.sendInterrupt` 零调用方**——「切会话时打断」这个出口从未接线,是死 API。 (原 ② 的工具执行帧解析缺口已修,见「当前状态」最后一条。) 2. **概览页「按路径」的可读性**:26 条路径挤在一行右对齐文本里,手机上很密——是否改成分组/折叠, 待主人定 3. 子会话流程(主人已确认搁置) 4. 图片 / 工具卡实机验证(后端暂未配置工具) 2. **概览页「按路径」的可读性**:26 条路径挤在一行右对齐文本里,手机上很密——是否改成分组/折叠, 待主人定 3. 子会话流程(主人已确认搁置) 4. 图片 / 工具卡实机验证(后端暂未配置工具) **接下来可做的大块**(后端都已就绪,几乎全是纯客户端欠账): 1. **REST `POST /api/v1/chat`(`stream:true`)** —— 当前性价比最高的一步: - 解决「**长文发不出去**」:WS 帧硬上限 4096 字节,且**超限是直接断连**(不是报个错), 而客户端**只有 WS** - 解决「**图片完全发不了**」:大图走 REST + base64(**先缩放 / 重编码**,见 `02/G3`) - 是「分享到 App」的前置(`02/F1–F4`) - **已侦察确认**:REST 发起的回合**会**在已连接的 WS 上流式回来(`stream:true` 与 `stream:false` 都会,同一个 orchestrator + 广播路径),所以能做到「**REST 发、WS 收**」, **不牺牲流式体验** - ⚠️ 两个坑必须一起处理:`stream:true` 走**有损总线**且**先回 200**,可能静默什么都没发生 (`02/G2`)→ 以 `message_persisted` 为准 + **绝不自动重试**;全链路**无幂等**(`02/G4`) 2. ~~**顺带解除 A1 / D14**:后端 v0.36 起流帧**已带 `sessionId` + `turnId`**,是本端 `WsEvent` 把字段丢了(`02/G1`)→ 透传并按会话过滤,即可退役「同一时刻只有一个会话在流式」这条长期约束~~ → ✅ **已完成**(2026-10-04):`WsEvent` 全部流式事件类透传 `sessionId`,`ChatViewModel.onEvent` 按 `isForSession` 过滤(`null` 仍放行);回归测试 `data/ws/WsEventSessionScopeTest.kt`。 D14 **确认只是文档约定**,代码里从未强制。**未做**:连接所有权(每 VM 一条 socket、旧 socket 不关) 仍是遗留问题 3. 其余纯客户端欠账:A3 `mode` / D2 会话内搜索 / D3 消息编辑删除 / A5 正向增量 / D4 未读(后端 v0.37 已实现) 4. **M6 打磨**(空态错误态、字体放大、冷启动 <1.5s、内存泄漏)与 **P1 语音** (先真机 spike,验不过就换触发方案) ## 构建(已全部在 PC 上) 开发已迁到 PC(Meowhome),**编译就在本地跑**: ```bat cd /d D:\Project\Code\Uni\Cyrene-For-Android gradlew.bat assembleDebug gradlew.bat testDebugUnitTest ``` > 后端(Cyrene v2)也已迁到同一台 PC,App 的服务器地址指向它即可(见「后端」一节)。 增量编译约 20–40 秒。**每做完一个模块就 push 一次**(主人 2026-10-04 定的默认动作)。 ## 验证方式(2026-10-04 主人修订) **旧的「UI 改动不要自己截图验证」规矩已作废。** 那条规矩的前提是「项目跑在手机上、 主人当时在看别的屏幕,截图也截不到」。**现在开发在 PC 上,主人明确说可以直接抓模拟器窗口。** 所以现在**允许也应该**自己截图验证,方法两条一起用: ```bash ADB="C:/Users/Aska/AppData/Local/Android/Sdk/platform-tools/adb.exe" "$ADB" exec-out screencap -p > app/build/e2e-shots/xx.png # 像素 MSYS_NO_PATHCONV=1 "$ADB" shell uiautomator dump /sdcard/ui.xml # 结构化布局(含 bounds) MSYS_NO_PATHCONV=1 "$ADB" shell cat /sdcard/ui.xml ``` - `uiautomator dump` 拿坐标最可靠(`bounds="[x1,y1][x2,y2]"`),驱动用 `input tap` / `input text`。 它读的是**结构化语义**而不是像素,所以定位点用它、判断观感用截图。 - 截图只落 `app/build/e2e-shots/`(该路径 gitignored),别散落到别处。 - **观感与设计取舍仍归主人拍板**——截图能让你把问题指清楚,但别替主人做审美结论。 ### 凭据处理红线 - 真实账号口令**只许运行时用**,**绝不写进任何文件**(源码 / 测试 / 夹具 / 文档 / 临时文件 / commit)。 - **打印任何服务端响应时,`"token"` 和 `"refreshToken"` 都要盖掉**—— 曾经只盖了 `token`,导致一个真实 refresh token 明文进了对话记录。 - **`adb logcat` 会记下 `input text` 的命令行本身**,所以用 `input text` 输入过凭据之后, 必须 `"$ADB" logcat -c` 清缓冲,且不要把含凭据的 logcat 存盘。 ## 环境要点 | 角色 | 位置 / 说明 | |---|---| | 本机 SDK | `~/Android/Sdk`(`platforms/android-37.0` 手工装 + 手写 `package.xml`;`build-tools/37.0.0` 是 ARM64 原生版) | | 本机 Gradle 配置 | `~/.gradle/gradle.properties`:`aapt2FromMavenOverride`、`sdkDownload=false`、`daemon=false` | | PC SDK | `C:\Users\Aska\AppData\Local\Android\Sdk`(已补 `android-37.0`),`JAVA_HOME` = JDK 21 | | PC 项目 | `D:\Project\Code\Uni\Cyrene-For-Android`(`local.properties` 指向 SDK) | | 后端源码 | `D:\Project\Code\Uni\Cyrene_v2`(**开发期间只读,不得修改**) | | 镜像 | Maven→阿里云、Gradle 发行包→腾讯、Android SDK 包→腾讯 | ## 后端(2026-10-04 实测确认在线) | 项 | 值 | |---|---| | 端口 | **10942**。已实测确认:`cyrene` 监听 `0.0.0.0:10942`;`GET /api/v1/meta/enums` 无 token 回契约里的 `401 {"error":{"code":"unauthorized","message":"auth: unauthorized"}}` | | 启停 / 状态 | `Cyrene_v2\scripts\ctrl.bat start|stop|status`(日志 `%USERPROFILE%\cy.log`,pid 文件 `%USERPROFILE%\.cyrene.pid`) | | 局域网 / 真机 | `http://10.0.0.10:10942`(以太网,网关 `10.0.0.1`)| | 其他可达地址 | ZeroTier `192.168.0.100` / `10.144.0.100`;Tailscale `100.99.64.15` | | 登录凭据 | **不写进文档**(`.env` 属敏感,不读不记),问主人 | **⚠️ 四个坑**: 1. **不要用 8080**。`.env.example` 的 `PORT` 默认值是 8080,但实际配置是 10942,而且 **这台机器的 8080 被一个 PHP 应用占着**(`X-Powered-By: PHP/8.4.1`,对 `/api/v1/*` 回 HTML 404)。 往 8080 打会得到一个「看起来像后端」的 HTML 404,很容易误判成后端起不来。 2. **后端不一定在跑**。2026-10-03 两次实测都是 down(无 cyrene 进程 / 10942 无人监听 / `curl 127.0.0.1:10942` 连接被拒),2026-10-04 主人说「开了」之后确实在线。 用之前先 `scripts\ctrl.bat status` 确认,**别默认它在跑**。 3. **另一个会话正在实时开发后端**(主人 2026-10-03 说明),且后端仓库**开发期间只读、不得修改**。 因此上表的端口与 `02` 里记的端点形状**都可能随时漂移**——联调前重新核对一次 `Cyrene_v2/Docs/api-client.md` 的契约版本号,别拿旧版本号当依据。 4. **`.env.example` 里的账号只是模板**(`admin` / `admin12345`),2026-10-04 实测登录回 `401 invalid username or password`。 ## 模拟器(2026-10-04 实测) AVD:**`Pixel_10_Pro_XL`**(`emulator -list-avds` 里唯一一个)。SDK 工具在 `C:\Users\Aska\AppData\Local\Android\Sdk\{platform-tools\adb.exe, emulator\emulator.exe}`。 ```bash ADB="C:/Users/Aska/AppData/Local/Android/Sdk/platform-tools/adb.exe" "$ADB" reverse tcp:10942 tcp:10942 # ← 必须先做,见下 "$ADB" install -r app/build/outputs/apk/debug/app-debug.apk "$ADB" shell monkey -p top.yeij.cyrene -c android.intent.category.LAUNCHER 1 ``` **⚠️ 这个 AVD 没有网络接口**:`ip -o addr` 只有 `lo` 和 `dummy0`,`ip route` 全空, `ping 10.0.2.2` / `ping 10.0.2.1` 都是 `Network is unreachable`。 **所以模拟器里填 `http://10.0.2.2:10942` 是走不通的。** 绕法:用 **`adb reverse`**,然后把 App 的服务器地址填 **`http://127.0.0.1:10942`**。 已实测通过——设备内 `toybox nc 127.0.0.1 10942` 拿到了后端真实的 `HTTP/1.1 401 Unauthorized {"error":{"code":"unauthorized"}}`。 **设备内没有 `curl` / `wget` / `javac`**(`toybox` 无 `wget` 子命令),探活只能用 `nc`; 且 `nc` 要**把 stdin 撑住**才收得到响应体,否则只出一堆空行: ```bash "$ADB" shell "(printf 'GET /api/v1/meta/enums HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n'; sleep 4) | toybox nc 127.0.0.1 10942" ``` **签名坑**:模拟器里原本装着一个 2026-05-26 的旧 `top.yeij.cyrene`,与本机 debug keystore 签名不匹配 → `INSTALL_FAILED_UPDATE_INCOMPATIBLE`。必须先 `adb uninstall top.yeij.cyrene` 再装。 **时钟提醒**:模拟器时钟比宿主机慢约 8 小时(约等于 UTC)。界面里 `at` 显示的「本地时间」 是模拟器时区格式化的结果,不是转换错误。 ## 易忘的实现约定 - 包名 `top.yeij.cyrene`,应用名 **Cyrene** - 版本:AGP **9.2.1**(**内置 Kotlin,不能再应用 `org.jetbrains.kotlin.android`**)/ Kotlin 2.4.0 / Compose BOM **2026.05.01** / Miuix **0.9.3** / Gradle 9.7.1 / compileSdk 37 / minSdk 33 / `buildToolsVersion = "37.0.0"` - **基础图标是 `MiuixIcons.Basic.Xxx`**(文档写 `MiuixIcons.Xxx` 是错的,以 AAR 为准);部分图标在 `icon.extended.*` 下、按 `MiuixIcons.Xxx` 访问(如 `MiuixIcons.Add` / `MiuixIcons.Delete`)——加图标前先看现有 import,别猜 - 列表项一律用 **`BasicComponent`**(Miuix 所有 Preference 的底座,MD3 圆角卡片)——但**它本身没有背景和圆角**,必须外面包一层 `ui/common/PreferenceCard.kt`(`surfaceContainer` + 16dp 圆角);页面底色是 `surface`,两者在浅/深色下都能区分 - **本项目没有导航库**(无 NavHost、无 Navigation Compose)。路由是 `ChatHomeScreen.kt` 里手写的布尔状态 + `BackHandler` 分层。加新页面照这个模式来,别引入导航库 - 输入框在 `Scaffold.bottomBar` + `imePadding()`;**Manifest 必须 `windowSoftInputMode="adjustResize"`**(否则 IME 高度被算两次,输入框会被顶到顶部) - 系统栏图标明暗在 `app/CyreneRoot.kt` 里随主题切换(`WindowInsetsControllerCompat`) - 返回层级:管理页→聊天、设置页→聊天、抽屉打开→先关抽屉(`ChatHomeScreen` 里的 `BackHandler`),都关着才回桌面 - 聊天列表 `reverseLayout` + `followBottom` 逻辑(只在滚动结束时更新跟随状态)才能「新消息自动置底、用户上滑时不打扰」 - 文本选择用 `SelectionContainer` 逐块包裹(气泡/旁白/思维卡/工具卡各自独立,「全选」不跨块) - Retrofit 3 的 kotlinx converter:Kotlin 侧必须用扩展函数 `json.asConverterFactory(mediaType)`(那个 `KotlinSerializationConverterFactory` 只是 JVM facade) - 明文 HTTP 已放行(`res/xml/network_security_config.xml`),否则自部署地址会报「网络不可用」 - **DTO 不用 enum**:`mode` / `scope` / `kind` / `status` / `role` / `platform` / `mood` 在后端都是**开放集合**,一律 `String` + 客户端映射,未知值**原样显示**(不丢弃/不报错/不占位符) - **服务端时间有两种形状**,不能共用一个解析器:**RFC3339 字符串**(trace / audit)与 **Unix 毫秒**(`backthink.at`) - **`auth` 的 `expiresAt` 是 Unix 秒**(不是毫秒)——`AuthManager` 里已经 `* 1000` 再比,别改错方向 - **别假设「文本字段就是纯文本」**:后端的 `backthink.conclusion` 存的是**Web 端标记** (`
` / `