Commit Graph
225 Commits
Author SHA1 Message Date
AskaEth 74a46bd2e3 fix(chat): 降级气泡不再给删改入口(W52/D);空态补一枚显式「重试」(W52/E)
## D —— W50 留下的窄尾巴:服务端不存在的那条气泡没有动作入口

`proactive_message` 落库失败那一档,客户端会画一条**只活在本端内存里**的气泡,它的 id 由本端自铸
(`proactive-local:` 前缀,见 `ProactiveIngest`)。而删除入口的判据 `canDeleteMessage` 只看 role ⇒
它是 assistant 行、长按能删 ⇒ 拿一个服务端没见过的 id 打 `DELETE`,最好的结果是 404。

修法按主人拍的口径收在 **UI 层**:新增 `ui/chat/MessageServerActions.kt`,在角色口径之上再叠一道
「这条消息在服务端存在吗」(判据只有 `id` 前缀这一条),组合成
`canEditMessageOnServer` / `canDeleteMessageOnServer`;气泡(菜单 + 「⋯」两个入口)与 VM 里那道闸
(`startEdit` / `startDelete`)**共用同一份**,不存在两套口径。
- **不动** `ChatDisplayItem`、数据模型与 `mergeMessages` 语义(原方案要加「是否服务端行」标志位,
  会连带动气泡渲染,超出范围)。
- 动作菜单里**只有**编辑与删除两个(一个 PATCH、一个 DELETE),都是打服务端的 ⇒ 降级气泡整菜单不出现,
  正是要的效果。**文本选择 / 复制不受影响**:它走 `SelectionContainer` 那条独立的路,`onLongPress`
  为 null 时反而不再与选字抢长按。

## E —— 首屏失败时给一个显式「重试」

登记在案的缺口:**REST 首屏失败、而 WS 从头到尾没断过**。这时没有任何状态变化可挂(连接没变过、
也没有断线窗口要补),屏幕会停在空态,直到用户切走再回来或上拉。

- `ChatViewModel.retryFirstPage()`:**只是转调** W51 提好的 `loadFirstPage()`(首屏唯一的一份实现,
  与连接恢复时补的那一次共用),**一个状态位都不自己写** —— `firstPageState` 的两个出口仍然只有那一处,
  W51 的 `reconnectPull` 判据依赖这条纪律。刻意**不是**定时器:项目从 W38 起就定死「状态驱动,不按时间轮」,
  而且反复重试会把「首屏失败」这个事实擦成 `Loading`。
- 空态那一档拆成两种真相(纯函数 `shouldOfferRetryOnEmpty`:不在加载中 + 挂着一个错误 + 列表为空):
  「还没有消息」照旧;「消息没能加载出来」配一枚 Miuix `TextButton`(`CenterHint` 多一个可选 `onRetry`)。
  其余时刻一个像素都不多画。

## 测试

- 新增 `MessageServerActionsTest`(6 条):钉住降级气泡两个入口都没有(并断言**旧判据确实说「能删」**,
  把「为什么需要这道闸」写进断言)、前缀与角色无关、只认**开头**的前缀、服务端行行为逐字不变、
  未知 role 仍 fail-closed。
- 新增 `FirstPageRetryTest`(5 条):正题一格成立;正常空会话 / 首屏在飞 / 屏幕上有东西(含乐观与流式气泡)
  都不给;三个布尔八种组合穷举,钉住「有且只有一格成立」。
- `./gradlew.bat --console=plain assembleDebug testDebugUnitTest`:**95 suite / 1216 tests / 0 失败 / 0 error**
  (基线 93 / 1205,+2 suite +11 tests,全为新增)。
- 真机验收未做(任务书禁碰 adb):按钮的位置与观感、以及长按降级气泡能选字,只能真机看。
2026-10-06 15:47:49 +08:00
AskaEth 4fce12249e docs(05): S7② 与 S3 的真机验收结清(S7② 通过;S3 在「在线冷启动进有内容次级会话」场景下否证)
S7②(通过):补洞请求打在已摘除的会话上 ⇒ 404 ⇒ sessionGone;那句 8 秒的告知抓到([33,378][1047,480]);
select(主会话) 的副作用 POST /sessions/web/read 两轮都观测到。⚠️ 同时**修正我自己写的判据**:原先要求
「read 出现在 GET /sessions 之前」——实测顺序不确定(两条协程各起),该顺序判据不成立;真正该验的
「refresh 失败时也能回去」本轮没做。

S3(否证):screenrecord + ffmpeg fps=8 抽帧 + 逐帧量两块区域的深色墨迹(不改生产代码)。时间线是
启动画面(新版图标)→ 「加载中…」 → 内容,**没有出现「还没有消息」空态**。局限:8fps 采样(<125ms
的闪烁会漏)、单场景、单 build,不写成「S3 不存在」。

方法论:别用墨迹的 x 宽度猜那是哪句话——我一开始据此误判,抽帧一看是「加载中…」。
2026-10-06 14:24:45 +08:00
AskaEth 9f04fc4304 docs(05): S7②/S3 第二轮受阻记录 —— 模拟器劣化与主机被占、不依赖 dump 的验证套路、收工状态
为什么:这一轮 S7② 仍未验(进次级会话那一步没成),但花掉的时间都是环境问题而非代码问题,
必须把「先看主机在跑什么」「盲点 + 用服务端流水判结果」这两条写下来,下一轮别再重摸。
2026-10-06 14:16:55 +08:00
AskaEth 0583b1bc31 docs(05): 补记 S7② 为什么这轮没验成 + 下次的干净起手(含判据与两个坑:表单错位、dump 陈旧缓存)
为什么:S7② 需要「App 停在已删会话里」才能造场景,而登录表单被前几轮实验搞乱(地址栏残留 W48 的
坏值、字段错位),input text 落不进目标字段;uiautomator dump 还会返回陈旧缓存(同一交互前后两张
dump 的地址栏展开态互相矛盾)。把判据与干净起手写下来,下一轮别再重摸。
2026-10-06 13:57:53 +08:00
AskaEth b604fefbd4 docs(05): 锚点补记二十九 —— W51 合并、S2/S7① 设备验收结论,并更正补记十九里 S7 那句写错的前提
改了什么:
- 新增锚点补记二十九:W51 做了什么(S2 的纯判据与「首屏从没成功过」这个判据为什么这么定、S7① 的
  GoneSessions、S7② 真正的缺口是「出路不能依赖网络」);设备验收结论(S2 通过:恢复网络后客户端自己
  补拉并显示出消息;S7① 通过:删掉会话后真踢一次 WS,重连窗口里没有注定 404 的补拉,而握手证明通路
  活着);以及 S3 / S7② **未验**(如实写明,不写成「没问题」)。
- 更正补记十九里 S7 那句「`sessionGone` 位没有消费者」:消费者一直都在(ChatScreen.kt:165-166 →
  ChatHomeScreen.kt:109 的 onSessionGone),由 b5c0b13(2026-10-04、该行所依据提交 ff699f5 的祖先)
  引入,已用 git merge-base --is-ancestor 验证。这句是我写错的,W51 的任务书因此也带着错前提。
- 记下这次同类教训(第二次):真机「没生效」时**先核设备上 APK 的 lastUpdateTime 与本地 APK 的时间**
  —— 我就是拿上一版 APK 测了半天;另记「撤 adb reverse 不会掐断已建立连接,要真抖 WS 用假后端的
  /__control/ws-close」。
2026-10-06 09:20:40 +08:00
AskaEth 620a4e77fa merge: W51 —— 次级会话连接恢复自己补拉(S2);已删会话不再发注定 404 的补拉、sessionGone 的出路不再依赖网络(S7) 2026-10-06 09:04:09 +08:00
AskaEth 9980fb2b28 fix(chat): 次级会话补上「连接恢复自己补拉」(S2);已删会话不再发注定 404 的补拉(S7)
## S2 —— 次级会话没有握手 history 这第二条安全网

- 新增纯判据 `ui/chat/ReconnectPull.kt`:连接恢复那一刻该补哪一段
  (Forward / FirstPage / Nothing)。判据只看四样:会话 id、首屏状态、掉线游标、
  「本端已知它没了」。
- `ChatViewModel`:把 init 那次首屏 `loadLatest` 提成 `loadFirstPage()`,init 与补拉
  **共用一份代码**(补出来的结果必须与首屏逐字段同形);新增 `FirstPageState` 作为判据输入。

为什么这么定:
- **为什么不是照抄主会话的 history**:服务端固定回放主会话(`ws_hub_conn.go`),要它回放
  次级会话就是改契约面。次级走的是本项目已有的那条 REST 口径(拉最新一页)。
- **为什么用「首屏从没成功过」而不是「本端有没有数据」**:服务端真的没有消息时列表**也是空的**,
  拿空列表当判据会让每一个空次级会话在每次重连都被整表重拉 —— 那正是 S4/S7 这一类问题的成因。
- **为什么 Loading 不算失败**:首屏还在飞时再发一次同样的请求会与它抢跑,还会改掉
  「history 帧一到就 loading=false」那段时序(S3 本轮**一个字节都不动**)。
- **为什么主会话排除在外**:它有 history 兜底,在那里再补一条 REST 就是给同一件事造第二套口径。
  主会话的兜底行为因此**逐字不变**(有洞走 Forward,其余 Nothing)。
- 补拉那一次把 `loading` 翻回真:屏幕上原本是「还没有消息」停在原地,如实应该是「加载中…」。

## S7 ① —— 注定 404 的补拉不再发生

- 新增 `core/session/GoneSessions.kt`(进程级、**不持久化**、登出/换账号时清空):本端已知
  「服务端没有这个会话」的 id 集合。写入口是 `SessionsViewModel.deleteSession` 成功那一刻
  (本端唯一的删除入口),读入口是**每一个** `ChatViewModel`。
- 判据第 1 条:已知它没了 ⇒ 什么都不发。于是本地删过的会话从此**零次**注定失败的往返;
  已经 404 过的会话从「每次重连各撞一次」收敛成**恰好一次**(`sessionGone` 同时成为补拉的闸)。

## S7 ② —— sessionGone 的出路不再依赖网络

那个位的消费者**一直都在**(`ChatScreen` 按它触发的 `LaunchedEffect` → `ChatHomeScreen` 的
`onSessionGone`:一句如实告知 + 带回主会话),所以本轮补的是它真正的缺口 —— 原来只靠 `refresh`
**成功**之后的 `resolveActiveSession` 回落(拉失败、或列表为空时刻意不回落 ⇒ 什么都不动)。
于是「已切回主会话」在网络不稳 / 服务端抖一下时是一句**做不到的话**,用户被留在已经没了的会话上、
没有任何出路。现在先在本端 `select(主会话)`(与用户点抽屉里那一行走的是**同一条**路,不另造机制),
`refresh` 退回本职:与服务端对齐、把抽屉里那条去掉。

## 未做 / 未验

- **没有真机验收**(任务书禁碰 adb):S2 的「连接恢复后次级会话自己补上」只有纯判据单测。
- 「REST 首屏失败、但 WS 从头到尾没断(因此没有任何状态变化可挂)」这一格**不在本轮范围内**,
  仍是已知缺口。
- 全程未碰 S3、主会话兜底与游标口径、W48 的 VM key、W49 的节流判据、W50 的主动消息消费路径。
2026-10-06 09:03:22 +08:00
AskaEth a9cc3c5737 chore(gitignore): 忽略仓库根的 IMG_*.png(主人从手机丢进来的素材,只用不存)
为什么:主人明确「原图不进仓库」——换图标用的 IMG_20261006_081803.png(5.8MB)留在工作树即可,
加一条只作用于仓库根的忽略规则挡住误提交(只挡 IMG_*.png,不影响别处的图片资源)。
2026-10-06 08:57:27 +08:00
AskaEth a025624798 feat(icon): 应用图标换成主人提供的新版画作(昔涟特写)
改了什么:
- mipmap-{mdpi,hdpi,xhdpi,xxhdpi,xxxhdpi}/ic_launcher.png:新画作按 48/72/96/144/192 缩放(lanczos)。
- 同目录 ic_launcher_round.png:同一画作按圆形 alpha 掩膜裁出(ffmpeg geq),供圆形掩膜的启动器。
- 同目录 ic_launcher_foreground.png:自适应图标前景按 108dp 画布全出血(108/162/216/324/432 像素)。
  第一版把画心缩到 76/108,实测在这台启动器上会露出背景一圈;改成铺满画布后任何掩膜下都不会露底。
- values/colors.xml:ic_launcher_background #F8F8F8 → #FFE6E0(用 ffmpeg 对源图左上角 300×300 取平均色得到,
  与画作底色同调;前景已铺满,这个底色只在极端掩膜下才可能露出)。

为什么:主人把新版图标源图放在仓库根(IMG_20261006_081803.png,5033×5033、5.8MB),要求替换应用图标。
**源图不进仓**(仍是未跟踪状态),只提交生成物;构建与安装均通过,实机底栏图标已是新版。
2026-10-06 08:30:53 +08:00
AskaEth 1d150cce85 docs(02): 客户端答复 —— 回执二十四·补:①推迟照办、②未读口径确认(维持 omitempty)、两条澄清,并反问 .end 能否带结果摘要
改了什么(只加了我们这一节,未碰他们那一节):
- ① 工具回看面推迟:照办。明确写死两条口径——不把「toolCalls[] 缺席」当「这轮没调工具」;
  不再按消息字面 join turnId(两者不是同一套标识)。也说明我们**不提前写**那个解析器。
- ② 未读面:回答他们的问题——**维持 omitempty 即可**,因为对我们权威来源是列表面(GET /sessions 恒带
  unreadCount),增量键只是省一次重拉的优化;接入口径按他们说的「键在才改、缺席什么都不做」。
  同时如实说明本端**还没消费**这个键(今天走的是 W49 的重拉,已实机验收),免得他们误以为已生效。
- 两条澄清:GET /api/v1/messages/{id} 我们从未用过(只有 PATCH/DELETE,SessionsApi.kt:130,137);
  tool_execution (a) 维持收到。
- 反问一件与回看面无关的事:能否给 tool_execution.end 加可选 resultSummary(截断、缺席语义)——
  因为 .end 只带 tool/phase/id、update 又没有生产者,实时工具卡的结果区结构性为空;这是投入最小的一条。
  明确说「不做也接受,但请明说」,我们要的是确定而不是一定要这个键。
- 告知 W50/W48/W49 已落地并实机验收(不需要他们动作)。

为什么:他们那条回执标红了「别把缺席当没调过」,并直接问我们要不要区分「确实 0 / 不知道」——
这两个都是会决定他们改不改代码的问题,必须当场答清。
2026-10-06 08:24:50 +08:00
AskaEth b23948bed0 docs(05/07): 锚点补记二十八 —— W48/W49 设备验收通过(换环境串味 / 抽屉未读角标)
改了什么:
- 05 新增锚点补记二十八:W48 的判别法(先清空服务端主会话再换账号,否则两账号同一份历史看不出串味)与结果
  (A 的本地气泡与持久行在 B 账号下都 0 次、抽屉残留 0 次);W49 的机制证据(抽屉关着时客户端自己在
  消息到达后 60ms 发出 GET /api/v1/sessions)与可见结果(次级会话行 未读 1、正在看的主会话无角标);
  并更正一处认知:红点是抽屉内会话行的未读角标,菜单图标上没有点。
- 07 的坑清单补两条:① 登录页地址栏留空时 App 会回落到上次记住的地址(我因此白追一小时「请求怎么打到
  真后端」);②「登出」是设置页最底部的图标按钮(图标是 MiuixIcons.Back、只有 contentDescription),
  截图上像「返回」很难找。

为什么:这批验收原来只有单测覆盖,结清后 05 的待办里不再挂着它;两条坑都是这次真实花掉时间的,
写下来避免下次重演。
2026-10-06 08:24:00 +08:00
AskaEth c905a47d6f docs(05/07): 锚点补记二十七 —— W50 落地并设备验收通过;细节单开 07(证据与避坑)
改了什么:
- 05 新增锚点补记二十七:结论(afee5f6 已推 / 1193 测试真跑 0 失败 / APK 尺寸)、四条硬要求逐条
  对应实现处、红线核对、设备四条判据全绿、环境坑一句话版、真后端那条主动路径是活的(副产品)、
  待办变化(W48/W49 验收环境已就绪;W50 的窄尾巴)。
- 新增 devdocs/07-W50-主动消息-验收与避坑.md:验收判据与逐条证据、完整环境坑(含绕法与命令)、
  复现整套验收的最短路径、随批次修掉的 timestamp 解码缺陷、以及「落库失败档」那条尾巴的收口建议。

为什么单开 07:05 已 1400+ 行,验收的操作细节与避坑属参考材料而非状态;05 只留结论与指针,
下一个我读 05 就能接手,需要动手复现时再翻 07。
2026-10-06 08:11:26 +08:00
AskaEth afee5f612d merge: W50 —— 她主动说的话实时进主会话(proactive_message 消费 + 帧解码修复 timestamp 类型不符) 2026-10-06 07:51:26 +08:00
AskaEth 8b5d15bdb6 fix(chat): 她主动说的话实时进主会话(W50 proactive_message 消费 + 帧解码修复)
缺口:她主动说的话既是实时帧、也是主会话历史里的一条 assistant 行,而安卓端实时看不到 ——
ChatViewModel.onEvent 里没有 ProactiveMessage 分支(落到 else -> Unit),另两个下行消费者也不认它;
而且帧里只解了 messageId(IM 侧的号),落库行的 id / createdAt 被 ignoreUnknownKeys 静默丢弃。
后端这条 Web 路由只发这一帧、不补 message_persisted,所以没有别的路能把它带进来。

一、帧解码(data/ws/WsEvent.kt)
- ProactiveData 与 ProactiveMessage 补上 id 与 createdAt(落库行的权威 id 与时间戳,同现同省)。
- 顺带修掉一个把整帧弄没的缺陷:载荷里的 timestamp 服务端是 Go time.Time,线上是 RFC3339 字符串,
  而本端按 Long? 解 ⇒ 解码抛异常、被 parse 的 runCatching 吞成 null,帧在成形之前就没了
  (所以只加一条 onEvent 分支根本碰不到它)。改成按线上的字面量收,数字形状也照样读出。

二、为什么按已落库行并入,而不是画一个只活在 UI 的气泡
真相是服务端历史里的那一行,实时帧只是它早到一步的副本:按 HistoryMessageDto.id 走既有的
mergeMessages 并入 ⇒ 实时能看到,且刷新 / 重开 / 切会话拉历史之后她的话仍在;同一条
(实时帧 + 握手回放 + 分页)无论到几次都只有一行,不需要任何额外去重。
判据抽成纯函数 proactiveIngestFor(ui/chat/ProactiveIngest.kt),可单测。

三、为什么必须自己钉死主会话
proactive_message 帧壳不带 sessionId(服务端构造这一帧时没设它,且该键是 omitempty),
而 isForSession 对空 sessionId 的口径是放行 ⇒ 这一帧会投给每一个活着的 ChatViewModel。
不钉就会在每个次级会话里都冒出一条她的话(比看不到更糟)。判据是等值比较、不是前缀:
主会话 web 与次级会话 web:xxx 是两个会话。

四、无 id 时为什么退化而不是丢弃
id 与 createdAt 一起缺席 = 服务端那一步落库失败了(它自己就说了这条没有历史行)。这时画一条
本地气泡,如实显示、不假装已落库:本地 id 用帧里已有的稳定键(IM 消息号,其次时间字面量),
同一个键第二次到达(重推 / 重放)也只是 upsert,不会成两个气泡。KDoc 写明它不在历史里、
刷新 / 重开后不会再有。
无 id 且无正文(帧里没有任何可显示的东西)才整帧跳过 —— 那一条画的是空气泡,只会让人以为出了别的事。

五、抽屉红点(复用 W49 那条路,不新写取数路径)
服务端不为这条路补 message_persisted ⇒ W49 的「非激活会话落新消息 ⇒ 节流静默重拉」不会被触发,
主会话的红点会停在她开口之前。SessionsViewModel 用同一个 persistedRoute 把这一帧接进同一条路
(复用 requestSilentRefresh 与既有的 InactiveSessionMessage 成因,不动节流判据本身):
停在别的会话 ⇒ 节流静默重拉一次(红点亮);正看着主会话 ⇒ 与 message_persisted 同一档
「看到即已读」,免得抽屉为她刚说的话亮一个红点。无 id 那一档不拉(历史里没有行,未读面上无事可做)。

测试
- 新增 WsEventProactiveFrameTest:真实帧形状(id / createdAt / RFC3339 的 timestamp)、数字形状容忍、
  落库失败那一档、以及「帧壳没有 sessionId ⇒ 会话闸对每个会话的 VM 都放行」这条事实。
- 新增 ProactiveIngestTest:归属(次级会话不并入、id 按等值比)、身份(有 id = 已落库行)、
  退化(本地气泡 / 空帧跳过)、不重复(重发与历史页都收敛成一行)。
- InactiveSessionPullTest 补一条钉子:她主动说的话该走拉列表还是「看到即已读」这条路。
2026-10-06 07:50:33 +08:00
AskaEth 5520d6015d docs(05): 更正代理旧路径引用(工具已在 devtools/ws-block-proxy/,历史条目不改写) 2026-10-06 07:45:31 +08:00
AskaEth 4c938c7330 docs(devtools): 假后端补「主动消息」验收配方(proactive_inject.py + README 场景五)
改了什么:
- 新增 devtools/fake-backend/proactive_inject.py:按真后端 SendProactive 的顺序
  (先落库、再发帧)造一条主动消息 —— 落一行 assistant 历史行,再广播带同一 id/createdAt 的
  proactive_message 帧。--without-id 是另一档(落库失败):不落行、帧上不带 id。
- README 新增「场景五」:完整命令序列 + 帧形状 + App 侧三条判据;端点速查/形状出处/已知不确定
  各补一条。
- 更正两处过时内容:§1 的工作目录还是旧 worktree 名 Cyrene-Android-FB;§8 说「客户端 W41 起先探
  /health」——W45 已把那整套删掉(SendFallback.kt:120),这个端点客户端不再消费。
- 记录踩到的坑:ws_probe.py --listen N 单独用只有 1 秒(ws_probe.py:201 是 min(listen, 1.0)),
  不带 --send/--ping 时连接会先关掉,之后所有广播都是 delivered=0。

为什么:
- 真后端没有任何 HTTP 入口能按需触发一次主动推送(backthink 只由心跳/事件决定,路由里只有一个
  GET /admin/backthink),所以「她主动说的话」在客户端这一段的验收只能自己造帧。真后端只把
  proactive_message 广播给在线连接、没有离线补发,帧壳又不带 sessionId(客户端必须自己钉主会话)
  —— 形状抄错或漏掉落库那条腿,验出来的结论就是假的。落成脚本 + 文档,下次不必重推。
2026-10-06 07:44:31 +08:00
AskaEth d9b7c22ecb docs(05): 锚点补记二十六——续接锚点(现状/环境/设备驱动协议/W50 在飞与合并检查清单/W38~W49 索引/待办排序/与后端状态/方法论教训),供下一会话直接接手 2026-10-06 07:38:29 +08:00
AskaEth d5d2feee36 docs(05): 锚点补记二十五——W48(账号指纹进 VM key 修 S1 + 搜索 VM 同类缺陷 + 5 处错注释 + 代理挪进 devtools)与 W49(非激活会话节流静默重拉修 S6,3 秒窗口 + 结构性 trailing + 一处刻意偏差)落地;合并后 1177 测试全绿 2026-10-05 22:30:08 +08:00
AskaEth 5dc6408bb9 merge: W49 —— 非激活会话落新消息时节流静默重拉(3 秒窗口 + 结构性 trailing),让抽屉红点在不开抽屉时也能出现 2026-10-05 22:28:39 +08:00
AskaEth 93cf3e359a fix(sessions): 非激活会话落新消息时自愈式节流重拉,抽屉红点不再等用户开一次抽屉(S6)
现象:App 停在主会话时,次级会话落了新消息 ⇒ 抽屉红点不出现,必须打开一次抽屉
(那次静默 refresh)才补上。

根因:message_persisted 只在 sessionId == activeSessionId 时才推进 / 更新,非激活
会话那一帧直接 return;而会话列表更新的触发点此前只有抽屉打开 / 新建 / 删除 / init
四处,于是「别的会话攒了新消息」没有任何人会去发现。

为什么是客户端自愈而不是等后端:这一帧不带 unreadCount(契约面缺一个增量),而本端
没有逐条时间线 —— 自己累加出来的数会与编辑 / 删除 / 别端操作漂移,那是造数不是修复。
帧壳本身已经说清「某个会话有新消息落库」这件事,所以本端只是据此去问一次服务端,
走的是与抽屉打开时那次完全相同的路径(refresh(silent = true),含已读游标遮罩),
只是不需要用户先开一次抽屉。后端把增量补上之后这条路可以退成不必走,但在此之前它
是唯一能把红点点亮的路。

节流窗口 3 秒,理由:
- 够快:3 秒落在「看起来就是刚发生的」这个感知里;5 秒往上就开始像卡了一下才补上;
- 够粗:一次静默重拉是两个请求(refresh 里还跟着一次角色目录),而播出方是按每条
  消息推帧的 —— 3 秒能把人手连打 5 条(约 2~4 秒)或一轮回复的连续落库收进同一个
  窗口,收到 1 秒就会漏成两三次请求;
- 不另立尺子:3 秒正是本项目已有的「等够久才值得为一次等待开口」那个量级
  (ChatScreen 的 SHARE_WAIT_NOTICE_MILLIS)。

trailing 怎么保证:窗口内的第二条起不拉,但排一次「窗口到点补拉」(等待时长 =
窗口 - 已过时间),到点直接拉一次并把窗口重开;同一时刻最多一个补拉任务(已排上时
后续成因只更新账本、不重排),所以最后一条之后必定还会再拉一次,红点不会停在旧值。
另外「正好等于窗口」算窗口已过(>= 而不是 >),补拉到点那一刻不会被自己刚开的窗口
挡回去。

哪几种情况不拉:当前打开的那个会话落新消息(那条路本来就有「看到即已读」,多拉一次
列表就是白花两个请求);同一窗口内的后续消息;窗口内且补拉已经排上。另外切会话那条
路刻意不接进来 —— select 紧接着就发推进已读游标的请求,此时重拉会与还没落地的游标
抢跑、把刚清掉的角标闪回来(抽屉打开时那一次紧跟在游标之后,本来就会补上这一拉)。

判据抽成纯函数(新增 InactiveSessionPull.kt):persistedRoute 决定这一帧走哪条路,
silentRefreshStep 决定这一刻该不该拉、拉完账本怎么走;时钟由调用方喂,不用 sleep
也就能穷举边界。

顺带把身份变化(重登成另一个账号 / 换了服务器地址)也接进这条路径,用 ContextChanged
绕过节流窗口 —— 那份列表换了主人,等满一个为「别的会话又攒了几条」而设的窗口没有
道理;登出时把账本与补拉任务一起作废(否则补拉会带着一个已经没有令牌的身份发请求)。
抽屉打开时那次 refresh、activeSessionId 的推进、已读游标的既有行为都没动。

验证:gradlew.bat assembleDebug testDebugUnitTest —— BUILD SUCCESSFUL;
1171 条单测 failures 0 / errors 0 / skipped 0,其中新增 InactiveSessionPullTest 13 条。
2026-10-05 22:28:19 +08:00
AskaEth e589ab303d merge: W48 —— 聊天页 VM key 带上账号指纹(修换环境后仍显示上个环境的会话)+ 注释纠错 + 把 WS 拦截代理挪进 devtools 2026-10-05 22:26:44 +08:00
AskaEth 40dac87dda docs(05): 补记二十四附——本轮两个决定(P1 主动消息消费我方接、tool_execution 选 a 并要 toolCalls[] 回看面带输出)与推送暂不接的口径 2026-10-05 22:26:09 +08:00
AskaEth 766843fdbd docs(02): 客户端答复回执二十二·补的四问——主动消息「不会两次」但真缺口是「我们没消费那帧 ⇒ 实时看不到」;tool_execution 选 (a) 并附取舍;纠正「每屏一条 WS」过时前提;推送暂不接并说明「应用内提示」在客户端零实现 2026-10-05 22:25:59 +08:00
AskaEth 4f860aa499 fix(chat): W48 聊天页 VM key 带上账号指纹,修换环境后仍显示上个环境的会话
缺陷:换账号或换服务器后,聊天页仍然摆着上一个环境的消息。成因是 ChatScreen 里那个
ChatViewModel 的 owner 是 Activity 级的 ViewModelStore,而登出只是把 ChatHomeScreen
移出组合、并不清理那个 store(ChatViewModel.onCleared 是空的,全仓也没有 ViewModelStore.clear())。
key 里只有 sessionId 时,换环境再进来 viewModel() 会把上一个环境那个还活着的实例原样取回来,
它继续收着那个环境的帧,屏幕上就是别的服务器的消息。主会话的 id 恒为 web,两次登录必然撞上
同一个 key,所以这不是理论风险。

为什么 VM key 要带账号指纹:指纹回答的是「这份本地状态属于哪个环境」。把它抽成纯函数
accountFingerprint(baseUrl, username),与 SessionPreferences 判断快照作废、
SessionsViewModel 落盘快照用的是同一份口径(SessionsViewModel 原本就地拼的那串改成调用
同一个函数,免得两套判据分叉——分叉的那一侧就是漏出去的那一侧)。同一个环境里切会话,
key 未变,各会话的状态照旧各留一份(这正是「切回来状态还在」依赖的);环境一变(换了服务器
或换了账号),key 就变,取到的一定是新实例。

会话内搜索那个小 VM 是同一类残留:它持有的命中就是消息正文,换环境后在主会话上重开搜索,
面板里就是别的服务器的消息。它的 key 同样带上指纹。

为什么本轮不回收旧 VM:要真正回收掉上一个环境那个实例,得自定义 ViewModelStoreOwner 或直接
清 store,而「切回来状态还在」这条体验正绑在 store 不清这件事上,属于要主人拍板的设计取舍。
本轮只保证换环境后取到的是新 VM,回收这件事不动。

顺带的零成本纠错(只改注释的成因描述,行为一字未动):ChatHomeScreen / ChatScreen /
ChatViewModel 里几处「onSessionGone 一被调用,那个会话的 VM 连同它的状态立刻被销毁」是错的——
变的是 key,被销毁的只是承载横幅的那段组合;VM 实例仍在 Activity 级的 store 里活着并继续收帧。
「横幅必须挂在上一层」那条结论本身是对的,原样保留;改的只是它前面的理由。
同时把 devtools 里那个窄代理的路径写对(此前注释指向一个并不存在的路径)。

开发工具挪出会被 clean 扫掉的位置:app/build/e2e-shots/ws_block_proxy.py 躺在 gitignored 的
app/build/ 里,一次 gradlew clean 就没了。挪进 devtools/ws-block-proxy/(与 fake-backend
同级同风格)并配 README,写清用途、用法(python ws_block_proxy.py 18098 10942,配
adb reverse tcp:10942 tcp:18098)与它最有价值的判据:调试时 Web 前端直连后端、不经过
adb reverse,所以代理日志里的每一行都只可能来自 App。脚本行为未改,也没有硬编码任何凭据。

新增单测 ChatViewModelKeyTest 钉住拼法:同账号同地址 => 同 key;换地址 / 换账号 / 换会话
=> 不同 key;username 缺失时退化成 baseUrl 一段,换地址仍然分得开。

验证:gradlew.bat assembleDebug 与 gradlew.bat testDebugUnitTest,
BUILD SUCCESSFUL;单测 1164 条,failures 0、errors 0、skipped 0。
2026-10-05 22:25:57 +08:00
AskaEth a4ac11a1c1 docs(05): 锚点补记二十四——流程教训:动 02 之前除 git status 还要 diff 它最新几十行(我差点在没读后端回执的情况下给后端写信);并登记后端正在等我们回的四件事与「每屏一条 WS」这个过时前提 2026-10-05 22:23:44 +08:00
AskaEth 7d1c56e03b docs(02): 给后端一份「我方实际依赖的线形状」清单(clientMsgId 幂等是我们敢重发的唯一安全基础、REST stream 回执必须仍是小 JSON、鉴权失败只认 401…)+ 两条已登记缺口的提醒 + 告知客户端已不再调 /health + 提供 CyreneRoute 日志作为联调取证 2026-10-05 22:22:58 +08:00
AskaEth c09c40388b docs(05): 锚点补记二十三——登记在飞的两个 worktree(W48 修 S1+注释纠错+工具归位;W49 修 S6 客户端自愈)与合并纪律 2026-10-05 22:21:49 +08:00
AskaEth 324be57c7a docs(02): 客户端登记——「非激活会话红点不实时增长」缺一个未读增量面(message_persisted 不带 unreadCount),并说明我方先用客户端自愈顶着、它不能替代增量面 2026-10-05 22:21:48 +08:00
AskaEth df8df67742 docs(05): 锚点补记二十二——③ 结案:阶梯机制正常(两次有效观测 route=Rest 且落库),W45 那轮异常未复现也因当时无 CyreneRoute 日志而无法定论;并写死一套可靠的设备驱动协议(确认前台→确认文字落点→从 dump 取发送键坐标) 2026-10-05 22:12:16 +08:00
AskaEth f2075c1f49 docs(05): 锚点补记二十一——移除键补验有效(附我复用旧坐标点空的教训与布局回流说明);③ 的第一次尝试没跑成(CyreneRoute 为空、代理无 POST、库无该条 ⇒ 发送没触发),并写明下一轮必须三步确认的做法 2026-10-05 22:10:00 +08:00
AskaEth df20a21fb5 docs(05): 锚点补记二十——W42 图片多选设备验收通过(多选器是真多选、计数「添加(2 项)」正确、输入栏出现两枚带移除键的缩略图),并登记移除键位置这处观感待主人定 + 两个未验的邻近格 2026-10-05 22:05:21 +08:00
AskaEth 669d05b781 docs(05): 锚点补记十九——子会话流程变成清单(S1~S9,每条带文件:行号与归类),先纠正我自己两条错前提,附 12 步设备验收骨架、优先级建议与 ws_block_proxy.py 不在 git 里的提醒 2026-10-05 22:01:20 +08:00
AskaEth ff699f5079 docs(05): 锚点补记十八——W47 静默按事故复位(清零点收进一个纯判据、事故=队列空到空),含为什么不选「只在成功时清零」与三条验收体验 2026-10-05 20:25:58 +08:00
AskaEth 3a07e94180 merge: W47 —— 队列静默改为按事故复位(队列排空即清零),不再只在补发成功时清零 2026-10-05 20:25:23 +08:00
AskaEth ce50bd2c24 feat(chat): 静默告知改为按事故复位,队列排空即清零
W43 的静默规则只在「补发真的成功一次」时把 queuedResendFailures 清零。若一段坏网络的
队列以回滚或被收编收场(一次都没成功发出去),这一格就永远挂着 ⇒ 之后连接恢复、再断
一次、用户又发消息时,那句解释再也说不出口。那不是减少噪音,是把解释永久关掉(主人
2026-10-06 裁决:哪个体验好用哪个)。

改成按事故复位:一个「事故」= 队列从空到非空、再回到空,结束就复位,不管它是怎么结束
的——发出去了、回滚了(内容退回输入栏)、还是被收编了(气泡换成权威消息)。清零点因此
有两个:补发成功一次(原样保留),以及队列排空(既有钩子 forgetQueuedNoticeIfDrained,
回滚那条路也走这里,它是队列清空的唯一两个入口之一)。

这样不会在长期断网时刷屏:阈值仍是 3;「一批从空到非空只说一次」仍由 queuedNoticeTold
管着;纯网络故障下消息会堆积在队列里 ⇒ 队列不清空 ⇒ 不算新事故、计数不复位 ⇒ 不会每条
消息都弹一次横幅。而事故一旦结束,下一次断线又能重新解释。

判据提为纯函数 queuedResendFailuresAfterDelivery(VM 与测试共用,跟
queuedSendNoticeAllowed 同一个写法),单测钉住:队列排空即复位(哪怕这一批一条都没成功
发出去)、队列还有货则计数保留、补发成功照旧清零、一个事故里失败 0/1/2 次仍允许告知而
到阈值静默、下一个事故重新告知。静默不动队列:那条既有用例(判定那一刻队列仍在、下一步
仍是发这条)一字未改。

只动告知判据与它的记账。队列与出队逻辑、sendRouteFor / 三档路由 / W38 分享落地 / W45
直接试 REST / W46 的 CyreneRoute 日志全部未碰。

验证:gradlew.bat clean assembleDebug 与 gradlew.bat testDebugUnitTest,
failure / error 均为 0。
2026-10-05 20:25:00 +08:00
AskaEth 798c6ee907 docs(05): 锚点补记十七——W45 阶梯设备验证通过(route=Rest、代理见 200、落库 1 条),附我连续三次误判的共同点与三条设备验证纪律,以及工具卡缺口的根因与登记位置 2026-10-05 19:53:16 +08:00
AskaEth 16699c4958 merge: W46 —— 发送路由判据带上原因,并加一行 CyreneRoute 诊断日志(DEBUG) 2026-10-05 19:50:59 +08:00
AskaEth d546f31755 feat(W46): 发送路由带上原因,并加一行 CyreneRoute 诊断日志
为什么要把「原因」也带出来
- 结果只有 Ws / Rest / Queue 三档,而「为什么是这一档」在设备上完全看不见。W45 那轮真机回归
  就卡在这里:那条消息进了补发队列、网络侧一条请求都没有,而「零请求 + 进队列」在判据里只可能
  来自 Queue 那一档;可顶栏同时显示「重连中…」。两者都只是结论,事后无法分辨「判据读到
  Offline」与「判据读了别的值」。
- 于是把判据抽成 sendRouteDecisionFor(wsDispatched, connection) -> (route, reason):reason 就是
  原来那三条规则各自的分支标识(FrameDispatched / ConnectionOffline / ConnectionNotReady)。
  sendRouteFor 现在读它的 route,所以两处不可能分叉。判据、优先级、行为逐字未改。

那一行日志(tag = CyreneRoute,Log.d,只在 DEBUG 构建打)
- 打点:ChatViewModel.sendThroughLadder 一进入就一笔,覆盖阶梯的三个分支(帧交出去 / 试 REST /
  直接进队列),一行 key=value、空格分隔:
  wsDispatched=... socket=<ChatSocket.state.value,权威值> mirror=<顶栏读的那个镜像>
  wsObject=<present|absent> route=... reason=... outcome=... fromQueue=...
- socket 与 mirror 必须同时打出来:mirror 是 collect 过来的一格,只会滞后、不会超前,两者不一致
  就说明顶栏那一刻显示的是上一格 —— 这正是「顶栏重连中…」与「这条进了队列」能同时出现的形态。
- 这是开发期诊断仪,不是产品行为:字段增删不构成兼容问题、不为它写测试(要钉的是纯函数那张全表)。
  不打 token / 请求体 / 正文 / localId。

顺带(同一轮的可观测性)
- ChatSocket 新增只读属性 hasLiveSocket(webSocket != null),只喂日志,不是产品逻辑的输入。
- 复核 WsState.Offline 的写点:ChatSocket 里只有两处(runConnectionLoop 的 ConfigMissing,以及
  endWithAuthFailure),close() 写的是 Idle;非 401 的握手失败(代理回 503)走 Closed ->
  Reconnecting,进不了这一档。

测试
- SendFallbackTest 新增两例:五档连接状态 × wsDispatched 真假的全表逐个钉住结果 + 原因,并钉住
  sendRouteFor 与判定同值;另一个钉住三个 reason 都可达、没有第四种组合。全量 1155 例,
  0 失败 0 错误。
2026-10-05 19:50:28 +08:00
AskaEth b8399b5949 docs(02): 客户端登记——工具调用记录在回合结束后消失,根因是落库消息没有工具字段(数据面缺失),并给出要补的四点与影响面
排查证据:助手行落库时客户端会为流式思维链做「打捞 + 滞留」,而工具没有字段可打捞、连同流式路一起被丢弃;后端其实已把工具调用落进 flow_graphs,缺的是取出来给聊天端。
2026-10-05 19:44:53 +08:00
AskaEth b18d864145 docs(05): 锚点补记十六——W45 把探测换成直接试 REST(判据/两个超时/被证伪的那条担心/删除与测试承接),并把本文件里所有关于探测的描述显式标为作废 2026-10-05 18:34:56 +08:00
AskaEth 1d548ddcda merge: W45 —— 发送阶梯改成直接试 REST,删掉 W41 的连通性探测(少一个端点、一套超时预算与一个失败面) 2026-10-05 18:34:15 +08:00
AskaEth ec8d54f3a5 feat(W45): 发送阶梯改成直接试 REST,删掉 W41 的连通性探测
为什么换成直接尝试
- W41 的阶梯是「WS 未就绪 ⇒ 先探 GET /api/v1/health ⇒ 通才走 REST」。
- 真机回归(devtools/ws_block_proxy.py 只掐 /ws/chat,HTTP 全放行)发现消息进了队列而不是回落
  REST;而 ProbeClientTest 证明那条链在 JVM 上是通的,Unreachable 生在连接之前,三种成因
  (建 client 抛 / 连上但一个字没写 / ConnectException)在外同形,那一轮无法定论。
- 结论:不该有探测。探测只是「能不能连上」的替身,而真正的 REST 请求本身就是最直接的检验
  ——试成了消息还顺手发出去了;试败了进队列(内容一个字节不丢,重发仍是同一个 clientMsgId)。
  探测唯一省下的是「完全无网时少发一个注定失败的请求」,代价却是一个独立 client、一套独立超时
  预算和一整套新的失败面。那个探针 client 还不复用连接池,每次都要现做一次冷握手,预算比主链路
  紧 4 倍(2.5 秒整通 vs 10 秒连接)——今天这个 bug 的形态就长在这上面。

改了什么
- 路由:wsDispatched ⇒ Ws(成功路径逐字不变);Offline ⇒ Queue;其余三档未就绪 ⇒ 直接试 REST。
- 直接试 REST 走既有的 REST 发送路径与请求体(同一份 chatRequest / 同一个 clientMsgId):服务端
  收下(200 stream:true / 202 queued / 200 duplicate)⇒ 完成、不进队列;网络类失败与没等到裁决
  ⇒ 进队列;服务端给过裁决(409 / 500 / 503 / 400 与 401)⇒ 回滚 + 既有文案,口径不变。
- 判据纯函数化:sendRouteFor 去掉探测入参;新增 restAttemptLanding(Result<ChatAck>? ⇒ 四档落点);
  ChatAck.isAccepted 从 ChatViewModel 的私有方法搬到判据文件。
- 删掉探测那一套:ConnectivityRepository / HealthApi / HealthDto / ApiClientFactory.createProbe /
  PROBE_TIMEOUT_MILLIS / Reachability / reachabilityOf / ChatViewModel.probeReachability /
  AppContainer 的接线(删前逐个确认过没有别的调用方)。
- 超时:一个 client、两个超时。client 上的连接超时 10 秒改成 2.5 秒;回落那一次投递的整通上限仍然
  是 10 秒(REST_FALLBACK_TIMEOUT_MILLIS 一个数都没动)。
- 测试:SendFallbackTest 按新事实重写;ProbeClientTest 删掉,其中仍然成立的事实搬到新的
  RestAttemptClientTest(真 OkHttp + 真 Retrofit + JDK HttpServer)。

为什么是这两个超时值
- 连接超时 2.5 秒:正常网络下一次 TCP 握手是毫秒到几十毫秒量级,2.5 秒是「人开始觉得卡」之前的
  量级,也与 W41 那 2.5 秒的探针预算对齐 ⇒ 换成直接试 REST 之后「无网时用户最多等多久」没有变长
  (完全无网时 ≤ ~2.5 秒进队列)。绝不许回到 10 秒:那会让每一条离线消息的用户都白等十倍的时间。
- 整通上限 10 秒:它管的是另一档——「连上了但一直不回话」(半死的代理、接住连接却不回应的
  服务端),到点也按网络类失败进队列。它同时是用户干等时间的上界,不许拉长。
- 整通上限放在调用点而不是 client 的 callTimeout:这个 client 是共用的,长文本 / 内联图片那两条
  本来就该走 REST 的路用的是同一个,client 级 callTimeout 会把它们一并框住,等于给一条本轮不打算
  碰的路引入新失败面。

为什么 Offline 仍然不试(保留 W41 的决定)
- 该档的语义是「握手 401 且续期也失败」(终态、不自动重连),会话已知是死的:试 REST 只会拿到
  401 并落到「回滚 + 一句误导人的鉴权错误」,把「已排队不会丢」换成更差的结果。W41 的理由
  (不鉴权的健康端点必然回 200 ⇒ 假通)随探测一起消失了,但结论换到直接试这边一字不用改。

验证
- gradlew.bat assembleDebug:BUILD SUCCESSFUL
- gradlew.bat testDebugUnitTest:1153 个用例,failures=0,errors=0
  (SendFallbackTest 14 例、RestAttemptClientTest 6 例全绿)

未做
- 没有 push。devdocs/05 的 W41 / W44 段落仍写着探测(那份由主会话维护,本轮只在回复里点出过时
  的句子,没有改它)。
2026-10-05 18:33:32 +08:00
AskaEth 2bee0b52b0 merge: W44 —— 判定 W41 缺口:A(503 判成 Offline)已否;B 只到形态,那个 Unreachable 生在连接之前,需一次设备取证(含 ProbeClientTest 与 createProbe 的 DEBUG 日志) 2026-10-05 15:50:19 +08:00
AskaEth 06028cc352 test(probe)+docs: W44 判定 W41 缺口——A(把 503 判成 Offline)已否;B 只到形态(那个 Unreachable 生在连接之前,三种成因在外面同形,需一次设备取证)
判定 A:否。ChatSocket.isAuthFailure 只认 HTTP 401(ChatSocket.kt:320-323),非 101 的握手响应
(代理回 503)走 SessionEnd.Closed ⇒ Reconnecting 退避重连(ChatSocket.kt:301-304 / 230-235);
而顶栏「重连中…」正是 Reconnecting 的文案(ChatScreen.kt:918,Offline 那档是「离线」)。
⇒ 那一刻 socket 真值就是 Reconnecting,sendRouteFor 在这一档会看探测结论,镜像也没滞后。

判定 B:形态成立,但「这条链根本发不出请求」不成立。
- 调用点是无条件求值的实参(ChatViewModel.kt:1614)⇒ Queued 这一档必然调用探测,
  连 Offline 那档也会(sendRouteFor 只是把结论丢掉,与本轮前的 KDoc 说法相反,已改)。
- 新增 ProbeClientTest(6 例,真 OkHttp + 真 Retrofit + JDK HttpServer):这条链能在真 socket 上
  留下 GET /api/v1/health、不带 Authorization、200 ⇒ Reachable、503 ⇒ Unreachable、
  2xx 但回执解析不出来 ⇒ Unreachable、黑洞服务端在 2.5 秒整通上限处失败
  ⇒「探测压根发不出去」不是代码事实。
- 真机代理日志里一条 health 都没有 ⇒ 那个 Unreachable 生在连接之前。三种成因在外面完全同形:
  (甲) 建 client 就抛(baseUrl 空/非法,Retrofit.baseUrl 直接抛);
  (乙) 连上了但一个字都没写(callTimeout 是整通上限含连接,而探测不复用连接池 ⇒ 每次冷握手,
       预算只有 2.5 秒,比共用客户端的 10 秒连接预算紧 4 倍);
  (丙) 请求出去了、回执非 2xx(只有这一档在对端留痕)。
  ⇒「代理日志没有 health 那一行」推不出「App 没探过」;(甲)(乙) 都不留痕。

本轮改动(除一处 DEBUG 日志外全是文档与测试,无行为变化):
- 新增 app/src/test/kotlin/top/yeij/cyrene/data/api/ProbeClientTest.kt(6 例)。
- ApiClientFactory.createProbe:DEBUG 下挂 BASIC 日志(与通用 create 同一约定)。这是本轮要的
  诊断仪——--> 打在连接之前,让「到底有没有探」与「死在连接还是死在回执」当场可分;探测不带令牌、
  没有请求体,日志不涉凭据。
- KDoc 修正「连探测都不做」这处失真(SendFallback 的 sendRouteFor、ChatViewModel 的调用点与
  probeReachability),把「三种成因 + logcat 判据」写进 ConnectivityRepository 类注释,
  并给 WsState.Offline 补上「非 401 的握手失败不属于这一档」。
- sendRouteFor 的 fail-closed、探测的 2.5 秒上限、三档路由一个字都没改;下一步要设备取证,
  步骤写在 devdocs/05 的 W44 一节。

验证:assembleDebug 成功;testDebugUnitTest 1156 例 / 0 失败 / 0 错误(新增 6 例全绿)。
2026-10-05 15:49:48 +08:00
AskaEth 866a3c37cd docs(05): 补记十五·补——W41 回归重跑成功并找到一个真缺口:WS 断/HTTP 通时消息进了队列而不是回落 REST,且探测请求根本没发出(含 dump 决定性判据与下一步该查的位置) 2026-10-05 15:29:54 +08:00
AskaEth 4460ca9277 docs(05): 锚点补记十五——W43(中性文案逐字 + 重试静默的阈值/记账/清零口径与一处待过目边界)、主人四条裁决的落点、以及一次没跑成的 W41 回归(如实记为未验证,附可复用的 WS 拦截代理与教训) 2026-10-05 15:23:04 +08:00
AskaEth 3c1d3fd44c merge: W43 —— 超限告知改入口中性措辞,补发反复失败就静默(静默不等于丢弃) 2026-10-05 15:22:34 +08:00
AskaEth 5556573986 feat(chat): W43 超限告知改入口中性措辞,补发反复失败就静默
① 超限告知(ShareRules.truncatedImagesNotice)改成**入口中性**措辞
原句以「分享的图片」开头,那是一句只对其中一条路成立的限定:W42 起这个函数同时被图片入口
用着(图片按钮已改多选),而图片入口里根本没有「分享」这件事。所以句子不再指认动作的来处,
只说两条路都成立的三件事:**有上限(4 张)、超出的没发、发出去的是前 N 张**。新句逐字:
「一次最多 4 张,超出的没发,只发送前 N 张」,N 就是真发出去的张数。
刻意不写「失败」「错误」:被上限截掉不是一次错误,是上限本身,那族措辞会让用户以为丢了东西。
两条路仍然共用同一句(只有一个出处),没有为此分裂成两份文案。

② 补发「多次重试失败就静默」(主人 2026-10-06 定案)
W40/W41 之后未连接时普通发送会进补发队列并给一句柔和告知,连接恢复后自动补发;主人新加的
规矩是**不要一直刷屏**:同一条(或同一批)反复重试都失败时,停止再弹告知,静静地等。
- 判据抽成纯函数 queuedSendNoticeAllowed(toldThisRound, failedResends):**静默闸优先**,其次是
  既有的「一轮从空到空只说一次」(W40 那条一字未改)。它只回答该不该说,从不碰队列。
- 阈值 QUEUED_RESEND_SILENCE_THRESHOLD = 3。理由:不许取 1~2(那等于根本没告知);第 1 次失败
  就是「刚进队列」那一刻,必须说;第 2、3 次仍允许开口(连接抖动后还是没连上,用户需要重新
  知道);第 3 次仍未成功说明已在反复失败里,再说只是把同一件事刷第二遍,从此停下。
- 记账口径:本会话的一个**补发批次**(= 队列从空到非空的那一段),与告知本身的记账同口径,
  不新造第二个概念;按单条记账会漏掉「每条只失败一两次、整批却一直没发出去」这种要静默的情形。
  落在 ChatViewModel 的实例字段上:VM 是按会话建的,所以天然按会话,也不引入全局单例——别的
  会话的反复失败不该把本会话的告知掐掉。
- 计什么:只计「补发没能上路、内容仍留在队列里」那种失败(它是唯一会反复发生的一种);已被
  服务端裁决 / 已回滚的那两类是一次性了结(内容退回输入栏并给了明确原因),不计数。
- 清零时机:**补发真的成功一次**就归零(WS 帧交出去、或回落 REST 被服务端收下)⇒「静默之后
  连接恢复并补发成功」之后,下一次离线**又能重新告知**,不会永久闭嘴。
- 静默不等于丢弃:这一个数只影响说不说,队列内容与补发的下一步一个字节都没动,连接一好照旧
  按序补发(W38/W40/W41 那条硬要求照旧成立);气泡上「排队中 · 还没连上」的小标也一直挂着。

既有的两句队列告知(排队中那一句 / 离线那一句)一字未改。

测试:ShareNoticeTest 与 ImageSendPlanTest 的超限断言改成新事实(不删断言);新增
QueuedSendSilenceTest 钉住那条规则的四个边(第一次失败告知、阈值前仍告知、阈值后静默、成功后
清零)以及「阈值不许取 1~2」「静默不动队列」两条性质。
assembleDebug 成功;testDebugUnitTest 1150 测试 / 0 失败 / 0 错误(基线 1143,本批新增 7)。
2026-10-05 15:21:24 +08:00
AskaEth f3ee2e0d7b docs(05): 锚点补记十四——W42 图片入口改多选(N 张按既有切分落成 N 条消息、切分抽成唯一纯函数由输入栏与分享共用),并登记两处待主人过目(N>=2 时偏好只套第一条、超限文案带「分享的」字样) 2026-10-05 14:42:20 +08:00
AskaEth 4355ebddea merge: W42 —— 图片入口改多选,N 张图按既有切分落成 N 条消息 2026-10-05 14:41:46 +08:00