From cfdc094c307ffe2bf4a338b08b1ee7486fcb8ae9 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 4 Oct 2026 22:29:57 +0800 Subject: [PATCH] =?UTF-8?q?fix(chat):=20=E8=AE=BE=E7=BD=AE=E9=A1=B9?= =?UTF-8?q?=E3=80=8C=E5=A5=B9=E6=AD=A3=E5=BF=99=E6=97=B6=E6=8C=89=E5=8F=91?= =?UTF-8?q?=E9=80=81=E3=80=8D=E4=B8=8D=E5=86=8D=E7=A9=BA=E8=BD=AC=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E5=8F=91=E9=80=81=E5=89=8D=E6=8C=89?= =?UTF-8?q?=E5=81=8F=E5=A5=BD=E5=86=B3=E5=AE=9A=E6=98=AF=E5=90=A6=E5=85=88?= =?UTF-8?q?=E6=89=93=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 设置页那个开关此前没有任何发送路径读它:interruptMode 全仓只出现在 AppSettings (定义 / 读写)与 SettingsScreen(渲染文案),于是把它切成「打断」后行为与「追加队列」 一模一样,ChatSocket.sendInterrupt 更是零调用方(死 API)。这正是一直挂在文档 devdocs/02 协同项 A2 上的欠账(后端 v0.56 已支持 scope,客户端没接)。 现在按契约 v0.56 §3.1 接上,判据抽成与发送路由同类位置的纯函数 interruptScopeForSend(mode, busy: Boolean):String? 1. 追加队列(默认档)⇒ 恒定返回 null,一个 interrupt 帧都不发,线上请求与今天逐字节相同; 2. 打断(wireScope = "current")且会话正忙 ⇒ 这条消息之前先发 interrupt{scope:"current"} (只取消在跑回合、保留待处理队列,队列里的消息按编排器续跑,一条都不丢); 3. 打断但会话空闲 ⇒ 不发(命中空闲会话是 no-op,白发一条帧只多一次往返,还会在 logcat 里制造「这里打断过」的假象)。 「正忙」只认服务端 session_busy 帧给的 busyMode != null,不用 queueDepth——队列深度 非零不等于有回合在跑(pending_ack / pending_drain 也会动它)。 接线点在 ChatViewModel.deliver() 的通道分支之前(app/src/main/kotlin/top/yeij/cyrene/ ui/chat/ChatViewModel.kt)。放在那里而不是 emitAndDeliver 开头是刻意的:上面每一段本端 拒绝(附件超限 / 图片处理失败 / 附件上传失败 / 引用不合法)都会「回滚、什么都不发」, 先打断她的回合再什么都不发是凭空的破坏。每次投递只经过那里一次,所以一次投递最多一条 打断帧,任何后续分支与重试都不会重复发。 偏好每次发送现读(不缓存、不订阅 Flow):「在设置页切成打断、然后立刻发的那一条」正是 本次要修的场景,缓存值要等 Flow 推到才更新,冷启动那一下必然没到——那就等于留了一个 静默不生效的窗口。读失败(IO / 损坏)回落默认档,也就是最少动作的那一档,与 AppSettings 把未知取值解成默认档是同一条纪律。 打断帧发不出去(只有「WS 未连接」一种原因)静默、不阻断本次发送:本次消息的成败与它 无关(REST 通道不看 WS 连接),且一直有既有的回执 / 回滚路径如实上报;反过来「打断失败」 没有对应得上的动作(重试等于把消息再发一遍,正是幂等键在防的事),喊一声只会误导。 于是打断没生效 = 行为退回默认档,是用户看得懂的状态,不是被藏起来的事。 测试:新增 InterruptScopeTest 8 条(两档 × 忙闲四格穷举 + 默认档零行为变化 + 偏好回落 方向),全仓 884 → 892。assembleDebug 与 testDebugUnitTest 全绿(0 失败 0 错误)。 设置页那两条文案原本就与 scope:current 的真实语义相符(「已排队的消息保留,继续依次 回答」),未改一个字。 文档:02 的 A2 状态表、05 的 A2 条目都写着「客户端尚未接入该字段」;03 里「打断模式 → UI 限制一次只发一条」的前提(interrupt 会清空 pending)也随之失效,三处一并补记更正。 --- .../top/yeij/cyrene/core/prefs/AppSettings.kt | 5 +- .../top/yeij/cyrene/data/ws/ChatSocket.kt | 6 +- .../yeij/cyrene/domain/chat/InterruptScope.kt | 28 +++++ .../top/yeij/cyrene/ui/chat/ChatViewModel.kt | 67 +++++++++++ .../cyrene/domain/chat/InterruptScopeTest.kt | 109 ++++++++++++++++++ devdocs/02-前后端协同事项.md | 2 +- devdocs/03-客户端细化开发方案.md | 7 +- devdocs/05-开发进度与交接.md | 7 ++ 8 files changed, 224 insertions(+), 7 deletions(-) create mode 100644 app/src/main/kotlin/top/yeij/cyrene/domain/chat/InterruptScope.kt create mode 100644 app/src/test/kotlin/top/yeij/cyrene/domain/chat/InterruptScopeTest.kt diff --git a/app/src/main/kotlin/top/yeij/cyrene/core/prefs/AppSettings.kt b/app/src/main/kotlin/top/yeij/cyrene/core/prefs/AppSettings.kt index 39a1dfd..929d0d7 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/core/prefs/AppSettings.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/core/prefs/AppSettings.kt @@ -26,8 +26,9 @@ enum class InterruptMode(val wireScope: String?) { /** * 追加队列(默认):忙时只把消息排进队列,不打断在跑回合。 * - * 该模式**不发** `interrupt`;一旦因别的触发点要发,就用省略 `scope` 的旧形状 - * (服务端 = `all`:取消在跑回合 + 清空待处理队列)——这是与旧版逐字节兼容的那一档。 + * 该模式**从不产生 `interrupt` 帧**(判据 `interruptScopeForSend` 的结论恒为 `null`): + * 默认档的线上请求与本偏好存在之前**逐字节相同**。它的 `wireScope` 是 `null`,正是 + * 「整键省略」那一形状——它在这里表达的是**没有要发的东西**,不是「发一条省略 scope 的帧」。 */ AppendQueue(null), diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/ws/ChatSocket.kt b/app/src/main/kotlin/top/yeij/cyrene/data/ws/ChatSocket.kt index 0087b81..d1df4b1 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/data/ws/ChatSocket.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/data/ws/ChatSocket.kt @@ -162,8 +162,10 @@ class ChatSocket( * 与加入 `scope` 之前**逐字节相同**);`"current"` = 只取消在跑回合、保留待处理队列。 * 其它取值原样发出,服务端**定向**回 `error{bad_request}` 且不打断任何会话(本层不预判取值域)。 * - * **今天没有任何调用方**:聊天的「打断」触发点属聊天区那一轮,本 API 先把传输面备好—— - * 调用方从 App 偏好(`InterruptMode`)取 `scope` 后传进来即可。 + * **调用方只有一处**:`ChatViewModel.interruptRunningTurn()`——它在一条消息真的要走上去**之前** + * 按 App 偏好(`InterruptMode`)决定要不要先打断,且**只在会话正忙时**发(判据是纯函数 + * `interruptScopeForSend`)。默认档「追加队列」恒不发,所以本方法对线上流量零影响。 + * 返回 false 只有一种原因(WS 未连接):调用方**静默**并不阻断本次发送,理由见那里的 KDoc。 */ fun sendInterrupt(sessionId: String, scope: String? = null): Boolean = send(WsOutbound.Interrupt(sessionId = sessionId, scope = scope)) diff --git a/app/src/main/kotlin/top/yeij/cyrene/domain/chat/InterruptScope.kt b/app/src/main/kotlin/top/yeij/cyrene/domain/chat/InterruptScope.kt new file mode 100644 index 0000000..ffd7315 --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/domain/chat/InterruptScope.kt @@ -0,0 +1,28 @@ +package top.yeij.cyrene.domain.chat + +import top.yeij.cyrene.core.prefs.InterruptMode + +/** + * 发这条消息**之前**要不要先发一条 `interrupt`:返回要发的作用域,`null` = 一个字节都不发 + * (纯函数,VM 与测试共用)。 + * + * ## 四条判据(协同 A2 的客户端半边,契约 v0.56 §3.1) + * + * 1. [InterruptMode.AppendQueue](**默认档**)⇒ 恒为 `null`,线上请求与接入本函数之前**逐字节相同**。 + * 这是最重要的一条:默认档不许有任何行为变化。[InterruptMode.AppendQueue] 的 `wireScope` + * 本来就是 `null`(见那里的 KDoc),所以这一档连「线上取值是什么」都不必判——它天生发不出东西。 + * 2. [InterruptMode.Interrupt](`wireScope = "current"`)**且会话正忙** ⇒ `"current"`: + * 只取消在跑回合、**保留待处理队列**(队列里的消息按编排器正常续跑,一条都不丢)。 + * 3. [InterruptMode.Interrupt] 但会话**空闲** ⇒ `null`:命中空闲会话在服务端是 no-op,白发一条帧 + * 只是多一次往返,还会在 logcat 里制造「这里打断过」的假象。 + * 4. 判据里**没有**「队列有多深」这一项,见下。 + * + * ## 「正忙」由谁判 + * 只认**服务端给的** `session_busy{mode}`(调用方传 `ChatUiState.busyMode != null`)。 + * **不用 `queueDepth`**:队列深度非零不等于有回合在跑(`pending_ack` / `pending_drain` 这类帧也会动它), + * 拿它当判据就会在空闲会话上乱发打断帧。 + * + * @param busy 这条消息要发出去的那一刻,该会话是否有回合在跑。 + */ +fun interruptScopeForSend(mode: InterruptMode, busy: Boolean): String? = + if (busy) mode.wireScope else null diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt index 8cac68e..122b215 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt @@ -9,11 +9,14 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import top.yeij.cyrene.app.AppContainer import top.yeij.cyrene.core.error.CyreneError +import top.yeij.cyrene.core.prefs.AppPrefs +import top.yeij.cyrene.core.prefs.InterruptMode import top.yeij.cyrene.data.api.CyreneException import top.yeij.cyrene.data.api.dto.ChatRequest import top.yeij.cyrene.data.api.dto.DocumentRefDto @@ -48,6 +51,7 @@ import top.yeij.cyrene.domain.chat.MessageFingerprint import top.yeij.cyrene.domain.chat.RetryKeyMemory import top.yeij.cyrene.domain.chat.SendChannel import top.yeij.cyrene.domain.chat.editableMessageOf +import top.yeij.cyrene.domain.chat.interruptScopeForSend import top.yeij.cyrene.domain.chat.readBounded import top.yeij.cyrene.domain.chat.toDocumentFailure import top.yeij.cyrene.domain.parse.ParsedBubble @@ -979,6 +983,13 @@ class ChatViewModel( } } + // 偏好「她正忙时按发送的行为」(协同 A2):**这条消息真的要走上去之前**,先把在跑回合打断。 + // 放在这里而不是 [emitAndDeliver] 开头,是刻意的:上面每一段本端拒绝(附件超限 / 图片处理 + // 失败 / 附件上传失败 / 附件引用不合法)都会「回滚、什么都不发」——先把她正在说的那一轮 + // 打断掉、再什么都不发,是凭空的破坏,而且用户看不到任何理由。 + // 每次投递只经过这里一次 ⇒ 打断帧**最多一条**,下面的通道分支与任何后续重试都不会再发。 + interruptRunningTurn() + when (ChatSendRouting.channelFor(text, hasImage = image != null, hasDocuments = payload.isNotEmpty())) { SendChannel.Ws -> { val sent = socket?.sendChat(sessionId, text, DEVICE_ID, localId) ?: false @@ -993,6 +1004,62 @@ class ChatViewModel( } } + /** + * 发这条消息之前,按偏好决定要不要先打断在跑回合(协同 A2 / 契约 v0.56 §3.1)。 + * + * 判据本身是纯函数 [interruptScopeForSend](默认档恒不发、忙且选了「打断」才发 `scope:"current"`, + * 命中空闲会话不发),这里只做两件事:**取当前偏好**、把帧发出去。它由 [deliver] 在**通道分支 + * 之前**恰好调用一次,所以一次投递最多一条打断帧。 + * + * ## 一条已知边界:调用顺序 ≠ 到达顺序(不在本端可控范围) + * 本端能保证的只有**调用顺序**(打断帧在这条消息之前发出去)。到达与服务端处理顺序取决于通道: + * WS 那条路两帧同连接,REST 那条路是「WS 发打断 + HTTP 发消息」两条道。极端时序下打断可能落在 + * **这条消息刚起的那一轮**上——`scope:"current"` 至少保证待处理队列不被清掉(队列里的消息按 + * 编排器续跑,一条不丢),但被取消的那一轮按契约不落库。要谈强序得服务端先给 `interrupt` 一个 + * 回执;本端**不为此发明等待时长**:凭空加延迟换不来任何保证,只会让每次发送都慢一截。 + * + * ## 帧发不出去时**静默**(不阻断本次发送、也不弹横幅) + * [ChatSocket.sendInterrupt] 返回 false 只有一种原因:WS 此刻没连着(`send` 在非 Connected 时直接 + * 返回 false,见 [ChatSocket])。此时刻意什么都不说,理由三条: + * 1. **本次消息的成败与它无关**:REST 那条通道与 WS 连不连着无关(长文 / 图片 / 附件照样发得出去), + * 而走 WS 那条自己会失败并走既有的回滚与提示——「发出去没有」这件事一直有唯一且如实的出口; + * 2. **没有什么动作与之对应**:用户看到「打断失败」能做的只有「再发一遍这条消息」,那等于把一条 + * 可能已经发出去的消息重发一次(正是幂等键在防的事),比不说更坏; + * 3. WS 断开本身另有可见信号(连接状态),不需要在一个尽力而为的动作上再喊一次。 + * + * 于是这一条的结果是:**打断没生效 = 行为退回默认档(追加队列)**——那是用户能看懂的状态 + * (消息排进队列、她先说完当前这一轮),不是一件被藏起来的事。 + * + * 偏好读不出来(DataStore IO 出错)同样**回落到默认档**(`AppPrefs` 的缺省值 = [InterruptMode.AppendQueue] + * = 不发任何帧):偏好读不到不该让一次发送失败,而回落方向必须是**最少动作**的那一档—— + * 与 AppSettings 把未知取值解成默认档是同一条纪律(「绝不悄悄换成更激进的档位」)。 + */ + private suspend fun interruptRunningTurn() { + val scope = interruptScopeForSend( + mode = currentInterruptMode(), + busy = _state.value.busyMode != null, + ) ?: return + socket?.sendInterrupt(sessionId, scope) + } + + /** + * 取当前的「她正忙时按发送的行为」档位(每次发送现读,不缓存、不订阅)。 + * + * 现读的理由:偏好是**用户在设置页里当场切**的东西,而上一个会话里读进字段的值要等 Flow 推到才更新 + * (冷启动那一下必然没到)——「切成打断后立刻发的那一条」正是这次要修的场景,缓存住就等于留一个 + * 静默不生效的窗口。DataStore 的 `data` 首次收集后走内存缓存,这里不会每发一条就碰一次磁盘。 + * + * 读失败(IO / 损坏)回落 `AppPrefs` 的缺省值:理由见 [interruptRunningTurn] 的「偏好读不出来」一段。 + * `CancellationException` 原样抛出(它是取消,不是「读失败」,不能吞成默认档)。 + */ + private suspend fun currentInterruptMode(): InterruptMode = try { + container.appSettings.data.first().interruptMode + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + AppPrefs().interruptMode + } + /** * 逐份把待发附件换成服务端存储名(`POST /api/v1/documents`,部件名 `file`、**保留扩展名**)。 * diff --git a/app/src/test/kotlin/top/yeij/cyrene/domain/chat/InterruptScopeTest.kt b/app/src/test/kotlin/top/yeij/cyrene/domain/chat/InterruptScopeTest.kt new file mode 100644 index 0000000..8e253e6 --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/domain/chat/InterruptScopeTest.kt @@ -0,0 +1,109 @@ +package top.yeij.cyrene.domain.chat + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Test +import top.yeij.cyrene.core.prefs.AppPrefs +import top.yeij.cyrene.core.prefs.InterruptMode + +/** + * A2 客户端半边:**发这条消息之前要不要先打断**(契约 v0.56 §3.1 的 `interrupt.scope`)。 + * + * 这个判据此前**根本不存在**——设置页那个开关没有任何发送路径读它,于是「打断」与「追加队列」 + * 行为完全一样,`ChatSocket.sendInterrupt` 一条帧都没发过。这里把它钉成纯函数,四条一次钉全: + * + * | 档位 | 会话正忙 | 该发什么 | + * |---|---|---| + * | 追加队列(默认) | 忙 | **什么都不发** | + * | 追加队列(默认) | 空闲 | **什么都不发** | + * | 打断 | 忙 | `interrupt{scope:"current"}` | + * | 打断 | 空闲 | **什么都不发** | + * + * 两条最容易写错的边界各占一列: + * - **默认档不许有任何行为变化**(第一行、第二行):线上请求必须与接入本判据之前逐字节相同; + * - **打断但空闲不发帧**(第四行):命中空闲会话在服务端是 no-op,白发一条帧只是多一次往返、 + * 还会在 logcat 里制造「这里打断过」的假象——所以「正忙」必须是**服务端说的** + * (`session_busy` ⇒ `ChatUiState.busyMode != null`),不是队列深度非零推出来的。 + */ +class InterruptScopeTest { + + @Test + fun `the default mode sends no interrupt frame even while she is busy`() { + // 最重要的一条:默认档是绝大多数用户手上的状态,它必须零行为变化。 + assertNull(interruptScopeForSend(InterruptMode.AppendQueue, busy = true)) + } + + @Test + fun `the default mode sends nothing while she is idle either`() { + assertNull(interruptScopeForSend(InterruptMode.AppendQueue, busy = false)) + } + + @Test + fun `interrupting while she is busy carries scope current verbatim`() { + // 逐字 "current",不是 "all":`all` 会连待处理队列一起清掉——那正是 A2 要修的那件事。 + assertEquals("current", interruptScopeForSend(InterruptMode.Interrupt, busy = true)) + } + + @Test + fun `an idle session gets no interrupt frame at all`() { + // 命中空闲会话是 no-op:这一帧唯一的效果是多一次往返与一条会骗人的日志。 + assertNull(interruptScopeForSend(InterruptMode.Interrupt, busy = false)) + } + + @Test + fun `the whole matrix of both modes against both busy states is pinned`() { + // 2 档 × 2 忙闲 = 4 格,一次穷举钉死;将来任何人动这个判据,这里先红。 + val matrix = InterruptMode.entries.flatMap { mode -> + listOf(true, false).map { busy -> + "${mode.name}/busy=$busy" to interruptScopeForSend(mode, busy) + } + } + + assertEquals( + listOf( + "AppendQueue/busy=true" to null, + "AppendQueue/busy=false" to null, + "Interrupt/busy=true" to "current", + "Interrupt/busy=false" to null, + ), + matrix, + ) + } + + @Test + fun `only the busy interrupt cell ever puts a frame on the wire`() { + val sending = InterruptMode.entries.filter { mode -> + listOf(true, false).any { busy -> interruptScopeForSend(mode, busy) != null } + } + + assertEquals(listOf(InterruptMode.Interrupt), sending) + } + + @Test + fun `the documented fallback for the send path is the default mode and it sends nothing`() { + // 发送路径取偏好读失败时回落 `AppPrefs()` 的缺省值(见 ChatViewModel.currentInterruptMode 的 KDoc)。 + // 这里钉住那个缺省值本身是**追加队列** = 不发任何帧 —— 读不到偏好时回落的方向必须是 + // 「最少动作」的那一档,绝不能悄悄变成更激进的「打断」。 + // (落盘字符串 → 枚举的映射另有 AppSettings.toInterruptMode 与 InterruptModeTest 钉着。) + val fallback = AppPrefs().interruptMode + + assertEquals(InterruptMode.AppendQueue, fallback) + assertNull(interruptScopeForSend(fallback, busy = true)) + assertNull(interruptScopeForSend(fallback, busy = false)) + } + + @Test + fun `the two modes only differ while she is busy`() { + // 空闲那一列两种档位必须**看起来一样**(都不发);差异只允许出现在「忙」这一列, + // 否则默认档就不是零行为变化了。 + assertEquals( + interruptScopeForSend(InterruptMode.AppendQueue, busy = false), + interruptScopeForSend(InterruptMode.Interrupt, busy = false), + ) + assertNotEquals( + interruptScopeForSend(InterruptMode.AppendQueue, busy = true), + interruptScopeForSend(InterruptMode.Interrupt, busy = true), + ) + } +} diff --git a/devdocs/02-前后端协同事项.md b/devdocs/02-前后端协同事项.md index 4a7fb6b..38b2b86 100644 --- a/devdocs/02-前后端协同事项.md +++ b/devdocs/02-前后端协同事项.md @@ -518,7 +518,7 @@ | 编号 | 事项 | 状态 | 优先级 | |---|---|---|---| | A1 | 流帧缺 `sessionId` | ✅ **已闭环**(后端 v0.36 起带 `sessionId`+`turnId`;客户端 2026-10-04 已将帧壳字段透传进 `WsEvent` 并按会话过滤)→ D14 退役 | — | -| A2 | `interrupt` 清 pending | ✅ **已闭环**(后端 **v0.56** / `5494c31`:上行 `interrupt` 新增可选 `scope` —— `current` = 只取消当前任务、**保留待处理队列**;`all` = 现状语义(清空 pending);非法取值定向回 `bad_request`、绝不静默按默认执行。**客户端尚未接入该字段**) | ~~中~~ 已闭环 | +| A2 | `interrupt` 清 pending | ✅ **已闭环**(后端 **v0.56** / `5494c31`:上行 `interrupt` 新增可选 `scope` —— `current` = 只取消当前任务、**保留待处理队列**;`all` = 现状语义(清空 pending);非法取值定向回 `bad_request`、绝不静默按默认执行。**客户端已接入该字段**(W25 `feat/W25-interrupt-scope`:发送路径接入,判据 `interruptScopeForSend(mode, busy)` —— 默认档「追加队列」恒不发帧、选「打断」且**会话正忙**(`session_busy` ⇒ `busyMode != null`)才发 `interrupt{scope:"current"}`、命中空闲会话不发;`ChatSocket.sendInterrupt` 由此获得唯一调用方) | ~~中~~ 已闭环 | | A3 | `mode` 不落库 | ✅ 已完成(后端 `b686d5f`;前端待接入 mode 字段) | 中 | | A4 | `title` 恒空 | ✅ 已闭环(后端自动命名 + `PATCH /sessions/{id}`;**客户端已接入** `c09a211`:列表改用服务端标题,本地「会话 N」序号机器整块退役) | 低 | | A5 | 无 resume cursor | ✅ 已完成(`after` 正向增量;WS `since` 持久化后置) | 中 | diff --git a/devdocs/03-客户端细化开发方案.md b/devdocs/03-客户端细化开发方案.md index ef8a25a..deed89d 100644 --- a/devdocs/03-客户端细化开发方案.md +++ b/devdocs/03-客户端细化开发方案.md @@ -111,10 +111,13 @@ - **连接状态机**:`Idle → Connecting → Connected → Reconnecting(退避 1/2/4…30s + 抖动) → Offline(需登录)`。握手 401 不再重试,直接登出。 - **重连补全**:主会话用新连接的 `history` + REST 补差(`before = 本地最后一条 createdAt`),次级会话走 REST,全程按 `id` 去重。⚠️ `02/A5` 已提供 `after` 正向增量端点,**客户端尚未接入**。 -- **事件路由**:结构化流帧(`message.*` / `thinking.*` / `tool_call.*` / `tool_execution.*`)**后端 v0.36 起已带 `sessionId` + `turnId`** → **必须先按 `sessionId` 过滤再分发**。⚠️ 历史上本端 `WsEvent.parse` 把这两个字段丢掉了,才产生「不带 `sessionId` 的帧归属当前 active turn」这一假设与 **D14 硬约束**;**D14 已退役**(2026-10-04:`WsEvent` 已透传帧壳上的 `sessionId`,`ChatViewModel.onEvent` 用 `isForSession` 过滤;**这条约束从未被任何代码强制过**,`ChatSocket.sendInterrupt` 至今无调用方)。另注意:广播是发给**每条连接**、**不按会话订阅**,且每个 `ChatScreen` 的 VM **各建一条 `ChatSocket`**——所以带着旧 socket 的旧 VM 也会收到新会话的帧,**过滤是必需的,不是优化**(已落地)。遗留问题:旧 VM 的 socket 仍**不会**随切会话关闭(每个 VM 一条,且 VM 存在 Activity 级 `ViewModelStore` 里,删会话也不回收)。 +- **事件路由**:结构化流帧(`message.*` / `thinking.*` / `tool_call.*` / `tool_execution.*`)**后端 v0.36 起已带 `sessionId` + `turnId`** → **必须先按 `sessionId` 过滤再分发**。⚠️ 历史上本端 `WsEvent.parse` 把这两个字段丢掉了,才产生「不带 `sessionId` 的帧归属当前 active turn」这一假设与 **D14 硬约束**;**D14 已退役**(2026-10-04:`WsEvent` 已透传帧壳上的 `sessionId`,`ChatViewModel.onEvent` 用 `isForSession` 过滤;**这条约束从未被任何代码强制过**,`ChatSocket.sendInterrupt` 在 W25 之前也一条帧都没发过——现已接上唯一调用方,见 `05` 的 A2 补记)。另注意:广播是发给**每条连接**、**不按会话订阅**,且每个 `ChatScreen` 的 VM **各建一条 `ChatSocket`**——所以带着旧 socket 的旧 VM 也会收到新会话的帧,**过滤是必需的,不是优化**(已落地)。遗留问题:旧 VM 的 socket 仍**不会**随切会话关闭(每个 VM 一条,且 VM 存在 Activity 级 `ViewModelStore` 里,删会话也不回收)。 - **流式段落状态机**:`index` 编号规则文档未定义 → 按首次出现顺序建段,`start` 建 / `delta` 追加 / `end` 收口;`stream_end` 才算整轮结束。要容忍丢帧(服务端发送缓冲 32,满则丢)。 - **忙碌/队列 UI 映射**:`session_busy{mode}` → 轻状态条;`pending_ack{queueDepth}` → 「已排队 N 条」;`pending_full{max}` → 队列满(默认 5);`session_interrupted{dropped}` → 「已打断,丢弃 N 条」。注意编排 `mode`(`idle/thinking/executing/subagent`)与消息渲染 `mode` 是两个命名空间。 -- **打断模式**:后端 `interrupt` 会清空 pending(`02/A2`)→ 该模式下 UI 限制一次只发一条并明确提示。 +- **打断模式**:~~后端 `interrupt` 会清空 pending(`02/A2`)→ 该模式下 UI 限制一次只发一条并明确提示。~~ + ⚠️ **补记(W25,2026-10-04)**:`02/A2` 已由后端 v0.56 修好——`scope:"current"` = 只取消在跑回合、 + **保留待处理队列**,客户端也已在发送路径按偏好发这个 `scope`(见 `05` 的 A2 补记)⇒ 「清空 pending」 + 这个前提**不再成立**,上面那条「限制一次只发一条」**不必做**:队列里的消息一条都不会丢。 --- diff --git a/devdocs/05-开发进度与交接.md b/devdocs/05-开发进度与交接.md index e3cfcb1..83b7252 100644 --- a/devdocs/05-开发进度与交接.md +++ b/devdocs/05-开发进度与交接.md @@ -20,6 +20,13 @@ **追加队列**(默认档,**根本不发 `interrupt`**)/ **打断**(`scope:"current"`:只取消在跑回合、**保留队列**)。 默认档**逐字节等于旧帧**,有测试钉住。**设置项渲染已实机验证**。 ⚠️ 契约里 `all` 是「**清空 pending**」,所以「追加队列」**不能**映射到 `all`(那样自相矛盾)。 + > ⚠️ **补记(W25,`feat/W25-interrupt-scope`)**:`b780695` 只做了「**设置项 + 帧形状**」—— + > **没有任何发送路径读这个偏好**,所以那之后「打断」与「追加队列」行为完全一样(开关空转), + > `ChatSocket.sendInterrupt` 也一条帧都没发过。现已接上:判据 `interruptScopeForSend(mode, busy)`, + > **默认档恒不发帧**(线上逐字节不变)、选「打断」且**会话正忙**才发 `scope:"current"`、 + > **命中空闲会话不发**(服务端 no-op,白发只多一次往返);「正忙」只认服务端 `session_busy` 的 + > `busyMode != null`,**不用** `queueDepth`(队列非零 ≠ 有回合在跑)。接线点在 + > `ChatViewModel.deliver()` 的通道分支之前,每次投递**最多一条**打断帧。 - **B1 `/upload`**(`cc2f266`):图片分流——**重编码后 >64 KiB 走上传(历史只留 URL),否则内联 base64**。 阈值理由是「内联的成本是**发多少次**而非发一次」:服务端把 `image` 原样落库 ⇒ 每页历史/每次 WS 回放 都重传。✅ **已实机端到端验证**(2026-10-04 深夜):209 KB 图经 Photo Picker 发出 → logcat