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」)。
This commit is contained in:
1 parent
4fce12249e
commit
84f343ce5f
6 files changed
+452
-15
No files matched your search
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<String, Set<String>>.applyBusyFrame(event: WsEvent): Map<String, Set<String>> {
|
||||
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<String>?.plusTurn(turnId: String?): Set<String> =
|
||||
if (turnId == null) orEmpty() else orEmpty() + turnId
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String> = 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<String, Set<String>> = 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<SessionItem>.withReadCursors(cursors: Map<String, Long>): List<SessionItem> =
|
||||
map { item -> cursors[item.id]?.let { readAt -> item.withReadCursor(readAt) } ?: item }
|
||||
|
||||
@@ -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<String>())
|
||||
|
||||
// —— 点亮 ——
|
||||
|
||||
@Test
|
||||
fun `an empty ledger means nothing is shown`() {
|
||||
// 冷启动、握手之后:服务端不回放该状态,本端什么都不知道 ⇒ 抽屉里不显示「正在生成」。
|
||||
val after = emptyMap<String, Set<String>>()
|
||||
.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<String, Set<String>>()
|
||||
.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<String, Set<String>>()
|
||||
.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<String, Set<String>>().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<String, Set<String>>()
|
||||
.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<String, Set<String>>()
|
||||
.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())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user