AskaEth
|
f9555fba85
|
docs(05): 锚点补记三十六 —— W57 通知消费点提到进程级(根因链:连接是进程级/订阅挂 Activity 级 VM/replay=0 丢帧)+ 长跑第3次基线(开关确认是开的,推翻上节那条猜想)+ W57 设备验收步骤与副作用
|
2026-10-06 22:23:09 +08:00 |
|
AskaEth
|
900d191b8d
|
merge: W57 —— 把「她主动说的话」的通知消费点提到进程级(新增 ProactiveFrameIntake;修 Activity 被划掉后收不到帧/不弹通知)
|
2026-10-06 22:22:26 +08:00 |
|
AskaEth
|
0e03eba6a4
|
feat(notify): W57 —— 「她主动说的话 ⇒ 弹通知」的帧消费点提到进程级
改了什么
- 新增 app/ProactiveFrameIntake:订 ChatSocket.events,把两类帧(proactive_message、
message_persisted{proactive:true})折成四个入参交给 ProactiveNotifier。纯函数
proactiveFrameOf 管「哪些帧算她主动说的话、每个入参取自帧上的哪个键」。构造即订阅,
刻意不提供 start/stop。
- AppContainer:新增 proactiveFrameIntake(与 chatSocket 同一层,进程级)。
- SessionsViewModel:删掉 onEvent 里那两处转交 proactiveNotifier 的调用,本类不再判断
通知的任何一件事(未读、「谁在跑」原样不动)。
- AppPresence / ProactiveNotifier 的 KDoc 跟着改:谁在读「在不在前台」、谁在喂帧。
为什么
改前这份订阅挂在 SessionsViewModel 的 viewModelScope 上。连接本身是进程级的
(ChatSocket 自带 scope,只有 close() 取消它,而 SessionsViewModel.onCleared 刻意不关),
所以从最近任务把 Activity 划掉之后连接照旧收帧——但 events 是 replay=0 的 SharedFlow,
没有订阅者的 tryEmit 当场丢掉:她的话进了连接、然后没了,通知永远不弹。这是 W53N 登记
的那一格缺口。
边界
- 判据与去重一个字节没动(仍在 ProactiveNotifier 的纯逻辑里)。
- 连接所有权没动:仍然只在登录态那一处 connect/close。
- 不是「新起一条连接」,只是把帧的消费点从 Activity 级提到进程级;VM 里那两处转交是
删而不是留(留两处等于靠去重去遮一次重复消费,且「谁在喂通知」会有两个答案)。
- deliver 里接住落地异常:这条订阅不会再被重建,抛出去就是进程崩溃 + 通知整个哑掉。
验收
assembleDebug 通过;testDebugUnitTest 全量 113 suites / 1370 tests / 0 failures /
0 errors(基线 112/1361,新增的 9 条全在 ProactiveFrameIntakeTest)。
|
2026-10-06 22:21:26 +08:00 |
|
AskaEth
|
6de2f7656a
|
docs(05): 锚点三十五补第 7 节 —— 保活长跑第2次复查结果作废(装包必然杀进程 / tcp:80 已指向真后端使注入 delivered=0 / 复查时进程本就不在),并写明重做需要的前提与代价
|
2026-10-06 22:14:13 +08:00 |
|
AskaEth
|
5f7489d70e
|
docs(05): 锚点补记三十五 —— 模拟器已切真后端(含全角冒号坑与「未知错误」根因);主人报的标签解析 bug 定位到后端畸形 markup <p><p>正文</p> 并已容错修复 + 真机验收;W54A/C/D + W55 + W56 五支合并推送(112/1361 绿);模拟器卡的原因诊断
|
2026-10-06 21:44:07 +08:00 |
|
AskaEth
|
a60e3abc12
|
merge: W56 —— 分享面板预览加「展开/收起」(长正文可读全文;展开态上限 240dp;短正文不显示控件)
|
2026-10-06 21:43:30 +08:00 |
|
AskaEth
|
14955ef6c0
|
feat(chat): 分享确认面板的正文预览可以展开 / 收起
问题:W54D 把正文那一窗改成「三行 + 内部滚动」之后「读不到整段正文」是解决了,但一次只看得见
三行。主人拍板「可以滚动 可以展开」——两个都要,所以这一轮补上后半。
改法(只动 ShareConfirmPanel.kt 与它的测试,ChatScreen / ChatViewModel 与接线一字未碰):
- 正文卡片里加一枚「展开 / 收起」(`Text` + `clickable`,不用 `TextButton`:按钮自带高度会把
这一行撑高一截)。它挂在「分享的文字」那一行的右端,不挂在正文末尾——展开后正文可能好几屏,
末尾那枚「收起」要滚到底才找得到。
- 收起态一个字没变:三行窗(SHARE_PREVIEW_TEXT_LINES = 3)+ 内部滚动。
- 展开态:正文那一窗撤掉自己的上限(`heightIn(max = Dp.Infinity)`,正是首帧那个状态),整段交给
外层预览区滚;外层上限由 150dp 抬到 240dp(SHARE_PREVIEW_EXPANDED_MAX_HEIGHT)。展开态**只留
这一层**滚动是刻意的:两层都设上限时手势先被内层吃掉、内层滚到底才轮到外层,两层上界还互相
牵制,最容易出的错就是「明明展开了还剩半截读不到」。
- 控件**只在正文排下来超过三行时出现**。判据是行数 > SHARE_PREVIEW_TEXT_LINES,不是
`hasVisualOverflow`:这一窗的高度不受限(`verticalScroll` 给子项的就是无上限约束),`Text` 自己
永远报「没有溢出」,拿它当判据会在最长的那种正文上漏掉控件;行数则与上限无关,首帧就可用。
- 展开态为什么是 240dp:这个上限管的是**整个预览区**,下面还压着描述输入框(最多 4 行)与两枚
按钮,面板贴着屏幕底、键盘一弹可用高度只剩半屏上下。150 → 240 在默认字号档上把正文窗从三行抬到
十行上下;再往上(例如整屏的六成)先被顶掉的会是按钮。⚠️ 这个数**没在真机上量过**,是按这笔账
取的保守值,观感由主人验收,要调只改这一个常量。
- 两条判据都做成纯函数(`sharePreviewTextOverflowsWindow` / `sharePreviewMaxHeight`),调用点只读
结论,单测钉的就是它们。
上一批留下的「首帧回流会不会看得出来」疑点,复核结论:**留着,且确认它看不见**(理由已写进
`ShareConfirmBody` 的 KDoc):① 首帧那一下撑不动任何别的东西——正文块是预览区里的最后一个元素,
可视像素只由外层那层独立的上限裁剪决定,上面图片/文件的位置一分不动;② 展开态本来就没有这一层
上限,不存在「量完再回落」;而展开 / 收起那一帧的尺寸变化是用户动作的**目的**,不是回流。真正多
出来的是控件本身(第二帧才出现,因为行数是排完才知道的),它与标签同用 `footnote2`、行高不变,
也顶不动任何东西。组合期 `rememberTextMeasurer` 那条路要外包 `BoxWithConstraints` 拿宽度、还要
自己复现字号与字体缩放,等于把排版做两遍——为一个看不见的跳动换掉结构不划算,不做。
已知取舍(如实记下,没修):转屏(Activity 重建)后展开态回到收起。它现在活在
`remember(preview.text)` 里,跨转屏要活下来只有两条路——进 `ChatUiState`(W54C 给「已取走的分享」
与描述走的就是那条路,但那是 ChatScreen / ChatViewModel 的地盘,本批红线不许碰),或
`rememberSaveable`(本仓一次都没用过的新写法,且弹层窗口带不带 saveable registry 没在真机上验过)。
两条都没走,留一条 ⚠️ 在代码里 + 留给主人定。
测试:新增 3 条(纯函数判据)——「什么时候该出现展开控件」(0/1/3 行不给、4 行起给,并接一份
40 行的真分享走一遍)、「展开态给多高」(150 / 240 两个字面量 + 展开严格更高 + 不超过收起态的
两倍)、「两句标签不同且非空」。展开与滚动本身单测够不着(本模块没有 Compose UI 测试的依赖),
如实说明;既有断言一条未放松。
验证:`./gradlew.bat assembleDebug testDebugUnitTest --tests "*ShareConfirm*"` → BUILD SUCCESSFUL
(ShareConfirmTest 15 条 = 既有 12 + 新增 3、ShareConfirmDraftTest 3 条,0 失败 / 0 错误);
全量 `./gradlew.bat testDebugUnitTest` → BUILD SUCCESSFUL,112 suite / 1361 tests / 0 失败。
|
2026-10-06 21:43:12 +08:00 |
|
AskaEth
|
9ff2de3a1d
|
merge: W55 —— 解析器剥掉落单标签(修后端畸形 <p><p>正文</p> 导致标签字面显示在气泡里)
|
2026-10-06 21:26:34 +08:00 |
|
AskaEth
|
f3c1121887
|
merge: W54D —— 分享确认面板正文预览改成三行窗口 + 内部滚动(发送前能读到整段正文)
|
2026-10-06 21:26:33 +08:00 |
|
AskaEth
|
a861881ec4
|
merge: W54C —— 转屏不再静默丢掉已取走的分享(面板状态与描述移进 ChatUiState,take 仍只发生一次)
|
2026-10-06 21:26:29 +08:00 |
|
AskaEth
|
b3ded77c23
|
merge: W54A —— 通知一事一条(通知 id / PendingIntent requestCode 按消息稳定派生,修她连说几句时后到覆盖先到)
|
2026-10-06 21:26:26 +08:00 |
|
AskaEth
|
464e91621e
|
fix(parse): 容错落单的段落标签,绝不把 <p> 当正文渲染
真机实测:后端产出的某条消息第三段是 `<p><p>那现在…?</p>`(两个开标签、一个闭标签)。
P_BLOCK_RE 是非贪婪的,先把外层 `<p>` 配到那个 `</p>`,内层那个裸 `<p>` 就成了「块内容」;
而 appendAnnotations 只认 `<action>` / `<narration>` / 星号,不认裸 `<p>`,于是它作为普通文本
落进气泡正文,屏幕上直接显示 `<p>那现在…`。
改了什么:新增 stripLooseTags,在切出 `<p>` 块之后按区剥掉「落单标签」——
- 消息里已有完整 `<p>…</p>` 块时,块内块外多出来的每个 `<p>` / `</p>` 一律剥掉,块外没被
ROLE_RE 认领的 `<action>` / `</action>` 同样剥掉;
- 一个完整块都没有时,维持既有「未闭合 `<p>` 整段回落字面文本」的契约(编辑框靠它保证不
静默改字),只再剥两类连「被截断的结构」都算不上的东西:落单的 `</p>` 与后面什么都没有的空开标签。
判据只认完整匹配已知标签词表的形状,`1 < 2` / `<3` / `a<b` / `<p2>` / `<P>` / `<p`(残缺) 一律原样保留。
为什么选择「剥」而不是「原样显示」:这些是服务端产出的标记、不是她说的话;渲染成正文等于往
气泡里塞一串用户既看不懂、也改不掉的字符,而剥掉只是少显示几个本就不该出现的字符,正文一个字
都不动。这只是客户端的容错,不代表服务端这样发是对的。
流式:stablePrefixEnd 不动。扣留点取「第一个没闭合的开标签」,而前缀里的每个开标签必定在同一个
前缀内配到闭标签,所以「是不是落单」在它被产出的那一刻就已定死,中间态剥掉、收口也不用改口;
放宽扣留(把重复的 `<p><p>` 当外层冗余)反而会让 `<p>甲<p>乙` 在中间态把 `<p>甲` 当字面文本吐上屏,
等 `</p>` 到了再跳成 `甲乙`。新增测试钉住这条:逐字符喂实测畸形串时,任何一帧都不许漏出标签,
且已显示的正文只许增不许改口。
测试:assembleDebug 通过;ChatContentParserTest 55 项全绿(新增 17 项);全量 110 suites /
1349 tests / 0 failures / 0 errors(主仓同 HEAD 基线 110 suites / 1332 tests,本次 +17 项)。
|
2026-10-06 21:26:07 +08:00 |
|
AskaEth
|
ec89d828d1
|
fix(chat): 分享确认面板的正文预览改成三行窗口 + 内部滚动
问题:正文那一窗此前是 maxLines = 3 + 省略号,超过三行的分享正文在按「发送」之前根本读不到。
面板里再没有第二处放着这段正文(输入框只剩「用户附加的描述」,初值恒为空),截断就等于
「要发出去的那段正文看不见」——而这正是用户点「发送」时唯一要确认的东西。
改法:正文那一窗高度恒为三行、内部可滚,外层预览区那 150dp 的上限原样不动,所以面板不会被
长正文撑高(图片 / 文件芯片 / 按钮的位置都不变)。滚到底后余量交给外层 scroll,嵌套滚动本身
就有,不用额外接线。
上限为什么不是写死的 dp:Miuix 的 footnote1 只给了 13sp 的字号,lineHeight 是 Unspecified,
行高由系统字体的 ascent/descent 决定、随「大字体」设置一起缩放。写死 dp 在默认字号档上勉强
对得上,到大字号档就会矮一截、把末行的字切一半。所以改为量:从 TextLayoutResult 上取第三行的
底边当上限,量到之前先按无上限排一次(heightIn(max = Dp.Infinity) 在确认过 Compose 的
Density.roundToPx 对 Infinity 的处理后是安全的,即「没有上限」)。
SHARE_PREVIEW_TEXT_LINES 由 private 改 internal,只为让单测能把「这一窗是三行」钉成字面量
(本模块没有 Compose UI 测试的依赖,滚动行为单测够不着)。
测试:新增两条——正文远超三行时纯函数层仍原样交付**完整**正文(一个字不少、不加省略号),
以及三行上限的字面量;既有断言一条未放松。
|
2026-10-06 21:11:30 +08:00 |
|
AskaEth
|
1142bf5ed1
|
fix(share): 转屏不再静默丢掉「已取走、还没确认」的那份分享 —— 面板状态移进 ChatViewModel
分享进来的内容被 `ShareIntake.take()` 取走之后没有归还路径,而它此前只挂在一格
`remember(sessionId)` 上:Activity 因转屏 / 深浅色切换重建时那次组合整份丢掉,那份分享
**既不在待发队列里、也不在界面上**(用户什么都没看到,也没有任何提示);用户已经输入到
一半的那句描述同样丢。
改法:把「已取走、正等确认的那份分享 + 用户补的描述」移进 `ChatUiState.shareConfirm`
(`ShareConfirmDraft`),那一格由 VM 持有,而 VM 活在 `ChatSessionSlots` 的 `ViewModelStore`
里、由 AppContainer 撑着 —— 比组合与 Activity 都活得久,所以重建之后面板照旧长出来,
描述也跟着回来(描述与正文绑在同一个值里,两者同生共死)。
`take()` 仍然只发生一次:认领那一步收进 `ChatViewModel.claimSharedContent`,闸门是纯函数
`shareConfirmClaim`,且**在 `take()` 之前** —— 手上已经有那份内容时一个字节都不动队列
(既不取新的一条,也不会把用户正看着的那份顶掉)。重建后的新一帧照样会走到判据这里,
而那时手上已经有东西(它活在 VM 里)⇒ 不会再取第二次。判据、出队、落地之间没有挂起点,
所以也不存在「取走了却没记住」的窗口。
顺带把这一格计入 `holdsUnsentWork`:这份内容已经没有退路,那一格被回收就等于把它删掉。
`ShareLanding` / `ShareIntake.take()` 里「取走之后直接发」的旧说法一并改成「交给确认面板」。
新增 ShareConfirmDraftTest:钉住「重建后的第二帧不再调 take、队列里那条也不许被取走」、
「内容与描述原样还在」、「队列为空时不造一个空面板」。
|
2026-10-06 21:09:15 +08:00 |
|
AskaEth
|
53339b369a
|
feat(notify): 她主动说的话改成「一事一条」——通知号按消息派生,后到的不再顶掉先到的
改前所有她主动说的话共用一个固定的通知 id,而同 id 的 `notify` 是**替换**:她连说几句时后来
的那条把先来的顶掉,用户拿起手机只看到最后一句,前面几句一声没响就没了(这正是 W50 判据下游
的落地形状)。
改成按消息派生:
- `ProactiveNotify.kt` 新增纯函数 `proactiveNotificationId(key)`:把**去重键**(`proactiveNotifyKey`
的输出)折成通知号。刻意与去重键同源——去重说「这是同一条」、派生说「这是同一条 ⇒ 同一个号」,
两处口径一旦分成两套,就会出现「认得出是同一条、却排出两条通知」的形状漂移(实时帧与落库帧
两条路都会到,这条尤其要命)。
- 两个坑都真的堵上:① 号必须 > 0,而 `hashCode()` 可以是负的、甚至正好是 `Int.MIN_VALUE`
(`abs(Int.MIN_VALUE)` 在 int 域里仍是它自己)⇒ **先展宽到 `Long` 再取绝对值**,不引任何新依赖;
② 不撞既有 id:全仓只有保活那条常驻通知(id = 1)与它 `PendingIntent` 的 request code = 0 是
占用者,派生的号从 1000 起步、宽度 2×10⁹,落点与它们**结构性**不相交。
- `ProactiveNotifier.post` 的签名带上这个号(`post: (text: String, notificationId: Int) -> Unit`),
`AppContainer` 的装配 lambda 同步改;`ProactiveNotification.kt` 删掉恒定的
`PROACTIVE_NOTIFICATION_ID` / `PROACTIVE_OPEN_REQUEST_CODE`,通知 id 与 `PendingIntent` 的
request code **一起**随消息走(只换 id 不换 request code,点后到的那条通知会串到先到那条的
`PendingIntent` 上)。
- 撞号仍会发生(`String.hashCode()` 只有 32 位):后果与改前一样是「后来者替换先来者」,已在
`proactiveNotificationId` 的 KDoc 里如实写清概率,以及为什么接受这一侧。**没有**放松任何既有断言。
测试:新增 `ProactiveNotificationIdTest`(同一条派生两次同号 / 两条不同消息不同号 / 任何输入恒 > 0,
最后一条用 hashCode 正好是 `Int.MIN_VALUE` 的字符串 `polygenelubricants` 钉住前提);另在
`ProactiveNotifierTest` 加一条端到端的号断言(两条路都到的第一条只发一次且号不变、第二条是另一个
号)。`ProactiveNotificationShapeTest` 里那条「不与保活那条相撞」改成扫一遍真实形状的键。
验证:`./gradlew.bat assembleDebug` → BUILD SUCCESSFUL;`./gradlew.bat testDebugUnitTest` →
BUILD SUCCESSFUL,111 suite / 1336 tests / 0 失败(基线 110 suite / 1332 tests,本批净增 4 条)。
|
2026-10-06 21:08:23 +08:00 |
|
AskaEth
|
e5e61e131d
|
docs(05): 锚点补记三十四 —— 压缩后续接(W54 三支并行在飞:通知一事一条/转屏丢分享/预览可滚动;第二批进程级订阅待开)+ 主人三条拍板(预览要滚动+展开、提示词已贴等回执、推送待研究)+ 环境核对
|
2026-10-06 21:02:43 +08:00 |
|
AskaEth
|
33c320219a
|
docs(05): 锚点补记三十三 —— 续接锚点(仓库现状/本轮八项已完成及其真机证据/保活长跑在跑/待主人拍板四条/环境与两个会踩空的地方/两条教训)
|
2026-10-06 20:59:32 +08:00 |
|
AskaEth
|
827f0e61a4
|
docs(05): 锚点三十二补第 6 节 —— 分享面板语义改对并上机验过(预览留正文 / 输入框空+占位符 / 正文+空行+描述 拼接端到端验过)+ 保活长跑因装包重起(新基线 20:13:35 / pid 25584)
另记两条:① 预览正文仍只有 3 行 ⇒ 发送前读不到全文,属观感取舍待主人定;② adb am start 的 --es 值会被空格拆开(我那次 URL 就是这么丢的,不是 App 吞的)。
|
2026-10-06 20:13:52 +08:00 |
|
AskaEth
|
30c53451dc
|
merge: W53X3 —— 分享面板的输入框改成「用户附加的描述」(默认空 + 占位符),正文留在预览区;发送时把分享正文与描述拼起来(空行分隔)
|
2026-10-06 20:10:03 +08:00 |
|
AskaEth
|
514901c12d
|
fix(share): 确认面板的输入框改成「用户附加的描述」——不再预填分享正文,发送时由正文+描述拼成
W53X 的第一版把那个输入框当成了分享正文的可编辑副本(`shareConfirmDraft(share)` 直接返回分享
正文、由 ChatScreen 预填进去),在真机上就渲染成「同一句话排两遍」:预览的「分享的文字」块一遍、
输入框里又一遍,而输入框里那句还看着像一段只读展示。主人澄清语义:那个框是**用户要附加的描述**
——「这次要发什么」归预览,「你想补一句什么」才归这个框,两件事被合成一件才是根因。
改成:
- 输入框初值**恒为空**(`shareCaption` 在取走一份分享时显式归零),占位符改成「加一句描述…」
(与输入栏那句「和昔涟说点什么…」同一个 `useLabelAsPlaceholder` 写法);预览区一个字没动
(图片缩略图 / 文件 chip / 分享正文都留着,那才是这次要发出去的东西)。
- 新增纯判据 `shareConfirmBody(share, caption)` = 分享正文 + 用户描述:两段都在时用**一个空行**
分隔,只有一段就只用那一段,都空就是空串(两侧空白按 `ShareRules.extract` / `sendShared`
的口径归一,中间一个字不动,也不加任何来源标记——那是项目红线)。
- 删掉 `shareConfirmDraft`(名字说「草稿/正文」、行为是「把正文当输入框初值」,留着就是骗人);
`shareConfirmCanSend(share, caption)` 改成**直接问 `shareConfirmBody`** 非空、或另有图片/文件,
于是「发送」可点与否与 `sendShared` 开头那条早退同源;描述为空时不再误把「纯文本分享」判成不可发。
- ChatScreen 的发送口由 `share.copy(text = shareDraft)` 改成
`share.copy(text = shareConfirmBody(share, shareCaption))`;接线语义(`take()` 只发生一次、
面板渲染位置、WindowDialog 选型)一字未动。
测试:`ShareConfirmTest` 跟着改到新语义,拼接规则**穷举**钉住(都有 / 只有正文 / 只有描述 / 都空,
分隔符写成字面量 `"\n\n"`),并钉住「都空且无文件 ⇒ 不能发」与「可点 ≙ 拼出来的正文非空或另有
附件」(同源性)。10 条测试全绿。
验证:`./gradlew.bat --console=plain assembleDebug testDebugUnitTest` → BUILD SUCCESSFUL,
110 suite / 1332 tests / 0 失败(基线 110 / 1330,本批净增 2 条)。
|
2026-10-06 20:09:35 +08:00 |
|
AskaEth
|
59012f711b
|
docs(05): 更正分享面板的语义 —— 输入框是「用户附加的描述」而不是分享正文的复刻
主人澄清:那个输入框(我这边先误判成「预览多余」)应当是用户附加描述的输入框。正确形态:
预览留着分享正文(它才是要发的内容)、输入框默认留空 + 占位符、发送时「分享正文 + 用户描述」拼接
(两段都在时空行分隔)、都空且无文件时按钮不可点。我先前那条「删预览正文」的指令方向反了,已更正,
W53X2 回来即按更正执行。
|
2026-10-06 20:06:00 +08:00 |
|
AskaEth
|
863ac93b25
|
docs(05): 锚点补记三十二 —— 续接锚点(主人两条新指令:复验分享面板「正文两次」、开跑保活长时间后台)+ 环境状态 + 两条各自照抄即可的跑法
|
2026-10-06 20:03:56 +08:00 |
|
AskaEth
|
d8e44c6692
|
docs(05): 锚点补记三十一 —— W53 四项落地(后台通知/退出确认框/保活文案与入口/分享确认面板)+ 真机验收全过
- 三支各自的机制与判据要点,含两条「子代理纠正任务书」的记录(Miuix 0.9.3 没有 extra.* 包;
proactive 判据后端放在 data 里而不是帧壳顶层——我读后端源码核过)+ 我自己补的那处
(AppPresence 补同源 StateFlow 当 key,修「去系统设置授权后回来服务没起」)。
- 真机验收逐条证据(后台通知的形状与点它回主会话、退出确认框、保活文案与「去系统设置」、
分享面板确实没被直接发出、INTERNET 来源是源码)。
- 仍未验/已知缺口如实列出(两条路去重只有单测、通知 id 覆盖、Activity 销毁后收不到帧、
转屏丢已取走的分享、S4 对照组没跑干净)。
|
2026-10-06 19:00:03 +08:00 |
|
AskaEth
|
62f552f87f
|
merge: W53X —— 分享进来先弹确认面板(预览图片/文件 + 可附文字 + 发送/取消),不再一到就发
|
2026-10-06 18:55:36 +08:00 |
|
AskaEth
|
77fbedc350
|
fix(keepalive): 权限「去系统设置里授权后回来」这一档现在真能把服务起起来(补 W53S 登记的那一格)
问题(W53S 标出、我核过代码确认):CyreneApp 里那个起停前台服务的 LaunchedEffect 的 key 只有
`keepAliveEnabled` 与登录态,而通知权限是**现读**的 ⇒ 用户在系统设置里授权后回到 App 时它不会重跑,
设置页已经显示「已开」而服务其实没起(要等下次启动或再动一次开关)。
修法:给 AppPresence 补一条**与既有 `appInForeground` 同源**的 StateFlow(`onStart/onStop` 里
从同一个集合重算,布尔 getter 语义一字未动),让那个 effect 收它当 key —— 「回到前台」正是
「从系统设置返回」必经、且必然变化的那一位。没有新增任何依赖。
|
2026-10-06 18:55:20 +08:00 |
|
AskaEth
|
0a43cedea9
|
docs(share): 补上「为什么是 WindowDialog 而不是 OverlayDialog」的硬理由
查了 Miuix 0.9.3 的源码(下载 sources jar 核对,不是猜):`OverlayDialog` 靠
`MiuixPopupUtils.DialogLayout` 把自己登记进 `LocalDialogStates` / `LocalRootDialogStates`,
而这两个 `staticCompositionLocalOf` **只有 Miuix 的 `Scaffold` 会 provide**
(`basic/Scaffold.kt` 的 Surface 里那层 CompositionLocalProvider)。
本面板的调用点在 `ChatScreen` 那个 `Scaffold` 的**外面**(与附件预览 / 编辑 / 删除三个弹层
同一处)⇒ 换用 `OverlayDialog` 会拿到一个没人读的默认空列表:**不报错、也不显示**,
分享进来会变成「什么都没发生」。这条写进 KDoc,免得后人只看到 W53S 用了 `OverlayDialog`
就顺手把这里改过去(两处调用点的处境不同:那个确认框长在设置页的 Scaffold 里)。
只改注释。验收:./gradlew.bat --console=plain assembleDebug testDebugUnitTest → BUILD SUCCESSFUL,
102 suite / 1278 tests / 0 失败。
|
2026-10-06 18:54:43 +08:00 |
|
AskaEth
|
b06688f1db
|
feat(share): 分享进来先确认再发 —— 新增确认面板
主人 2026-10-06 拍板:分享进来的内容不再「一落地就发」,要像其他 IM 一样先给一个
确认面板。此前 ChatScreen 的分享落地 effect 取走内容后**直接**调 sendShared,
用户没有任何确认机会;现在它把取走的那一份放进 Compose 状态,面板靠它渲染。
新增 `ui/chat/ShareConfirmPanel.kt`(Miuix 自己的 `WindowDialog`,不是 Material3):
- 预览:图片缩略图(与待发草稿预览同形)+ 文件芯片(显示名 / 体积)+ 分享正文(三行,
全文在下面那个输入框里,可编辑);
- 输入框:初值就是分享进来的正文本身,**一个字符都不加**(不拼来源标记——那是红线);
- 「取消」= 本次分享作废(不发送、也不塞回待发队列);「发送」才走既有 sendShared。
为什么这么接线(都在 ChatScreen 那一小段里):
- **取走只发生一次**:`ShareIntake.take()` 只在 `shareConfirm == null` 时被调用,而
`shareConfirm == null` 同时是这个 effect 的键 ⇒ 面板一关就再跑一遍判据、把面板开着时
攒下的下一条分享接上(不加这个键,那一条会一直躺在队列里等连接状态碰巧再变一次);
- 面板开着时新来的分享**原地留在队列里等**,不覆盖用户正看着的那一份;
- 告知按**真正发出去的那一份**算(`share.copy(text = 输入框内容)`):正文可能刚被用户改过,
拿改之前那份去算会说出「正文较长、本想作为文件发送」这种与事实不符的话。
未动:shareLanding / shareNoticeFor / shareWaitingNotice 的语义,sendShared 里
「超长正文 → 文件」那条裁决,ShareIntake 的提取与暂存,W49/W50/W51 的判据。
判据抽成纯函数并单测(`ShareConfirmTest`,8 条):预览该显示哪几样(图片数 / 文件 / 正文是否
非空)、输入框初值、以及「发送」可点 ≙ 真的会有东西发出去(与 sendShared 开头那条早退同一条)。
验收:./gradlew.bat --console=plain assembleDebug testDebugUnitTest → BUILD SUCCESSFUL,
102 suite / 1278 tests / 0 失败(基线 101 / 1270)。
|
2026-10-06 18:53:41 +08:00 |
|
AskaEth
|
6505512c0a
|
merge: W53S —— 退出登录加 Miuix 确认对话框;保活那行补「24 小时累计 6 小时」上限事实 + 权限被拒时的「去系统设置」入口
|
2026-10-06 18:53:34 +08:00 |
|
AskaEth
|
bbd02fc279
|
feat(settings): 退出登录加二次确认;保活那行补系统上限并给「去系统设置」入口
两件事都在这一个切片里,都只碰 ui/settings/**。
一、「退出登录」的确认框
- 现状:W52C 把它改成整行可点的 BasicComponent(红色标题 + Remove 图标),而它紧挨着
「版本」那一行,两条长得几乎一样 ⇒ 误触代价是一次真登出(AuthManager.logout 会吊销
服务端 refresh 并把本机凭据整份清空,没有撤销)。W52C 自己把这条风险标了出来。
- 改成:点条目只立一个本页的 remember 状态(不进任何 VM),弹出的确认框里
「取消 / 点框外 / 系统返回键」三个出口都只是关掉框,一个字都不改;只有确认那一路
才调 onLogout —— 行为链路与加框之前完全一致。
- 控件用的是 Miuix 自己的 OverlayDialog(top.yukonga.miuix.kmp.overlay),由本页 Scaffold
自带的 MiuixPopupHost 承载:标题 / 说明 / 内容分槽,正是为这种事准备的。
(注意 Miuix 0.9.x 并没有 extra.* 包,对话框只在这两个包里,别照旧版 import 去猜。)
- 说明文案如实带上代价:退出后本机登录信息整份清空,服务器地址也要重填。
二、「后台保持连接」那行
- 补事实:dataSync 型前台服务在 targetSdk 37 下,24 小时内累计最多 6 小时会被系统回调
onTimeout(KeepAliveService 到点自停以免 ANR)⇒「全天候保活」做不到。原先这句只写在
KeepAliveService 的代码注释里,用户看不到;现在写进开关下面那行字,且落在「关 / 开」
两档——只写在「已开」那一档等于默认所有人都已经开过一遍,而默认态恰恰是关的。
- 补入口:权限把功能挡住的两档(刚被拒 / 开关开着但权限被撤)原先只说「到系统设置里允许」,
用户得自己摸进去找。现在这两档下面多一个「去系统设置」按钮,直接跳到本 App 的通知设置页
(ACTION_APP_NOTIFICATION_SETTINGS + EXTRA_APP_PACKAGE;厂商砍了这个页面就退到应用详情页,
两次都失败就什么都不做,绝不崩)。按钮出现的条件与那两句文案一一对应,两处不会分叉。
- 顺手复核:点了按钮从系统设置回来时,由 ActivityResult 回调现读一次真实授权,把过期的
「刚刚被拒」清掉 —— 比挂生命周期钩子直接,也不需要它。
- 测试:KeepAliveSummaryTest 原有的 4 条断言一条没动(新增事实挂在「关 / 开」两档,正好
不吃「已开」与「起不来」那两条);新增两条 —— ① 关 / 开两档都必须写明 24 小时窗口与
6 小时上限;② 权限挡住的那两档都必须把用户指向系统设置(与按钮的判据同源)。
验收:./gradlew.bat --console=plain assembleDebug testDebugUnitTest
BUILD SUCCESSFUL;101 suite / 1272 tests / 0 失败(基线 101/1270)。
|
2026-10-06 18:52:31 +08:00 |
|
AskaEth
|
53c7494389
|
merge: W53N —— 她主动说的话在后台/不在聊天页时发系统通知(只这一档;读回 proactive 判据 + 按 id 去重 + 点通知切主会话)
|
2026-10-06 18:52:20 +08:00 |
|
AskaEth
|
611bc5f58a
|
feat(W53N): 她主动说的话在后台/不在聊天页时发一条系统通知(只这一档,普通消息不弹)
改了什么:
1. WsEvent:把落库帧上被丢掉的 `proactive` 判据**读回来**(新增 MessagePersisted.proactive)。
此前 PersistedData 只解 message,而生产 Json 是 ignoreUnknownKeys=true ⇒ 这个键既不解也不报错,
「她主动说的话走了 IM 桥那条落库路」在客户端完全不可见。两层落点都认(data.proactive 与帧壳
顶层 proactive 取或,契约只说了「帧顶」);值用**宽容读法**(只认字面 true),形状不认识时按
「不是 proactive」——判错成真会给普通消息弹通知,判错成假只是漏一次提醒,方向必须偏不打扰。
2. core/notify(新):判据 + 去重 + 落地
- shouldNotifyProactive(appInForeground, viewingTargetSession):语义只有一条——**这条消息此刻
会不会正好被用户看到**。四档组合都写进 KDoc(含「不在前台、聊天页却还挂着」那一档)。
- RecentProactiveNotices:进程级、容量 64、按插入序淘汰。记的是「**到达过**」而不是「弹过」,
因为「用户正看着」那一次也算这条已经到达(他看过了,另一条路再补一帧时不该为一句看过的话弹)。
- ProactiveNotifier:两条路共用一处;顺序是 归属 → 有没有字 → 记账/查重 → 判时机。
- proactiveNotifyKey:落库行 id > IM 消息号 > 时间字面量 > 正文(服务端一个键都不给时的兜底)。
实时帧的 data.id 与落库帧的 message.id 是**同一个值**,去重就靠它。
- proactiveNotificationText:复用权威解析器 domain.parse 剥标记(不再写第二份剥标签正则)、
折成一行、超 120 字截断;截断不劈开代理对(emoji)。
3. 前台判定不引新依赖:MainActivity 的 onStart/onStop 按**实例**登记到一个进程级集合
(配置变更时新实例的 onStart 先于旧实例的 onStop,布尔量在那里必然误判成「后台」);
聊天页那一半由 ChatScreen 的组合报告(DisposableEffect)。两者在 AppContainer 那层汇合。
4. 通知本体:自建渠道 `proactive`(DEFAULT,**要提醒人**,与 keep_alive 的 LOW 分开)、通知 id=2
(与保活那条 id=1 分开,同 id 是替换会把常驻通知顶掉)、标题「昔涟」+ 正文是她的原话、
setAutoCancel、PendingIntent.FLAG_IMMUTABLE(且 request code 与保活那条分开:两者的 Intent
filterEquals 相等而 PendingIntent 判据不含 extras,同 code 会复用先建的那个、丢掉这个 extra)。
通知权限现读(复用 core.keepalive.notificationsAllowed),没授权就不发。
5. 点通知 ⇒ 打开 App 并切回主会话:Intent extra → 进程级待落地意图(ProactiveTapIntake,通知可能
在 App 没起来 / 没登录时被点)→ ChatHomeScreen 落地成「关掉设置/管理/关于/抽屉 + select(web)」,
走的是用户点抽屉里那一行的同一条路,不引导航库。
为什么这样切:通知逻辑**绝不碰连接**(那条共享连接的所有权仍只在登录态那一处),只在帧**已经到达
之后**做判断;判断与去重全是纯函数/纯类,可单测;Android 落地只有一层薄薄的通知构造。
验证:W53N worktree(基线 f8ac95c)`assembleDebug testDebugUnitTest` 全绿 —— 109 suite / 1320
tests / 0 失败(基线 101 / 1270 / 0,新增 8 suite / 50 tests)。真机弹通知、点通知的落地未验(留给主人)。
|
2026-10-06 18:51:24 +08:00 |
|
AskaEth
|
659ab83f00
|
docs(05): 锚点三十补第 7 节 —— 新登记「分享确认面板」(主人 2026-10-06 提)
现状已核实:分享一进来就在 ChatScreen 的 ShareLanding.Send 支被立刻发出(ShareIntake.take() → sendShared),
用户没有任何确认机会。主人要的是像其他 IM 一样:能预览(图片/文件名与大小)、能附文字(预填分享自带的文本)、
由用户点发送才真发、可取消。已指派 W53X 且写明边界(不动 shareLanding/sendShared 语义、take 只许一次)。
|
2026-10-06 18:47:02 +08:00 |
|
AskaEth
|
ed0824f335
|
docs(05): 锚点三十补第 6 节 —— INTERNET 显式声明的验收证据 + 构建锁协议写死三条
锁协议:① 一行一个占用者;② 只删自己那行,绝不整文件删除(这批真踩到);③ 改/删前先读一遍。
|
2026-10-06 18:43:14 +08:00 |
|
AskaEth
|
f3d8d28cf7
|
fix(manifest): 显式声明 INTERNET(不再搭在 okhttp-android 的传递清单上)
原来 release 能联网是靠 com.squareup.okhttp3:okhttp-android:5.5.0 的清单把 INTERNET 合并进来的;
换掉/排除那个依赖,release 会静默失去网络权限(debug 侧 AGP 也注入同一份,所以本地怎么试都看不出来)。
现在显式写进源码。验收::app:processReleaseMainManifest 的 merger 报告里该权限已是
ADDED from app/src/main/AndroidManifest.xml(并 MERGED from okhttp-android)⇒ 来源是源码。
|
2026-10-06 18:43:13 +08:00 |
|
AskaEth
|
f8ac95ceed
|
docs(05): W52 待办变化 —— S4/S5/S9 移出待办,并标明仍未验/未做的三项
- S4 → W52A(真机未验,只有单测+走查);S5/S9 → W52B(真机通过);W52C/W52DE 三项真机通过。
- 仍在待办:保活→前台服务/通知/13+ 权限流 未验;INTERNET 显式声明未做;构建锁协议未写死;
主人待拍板的保活第 3 件(收到消息弹系统通知 + 接上 proactive 判据)。
- 另记:登出条目观感已实机确认(红字「退出登录」+ 箭头离开方框图标,一眼是登出)。
|
2026-10-06 16:15:02 +08:00 |
|
AskaEth
|
46855af2dd
|
docs(05): W52/E 复验补记 —— 「重试」不出现的根因是判据极性接反(已修,真机端到端通过)
根因:判据参数语义是「有没有东西」,调用点传了 isEmpty() ⇒ 唯一会求值的场景里结论恒假。
抓到方式:一次性诊断日志(三个输入全真而结论为假 ⇒ 只剩极性)。修在 0054a3a。
教训:判据单测全绿 ≠ 接线正确——极性/语义类错只有调用点会露,而调用点在 Compose、无 UI 测试装置 ⇒
只能靠真机验收。这是设备验收抓住单测抓不到的错的第二例。
|
2026-10-06 16:14:44 +08:00 |
|
AskaEth
|
0054a3a97a
|
fix(chat): 首屏失败的「重试」按钮永远不出现 —— 判据极性接反(W52/E 真机验收暴露)
改了什么:
- ChatScreen 里 shouldOfferRetryOnEmpty 的第三个实参从 `state.items.isEmpty()` 改成
`state.items.isNotEmpty()`。
为什么:判据是 `!loading && hasError && !hasItems`,参数语义是「列表里**有没有**东西」;调用点传的是
`isEmpty()`(极性相反),于是「列表为空」这个**唯一会求值**的场景里结论永远是 false ⇒ 按钮永远不出现。
判据单测用的是正确语义(不含调用点),所以全绿——这条只有真机才会露出来。
怎么定位的:先加一条一次性诊断日志把 UI 实际看到的三个输入打出来(`loading=false hasError=true
items=0 retry=false` —— 三个输入全真而结论为假 ⇒ 只可能是极性),核对判据本体后确认;定位完日志已删。
真机复验:断网冷启动 ⇒ 「消息没能加载出来」+「重试」;恢复网络后点它 ⇒ 发出
`GET /api/v1/sessions/web/messages` 且离线期间落库的那条出现在屏幕上;按钮随列表非空消失。
|
2026-10-06 16:14:27 +08:00 |
|
AskaEth
|
126a706422
|
docs(05): 锚点补记三十 —— W52 五项落地(已合并推送/101 suite 1270 tests 真跑绿)+ 真机验收结果
- 五项各自的机制与判据要点(S4 一格一份 ViewModelStore 与四条豁免、S5/S9 纯账本与既有 interrupt 出口、
登出条目、降级气泡屏蔽服务端动作、保活开关与 Start/Stop/Leave 判据)。
- 真机验收:设置页两行 / 抽屉「正在思考…」/「停止」发既有 interrupt 帧 / 降级气泡无删改入口 **通过**;
**首屏失败「重试」未通过**(断网冷启动下按钮没出现,根因未定位,写了下一步的诊断办法)。
- 保活开关→服务、S4 回收 未验(时间不够),如实标注。
- 三条教训:① 判据第一步要防「别的 App 抢前台」(gms 账号页把我们的 dump 全吃了);
② 更正 INTERNET 那条:release 的 INTERNET 来自 okhttp-android 传递依赖,建议显式声明;
③ 构建锁协议有洞(有人整文件删除导致别人的行没了)。
|
2026-10-06 16:10:37 +08:00 |
|
AskaEth
|
579eccdfd1
|
merge: W52K —— 「后台保持连接」开关 + 前台服务(默认关;开了才有常驻通知)
|
2026-10-06 16:02:42 +08:00 |
|
AskaEth
|
0243fb78d4
|
feat(设置): 新增「后台保持连接」开关与前台服务(默认关)
现状是一件保活都没做:Android 12+ 会把 cached 进程冻结,进程级共享的那条 WS 连接
(AppContainer.chatSocket)久了就断。这一批只解决「进程别被冻」这一件事,连接本身的生死
仍然归 SessionsViewModel(登录连、登出关),本服务不碰它、也不订阅任何帧。
- core/keepalive/KeepAliveService:dataSync 型前台服务,职责只有让进程活着。常驻一条 LOW
级静默通知(不响不震、setOnlyAlertOnce、点一下回 App),渠道只在服务真的起来时才建。
- core/keepalive/KeepAlivePolicy:起停判据抽成纯函数。shouldKeepAliveRun 三个条件缺一不可
(开关开 / 已登录 / 通知权限在);keepAliveAction 再补上「开关开着但登录态未定(自举中)
一律不动」——不补的话热启动会看到通知闪一下,而中间那段进程正好没人保护。判据刻意**不**查
「现在跑着没有」:那份进程内记忆会被「系统收掉服务却不走 onDestroy」写坏,于是该起的时候
一声不响地没起(保活最不能出的错);重复 start/stop 本身是无害的。
- CyreneApp:全 App 唯一一处按判据起停,开关或登录态一变就重判一次 ⇒「启动时开着且已登录就
在跑」「登出即停」「关开关即停」三件事不需要各自一条路径。
- 设置页新增一行(默认关),文案如实写清代价(常驻一条通知 + 多耗一点电)与当下真实状态;
Android 13+ 申请 POST_NOTIFICATIONS,被拒则开关弹回关闭并说明原因,绝不假装开着。
实现落在新文件 KeepAliveRow.kt:SettingsScreen.kt 只加一行调用(放在「关于」之前,不碰
最底下的登出块),避开 W52C 正在编辑那一段。
- KeepAliveService.onTimeout:targetSdk 37 下 dataSync 前台服务 24 小时内累计跑满 6 小时会被
系统回调,不自己停就 ANR 崩掉整个 App —— 照办 stopSelf,不绕(绕它要么换类型要么加心跳,
都超出本功能边界)。
- Manifest:FOREGROUND_SERVICE / FOREGROUND_SERVICE_DATA_SYNC / POST_NOTIFICATIONS + service
(exported=false、foregroundServiceType=dataSync)。
测试:新增 3 个文件 13 条 —— 默认关、三条件缺一不可、四入参全表一致、文案四档各不
相同且「跑不了时一个字都不提已开」、渠道重要级只许 LOW/MIN。基线 93 suite/1205 tests 全部保持
通过,本树 96 suite/1218 tests/0 失败。
|
2026-10-06 15:59:28 +08:00 |
|
AskaEth
|
d6b0c7cb20
|
merge: W52A —— 常驻 VM 保留最近 3 格 + LRU 回收(S4):一格会话一份 ViewModelStore
冲突仅一处(deleteSession 的 onSuccess 同一锚点):W52B 的 forgetBusy 与 W52A 的 reclaim 是同一件事
的两半,两侧都留 —— 一个让还活着的 VM 别再发注定 404 的补拉,一个把那一格的内存交出去。
|
2026-10-06 15:58:06 +08:00 |
|
AskaEth
|
37289391f8
|
merge: W52B —— 抽屉能看出哪个会话正在跑(S9)+ 行内「停止」入口(S5)
|
2026-10-06 15:56:23 +08:00 |
|
AskaEth
|
bbc47dbd31
|
feat(chat): 常驻 ChatViewModel 加 LRU 回收(S4)—— 一格一份 ViewModelStore,最近 3 格之外整格交出去
S4 的病根:每个访问过的会话各留一个常驻 VM(都订阅那条共享连接 ⇒ 每次重连各自发一轮
after 补拉)、各留一份消息列表(含内联 base64 图片),而删会话也不回收。此前**没有按会话
交出一格的手段**:所有 VM 塞在 Activity 级那一个 ViewModelStore 里,而它只有整体 clear(),
W48 只能做到「让 key 换一代、旧实例继续留着」—— 旧实例照样活着、照样收帧。
这次把「哪几格留着」这件事单独拎出来:
- 新增 `core/session/ChatSessionSlots.kt`(进程级,挂在 AppContainer 上,与 GoneSessions
同一层):一格一份 ViewModelStore。`ChatScreen` 改成
`viewModel(viewModelStoreOwner = <槽位>, key = chatViewModelKey(...))`,所以「交出去一格」
就是那一份 store 自己的 `clear()` ⇒ 那一格的 `onCleared()` + `viewModelScope` 取消
(订阅、补拉、在飞的请求一起停)。共享连接的生死一个字节都没碰:主人仍是
SessionsViewModel,`ChatViewModel.onCleared` 照旧**不** close(现在它会真的被调到,
所以这句注释也一并写成事实)。
- 新增判据 `core/session/chatSessionEvictions`(**纯函数**,VM 与测试共用):
「最近」的口径 = **最后一次成为当前会话的时刻**(由 ChatScreen 在组合到某个会话时写入);
候选要同时满足「不是主会话 ∧ 不是屏幕上那一格 ∧ 没有没交代完的事」,按最旧的先走,
保留最近 `keep` = 3 格(主人口径,含被豁免的那些占的名额——不然「最多 3 格」会变成
3 + 豁免数)。两条豁免名额之外的豁免各自都有硬理由:屏幕那一格死了会「不再更新、连
发送都静默不发」,还有没交代完的事那一格(草稿 / 排队等连接的发送)死了内容**回不来**。
- 删会话时那一格当场交出去(`SessionsViewModel.deleteSession`,与 W51 的
`goneSessions.mark` 相邻:一个是「别发注定 404 的补拉」,一个是「这一格本来就不该留」);
登出 / 换账号时 `clear()` 一格不留(与快照 / 已读游标 / GoneSessions 同档纪律)。
- 「还有没交代完的事」收的是**现读的探针**而不是打开那一刻的快照:回收发生在别的会话被
打开的那一刻,而那正是用户可能刚在前一格打了草稿、还没切走的时候。
刻意不动的红线:W48 的 `chatViewModelKey` 拼法一个字没改(新槽位的 key 里同样带账号指纹,
两道闸门各管一段);W49 的节流判据、W50 的 ProactiveIngest 判据、W51 的 reconnectPull
判据都没碰,也没有把「连接恢复」做成整表重拉。
测试:新增 `ChatSessionEvictionTest`(14 例,穷举判据的每一条边界:N=3、最旧先走、主会话 /
屏幕上那一格 / 有草稿的豁免、豁免占名额、并列退回账本顺序、结论是带账号指纹的槽位 key)
与 `ChatSessionSlotsTest`(9 例,钉住「没被收的一格还是原来那一格(切回来状态还在)」与
「被收的一格下次拿到的是新的一格」、空槽位会被下一次回收扫掉、探针现读的效果)。
同批把已经说错的注释改成事实:GoneSessions / AppContainer.chatSocket / ChatHomeScreen /
ChatScreen 里那几处「VM 活在 Activity 级 ViewModelStore 里、切走 / 删会话都不回收」。
|
2026-10-06 15:56:13 +08:00 |
|
AskaEth
|
84f343ce5f
|
feat(sessions): 抽屉里看得见「哪个会话正在生成」,并给一个能停的入口(S5 + S9)
S9 登记的是「这个会话正在生成」既看不见、也没有可查的状态面:`session_busy` /
`session_idle` / `turn_begin` 都带 sessionId 且在广播,但 `SessionsViewModel.onEvent`
一个都不认。S5 要的「能看见 + 能停」正是同一份状态的下游。
看得见(S9)
- 新增 `ui/sessions/SessionBusy.kt`:把下行帧折成「本端已知在跑的会话」账本,判据是纯函数
`applyBusyFrame`(可单测,先例 `silentRefreshStep`)。点亮只认 `session_busy` 与
`turn_begin` 这两条活帧——握手不回放该状态,所以没收到过帧的会话一定不显示(宁可没有);
收尾认 `session_idle`(无条件)、`turn_end`(只收本端认得的那一路,收完一路不剩才灭灯)、
`session_interrupted`(`activeTurns > 0` 时**不**灭——`scope:"current"` 只停一路,无条件灭
就是 v2 前端审计记成「会错」的 A7 那条)。
- `SessionsViewModel` 用一路订阅把账本接进来,并在**离开 Connected 时当场作废**:掉线期间
服务端那边跑完了什么本端收不到,留着就是一盏永远不灭、按钮永远点不动的灯。
- 抽屉那一行:忙碌时摘要行下面多一行「正在思考…」(与聊天页 `busyLabel` 逐字相同)。
另起一行而不顶掉摘要,也不从 `endActions` / 标题列里分走任何一个 dp。
能停(S5)
- 新的上行动作 `SessionsViewModel.stopGenerating(sessionId)`,走**既有的** interrupt 出口
(`WsOutbound.Interrupt` 本来就带 sessionId,既有的帧表达得了「停某一个会话」,没有新造帧)。
恒发 `scope:"current"`:本端从不发 `all` 是一条既有不变式,而 `all` 会静默清空待处理队列
(那份队列是用户自己敲进去的),本端也没有「丢弃 N 条」的告知面。
- 抽屉行里「停止」与「置顶」共用**同一个 endActions 槽位**:抽屉固定 300dp,`BasicComponent`
把内部可用宽度的 60% 当 `endActions` 硬上限,现有的三个 40dp 动作已经贴着边,
再并一个会把图标挤到裁剪。这一轮结束(灯灭)置顶立刻回来。
纪律
- `onEvent` 里只**前置**一次折帧,`when` 的分支一个字节没动 ⇒ W49 的节流静默重拉与
[persistedRoute] 的两档语义不变;无关帧(流式 delta)原样返回同一张表,不重推状态。
- 新增 18 条单测钉住上述每一条(含「turn_id 认不得就不灭灯」「并行两路都要收」「stream_end
不灭灯」「停止不发 all」)。
|
2026-10-06 15:55:04 +08:00 |
|
AskaEth
|
aace581fb0
|
merge: W52C —— 退出登录从小图标按钮改成带文字的危险行条目,图标换 Remove
|
2026-10-06 15:50:31 +08:00 |
|
AskaEth
|
27116fa434
|
merge: W52DE —— 降级气泡不再给删改入口(proactive-local: 前缀屏蔽服务端动作);空态补一枚显式「重试」
|
2026-10-06 15:50:30 +08:00 |
|
AskaEth
|
85818105ce
|
feat(settings): 退出登录从小图标按钮改成带文字的危险行条目,图标换 Remove(W52C)
设置页最底部原来是一枚居中、无文字的图标按钮,图标用的还是「返回」箭头(MiuixIcons.Back),
真机上看着就是返回键——主人为此翻半天才找到登出在哪。这是本次改动的由来。
- 改成 Miuix 行条目(BasicComponent + 外层 PreferenceCard,与同页其它条目同形),文字「退出登录」,
标题与图标都用 MiuixTheme.colorScheme.error 危险色。
- 图标换成 MiuixIcons.Remove:Miuix 里没有专门的登出/退出字形(Basic 只有 7 个图标),
extended 里只有它画的是「箭头离开方框」,语义最接近「退出」;Back 是本条要修的毛病,
Import 方向相反(箭头进方框=导入/登录),Delete/Refresh 之类会被误读成删号或重来。
- 图标 contentDescription 置 null:文字已经把这条读出来了,图标再报一遍只会让读屏重复。
行为一字不变:仍然点了就登出(onLogout 一路接到 CyreneApp 里的 authManager.logout()),
没有新增确认流程。
|
2026-10-06 15:48:54 +08:00 |
|
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
|
d0b8c5f2f7
|
docs(05): 写死纪律——不准用 WaitFor 等子代理(会把回合锁住,主人无法插话)
为什么:主人当场打断我的 WaitFor 并明确「纪律是不准用 WaitFor」。这条在后端仓库 AGENTS.md §10 里原本
就有,本项目文档此前没写、我因此重犯了一次。正确做法:派完就结束回合,等待期间只做不冲突的并行工作
或停手。
|
2026-10-06 15:46:40 +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 |
|
AskaEth
|
5a9f52a069
|
feat(chat): 图片入口改多选,N 张图按既有切分落成 N 条消息
主人 2026-10-05 定案「那肯定要多选」:图片按钮从 PickVisualMedia(单选、回一个 Uri)换成
PickMultipleVisualMedia(系统 Photo Picker 的多选形态,仍然零权限,不退回落 SAF / 存储权限)。
文件按钮那条 OpenMultipleDocuments 一个字没动。
为什么复用分享路径那一段(不另起一套发送逻辑):
分享落地(ChatViewModel.sendShared)早就把「一条消息只能带一张图 ⇒ N 张图 = N 条消息」做完了,
那段切分本来内联在它自己的 forEach 里,不是可复用的纯函数。这次把判据抽成纯函数 imageSendPlan
(ui/chat/ImageSendPlan.kt,跟 SharePayload.kt 与 SendQueue.kt 一样的写法,可单测),投递抽成
emitPlannedMessages(私有),输入栏那条路与分享落地那条路共用同一段:两处各写一份循环,迟早会在
「哪条带正文 / 哪条带附件 / 哪条清草稿 / 超过上限怎么办」上分叉。每张图各自的解码 / 缩放 / 重编码
与「内联还是上传」的取舍仍旧逐张走既有实现(deliver 与 deliverImage),一行没改。
为什么 N 张 = N 条(而不是一条带多张):
契约里 chat.image 是单图字段,一次上行只能带一张,所以多选进来的 N 张只能落成 N 条消息:
第一条带正文 + 第一张图 + 全部附件,其余每条一张图,顺序 = 用户在多选器里选的顺序。契约与服务端
一个字没动。
上限与告知沿用分享路径的口径:
上限仍是 ShareRules.MAX_IMAGES(4)。超限那几张不静默丢,如实告知;那句文案抽成
ShareRules.truncatedImagesNotice,分享入口与选图入口共用同一句(逐字未改,ShareNoticeTest 钉住)。
系统选择器自己会把可选张数卡在 4 上,只有回落成 ACTION_OPEN_DOCUMENT 的设备(它表达不了最多几张)
以及「草稿里已有几张、再选几张」这两种情形会真超,那时由入口与投递两处说出同一句。
其余口径与今天一致:
输入栏最多攒 4 张(到顶后不再显示图片入口),每张各自可移除;取消选择什么都不做;
发送时第一条带正文并清草稿(N 条是一次发送,草稿只清一次),失败的那一条只把自己那一张还给输入栏。
一次发送切出的多条里只有第一条套用「她忙时发消息」偏好——第 2 条起声明打断就是打断自己刚起的
上一轮(与分享路径不声明同一条理由),N = 1 时与今天逐字节相同。
顺带:
- ChatUiState.draftImage(String?)改为 draftImages(List<String>),rollbackPending 把失败那张补回
待发列表(不再覆盖),输入栏渲染成每张各自带移除键的一排预览;
- QueueFullOutcomeTest 里断言单图的四处改成新事实(断言一条没删);
- 新增 ImageSendPlanTest,覆盖 N = 0 / 1 / 2 / 4 / 5(超限),并钉住附件只跟第一条、顺序不重排、
超限那句与分享路径同一句。
|
2026-10-05 14:41:21 +08:00 |
|
AskaEth
|
97992bdb6b
|
docs(05): 锚点补记十三——W41 三级阶梯(未连接先探 health,通就回落 REST 发出去、不通才进队列),含 Offline 为何不探测、gapCursor 那个被补掉的洞、以及图/附件走不到这条路的边界
|
2026-10-05 14:35:14 +08:00 |
|