From 84f343ce5f4b8ed144bb7a55004ec2766b6f289b Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 6 Oct 2026 15:55:04 +0800 Subject: [PATCH] =?UTF-8?q?feat(sessions):=20=E6=8A=BD=E5=B1=89=E9=87=8C?= =?UTF-8?q?=E7=9C=8B=E5=BE=97=E8=A7=81=E3=80=8C=E5=93=AA=E4=B8=AA=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E6=AD=A3=E5=9C=A8=E7=94=9F=E6=88=90=E3=80=8D=EF=BC=8C?= =?UTF-8?q?=E5=B9=B6=E7=BB=99=E4=B8=80=E4=B8=AA=E8=83=BD=E5=81=9C=E7=9A=84?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=EF=BC=88S5=20+=20S9=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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」)。 --- .../top/yeij/cyrene/data/ws/ChatSocket.kt | 15 +- .../top/yeij/cyrene/ui/chat/ChatHomeScreen.kt | 1 + .../yeij/cyrene/ui/sessions/SessionBusy.kt | 88 ++++++++ .../ui/sessions/SessionDrawerContent.kt | 61 +++++- .../cyrene/ui/sessions/SessionsViewModel.kt | 102 +++++++++ .../cyrene/ui/sessions/SessionBusyTest.kt | 200 ++++++++++++++++++ 6 files changed, 452 insertions(+), 15 deletions(-) create mode 100644 app/src/main/kotlin/top/yeij/cyrene/ui/sessions/SessionBusy.kt create mode 100644 app/src/test/kotlin/top/yeij/cyrene/ui/sessions/SessionBusyTest.kt 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 5e544ea..15eef33 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 @@ -209,11 +209,16 @@ class ChatSocket( * 与加入 `scope` 之前**逐字节相同**);`"current"` = 只取消在跑回合、保留待处理队列。 * 其它取值原样发出,服务端**定向**回 `error{bad_request}` 且不打断任何会话(本层不预判取值域)。 * - * **调用方只有一处**:`ChatViewModel.interruptRunningTurn()`——它在一条消息真的要走上去**之前** - * 按 App 偏好(`InterruptMode`)决定要不要先打断,且**只在会话正忙时**发(判据是纯函数 - * `interruptScopeForSend`)。默认档「追加队列」与「并行」这一档都恒不发(后者的 `wireScope` - * 就是 `null`:并行与打断互斥),所以本方法只服务「打断」这一档。 - * 返回 false 只有一种原因(WS 未连接):调用方**静默**并不阻断本次发送,理由见那里的 KDoc。 + * **调用方有两处,两处都只发 `"current"`**(本端从不发 `all`,见 `devdocs/02` 的 A2 补记): + * 1. `ChatViewModel.interruptRunningTurn()`——它在一条消息真的要走上去**之前**按 App 偏好 + * (`InterruptMode`)决定要不要先打断,且**只在会话正忙时**发(判据是纯函数 + * `interruptScopeForSend`)。默认档「追加队列」与「并行」这一档都恒不发(后者的 `wireScope` + * 就是 `null`:并行与打断互斥),所以本方法只服务「打断」这一档。 + * 2. `SessionsViewModel.stopGenerating(sessionId)`——抽屉里那一行的「停止」(W52B):它是一个 + * **独立的动作**(不读偏好),停的是**指定会话**正在跑的那一轮。`WsOutbound.Interrupt` + * 本来就带 `sessionId`,所以「停某一个会话」这条既有出口表达得了,不需要新帧。 + * + * 返回 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/ui/chat/ChatHomeScreen.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatHomeScreen.kt index 56d807c..61a909e 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatHomeScreen.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatHomeScreen.kt @@ -137,6 +137,7 @@ fun ChatHomeScreen( onDelete = { sessionsViewModel.deleteSession(it.id) }, onTogglePin = { sessionsViewModel.setPinned(it.id, !it.pinned) }, onRename = { sessionId, title -> sessionsViewModel.renameSession(sessionId, title) }, + onStopGenerating = { sessionsViewModel.stopGenerating(it.id) }, onOpenIot = { /* P2:IoT 页面 */ }, onOpenAdmin = { drawerOpen = false diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/sessions/SessionBusy.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/sessions/SessionBusy.kt new file mode 100644 index 0000000..c57e645 --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/sessions/SessionBusy.kt @@ -0,0 +1,88 @@ +package top.yeij.cyrene.ui.sessions + +import top.yeij.cyrene.data.ws.WsEvent + +/** + * 「这个会话正在跑」的本端账本(**纯逻辑**,`SessionsViewModel` 与测试共用)。 + * + * 形状是 `Map<会话 id, 本端见过的、还没收到收尾帧的在途路 id>`。**空集合也是在跑**—— + * `session_busy` 帧本身通常不带 `turnId`(它走编排器的状态通道),只凭它也知道那个会话忙。 + * + * ## 只有活帧能点亮,而且绝不外推 + * 唯二的点亮来源是 [WsEvent.SessionBusy](服务端说它忙)与 [WsEvent.TurnBegin](服务端刚起了 + * 一路):两条都是**连接着的这一刻**推下来的帧。握手**不回放**该状态(S9 登记的那一格),所以 + * 「本端连上之后还没收到过任何一条这样的帧」= **不知道** ⇒ 抽屉里什么都不显示(宁可没有)。 + * 也不从别的线索外推——REST 列表不带这个状态,聊天页有没有在转也不带会话归属, + * 拿它们当证据就等于把「本端不知道」说成「它闲着」或「它忙着」。 + * + * 把 [WsEvent.TurnBegin] 也算证据,是因为它与 [WsEvent.SessionBusy] **同源**:服务端启动一路时 + * 两条一起发(一个是状态、一个是这一路的身份)。收到它就等于服务端在那一刻确认「这个会话在跑」, + * 不是本端的推断——而它比 `session_busy` 更频繁(每一路都有),所以 `session_busy` 那一帧如果 + * 因为重连错过,下一路起来时灯还能亮回来。 + * + * ## 收尾:三条帧,方向都是「宁可少显示」 + * - [WsEvent.SessionIdle](规范 §1.5:**全部**在途路收尾且 pending 空)⇒ 无条件收掉。 + * - [WsEvent.SessionInterrupted] ⇒ 只有 `activeTurns` 说没有别的路在跑时才收掉。`scope:"current"` + * 只取消在跑的那一路、别的路照跑,无条件收掉就会在并行时显示「已经停了」——那正是 v2 前端审计 + * 记成「会错」的一条(`Cyrene_v2/DevDocs/audit/前端同步缺口-2026-10-05.md` 的 A7)。 + * 键缺席(`null`,v0.65 之前的老服务端)⇒ 那一版还没有并行这回事,单路被打断就是真的闲了 ⇒ 收掉。 + * - [WsEvent.TurnEnd] ⇒ 只收它**认得**的那一路([WsEvent.TurnBegin] 记下来过),且收掉之后一路不剩 + * 才灭灯。帧壳 `turnId` 没见过(本端中途才接上、或那一帧的键缺席)就什么都不做:拿别的一路的 + * 收尾去灭灯,会把还在跑的那一路报成空闲。 + * + * `stream_end` **刻意不参与**:它只结束某一次模型流(一轮里有多个正文段、工具循环里还有多次), + * 而 `turn.end` 才结束整轮。按它灭灯就是上面 A7 那类错误的另一个入口。 + * + * 幂等:同一帧重复到达(重连补拉、服务端重复广播)不改变结论。 + */ +internal fun Map>.applyBusyFrame(event: WsEvent): Map> { + val sessionId = event.sessionId.sessionKey() ?: return this + return when (event) { + is WsEvent.SessionBusy -> + this + (sessionId to this[sessionId].plusTurn(event.turnId.sessionKey())) + + is WsEvent.TurnBegin -> + this + (sessionId to this[sessionId].plusTurn(event.turnId.sessionKey())) + + is WsEvent.TurnEnd -> { + val turnId = event.turnId.sessionKey() + val tracked = this[sessionId].orEmpty() + if (turnId == null || turnId !in tracked) { + this + } else { + val left = tracked - turnId + if (left.isEmpty()) this - sessionId else this + (sessionId to left) + } + } + + is WsEvent.SessionIdle -> this - sessionId + + is WsEvent.SessionInterrupted -> + if ((event.activeTurns ?: 0) > 0) this else this - sessionId + + else -> this + } +} + +/** + * 抽屉里那一行「正在跑」的字。 + * + * 与聊天页输入栏下方那一行**逐字相同**(`ChatScreen.busyLabel`):同一类服务端帧说的是同一件事, + * 两处各写一句就会各自漂移。措辞不分 `mode`(`thinking` / `executing` / `subagent`)——聊天页 + * 也只看「忙不忙」这一个事实,用户要的是「她在干活、可以停」,不是编排细节。 + */ +internal const val SESSION_BUSY_LABEL = "正在思考…" + +/** + * 停止入口上的字。用文字而不是图标:`MiuixIcons.Basic.*` 里只有 7 个(`Close` / `Search` / …), + * 没有一个读作「停止」,而**猜**一个语义相近的图标(`Pause`?`Remove`?)正是本项目禁止的动作 + * (先例:`LoginScreen` 的「显示 / 隐藏」也是在 `IconButton` 里放两个字)。 + */ +internal const val SESSION_STOP_LABEL = "停止" + +/** 帧里的 id:空白 = 坏帧(不知道说的是谁)⇒ 整帧不消费,与 `SessionsViewModel.onSessionRead` 同一条口径。 */ +private fun String?.sessionKey(): String? = this?.takeIf { it.isNotBlank() } + +/** 把这一路记进去;`turnId` 缺席(帧壳没给)时只维持「在跑」这个事实,不记无法配对的路。 */ +private fun Set?.plusTurn(turnId: String?): Set = + if (turnId == null) orEmpty() else orEmpty() + turnId diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/sessions/SessionDrawerContent.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/sessions/SessionDrawerContent.kt index 928f3b6..d36d28b 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/sessions/SessionDrawerContent.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/sessions/SessionDrawerContent.kt @@ -62,6 +62,9 @@ import top.yukonga.miuix.kmp.theme.MiuixTheme * 两边签名逐字对应。 * @param onDismissError 收起列表下方那条错误([SessionsUiState.error])。它不会自己消失, * 所以必须有个关闭入口;关掉的只是**本端已经显示的**这一条(边界见 `SessionsViewModel.dismissError`)。 + * @param onStopGenerating 停掉这个会话正在跑的那一轮(只对 [SessionsUiState.busySessions] 里那些行 + * 出现)。它落到的出口与「她忙时发消息 = 打断」那一档**是同一个**(见 + * `SessionsViewModel.stopGenerating`)。 */ @Composable fun ColumnScope.SessionDrawerContent( @@ -71,6 +74,7 @@ fun ColumnScope.SessionDrawerContent( onDelete: (SessionItem) -> Unit, onTogglePin: (SessionItem) -> Unit, onRename: (String, String) -> Unit, + onStopGenerating: (SessionItem) -> Unit, onOpenIot: () -> Unit, onOpenAdmin: () -> Unit, onOpenSettings: () -> Unit, @@ -111,11 +115,16 @@ fun ColumnScope.SessionDrawerContent( // 角色名在这里回填而不是在 VM 里拼进 SessionItem:这里能拿到角色表(也就能表达 // 「角色表还没回来」的降级),而且 [personaLabelFor] 是一条可单测的纯函数。 personaLabel = personaLabelFor(session.personaId, state.personas), + // 「谁在跑」是**整个列表**共用的一份(`session_busy` 说的是别的会话时本端也要收下), + // 所以它挂在 state 上、按 id 逐行查,而不是塞进 SessionItem(那一份是 REST 快照的 + // 投影,每次重拉都会被整表替换,塞进去反而要让重拉记得把它带回来)。 + busy = session.id in state.busySessions, active = session.id == state.activeSessionId, onClick = { onSelect(session.id) }, onTogglePin = { onTogglePin(session) }, onRename = { pendingRename = session }, onDelete = { pendingDelete = session }, + onStop = { onStopGenerating(session) }, ) } } @@ -327,16 +336,22 @@ private val PERSONA_LIST_MAX_HEIGHT = 320.dp * * @param personaLabel 该会话绑定的**角色名**([personaLabelFor] 的回填;拿不到就不显示)。 * 它挂在摘要行前面——「这个会话是谁在答话」比最后一条消息更该先被看到。 + * @param busy 这个会话**本端已知**正在跑(S9)。它多带来两样东西:摘要行下面那行「正在思考…」, + * 以及把置顶按钮换成「停止」。判据与收尾规则见 `session_busy` 那一套(`SessionsViewModel`)。 + * 注意它**不是**「服务端此刻在跑」——握手不回放该状态,没收到过帧的会话就是 `false`(宁可没有)。 + * @param onStop 停掉正在跑的那一轮([SessionsViewModel.stopGenerating])。 */ @Composable private fun SessionRow( session: SessionItem, personaLabel: String?, + busy: Boolean, active: Boolean, onClick: () -> Unit, onTogglePin: () -> Unit, onRename: () -> Unit, onDelete: () -> Unit, + onStop: () -> Unit, ) { val titleColor = BasicComponentDefaults.titleColor().color val summaryColor = BasicComponentDefaults.summaryColor().color @@ -355,16 +370,30 @@ private fun SessionRow( modifier = Modifier.padding(horizontal = 4.dp), ) } - IconButton(onClick = onTogglePin) { - Icon( - imageVector = if (session.pinned) MiuixIcons.Unpin else MiuixIcons.Pin, - contentDescription = if (session.pinned) "取消置顶" else "置顶", - tint = if (session.pinned) { - MiuixTheme.colorScheme.primary - } else { - MiuixTheme.colorScheme.onSurfaceVariantSummary - }, - ) + // 「停止」与置顶**同一个槽位**,不是并列的两个:上面那段注释算过,`endActions` 的 + // 硬上限(内部可用宽度的 60%)连现有的三个 40dp 动作都快顶不住,再并一个就会把 + // 图标挤到裁剪。正在跑的那几行里「停」比「置顶」要紧,而置顶**不丢**:这一轮结束 + // (灯灭)它立刻回来。 + if (busy) { + IconButton(onClick = onStop) { + Text( + text = SESSION_STOP_LABEL, + style = MiuixTheme.textStyles.footnote1, + color = MiuixTheme.colorScheme.primary, + ) + } + } else { + IconButton(onClick = onTogglePin) { + Icon( + imageVector = if (session.pinned) MiuixIcons.Unpin else MiuixIcons.Pin, + contentDescription = if (session.pinned) "取消置顶" else "置顶", + tint = if (session.pinned) { + MiuixTheme.colorScheme.primary + } else { + MiuixTheme.colorScheme.onSurfaceVariantSummary + }, + ) + } } if (!session.isMain) { IconButton(onClick = onDelete) { @@ -416,6 +445,18 @@ private fun SessionRow( overflow = TextOverflow.Ellipsis, ) } + // 第三行只给正在跑的会话。**另起一行而不是顶掉摘要**:摘要(最后一条消息)在这一刻 + // 仍然有用——用户切走之后正是靠它认回「我刚才在哪个会话里说话」——而这一行是整行 + // 宽度的普通文本,不需要从 `endActions` 或标题列里分走任何一个 dp。 + if (busy) { + Text( + text = SESSION_BUSY_LABEL, + fontSize = MiuixTheme.textStyles.body2.fontSize, + color = MiuixTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } } } diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/sessions/SessionsViewModel.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/sessions/SessionsViewModel.kt index 2dc2f0e..e46bebb 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/sessions/SessionsViewModel.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/sessions/SessionsViewModel.kt @@ -17,6 +17,7 @@ import top.yeij.cyrene.data.api.CyreneException import top.yeij.cyrene.data.api.dto.SessionSummaryDto import top.yeij.cyrene.data.ws.ChatSocket import top.yeij.cyrene.data.ws.WsEvent +import top.yeij.cyrene.data.ws.WsState data class SessionItem( val id: String, @@ -58,6 +59,13 @@ data class SessionsUiState( val personasLoaded: Boolean = false, /** 角色目录拉取失败的原因。与会话列表的 [error] 分开:它只影响弹层里那几行,不动列表状态。 */ val personaError: String? = null, + /** + * **本端已知正在跑**的会话 id(S9)。空 = 一个都不知道,抽屉里不显示任何「正在生成」。 + * + * 它**不是**「服务端此刻在跑哪些会话」——握手不回放该状态,本端只知道**连上之后**收到过帧的 + * 那些(判据与收尾规则见 [applyBusyFrame])。所以它是「宁可没有」的一侧:拿不准就不显示。 + */ + val busySessions: Set = emptySet(), ) /** @@ -128,6 +136,15 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() { */ private var trailingRefreshJob: Job? = null + /** + * 「谁在跑」的账本(S9):会话 id → 本端见过的、还没收到收尾帧的在途路 id。 + * + * 与 [SessionsUiState.busySessions] 是**同一件事的两面**:这一份带配对用的路 id(`turn.end` + * 要靠它才知道收掉的是哪一路),推给界面的那一份只要 id 的集合。两份只在一处一起写 + * ([onBusyFrame]),不会各自漂移。 + */ + private var busyTurns: Map> = emptyMap() + /** * 手里这份列表是**为哪个身份**拉回来的([fingerprint]);`null` = 这份列表没有主人 * (登出清空之后、换账号还没重拉)。 @@ -180,6 +197,7 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() { trailingRefreshJob = null silentRefreshThrottle = SilentRefreshThrottle() pulledForIdentity = null + busyTurns = emptyMap() _state.value = SessionsUiState() } @@ -207,6 +225,18 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() { } // 未读的两个下行来源都在这里收敛;见 [onEvent]。 viewModelScope.launch { socket.events.collect(::onEvent) } + // 「谁在跑」只在**连接着的这段时间**里成立:握手不回放该状态,掉线期间服务端那边起了 + // 什么、结束了什么,本端一个字节都收不到(S9 登记的那一格)。所以一离开 Connected 就当场 + // 作废——否则一次「掉线时正好跑完」会让那盏灯**永远**亮着,而它旁边那个「停止」也永远点 + // 不动(发出去只会撞上服务端对一个空闲会话的 no-op):那是这个功能唯一会骗到人的地方。 + // 代价是重连后要等下一路起来灯才回来(可能是这一轮、也可能是下一轮)——宁可没有。 + viewModelScope.launch { + socket.state.collect { ws -> + if (ws == WsState.Connected || busyTurns.isEmpty()) return@collect + busyTurns = emptyMap() + _state.update { it.copy(busySessions = emptySet()) } + } + } } /** @@ -394,6 +424,9 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() { // 记的是「本端已知」而不是「用户界面该怎么表现」:这不是要让谁弹一句告知 // (用户自己删的,他知道),只是让下一次补拉**事前**就判得掉。 container.goneSessions.mark(sessionId) + // 这个会话在本端从此没有行可显示 ⇒ 「谁在跑」里那条也当场作废(留着它只会让 + // 账本永远不为空,而它对应的那一行早就不存在了)。 + forgetBusy(sessionId) if (_state.value.activeSessionId == sessionId) { select(SessionSummaryDto.MAIN_SESSION_ID) } @@ -457,8 +490,14 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() { * 一个会话」的场景(`ChatViewModel`)用的,而本 VM 持有**整个会话列表**——按 activeSessionId * 过滤会把「别的会话被读了」这一帧直接丢掉,正是最该处理的一半。所以改为按帧里的会话 id * **逐行定位**([applyReadCursor],影响面严格限制在那一行)。 + * + * 「谁在跑」(S9)比未读更早折一遍:[onBusyFrame] 的判据与上面的纪律**同一条**——它认的是 + * 帧里那个会话 id,不是当前打开的那个(抽屉要回答的正是「别的会话在不在跑」)。这一折**只读** + * 帧、不碰下面的分支,所以两条既有路径(W49 的节流静默重拉、[persistedRoute] 的两档)语义 + * 一个字节都没变。 */ private fun onEvent(event: WsEvent) { + onBusyFrame(event) when (event) { // 别处(Web 面板 / 别的设备 / 本端另一次请求)读了:清掉那一行的红点。 is WsEvent.SessionRead -> onSessionRead(event) @@ -474,6 +513,58 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() { } } + /** + * 把一帧折进「谁在跑」的账本(判据与收尾规则全在纯函数 [applyBusyFrame] 里),并只在**结论 + * 真的变了**时推一次状态。 + * + * 「变了才推」不是优化而是必需:这个函数对**每一帧**都会跑一遍,而流式那一串(`message.delta` + * / `thinking.delta`)是刷新率级的。无关帧在 [applyBusyFrame] 里原样返回**同一张表**,这里据此 + * 直接返回,`_state` 因此一帧都不会被这些帧碰到。 + */ + private fun onBusyFrame(event: WsEvent) { + val next = busyTurns.applyBusyFrame(event) + if (next == busyTurns) return + busyTurns = next + _state.update { it.copy(busySessions = next.keys.toSet()) } + } + + /** 本端从此不再(也不该再)关心这个会话在不在跑:它已经被删掉、本端再也没有可显示的那一行。 */ + private fun forgetBusy(sessionId: String) { + if (sessionId !in busyTurns) return + busyTurns = busyTurns - sessionId + _state.update { it.copy(busySessions = busyTurns.keys.toSet()) } + } + + /** + * 停掉**某一个会话**正在跑的那一轮(抽屉里那一行「停止」,W52B)。 + * + * 走的是**既有的上行出口** [ChatSocket.sendInterrupt]:`interrupt{sessionId, scope}` 本来就带 + * 会话归属(`WsOutbound.Interrupt` 的第一个参数就是它),所以「停某一个会话」既有的帧表达得了, + * **不需要新造帧**。 + * + * ## 为什么恒发 `scope:"current"` + * 不读「她忙时发消息」那一档偏好:用户按的是一个**明确的动作**(停),不是「她忙时我该怎么办」 + * 那档策略的延续。取值与 `InterruptMode.Interrupt.wireScope` 同一个,两条理由: + * 1. **本端从不发 `all`** 是一条既有不变式(`devdocs/02` 的 A2 补记、`devdocs/03` §4), + * `dropped > 0` 那条「已打断,丢弃 N 条」的告知面在本端**不存在**; + * 2. `all` 会连**待处理队列**一起清掉,而队列里那些是用户自己敲进去、还没发出去的东西。 + * 静默丢掉它们比不给这个按钮更坏。 + * + * 于是这个按钮的语义是「**停掉正在跑的这一轮**」:排队中的消息照常会跑、灯随后会再亮起来—— + * 那是实情,不是没按到。 + * + * ## 刻意不在这里乐观灭灯 + * 灯由服务端帧驱动(`session_interrupted` / `turn.end` / `session_idle`,见 [applyBusyFrame])。 + * 本端自己先灭,就等于替服务端宣布了一件还没发生的事;而帧一到,灯自然就灭了。 + * + * 发不出去(WS 没连着)时**静默**:与 `ChatViewModel.interruptRunningTurn` 同一条纪律 + * (那里写了三条理由)。而这一刻灯本来也不会亮——连接一断,账本当场作废(见 init 里那条订阅)。 + */ + fun stopGenerating(sessionId: String) { + if (sessionId.isBlank()) return + socket.sendInterrupt(sessionId, STOP_GENERATING_SCOPE) + } + private fun onSessionRead(event: WsEvent.SessionRead) { if (event.sessionId.isBlank()) return recordReadCursor(event.sessionId, event.readAt) @@ -612,6 +703,17 @@ internal fun unreadBadgeText(count: Int): String? = when { /** 角标显示的未读上限;超过就显示 `99+`。 */ internal const val UNREAD_BADGE_MAX = 99 +/** + * 「停止」那一帧的 `scope`(见 [SessionsViewModel.stopGenerating])。 + * + * 取值必须与 `InterruptMode.Interrupt.wireScope` 一致——本端**从不发 `all`** 是一条既有不变式 + * (`devdocs/02` 的 A2 补记、`devdocs/03` §4),而 `all` 会静默清空待处理队列。 + * 这里写字面量而不是引用那个枚举:那边的类型是 `String?`(`Parallel` 档就是 `null`), + * 直接传下去会让「取值一旦被改坏」退化成「发出一条省略 scope 的帧」= 服务端眼里的 `"all"`, + * 而那正是最坏的方向。两处一致由 `SessionBusyTest` 钉住。 + */ +internal const val STOP_GENERATING_SCOPE = "current" + /** 整表替换(REST 快照)时,把本地已知已读的那些行压回 0 未读;见 `SessionsViewModel.readCursors`。 */ internal fun List.withReadCursors(cursors: Map): List = map { item -> cursors[item.id]?.let { readAt -> item.withReadCursor(readAt) } ?: item } diff --git a/app/src/test/kotlin/top/yeij/cyrene/ui/sessions/SessionBusyTest.kt b/app/src/test/kotlin/top/yeij/cyrene/ui/sessions/SessionBusyTest.kt new file mode 100644 index 0000000..676d4d1 --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/ui/sessions/SessionBusyTest.kt @@ -0,0 +1,200 @@ +package top.yeij.cyrene.ui.sessions + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import top.yeij.cyrene.core.prefs.InterruptMode +import top.yeij.cyrene.data.ws.WsEvent + +/** + * S9「这个会话正在生成」那一套纯逻辑:账本的折帧规则,以及「停止」帧的 scope。 + * + * 这一批全部是**放在 JVM 上能断言的**那一半——屏幕上到底长什么样(第三行、按钮挤不挤得下) + * 只有单测证明不了,报告里如实标了。 + * + * 两组纪律在每一条里都能看到: + * 1. **只有活帧能点亮**(`session_busy` / `turn_begin`),握手不回放 ⇒ 没收到帧的会话**一定**不显示; + * 2. **收尾宁可少显示**:拿不准(`turn.end` 认不得那一路、并行的另一路还在跑)就继续亮着, + * 但绝不在「其实已经闲了」时还亮着——那是这个功能唯一会骗到人的方向。 + */ +class SessionBusyTest { + + private fun busy(sessionId: String) = mapOf(sessionId to emptySet()) + + // —— 点亮 —— + + @Test + fun `an empty ledger means nothing is shown`() { + // 冷启动、握手之后:服务端不回放该状态,本端什么都不知道 ⇒ 抽屉里不显示「正在生成」。 + val after = emptyMap>() + .applyBusyFrame(WsEvent.SessionRead(sessionId = "web", readAt = 1L)) + .applyBusyFrame(WsEvent.MessagePersisted(message = null, sessionId = "web")) + + assertTrue(after.isEmpty()) + } + + @Test + fun `session_busy lights up its own session`() { + val after = emptyMap>() + .applyBusyFrame(WsEvent.SessionBusy(sessionId = "web:1", mode = "thinking")) + + assertTrue("web:1" in after) + } + + @Test + fun `session_busy for a session does not touch its neighbours`() { + val after = busy("web").applyBusyFrame(WsEvent.SessionBusy(sessionId = "web:1", mode = "executing")) + + assertTrue("web" in after) + assertTrue("web:1" in after) + assertEquals(2, after.size) + } + + @Test + fun `turn_begin is live evidence too`() { + // 它与 session_busy 同源(服务端启动一路时两条一起发),但更频繁:session_busy 那一帧 + // 若因重连错过,下一路起来时灯还能亮回来。 + val after = emptyMap>() + .applyBusyFrame(WsEvent.TurnBegin(sessionId = "web:1", turnId = "t1")) + + assertTrue("web:1" in after) + assertEquals(setOf("t1"), after["web:1"]) + } + + @Test + fun `a blank session id is a bad frame and changes nothing`() { + // 空白 = 不知道说的是谁(与 onSessionRead 同一条口径):既不点亮,也不误灭别人。 + val after = busy("web").applyBusyFrame(WsEvent.SessionBusy(sessionId = "", mode = "thinking")) + + assertEquals(setOf("web"), after.keys) + } + + @Test + fun `repeating a frame is idempotent`() { + val frame = WsEvent.SessionBusy(sessionId = "web", mode = "thinking") + + val once = emptyMap>().applyBusyFrame(frame) + + assertEquals(once, once.applyBusyFrame(frame)) + } + + // —— 收尾 —— + + @Test + fun `session_idle always clears`() { + // 规范 §1.5:它只在**全部**在途路收尾且 pending 空时才发 ⇒ 到了就是真的闲了。 + val after = busy("web").applyBusyFrame(WsEvent.SessionIdle(sessionId = "web", mode = "idle")) + + assertTrue(after.isEmpty()) + } + + @Test + fun `turn_end with no matching turn_begin changes nothing`() { + // 本端中途才接上(没见过这一路的 turn_begin):拿别人家的收尾去灭灯,会把还在跑的那一路 + // 报成空闲。宁可继续亮着,等 session_idle 这个权威口径。 + val after = busy("web").applyBusyFrame(WsEvent.TurnEnd(sessionId = "web", turnId = "t-unknown")) + + assertTrue("web" in after) + } + + @Test + fun `turn_end retires the leg it knows about`() { + val after = emptyMap>() + .applyBusyFrame(WsEvent.TurnBegin(sessionId = "web", turnId = "t1")) + .applyBusyFrame(WsEvent.TurnEnd(sessionId = "web", turnId = "t1")) + + assertTrue(after.isEmpty()) + } + + @Test + fun `two parallel legs need both turn_end frames to clear`() { + // 忙时并行规范 §2.3:先收尾的那一路不是「这个会话闲了」。 + val started = emptyMap>() + .applyBusyFrame(WsEvent.TurnBegin(sessionId = "web", turnId = "t1")) + .applyBusyFrame(WsEvent.TurnBegin(sessionId = "web", turnId = "t2")) + + val afterFirst = started.applyBusyFrame(WsEvent.TurnEnd(sessionId = "web", turnId = "t1")) + val afterSecond = afterFirst.applyBusyFrame(WsEvent.TurnEnd(sessionId = "web", turnId = "t2")) + + assertTrue("t2 还在跑,灯不能灭", "web" in afterFirst) + assertTrue(afterSecond.isEmpty()) + } + + @Test + fun `an interrupt that leaves other legs running keeps the light on`() { + // `scope:"current"` 只取消在跑的那一路、别的路照跑。无条件收掉就会在并行时显示「已经停了」 + // ——那正是 v2 前端审计记成「会错」的那条(前端同步缺口 A7)。 + val after = busy("web").applyBusyFrame( + WsEvent.SessionInterrupted(sessionId = "web", mode = "executing", dropped = 0, activeTurns = 1), + ) + + assertTrue("web" in after) + } + + @Test + fun `an interrupt with nothing left clears`() { + val after = busy("web").applyBusyFrame( + WsEvent.SessionInterrupted(sessionId = "web", mode = "idle", dropped = 0, activeTurns = 0), + ) + + assertTrue(after.isEmpty()) + } + + @Test + fun `an old server without activeTurns also clears on interrupt`() { + // `activeTurns` 是忙时并行规范(v0.65)才加的键。键缺席的那一版根本没有「另一路」这回事, + // 单路被打断就是真的闲了 ⇒ 收掉。 + val after = busy("web").applyBusyFrame( + WsEvent.SessionInterrupted(sessionId = "web", mode = "idle", dropped = 0), + ) + + assertTrue(after.isEmpty()) + } + + @Test + fun `stream_end never clears`() { + // 它只结束某一次模型流(一轮里有多个正文段、工具循环里还有多次),结束整轮的是 turn.end。 + // 按它灭灯就是 A7 那类错误的另一个入口。 + val after = busy("web").applyBusyFrame( + WsEvent.StreamEnd(stopReason = "stop", sessionId = "web", turnId = "t1"), + ) + + assertTrue("web" in after) + } + + @Test + fun `an unrelated frame keeps the very same ledger`() { + // 流式那一串是刷新率级的。无关帧必须原样返回**同一张表**,否则 `SessionsViewModel` 会为 + // 每一帧 delta 重新分配、重推一次状态(那正是「每帧都在重组抽屉」的写法)。 + val ledger = busy("web") + val after = ledger.applyBusyFrame(WsEvent.MessageDelta(delta = "一", index = 0, sessionId = "web")) + + assertSame(ledger, after) + } + + // —— 文案与「停止」帧的 scope —— + + @Test + fun `the busy label says what the chat page says`() { + // 同一个服务端帧(忙)说的是同一件事,两处各写一句就会各自漂移;顺带钉住它不带「…」以外的 + // 编排细节(mode 不参与措辞)。 + assertEquals("正在思考…", SESSION_BUSY_LABEL) + assertEquals("停止", SESSION_STOP_LABEL) + } + + @Test + fun `the stop frame keeps the client-only-current invariant`() { + // 本端**从不发 `all`**(devdocs/02 A2 补记、devdocs/03 §4):它连待处理队列一起清掉,而 + // 「已丢弃 N 条」那句话在本端没有落点。所以这个常量必须与「打断」档同一个取值。 + assertEquals(InterruptMode.Interrupt.wireScope, STOP_GENERATING_SCOPE) + + assertFalse("空串在服务端也是 `all`,绝不能滑到那一档", STOP_GENERATING_SCOPE.isBlank()) + } + + @Test + fun `the ui state ships knowing nothing`() { + assertTrue(SessionsUiState().busySessions.isEmpty()) + } +}