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
|
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
|
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
|
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
|
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
|
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
|
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
|
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
|
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
|
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
|
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
|
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
|
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
|
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
|
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
|
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
|
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
|
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
|
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
|
9f77fb469c
|
feat(chat): W41 未连接时先探一次健康端点,通就回落 REST 发出去,不通才进队列
主人第二次定案:「未连接状态你就不能拿 httpAPI 尝试吗?」→ 明确为「可以走 health 端点
验证连通性」。W40 的「未连接就进队列」保留为最后一级,队列、气泡小标与那套柔和告知原样复用。
## 为什么先探测再决定,而不是直接排队
网关只有一条:POST /api/v1/chat 与 /ws 是同一个端口上的两条车道。握手被挡(代理不转发
Upgrade、中间设备掐长连接)时 REST 往往是通的,直接排队等于把「WS 这条车道暂时不通」当成
「后端不可用」,让用户白等一次重连——而那一刻这条消息本来就能发出去。
## 三级阶梯(判据是纯函数 sendRouteFor,见 ui/chat/SendFallback.kt)
1. WS 就绪 → 照旧走 WS。成功路径的字节与状态机一个字没变:sendThroughLadder 的第一件事
仍然是 sendChat,前面不加任何判断、不挂任何起点。
2. 未就绪 → 探一次 GET /api/v1/health(2.5 秒整通上限,只看 2xx;失败 / 超时 / 抛异常一律
按不通处理,fail-closed)。通 → 用既有的 POST /api/v1/chat 把这条发出去(短文本也走
REST);不通 → 进队列。
3. 回落 REST 失败:网络类与超时 → 进队列;服务端给过裁决的(明确拒绝、401)与配置类错误
(证书 / 明文被禁 / 地址格式 / 解析不出来)仍旧回滚加文案,不装成「还没连上」。
4. 补发走同一个函数(fromQueue=true):阶梯只有一份实现,两条路不会分叉成两种行为。
Offline 那一档直接进队列、连探测都不做:它的语义是握手 401 且续期也失败(会话已知是死的),
而健康端点不鉴权(Cyrene_v2/internal/services/gateway/routes.go:51)必然回 200 —— 探了只会
发出一次注定 401 的 REST,把 W40 那句「已排队不会丢」换成一次回滚加一句误导人的鉴权错误。
## 为什么重发是安全的
回落与补发用的都是那条乐观气泡的 localId,也就是上行幂等键 clientMsgId,一个字节都不新铸。
服务端按 (session_id, client_msg_id) 唯一索引仲裁:重复到达只回 chat_duplicate /
duplicate:true,不再落库、不再起回合(契约 v0.56 §3.13)。于是「超时了但服务端其实已经
收下」这种最难判的情况不需要本端猜。
## 回复怎么回来
那一刻没有 WS 连接,内容帧没人收,靠重连时的正向增量补回来(gapCursor → catchUp →
loadAfter → absorb)。本 VM 从没连上过时 gapCursor 是 null、重连那一步什么都不会拉,所以
回落发出去且服务端已受理之后补记一次游标(armGapCursorForFallback,只在它还是 null 时补)。
## 其他
- 探测结果不缓存,每条消息最多探一次(只有 WS 没把帧交出去才问);WS 就绪那条路上一次探测
都不发。
- 补发从同步循环改成单飞协程:阶梯里要探测、要发 REST,会挂起,「两个触发点都在主线程上
所以不可能交错」这条旧论证不成立 ⇒ 改用 drainingQueue 单飞闸加串行出队,出队仍在有结论
之后。
- 离线时的上界:探测不通就直接进队列、一次 REST 都不会发,用户最多等探测那一段(约 2.5 秒);
回落那一次 REST 另有 10 秒上限(stream:true 的回执本该毫秒级回来)。
- 新增测试 SendFallbackTest(17 例):优先级、fail-closed、Offline 不探测、回落失败分档、
探测超时落在 2 到 3 秒、有图或有附件的消息压根不进这层阶梯。
- devtools/fake-backend 补上 GET /api/v1/health,否则本机跑假后端时这条阶梯会退化成直接排队。
|
2026-10-05 14:33:34 +08:00 |
|
AskaEth
|
df9fc85f76
|
feat(chat): 普通发送未连接时进队列(不再回滚),连接恢复后按发送顺序自动补发
主人 2026-10-05 裁决:普通发送在「未连接」时进队列。
此前 deliver 拿到 sent=false 就 rollback:气泡摘掉、正文退回输入栏,外加一句「还没连上,
这条没发出去,请稍后手动重试」。内容是没丢,但把「网络还没好」说成了一次要用户自己负责的
失败,而且他还得对着输入栏再发一遍。这一批按 W38「分享落地等连接」那套语义重做普通发送:
不报错、内容不丢、连接恢复后自动补发。W38 的落地时机、PendingShareHolder.take() 的原子语义、
ChatSocket 的连接状态机一个字都没动。
① 三档处置:未就绪就排队(新纯函数 wsSendOutcome,ui/chat/SendQueue.kt)
判据的输入只有两个:sendChat 的回值,以及这一刻 socket 自己的连接状态。sent=true ⇒ Dispatched
(帧已经交给 socket,成功路径一个字没动:不打标记、不说告知、不写任何状态);sent=false 且
连接就绪 ⇒ Failed(连接是好的,那 false 只可能来自 socket 对象已空 / 已被 cancel / 序列化抛
异常,「发送失败」在这一刻是事实 ⇒ 走今天那条 rollback + SEND_FAILED_MESSAGE);sent=false 且
未就绪 ⇒ Queued(ChatSocket.send 第一行就按状态挡掉,帧连序列化都没做、服务端一个字节都收不到,
所以既不回滚也不进 error 横幅)。未就绪四档(Idle / Connecting / Reconnecting / Offline)
一视同仁:主人的原话是「未连接就进队列」,Offline 只是不会自己变好,内容仍然一个字节都不会丢,
两档的差别只在告知措辞(这一点与 W39 给分享落地定的规矩一致)。
② 队列与补发:顺序与「只发一次」的保证点
deliver 的 WS 分支只改了「未就绪」这一支:queueForConnection 把这条留在待发态
(ChatUiState.queuedForConnection,条目只有 localId 与投递那一刻定下的并行声明;正文 / 图片 /
附件仍从那条气泡上现取,同一件事不留第二份真相源,也就不存在两条真相分叉)。触发点只有一个:
socket 状态变成 Connected 那一刻(onConnectionChanged,由状态流驱动、没有任何轮询),外加
入队之后的一次竞态兜底(连接恰好在投递与入队之间就绪时,那一次补发会扑空)。补发从队头开始,
FIFO 顺序 = 用户发送的先后顺序。
「只发一次」的保证点是「队头 + 出队恰好一次」:队头只在这一步有了结论(发出去了 / 真失败 /
气泡已经不在了)之后才被 dropQueuedSend 摘掉,所以重复的触发拿到的是同一个键、不会多出第二次
出队;flushQueuedSends 是同步函数(一个挂起点都没有),两个触发点在主线程上不可能交错。
第二道保证在服务端:补发复用同一个 clientMsgId(绝不铸第二个),真重复到达也只会被回
chat_duplicate,不会落成第二条消息。补发又失败时按今天的失败路径处理(回滚 + 文案),
绝不静默吞掉。已知边界(写在 KDoc 里):打断声明不随补发重放——补发时在跑的那一轮早就不是
用户按发送时看到的那一个了,隔半分钟再去打断可能毁掉他没见过的一轮。
③ 状态表达:仍是六态,③ 里多一句更准的话
不新增第七态:等连接的这条走六态里的 ③ 排队中(phaseOf 的判据从「收到过入队回执」扩为
「收到过回执,或本端因未连接把它留住了」),两类排队的区别由气泡小标说清——新纯函数
pendingBubbleFooter 给 ③ 加四个字:「排队中 · 还没连上」。为什么挂在气泡上而不只是那句告知:
告知八秒就收走,而这是个会持续几十秒的状态,牌子撤掉之后光看「排队中」分不出是「她已经收下、
前面还排着队」还是「连都没连上、一个字节都没上路」。柔和告知走新通道 ChatUiState.sendQueueNotice
(与 shareNotice 分开:两条描述的是不同的动作,可以同时挂着),文案 SEND_QUEUED_NOTICE 是
「还没连上,这条排队等着,连上后自动发出」;Offline 那一档换 SEND_QUEUED_OFFLINE_NOTICE
「现在处于离线,需要重新登录或检查服务器地址;这条还没发出去,已排队不会丢」——不提「连上」
「自动发」,那一档没有会自动到来的连接。不刷屏:一轮「队列从空到空」只说一次(连续几条离线
消息不会每按一次发送就弹一遍),记账在 queueForConnection。
④ 未覆盖:REST 通道(长文 / 图片 / 附件)保持今天的行为
POST /api/v1/chat 那条路仍是失败回滚 + 既有网络错误文案,本批不动。两条理由:一是它给不出
「未连接」这个信号(拿到的只是一次网络错误,而请求可能已被服务端收到、也可能没有),
判据不可靠;二是要补发就得重跑图片重编码与附件上传换存储名,而本地 URI 的读授权可能已经失效。
代价:未连接时发长文 / 图片 / 附件仍要用户手动重发一次;影响面仅这一条通道,WS 那条(短文本,
日常绝大多数消息)已经覆盖。
⑤ 未连接时不再有的那句话
「还没连上,这条没发出去,请稍后手动重试」(W39 加的 SEND_NOT_CONNECTED_MESSAGE)与它的判据
sendFailureMessage 一并删除:回滚那条路现在只在连接就绪时才走,这句话已经没有出口,留着就是
一段描述旧行为的死文案。SendFailureMessageTest 的断言改成新事实(未就绪 ⇒ 排队,而不是回滚
加那句话)并连同新的队列判据并入 SendQueueTest,一条断言都没删。另把「空输入什么都不发生」
这条边界抽成纯函数 hasSendableContent,输入栏的 send 与投递口的 emitAndDeliver 共用同一条判据
(行为不变,只是原本写在两处的同一个 if 现在可单测,也不会再分叉)。
测试:新增 SendQueueTest(21 条:三档判据穷举 WsState.entries、两档告知措辞、队列顺序与出队
恰好一次、气泡小标与六态、空输入边界)。assembleDebug 成功;testDebugUnitTest 1118 条
(1101 减 4 加 21),failures 0 / errors 0。
|
2026-10-05 13:57:54 +08:00 |
|
AskaEth
|
a763592ace
|
fix(chat): 分享告知不再两句叠加;未连接与发送失败分开说;Offline 不再承诺「连上后自动发出」
三处「对用户不够诚实」的措辞缺陷,各自独立修复。行为上只多了一处「少说一句」,
其余语义一字未改。
① 分享告知两句叠加(ChatScreen.shareNoticeFor)
实机读到的原文是「有 2 个文件读不到,未发送;分享的内容是空的,没有可发送的内容」,
读起来像两个不同的问题。可这两句说的是不同的免责,而「认下了地址却读不到」本身就蕴含
「没有可发送的内容」——叠上去一个字都没多说。故丢件计数为真时不再追加「内容是空的」那句;
其余组合保持今天的行为(只有丢件计数时只说丢件计数;「读不进来」与丢件计数说的是两件事,
照样并列)。KDoc 里记下了这一档今天的真实出口:worthDelivering 要求 isEmpty 的分享必须
有丢件计数或 contentUnreadable 才值得往下走,而后者已被「读不进来」那一档接走,
所以那句加了前提之后一次都不会再出现。
② deliver 的 WS 分支:连接未就绪不等于发送失败(ChatViewModel)
sendChat 回 false 有三种成因(连接状态不是 Connected / socket 对象已空或已被 cancel /
序列化抛了异常),此前共用一句「发送失败,请重试」,用户无从分辨「这条根本没上路」
与「发出去了但失败了」,而后者在连接未就绪时不是事实。现在按失败那一刻的连接状态分开说:
不是 Connected 时说「还没连上,这条没发出去,请稍后手动重试」,仍是 Connected 时逐字保持
「发送失败,请重试」。回滚照旧(摘掉气泡、内容退回输入栏、记下幂等键),没有自动重发,
普通发送的其它语义没动。判据抽成纯函数 sendFailureMessage(WsState),用例覆盖
WsState.entries 的每一个取值。连接状态取 socket 自己的 StateFlow——那正是 sendChat 判的值,
不用界面镜像过来的 ChatUiState.connection,免得晚一帧读到 Connected 反而说错话。
③ WsState.Offline 不再等一句永不到来的「连上」(ShareLanding / ChatScreen)
Offline 是终态(握手 401 且续不上、或缺少 token / 服务器地址),它不会自动重连。
对它说「连上后自动发出」,用户等的是一个永远不会到来的连接——拿一句不兑现的承诺糊住
一个需要他动手的状况,比不说更糟。新增 ShareLanding.TellOffline:等够久、同一轮只说一次
这些时机与可恢复的那几档完全一致,只有措辞不同:「现在处于离线,需要重新登录或检查
服务器地址;分享的内容一个字节都没发出去,不会丢」。W38 的落地时机一个字没改:
仍然不 take()、不发送,连接恢复后照旧自动补发。哪档配哪句由纯函数 shareWaitingNotice 定,
不再散在 Compose 的 when 里,于是「Offline 换口径」这件事不需要组合界面就能测。
测试:ShareNoticeTest 把「两句都在」的断言改成新事实并穷举同族组合;ShareLandingTest 新增
Offline 的差异、时机与文案用例;新增 SendFailureMessageTest 覆盖每一种 WsState。
assembleDebug 成功;testDebugUnitTest 1101 条,failures 0 / errors 0。
|
2026-10-05 13:41:52 +08:00 |
|
AskaEth
|
aeb37458e8
|
fix(chat): 分享落地必须等到连接就绪,超时不再等于发送失败
为什么超时不能等于发送失败
WS 还没连上时投递的分享(ACTION_SEND)此前走成了「发送失败,请重试」,内容还从待发队列里
永久消失。来处是「等连接」那句 withTimeoutOrNull 的返回值被丢掉了:超时与成功走同一条下游,
照样 take() 再 sendShared → deliver → ChatSocket.sendChat 回 false ⇒ ChatViewModel 落到
rollback(localId, "发送失败,请重试")。实机取证(2026-10-05 11:56):GET /ws/chat status=200
前后的窗口里投递分享,界面上是失败横幅 + 正文退回输入栏,而后端零请求(POST /api/v1/chat
一条都没有,也没有任何 error 帧)——那条横幅把「一次都没尝试发出去」说成了「发送失败」。
ShareIntake.take() 是原子出队且没有归还路径:那一次「其实一次都没发出去」的失败之后,内容
既不在 holder 里、也不在服务端,只剩回滚进输入栏的那一份(多图分享里除首条之外,连输入栏
都回不去)。重试资格因此一起丢了。所以判据只有一条:没连上就一个字节都别动——不 take()、
不发送、不产生任何 error 横幅。
为什么取走时机必须晚于连接就绪
短文本走 WS chat 帧,而分享冷启动必然撞上握手还没完成;帧发不发得出去完全取决于握手结果。
所以等待里不再有「到点就照发」这一档,只有「连上才发」:连接恢复之后由连接状态自己把这条
落地重新触发一遍。
改法
- 判据抽成纯函数 shareLanding(ui/chat/ShareLanding.kt,可单测):没有待发 ⇒ Nothing;已连接
⇒ Send(压过后两条,且返回的是单值动作,所以发送与告知不可能同时发生);未就绪且还没等够
⇒ KeepWaiting;未就绪、已等够、还没说过 ⇒ TellWaiting;已经说过 ⇒ KeepWaiting(不刷屏)。
- ChatScreen 的那个 effect 把连接状态并进键,连接后来就绪会自动重跑、自动补发;「不会重复
发送」仍然由 ShareIntake.take() 的原子性兜住——哪怕某一次拿着已经过期的快照进来,take()
给出的也只是队列里的下一条或 null。
- 等太久只做一件事:经既有的柔和通道 ChatViewModel.showShareNotice 说一句「还没连上,分享的
内容会留着,连上后自动发出」(不是带 X 的 error 横幅),同一次等待只说一次。
- SHARE_CONNECT_TIMEOUT_MILLIS 改名 SHARE_WAIT_NOTICE_MILLIS:它不再是发送的时限,只是
「等多久才值得说那一句」;「等够久」的计时只跟「队列里有没有东西」与「连上了没有」走,
连接在 Connecting / Reconnecting 之间抖动不重置它。
- 普通发送路径(输入框、图片、附件)一行未动。
测试
- ShareLandingTest(新,7 条):未就绪在任何连接状态下都恒不 Send(不取、不发);未就绪且没
等够恒 KeepWaiting(也不告知);等够了只说一次、说过了就不再刷屏;已连接恒 Send(压过告知);
无待发恒 Nothing(任何连接状态下都不做事);文案必须说清「还没连上」与「自动发出」,且不含
「失败」「重试」。
assembleDebug 与 testDebugUnitTest 全绿(1091 tests / 0 failures / 0 errors)。
|
2026-10-05 13:21:09 +08:00 |
|
AskaEth
|
6cdf111419
|
fix(share): 认下字符串形态的 EXTRA_STREAM,并在一条内容都没读进来时如实告知
为什么字符串形态必须认
真实世界里 EXTRA_STREAM 有三种形态:规范推荐的 Uri(多选时 ArrayList<Uri>)、String[]、
ArrayList<String>。Android 上确实有一部分发送方(历史遗留 / 跨平台壳应用)在用后两种,而本端
此前完全认不出:getParcelableExtra 遇到 String[] 只往 logcat 打一句 ClassCastException 再回
null,getParcelableArrayListExtra 同理。实测 am start ... --esa android.intent.extra.STREAM
uri1,uri2 投两张图进来就是这个样子。
为什么静默是 bug
上面那次分享的结局是:后端零请求、库里零新增、界面连一句软告知都没有。用户点了分享,没有任何
东西告诉他"没读进来"还是"在路上"——这是最糟的那种失败形态,所以本次一并消灭:带着内容迹象却
一条可用内容都没提取出来时,走既有的柔和通道说一句"这次分享的内容没能读进来,没有发送任何内容"。
EXTRA_TEXT 与 EXTRA_STREAM 都为空(用户确实没分享任何东西)时仍然保持静默,那条边界有测试钉住。
做了什么
- ShareIntake 改成读 EXTRA_STREAM 的原始值再按形态分派(Bundle.get 不做类型检查),Uri / Uri
数组 / String / CharSequence / 数组 / 列表一并摊平;顺带不再往 logcat 丢那两行 ClassCastException。
- 字符串形态只认 content://:那是本端新开的一条读入口,送进来的只是一段文本,没有任何框架级担保。
file:// 等于让发送方替本端指定一个本地路径、由本端拿自己的权限去读它;http(s):// 是网络地址,
收下它等于让第三方的一行文本指定一个 URL 由本应用代取。被拒的条目逐条跳过,不让整次分享失败。
- 优先顺序与去重不变:EXTRA_STREAM 的规范形态 -> 字符串兼容形态 -> ClipData(仅前者全为空时兜底),
trim / 去空 / 去重,先来的先算;重复项既不算截断,也不把真图挤到上限之外。
- 「带着内容迹象」的判据取 EXTRA_STREAM 的原始值(由调用方读了原始值判出来,含形态本端认不出的那种),
不是「认下来的地址」——否则「有值但一条都认不出」恰恰会被判成「什么都没分享」而继续静默。
- SharedContent 新增 contentUnreadable 与 worthDelivering:后者是唯一判据,ShareIntake 用它决定
要不要往下接,shareNoticeFor 用它守卫"什么都没有就一个字都不说"那条边界,两处不会走岔。
- 分类(图片 vs 文档)、落 cache、截断与拷贝失败的既有语义一概不动。
测试
- ShareExtractionTest:字符串形态的顺序与逐条跳过、两种形态并存去重、scheme 白名单表、
读不进来 / 形态认不出 / 静默三条边界。
- ShareStreamExtrasTest(新):按真实对象验形态分派(String[] / ArrayList<String> / String /
CharSequence / 认不出的形态不猜但照样算带着内容),并接到提取层验一次端到端。
- ShareNoticeTest(新):"没能读进来"成句且不说成发送失败、正常落地一个字不说、空白分享仍然静默。
assembleDebug 与 testDebugUnitTest 全绿(1084 tests / 0 failures / 0 errors)。
|
2026-10-05 13:05:20 +08:00 |
|
AskaEth
|
39a63f8d30
|
fix(chat): 并行路角标改以 turn_begin.kind 为准,不再拿 leg 冒充并行
leg 是服务端**会话内的路序号**:beginLegLocked 对每一条新路都自增 legSeq,
普通串行回合照样占号——会话里发第二条普通消息,leg 就是 2。而角标旧判据是
leg >= 2,于是那条串行消息被挂上「并行路 2」,凭空声称存在并不存在的并行。
「是不是并行路」真正的判据是 turn_begin.kind(turn = 普通串行回合,
parallel = 忙时另起的一路);解析层本来就把它读出来了,只是绑定时丢掉了。
- TurnBinding 新增 parallel;withTurnBegin 收下 kind,由 isParallelLegKind
判定:只有逐字等于 parallel 才算,turn、缺席、空串、大小写不同一律不算。
- 合并规则:新建按 kind 定;同 turnId 重复到达时取或(old.parallel || 本帧
是并行),一旦为 true 就永远是 true。与 closed 同一条纪律——单调的既成事实
不许被后到的帧翻回去;也顾及 steer_ack 可能先建一条没有 kind 的占位表、
随后才由这一路自己的 turn_begin 补上并行的现实顺序。
- 降级:kind 不来的老服务端上 parallel 恒为 false,角标一枚都不挂——宁可少挂
一枚,也不拿 leg 去猜、去多说一句假话。
- legBadgeOf 改成同时看 parallel 与 leg,三处调用点(提问侧、回复侧、流式气泡)
都跟着传 parallel;文案仍是「并行路 N」。
测试:ParallelTurnsTest 补 kind=turn / kind=parallel / kind 缺席三档、重复帧的
合并(含「已收口的并行路不被重新打开也不被降级」);TurnStreamsTest 把
「leg>=2 就挂标」的断言改成以 kind 为准,并补上那条真机回归——会话里第二条
普通消息(leg=2、kind=turn)在提问侧与回复侧都不挂角标。
|
2026-10-05 11:59:21 +08:00 |
|
AskaEth
|
6e00697110
|
feat(chat): 分享进来的消息加一枚「用户分享」渲染层角标
背景:ACTION_SEND 分享进来的内容在会话里与手打的完全看不出区别,主人要求「可识别」。
与后端定过的红线(devdocs/02 的 F4、ShareRules 的「为什么正文一个字都不加」)要求人类可见
标记只能放在客户端渲染层,所以本次正文、content 与任何上行字段一个字节都没动。
改了什么:
- ChatViewModel 的 emitAndDeliver 多一个 localIdPrefix 参数(默认 local-,手打那条路逐字不变),
sendShared 的每一条投递都传 share-:多张图会发多条消息,它们都是分享进来的,所以每条都带。
前缀只加在幂等键前面,后面仍是随机 UUID,唯一性不受影响。
- MessageSource.kt 新增两个常量与两个纯函数:isSharedClientMsgId 只认 share- 且要求后面还有尾巴
(恰好 share- 一律 false:本端铸键永远是 share-uuid,没有尾巴的键不是本端铸的,不能替一条
来路不明的消息声明「这是你分享的」);isSharedMessage 在它之上加一道只认 user 侧的闸。
- MessageBubble 的 bubbleFooter 多一个 shared 参数,角标 [用户分享] 紧挨来源角标拼在角标行里,
沿用这行既有的字级与颜色,不新造样式、不用 Material3,也只对用户侧气泡出现。
- 新增 SharedMessageBadgeTest:判据的六类边界取值与角标行里的落位。
为什么用幂等键而不是正文标记:clientMsgId 由服务端原样回填,是客户端读得回来的既有字段,
「这条怎么进来的」写在它上面不用新增任何协议,也不污染用户正文。
|
2026-10-05 11:30:56 +08:00 |
|
AskaEth
|
f2fbdff9a7
|
W33:并行落回排队的告知加一道本端档位闸 + 忙时被拒那条横幅的只读查证
一、改了什么
加一道闸:pending_ack{reason:"parallel_limit"} 那句降级告知(「她正忙,这条没能另起
一路…」),只在本端当前的「她忙时发消息」档位确实处于「并行」档(InterruptMode.Parallel)
时才给。判据扩了既有纯函数 parallelLimitNoticeFor 的签名(reason + mode 一起判),
本端不在并行档时一律返回 null;调用点在 onEvent 的 PendingAck 分支,档位沿用
currentInterruptMode() 现读(偏好是用户在设置页当场切的东西,缓存住就留一个静默不生效
的窗口),因为 onEvent 不是 suspend 函数,这一句起一个协程去读(与 ensureMessageKnown
同一形态),不起常驻订阅。
二、为什么
pending_ack 是广播帧,每条已认证连接都收得到。极端情况下「别的端在同一会话声明并行
又落回排队、而本端并未选并行」时,本端也会收到这一帧并说那句话 —— 这就是误报。
可那件事与本端无关:用户没选并行时,他这条消息本来就是普通排队、他预期的就是排队
⇒ 落回排队不是意外 ⇒ 不说并不构成静默(与「绝不静默」不冲突)。反过来,在那个时刻
说一句「这条没能另起一路」会让用户以为是自己刚发的那条出了岔子;误报会教用户不信任
这条提示,代价比少说一句大。
三、只读查证(主人点名:忙时被拒那条横幅到底会不会出现)
结论:不缺,pending_full 不带 clientMsgId 时共三档落位,其中两档都写 error,横幅照常
渲染 ——
· 恰好一条待发气泡 ⇒ 回滚档,error = QUEUE_FULL_MESSAGE(她正忙且队列已满,这条没
发出去,请稍后手动重试);
· 两条及以上 ⇒ 说不准档,error = QUEUE_FULL_UNKNOWN_MESSAGE(她忙不过来,这条可能
没发出去),两个气泡一个都不动;
· 两档都带 errorIsQueueFullNotice = true(柔和档配色),横幅由 ChatScreen 同一条
error 通道渲染。
只有「零条待发气泡」那一档是 Ignore,按设计什么都不写:没有气泡会挂住,也就没有要说
的,不能凭空弹一句 —— 理由在 queueFullOutcome 的 KDoc 与 QueueFullOutcomeTest 的两条
测试里(既有,非本次新增)。即「没带 clientMsgId」不等于「说不准档」,本次查证没有
发现需要改代码的地方。
四、测试
新增 3 条(ParallelLimitNoticeTest):追加队列档 + 该 reason ⇒ 不提示;打断档 + 该
reason ⇒ 不提示;再加一条把三档一起摆出来的汇总断言(只有并行档给文案)。「并行档 +
该 reason ⇒ 提示」由既有的那条测试覆盖(现在显式传 InterruptMode.Parallel)。总测试
1030 → 1033,0 失败 0 错误。
|
2026-10-05 10:12:41 +08:00 |
|
AskaEth
|
e85209eebb
|
fix(chat): 忙时被拒那条横幅调柔和(只它,真错误照旧红)+ 补 pending_ack 的并行降级告知
一、忙时被拒的横幅改用柔和档颜色(它不是错误,不该刺眼)
改了什么:那句告知(她正忙且队列已满,这条没发出去,请稍后手动重试)一直走 state.error 这一条通道,
ChatScreen 里那条 Banner 的调用点对所有 error 一律用 colorScheme.error(正红)。现在给 ChatUiState
加一位 errorIsQueueFullNotice(只决定配色),忙时被拒的两条入口(WS 广播 pending_full 与定向
error{chat_busy} 的队列满处置、REST 409 chat_busy 的回滚)把这一位写成 true,ChatScreen 那一处调用
据此在 onSurfaceVariantSummary(同文件里分享/附件/定位三条告知用的那档柔和色)与 error 之间选色。
为什么:主人裁决形态不变(摘乐观气泡 + 顶部横幅如实告知 + 正文/图片/附件还给输入栏 + 重发复用幂等键),
但红色不要太醒目——忙时被拒不是坏了,是真没排上,红得像故障会吓人。而它与真错误(登录失效、网络失败、
服务端 4xx、图片与附件处理失败……)共用 error 这同一条通道,直接改那个调用点的颜色会把真错误一并调柔和,
所以先分档再选色:写 error 的每一处都显式带着这一位,边界写进了 ChatUiState.errorIsQueueFullNotice
与 Banner 的 KDoc,免得后来人统一改色。
二、消费 pending_ack 的 reason,如实告知「并行没排上、已按普通排队走」
改了什么:reason 字段在解析层早就原样保留(WsEvent.kt 的 PendingAck.reason),但一直没有任何地方消费它
——收到它时界面与普通排队毫无区别。新增纯函数 parallelLimitNoticeFor(reason)(已知值给一句话,未知值 /
缺席 / 空白给 null,不为不认识的 reason 编文案)、常量 PARALLEL_LIMIT_REASON 与 PARALLEL_LIMIT_NOTICE,
以及状态位 ChatUiState.parallelLimitNotice(与 shareNotice / attachmentNotice 同一套机制:8 秒自动收走、
用柔和色、不给关闭按钮),在 WS 的 PendingAck 分支里接上。
为什么:设置页新增并行档之后,开关 ON 不等于一定起第二路——服务端 CHAT_MAX_PARALLEL_TURNS 小于 2 或
额度已满时会让它落回排队,并回 pending_ack{reason:"parallel_limit"}。用户选了并行却看不出区别就是静默,
违反项目铁律「绝不静默」;而这条不是错误、是降级,所以措辞把三件事说清(并行没排上、这条没丢、她做完
手上这句就轮到它),配色与上面那条忙时告知同一档。
测试:新增 ParallelLimitNoticeTest 8 条(reason 的三档穷举 + 横幅温度的边界:忙时被拒为柔和、真错误不
继承那份柔和)。assembleDebug 与 testDebugUnitTest 全绿:1030 个测试,0 失败 0 错误(基线 1022,新增 8)。
|
2026-10-05 09:55:53 +08:00 |
|
AskaEth
|
a776e9f608
|
feat(settings): 设置页「她忙时发消息」追加第三档「并行」——忙时不打断、另起一路(chat.parallel=true)
主人要求:在既有的「追加队列 / 打断」之下再追加一档「并行」。三档语义互补——追加队列进队列等
(默认档,逐字节不变)、打断先发 interrupt 再发这条、并行不打断、另起一路同时处理。
改了什么:
- AppSettings:InterruptMode 加第三个值 Parallel(wireScope = null, wireParallel = true),并给枚举
补出第二根线 wireParallel(wireScope 只管 interrupt 的 scope,表达不了「要声明并行」这件事)。
既有两个值的 name 与落盘键 interrupt_mode 一个字未动——改名会让已存偏好读不出来并静默回落默认档。
落盘字符串 → 档位的映射抽成顶层纯函数 toInterruptMode(AppSettings 本体要 Android + DataStore,
单测里起不来),新增的第三个名字能正确读回,未知字符串仍回落 AppendQueue(既有行为未变)。
- 判据抽成纯函数 parallelForSend(mode, busy) 与 parallelWireValue(declare):前者与
interruptScopeForSend 共用同一个忙判据(ChatUiState.busyMode != null),只在会话正忙时为真。
规范 §2.2 没写空闲时要不要也声明——空闲时这条本来就是它自己的回合,声明与不声明在服务端是同一
件事,故取与打断同一个判据(最小惊讶:空闲时三档逐字节完全相同)。
- 两条互斥由枚举取值本身保证,不是靠约定:并行档 wireScope 是 null ⇒ 绝不发 interrupt;另外两档
wireParallel 是 false ⇒ 绝不带 parallel 键。默认档「追加队列」线上零差异由此结构性成立。
- 发送路径接线:ChatViewModel.deliver 只读一次偏好、同时喂给两个判据(读两次会在用户切档的瞬间
出现「既打断又并行」);ChatSocket.sendChat 与 ChatRequest 各加一个 parallel 参数,false 一律
翻成整键省略——显式 false 会被序列化成 parallel:false,那是「显式否认」而不是「未声明」,
默认档就不再逐字节不变。
- 设置页:新选项标题「并行」,summary 说清后果——不打断她正在说的这一句、两路各算一遍会多花一份
算力与 token(主人裁决:峰值 token 近翻倍可接受,但用户有权知道)。UI 仍是 Miuix,与同栏另外
两条逐字同形,勾选标记位置相同。
- 分享路径同样不套用这一档偏好(W26 的 allowInterrupt 因此更名为 applySendPreference,它现在管
的是整档偏好):分享是批量投递,逐条索要并行会被服务端 2 路额度把同一次分享劈成两种待遇,且每
条各算一遍;用户也没按过发送键说这条要并行。单张图时同样不声明,规则才能一句话说清。
为什么:主人 2026-10-05 的裁决(真两路并行)需要客户端有一个显式声明入口;W30 已备好 chat.parallel
的线上形状(默认 null = 整键省略),本次只把设置页这一档与发送路径接通,上行仍是纯增量——没选
「并行」的用户线上一个字节都不变。
验收:assembleDebug 通过;testDebugUnitTest 通过(1022 测试 / 0 失败 / 0 错误;基线 1004,新增 18:
ParallelForSendTest 13 条 + InterruptScopeTest 1 条 + InterruptModeTest 4 条)。
|
2026-10-05 09:37:26 +08:00 |
|
AskaEth
|
29299b190e
|
feat(chat): 忙时并行回合的客户端半边 —— 六态 + 灰态 + (sessionId, turnId) 两级分桶 + 新帧消费(全部可降级)
依据:Cyrene_v2/DevDocs/_specs/忙时并行-规范草案.md(§1 行为 / §2 接口 / §4 主人裁决)。
后端 v0.65 尚未落地这些帧,所以本次的新键与新帧全部按可降级实现:帧不来、字段缺席时,
App 在今天的真后端上行为与改前逐字相同(下面的「降级保证」逐条给了证据)。
一、改了哪些文件
新增(纯逻辑,可单测)
- app/src/main/kotlin/top/yeij/cyrene/domain/chat/ParallelTurns.kt
两级分桶键 TurnBucket(sessionId, turnId) 与 turnBucketOf / closingBucketOf;
六态枚举 TurnPhase 与 turnPhaseOf;灰态收口判据 closesAnswering;
消息↔回合绑定 TurnBinding 及其纯函数(withTurnBegin / withSteeredMessage / closingTurn /
allClosed / bindingOfMessage);路小标文案 legBadgeOf。
- app/src/main/kotlin/top/yeij/cyrene/ui/chat/TurnStreams.kt
StreamingLeg(bucket, StreamingState) 与 withLeg / beginLeg / finishingLeg / withoutLeg;
六态在状态上的推导 ChatUiState.phaseOf(消息 / 乐观气泡);
路小标与「跳到提问」的查表 legBadgeForMessage / legBadgeForTurn / jumpIndexForTurn。
修改
- data/api/dto/MessageDto.kt:HistoryMessageDto 增可选 turnId(历史行 / 落库行都用它)。
- data/api/dto/ChatDto.kt:ChatRequest 增可选 parallel(REST 与 WS chat 同形)。
- data/ws/WsOutbound.kt:chat 增可选 parallel、interrupt 增可选 turnId,省略即整键不进线上。
- data/ws/WsEvent.kt:新增 turn_begin / turn.end / steer_ack 三个事件与其载荷;
session_busy / session_idle / pending_drain 增可选 activeTurns,session_interrupted 增
activeTurns + cancelledTurns,pending_ack 增可选 reason。
- ui/chat/ChatViewModel.kt:流式解析器与「本回合见过结构化正文帧」由单值升为按桶的 map/set,
新增「本会话最近见过的 turnId」锚点;消费 turn_begin / steer_ack / turn.end;
助手落库行按其 turnId 收口灰态并只摘掉它自己那一路的流式气泡;排队回执记进 queuedKeys。
- ui/chat/MessageBubble.kt:MessageBubble / PendingBubble 增 phase 与 legBadge,
正文气泡增灰态配色,新增并行路小标(可点即跳);角标文案抽成纯函数 bubbleFooter。
- ui/chat/ChatScreen.kt:按桶取每一路的流式状态并渲染;传入六态与小标;忙碌行加「并行 N 路」。
- 新增 4 个测试文件(共 82 条):ParallelTurnsTest、TurnStreamsTest、
WsEventTurnFramesTest、WsOutboundParallelTest。
二、为什么这么做
- 界面必须按 (sessionId, turnId) 两级分桶(规范 §1.7 第 1 件硬要求):两路并行时两个不同
turnId 的 message.delta / thinking.delta / tool_call 会交错到达,照老代码「一律累加到当前那
一个流式气泡」会把两条回复糊成一条(规范 §1.4 点名的那件事)。现在每一路各有一份解析器、
一份思维链、一份工具卡表、一个流式气泡。
- 灰态(④ 已读、未答)的唯一权威收口事件是 turn.end(规范 §1.2)。本端另外在 stream_end
(既有的「整轮结束」帧,v0.36 起带 turnId)与「同 turnId 的助手落库行」上也收口:
前者在 turn.end 到位之前是唯一能收口的帧,后者是规范给的兜底。三者顺序不定、重复到达都无害
(纯函数幂等)。
- 六态判定与收口判据的不变量都抽成纯函数,可直接单测;判定顺序即证据强度:
被拒 > 已绑定未收口 > 已绑定已收口 > 本地乐观气泡 > 排队回执。
三、字段缺失时的降级保证(本次最要紧的一条)
- 全链路没有一处「turnId 为空就不渲染」。turnBucketOf 在帧壳没给 turnId 时先回落本会话最近
见过的 turnId、再回落匿名桶(turnId 为空串)⇒ 所有无 turnId 的帧落在同一个桶里,
那就是老代码那唯一一个流式气泡。
- turn_begin / steer_ack.turnId / turn.end 不来 ⇒ 一条绑定都建不起来 ⇒ 六态里只可能出现
①②③⑤,灰态根本不会进入;界面与改前逐字相同。
- activeTurns 缺席时不动这个计数(null 是「不知道」,0 是「确实空闲」,两件事不许互相顶替);
pending_ack.reason 缺席就是 null,绝不伪造。
- 上行三个新字段(chat.parallel、interrupt.turnId、ChatRequest.parallel)本端今天一个字节都
不发:真并行要用户显式声明,而声明入口不在本次范围内。服务端照旧走排队 + 回合内 steer。
- 已被单测钉住的证据:
ParallelTurnsTest「所有无 turnId 的帧落进同一个桶」「两个空白 turnId 不算匹配,绝不收口」;
TurnStreamsTest「单路时长度恒为 1」「没有任何 turn 帧时已落库=②、本地气泡=①」;
WsEventTurnFramesTest「老形状的帧解出来新键一律为 null(不是 0、不是空串)」;
WsOutboundParallelTest「省略时那条帧逐字节相同,且不带该键」。
四、等 v0.65 的部分
- turn_begin / steer_ack.turnId / turn.end 的真实到达,历史行的可选 turnId,activeTurns /
pending_ack.reason / cancelledTurns 的真实取值。
- 真并行的触发入口(谁在什么时候把 chat.parallel 置 true)——本次只备好线上形状,不接 UI。
- 仍未决且影响客户端的两项:graph_update 两路互相顶掉(规范未决 10,等 design/19 裁决);
按 turnId 单独核算用量(规范未决 14,可选)。
五、规范里与本端既有定案冲突的一处(如实登记)
规范 §1.1 ⑥「被拒」要求气泡带红色「未送达 · 会话正忙」并可重试;本端被拒(pending_full /
定向 chat_busy)的既有定案是摘掉乐观气泡 + 横幅如实告知 + 正文还给输入栏(可再按一次发送,
幂等键保证不会落成第二条消息)。既有行为由现有测试钉住,本次不改;六态判定里保留了 Rejected
档并由单测直接覆盖,只是本端的现实路径走的是回滚那一支。
验证:assembleDebug 绿 + testDebugUnitTest 绿(0 失败 0 错误 0 跳过)。
|
2026-10-05 09:10:45 +08:00 |
|