fix(chat): 设置项「她正忙时按发送」不再空转 —— 发送前按偏好决定是否先打断

设置页那个开关此前没有任何发送路径读它: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)也随之失效,三处一并补记更正。
This commit is contained in:
AskaEth committed 2026-10-04 22:29:57 +08:00
1 parent b8f0833137
commit cfdc094c30
8 files changed
+224 -7

No files matched your search

@@ -26,8 +26,9 @@ enum class InterruptMode(val wireScope: String?) {
/** /**
* 追加队列(默认):忙时只把消息排进队列,不打断在跑回合。 * 追加队列(默认):忙时只把消息排进队列,不打断在跑回合。
* *
* 该模式**不发** `interrupt`;一旦因别的触发点要发,就用省略 `scope` 的旧形状 * 该模式**从不产生 `interrupt` 帧**(判据 `interruptScopeForSend` 的结论恒为 `null`):
* (服务端 = `all`:取消在跑回合 + 清空待处理队列)——这是与旧版逐字节兼容的那一档。 * 默认档的线上请求与本偏好存在之前**逐字节相同**。它的 `wireScope` 是 `null`,正是
* 「整键省略」那一形状——它在这里表达的是**没有要发的东西**,不是「发一条省略 scope 的帧」。
*/ */
AppendQueue(null), AppendQueue(null),
@@ -162,8 +162,10 @@ class ChatSocket(
* 与加入 `scope` 之前**逐字节相同**);`"current"` = 只取消在跑回合、保留待处理队列。 * 与加入 `scope` 之前**逐字节相同**);`"current"` = 只取消在跑回合、保留待处理队列。
* 其它取值原样发出,服务端**定向**回 `error{bad_request}` 且不打断任何会话(本层不预判取值域)。 * 其它取值原样发出,服务端**定向**回 `error{bad_request}` 且不打断任何会话(本层不预判取值域)。
* *
* **今天没有任何调用方**:聊天的「打断」触发点属聊天区那一轮,本 API 先把传输面备好—— * **调用方只有一处**:`ChatViewModel.interruptRunningTurn()`——它在一条消息真的要走上去**之前**
* 调用方从 App 偏好(`InterruptMode`)取 `scope` 后传进来即可。 * 按 App 偏好(`InterruptMode`)决定要不要先打断,且**只在会话正忙时**发(判据是纯函数
* `interruptScopeForSend`)。默认档「追加队列」恒不发,所以本方法对线上流量零影响。
* 返回 false 只有一种原因(WS 未连接):调用方**静默**并不阻断本次发送,理由见那里的 KDoc。
*/ */
fun sendInterrupt(sessionId: String, scope: String? = null): Boolean = fun sendInterrupt(sessionId: String, scope: String? = null): Boolean =
send(WsOutbound.Interrupt(sessionId = sessionId, scope = scope)) send(WsOutbound.Interrupt(sessionId = sessionId, scope = scope))
@@ -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
@@ -9,11 +9,14 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import top.yeij.cyrene.app.AppContainer import top.yeij.cyrene.app.AppContainer
import top.yeij.cyrene.core.error.CyreneError 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.CyreneException
import top.yeij.cyrene.data.api.dto.ChatRequest import top.yeij.cyrene.data.api.dto.ChatRequest
import top.yeij.cyrene.data.api.dto.DocumentRefDto 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.RetryKeyMemory
import top.yeij.cyrene.domain.chat.SendChannel import top.yeij.cyrene.domain.chat.SendChannel
import top.yeij.cyrene.domain.chat.editableMessageOf 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.readBounded
import top.yeij.cyrene.domain.chat.toDocumentFailure import top.yeij.cyrene.domain.chat.toDocumentFailure
import top.yeij.cyrene.domain.parse.ParsedBubble 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())) { when (ChatSendRouting.channelFor(text, hasImage = image != null, hasDocuments = payload.isNotEmpty())) {
SendChannel.Ws -> { SendChannel.Ws -> {
val sent = socket?.sendChat(sessionId, text, DEVICE_ID, localId) ?: false 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`、**保留扩展名**)。 * 逐份把待发附件换成服务端存储名(`POST /api/v1/documents`,部件名 `file`、**保留扩展名**)。
* *
@@ -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),
)
}
}
+1 -1
View File
@@ -518,7 +518,7 @@
| 编号 | 事项 | 状态 | 优先级 | | 编号 | 事项 | 状态 | 优先级 |
|---|---|---|---| |---|---|---|---|
| A1 | 流帧缺 `sessionId` | ✅ **已闭环**(后端 v0.36 起带 `sessionId`+`turnId`;客户端 2026-10-04 已将帧壳字段透传进 `WsEvent` 并按会话过滤)→ D14 退役 | — | | 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 字段) | 中 | | A3 | `mode` 不落库 | ✅ 已完成(后端 `b686d5f`;前端待接入 mode 字段) | 中 |
| A4 | `title` 恒空 | ✅ 已闭环(后端自动命名 + `PATCH /sessions/{id}`;**客户端已接入** `c09a211`:列表改用服务端标题,本地「会话 N」序号机器整块退役) | 低 | | A4 | `title` 恒空 | ✅ 已闭环(后端自动命名 + `PATCH /sessions/{id}`;**客户端已接入** `c09a211`:列表改用服务端标题,本地「会话 N」序号机器整块退役) | 低 |
| A5 | 无 resume cursor | ✅ 已完成(`after` 正向增量;WS `since` 持久化后置) | 中 | | A5 | 无 resume cursor | ✅ 已完成(`after` 正向增量;WS `since` 持久化后置) | 中 |
+5 -2
View File
@@ -111,10 +111,13 @@
- **连接状态机**:`Idle → Connecting → Connected → Reconnecting(退避 1/2/4…30s + 抖动) → Offline(需登录)`。握手 401 不再重试,直接登出。 - **连接状态机**:`Idle → Connecting → Connected → Reconnecting(退避 1/2/4…30s + 抖动) → Offline(需登录)`。握手 401 不再重试,直接登出。
- **重连补全**:主会话用新连接的 `history` + REST 补差(`before = 本地最后一条 createdAt`),次级会话走 REST,全程按 `id` 去重。⚠️ `02/A5` 已提供 `after` 正向增量端点,**客户端尚未接入**。 - **重连补全**:主会话用新连接的 `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,满则丢)。 - **流式段落状态机**:`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` 是两个命名空间。 - **忙碌/队列 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」
这个前提**不再成立**,上面那条「限制一次只发一条」**不必做**:队列里的消息一条都不会丢。
--- ---
+7
View File
@@ -20,6 +20,13 @@
**追加队列**(默认档,**根本不发 `interrupt`**)/ **打断**(`scope:"current"`:只取消在跑回合、**保留队列**)。 **追加队列**(默认档,**根本不发 `interrupt`**)/ **打断**(`scope:"current"`:只取消在跑回合、**保留队列**)。
默认档**逐字节等于旧帧**,有测试钉住。**设置项渲染已实机验证**。 默认档**逐字节等于旧帧**,有测试钉住。**设置项渲染已实机验证**。
⚠️ 契约里 `all` 是「**清空 pending**」,所以「追加队列」**不能**映射到 `all`(那样自相矛盾)。 ⚠️ 契约里 `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**。 - **B1 `/upload`**(`cc2f266`):图片分流——**重编码后 >64 KiB 走上传(历史只留 URL),否则内联 base64**。
阈值理由是「内联的成本是**发多少次**而非发一次」:服务端把 `image` 原样落库 ⇒ 每页历史/每次 WS 回放 阈值理由是「内联的成本是**发多少次**而非发一次」:服务端把 `image` 原样落库 ⇒ 每页历史/每次 WS 回放
都重传。✅ **已实机端到端验证**(2026-10-04 深夜):209 KB 图经 Photo Picker 发出 → logcat 都重传。✅ **已实机端到端验证**(2026-10-04 深夜):209 KB 图经 Photo Picker 发出 → logcat