From c905a47d6f430c07ff38882633b7fc393dba5b64 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 6 Oct 2026 08:11:26 +0800 Subject: [PATCH] =?UTF-8?q?docs(05/07):=20=E9=94=9A=E7=82=B9=E8=A1=A5?= =?UTF-8?q?=E8=AE=B0=E4=BA=8C=E5=8D=81=E4=B8=83=20=E2=80=94=E2=80=94=20W50?= =?UTF-8?q?=20=E8=90=BD=E5=9C=B0=E5=B9=B6=E8=AE=BE=E5=A4=87=E9=AA=8C?= =?UTF-8?q?=E6=94=B6=E9=80=9A=E8=BF=87=EF=BC=9B=E7=BB=86=E8=8A=82=E5=8D=95?= =?UTF-8?q?=E5=BC=80=2007=EF=BC=88=E8=AF=81=E6=8D=AE=E4=B8=8E=E9=81=BF?= =?UTF-8?q?=E5=9D=91=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改了什么: - 05 新增锚点补记二十七:结论(afee5f6 已推 / 1193 测试真跑 0 失败 / APK 尺寸)、四条硬要求逐条 对应实现处、红线核对、设备四条判据全绿、环境坑一句话版、真后端那条主动路径是活的(副产品)、 待办变化(W48/W49 验收环境已就绪;W50 的窄尾巴)。 - 新增 devdocs/07-W50-主动消息-验收与避坑.md:验收判据与逐条证据、完整环境坑(含绕法与命令)、 复现整套验收的最短路径、随批次修掉的 timestamp 解码缺陷、以及「落库失败档」那条尾巴的收口建议。 为什么单开 07:05 已 1400+ 行,验收的操作细节与避坑属参考材料而非状态;05 只留结论与指针, 下一个我读 05 就能接手,需要动手复现时再翻 07。 --- devdocs/05-开发进度与交接.md | 48 ++++++++++++++++ devdocs/07-W50-主动消息-验收与避坑.md | 80 +++++++++++++++++++++++++++ 2 files changed, 128 insertions(+) create mode 100644 devdocs/07-W50-主动消息-验收与避坑.md diff --git a/devdocs/05-开发进度与交接.md b/devdocs/05-开发进度与交接.md index 7192dbf..dda0f5c 100644 --- a/devdocs/05-开发进度与交接.md +++ b/devdocs/05-开发进度与交接.md @@ -1461,3 +1461,51 @@ curl -s -X PATCH "$BASE/api/v1/sessions/$SID" -H "Authorization: Bearer $TOK" \ > ### 7. 一条方法论(今天最大的教训,写给下一个我) > 我今天**四次**栽在自己的测量上(黑屏、前台不是本 App、复用坐标、不确认文字落点),**三次**拿「对端日志缺席」当「客户端没做」的证据。 > ⇒ **先怀疑自己的测量,再怀疑代码**;**要判客户端行为,只看客户端自己的输出**;**一次只改一个变量**。 + +--- + +## ★★ 锚点补记二十七 · W50 落地并**设备验收通过**(她主动说的话,现在实时到安卓) + +> 这一段是**已验证**的状态,直接当事实用。操作细节与全部证据见 `07-W50-主动消息-验收与避坑.md`。 + +### 1. 结论 +**`proactive_message` 帧现在有消费点了**:她主动说的话**实时**出现在主会话,刷新/重启后仍在、按 `id` 不重复; +无 `id`(后端落库失败那一档)退化成**本地气泡**、绝不静默丢弃。 +- 合并提交 **`afee5f6`**(`android`,已推);W50 分支与 worktree 已删、构建锁已放。 +- 全量(合并后的树,`testDebugUnitTest --rerun` **真跑**):`BUILD SUCCESSFUL` / **91 suite · 1193 tests · 0 failed · 0 error**(基线 1177 + 新增 16)。 +- APK:`app/build/outputs/apk/debug/app-debug.apk`(47,850,706 B)。 + +### 2. 四条硬要求逐条成立(合并前核过) +| 要求 | 实现处 | +|---|---| +| 按**已落库的 assistant 行**经 `mergeMessages` 并入,不造只活在 UI 的气泡 | `ChatViewModel.onProactiveMessage` ⇒ 与 `onPersisted` **同一条** `mergeMessages` | +| **自己钉死主会话**(帧壳不带 `sessionId`,`isForSession` 会放行给每个 VM)| `ui/chat/ProactiveIngest.kt` 的纯函数 `proactiveIngestFor`:`sessionId != MAIN_SESSION_ID ⇒ Skip`(**等值**比较,`web` ≠ `web:xxx`)| +| 无 `id` 退化成本地气泡,**不许静默丢弃** | 同上 `Bubble`(本地 id `proactive-local:` + 帧里已有的稳定键 ⇒ 重投也是 upsert);只有「无 id **且**正文空白」才 Skip | +| 顺带触发 W49 的节流静默重拉 | `SessionsViewModel.onProactiveMessage`:复用 `persistedRoute` + `requestSilentRefresh`(**没有**新取数路径);另补「正看着主会话 ⇒ 看到即已读」那一档 | + +红线核对:`mergeMessages` 语义未动(只被调用)、W49 节流判据未动、W48 的 VM key 未动、**没有**接成「收到就整表重拉」。 + +### 3. 顺带修掉一个不修就等于没做 W50 的缺陷 +`ProactiveData.timestamp` 线上是 RFC3339 **字符串**(Go `time.Time`),旧声明 `Long?` ⇒ 解码抛异常被 +`WsEvent.parse` 的 `runCatching`(`WsEvent.kt:654`)吞成 `null` ⇒ **整帧在成形之前就没了**(只加 `onEvent` 分支碰不到它)。 +现在按字面量收(字符串/数字都吃)。详见 `07` §4。 + +### 4. 设备证据(假后端,四条判据全绿) +①主会话**实时**出现(`delivered=1`,5s 后 dump 到正文 + 角标「发送至 网页」);②次级会话 **0 次**; +③重启后 **1 条**、**同 id 重投仍 1 条**;④`--without-id` 档本地气泡 **1 条**、重启后 **0 条**而落库那条仍 1 条。 + +### 5. 这一次的环境坑(一句话版;完整版在 `07` §2) +**`input text`/keyevent 的标点会被 Gboard 转成全角**(系统 locale 是 zh-CN)—— +绕法是「地址栏只填**纯字母** `localhost`」(`normalizeBaseUrl` 会补 `http://`,`ApiClientFactory.kt:109`) ++ `adb reverse tcp:80 tcp:<后端端口>`(**需 root**,且 `adb root` 会清掉所有 reverse)。 +另:点太快会 **ANR 被强杀**(冷启动等 25~35s 再动 + 三个动画开关置 0);黑屏挡不住(每次交互前 `keyevent 224` + 确认前台); +`adb shell /sdcard/...` 要 `MSYS_NO_PATHCONV=1`;Python 要 `PYTHONIOENCODING=utf-8`。 + +### 6. 副产品:真后端那条主动路径是活的 +验收前 App 还连着真后端时,主会话历史里有一条 `发送至 cyrene-backthink` 的她的话(「早安呀,亲爱的♪ …」)—— +**落地行、来源角标、主会话归属三者都对**,与本次修的消费口径一致(那条来自历史拉取,不是实时帧)。 + +### 7. 待办变化 +- 上面第 3 项「W48/W49 设备验收」**环境已就绪**(模拟器已 root、reverse 指向真后端)⇒ 现在是**可做**状态。 +- W50 留了一条**窄尾巴**(只在「落库失败」档可达:降级气泡长按可删 ⇒ 会用本地 id 打服务端报错);收口做法见 `07` §5。 +- 已更正「代理旧路径」那条陈旧项(见 `5520d60`)。 diff --git a/devdocs/07-W50-主动消息-验收与避坑.md b/devdocs/07-W50-主动消息-验收与避坑.md new file mode 100644 index 0000000..318cae3 --- /dev/null +++ b/devdocs/07-W50-主动消息-验收与避坑.md @@ -0,0 +1,80 @@ +# W50「她主动说的话实时到安卓」——验收证据与避坑 + +> 状态:**W50 已合并并验证**(`android` 分支 `afee5f6`,已推)。本文件是该批次的**证据与操作细节**; +> 状态与待办仍以 `05-开发进度与交接.md` 的锚点补记二十七为准(那里只放结论与指针)。 + +--- + +## 1. 验收判据与证据(真机 = 模拟器 `emulator-5554`,假后端) + +场景是怎么造出来的:`python devtools/fake-backend/proactive_inject.py --port 18090 --text "主动消息验收一"` +(它按真后端的顺序**先落一行 assistant 历史行、再广播带同一 `id`/`createdAt` 的 `proactive_message` 帧**)。 + +| 判据 | 操作 | 证据 | 结果 | +|---|---|---|---| +| ① 主会话**实时**出现 | 注入后 5s 取 dump | 广播回执 `delivered=1`;dump 里有 `[78,406][400,472] \| 主动消息验收一`,其下 `发送至 网页` | ✅ | +| ② 次级会话**不**出现 | 抽屉 → 「新会话」 | 该页 dump 里 `grep -c 主动消息验收一` ⇒ **0**(页面显示「还没有消息」) | ✅ | +| ③ 刷新/重启后**仍在且只一条** | ①切回主会话 ②同 `id` 重投一帧 ③`force-stop` 后重启 | ①计数 **1** ②重投后仍 **1**(按 `id` upsert 生效)③重启后 **1** | ✅ | +| ④ 无 `id`(后端落库失败)**不静默丢**、也不假装落库 | `--without-id` 注入 → 再重启 | 注入后计数 **1**(本地气泡);重启后 **0**(历史里没有它),而落库那条仍 **1** | ✅ | + +截图(主会话单条助手气泡 + 左下对齐)与 dump 一致,渲染无重叠。判据以 **dump 计数**为准(比截图更硬,也更省上下文)。 + +## 2. 环境坑(这次花掉的时间都在这,别再重复) + +1. **`input text` / `input keyevent` 打出的标点会变全角**(`:`→`:`、`.`→`。`、`/`→`/`)。 + 原因:模拟器**系统 locale 是 zh-CN**,Gboard 走中文标点;字母数字不受影响。 + **绕法(不必改 locale)**:`normalizeBaseUrl`(`app/.../data/api/ApiClientFactory.kt:109`)在缺 scheme 时 + 会补 `http://` ⇒ 服务器地址栏只填**纯字母** `localhost`,再用 `adb reverse tcp:80 tcp:<后端端口>` + 把设备的 80 端口接到后端。**附带好处:切假后端/真后端只要改 reverse,一个字都不用重打。** +2. **`adb reverse tcp:80` 需要 root**:`adb root` 在本 AVD(userdebug)可用, + 但它**会重启 adbd ⇒ 所有 `adb reverse` 丢失**,之后必须重新加(包括 `tcp:10942`)。 +3. **点太快会 ANR 被系统强杀**:实测 `ANR in top.yeij.cyrene … Waited 5065ms for MotionEvent(… (367.0, 780.0))` + 紧跟 `Force finishing activity`。App 冷启动要跑类校验(日志里每条 100~170ms), + **等 25~35 秒再动**;并把三个动画开关置 0(`window_animation_scale` / `transition_animation_scale` / `animator_duration_scale`)。 +4. **屏幕会自己黑**(`svc power stayon true` + `screen_off_timeout` 都不一定挡得住): + 交互前 `input keyevent 224`,并用 `dumpsys window | grep mCurrentFocus` **确认前台是本 App**—— + 黑屏时点击会落到桌面(这次白跑过两轮),回到桌面后必须重新 `am start`。 +5. **`adb shell … /sdcard/…` 的路径会被 MSYS 改写**(变成 `C:/Program Files/Git/sdcard/…`)⇒ 命令前 + `export MSYS_NO_PATHCONV=1`。 +6. **Python 输出 GBK 会炸**(`UnicodeEncodeError: 'gbk' codec can't encode character`)⇒ + `export PYTHONIOENCODING=utf-8`(`ws_probe.py` 里那段 `reconfigure` 就是同一件事)。 +7. **`uiautomator dump` 时好时坏**(`ERROR: null root node returned by UiTestAutomationBridge`):失败就重试; + 文字/坐标判据一律优先用 dump(`bounds | text`),截图只在需要看观感时用。 +8. **滑动手势会被系统返回手势吃掉**:从屏幕底部附近起滑会直接回桌面。真要滚动就避开底部区域 + (例如 `input swipe 540 1500 540 800 300`),别用 `1900 → 700`。 +9. 验收结束时的状态:`adb root` 仍是开的;reverse 为 `tcp:80 → tcp:10942` 与 `tcp:10942 → tcp:10942` + (都是真后端);App 拿的是假后端签发的 token ⇒ 会在真后端的 401 上登出,登录页地址栏留着 `localhost`。 + +## 3. 复现整套验收的最短路径 + +```bash +export PATH="$PATH:/c/Users/Aska/AppData/Local/Android/Sdk/platform-tools" +export MSYS_NO_PATHCONV=1 PYTHONIOENCODING=utf-8 + +# 1) 假后端 + 反向端口(80 ⇒ 假后端,地址栏填 localhost 即可) +python devtools/fake-backend/fake_backend.py --port 18090 & +adb root && adb reverse tcp:80 tcp:18090 + +# 2) App 侧:清数据 → 登录页填 localhost / 任意用户名密码 → 登录(假后端不校验) +adb shell pm clear top.yeij.cyrene +adb shell am start -n top.yeij.cyrene/.app.MainActivity # 等 25~35s 再动 + +# 3) 注入 + 看判据(四条判据见第 1 节) +python devtools/fake-backend/proactive_inject.py --port 18090 --text "主动消息验收一" +python devtools/fake-backend/proactive_inject.py --port 18090 --text "落库失败验收" --without-id +``` + +## 4. 这个批次顺带发现的客户端缺陷(已随 W50 修掉) + +`proactive_message` 载荷里的 `timestamp` 在服务端是 Go `time.Time` ⇒ 线上是 **RFC3339 字符串**, +而客户端旧声明是 `Long?` ⇒ kotlinx 解码抛异常、被 `WsEvent.parse` 的 `runCatching` +(`app/.../data/ws/WsEvent.kt:654`)**吞成 null** ⇒ 整帧在成形之前就没了。 +也就是说:**只加一条 `onEvent` 分支根本碰不到帧**。现在按字面量收(字符串/数字都吃); +该字段全仓零消费点,排序去重一律用 `createdAt`。 + +## 5. W50 留下的已知窄尾巴(**只在「落库失败」那一档可达**) + +降级气泡是一条 `role=assistant` 的 `messages` 行,而删除入口的判据 `canDeleteMessage` 只看 role ⇒ +长按能删它,删除会用本地 id(`proactive-local:` 前缀)打服务端并失败报错。 +**没修的理由**:要收口得给每条消息加「是否服务端行」的标志位,会动 `ChatDisplayItem` 与气泡渲染, +超出该批次范围。**最省的收口方式**:在 UI 层按 `proactive-local:` 前缀屏蔽动作入口。