fix(chat): 四件可见修复 —— 回退正文不再丢 / 错误横幅可关 / 分享不自杀 / 清两处死码

1) stream_chunk 不再被静默丢掉(整段正文丢失风险)
   - ChatViewModel.onEvent 新增 WsEvent.StreamChunk 分支,把 delta 照 MessageDelta 的同一形态喂进
     流式解析器。
   - 依据:后端契约写明「未映射/无法确定的事件仍走 stream_chunk + stream_end(非流式回退路径)」
     (Cyrene_v2/Docs/api-client.md §3.1)。原先它落到 onEvent 末尾的 else -> Unit 被静默吞掉 ——
     服务端一旦走进回退路径,整段回复正文一个字都不显示,而且没有任何信号说明正文丢了。
   - stream_chunk 不带 index(那是 message.start 的字段),所以不读 index;解析器与 MessageDelta
     一样按需创建,按「同一个当前流」处理。
   - 未改 WsEvent 的解析形状,只加消费分支。

2) 错误横幅可以关掉了(两个 dismissError 不再是死方法)
   - ChatViewModel.dismissError / SessionsViewModel.dismissError 原本全仓零调用方,现各接上关闭
     入口,并各补 KDoc 写明边界:清掉的是本端已经显示的那条错误,不代表服务端那边没事;不做自动
     消失。
   - ChatScreen.kt 的 private Banner 增加可选 onDismiss,有它时右侧挂 MiuixIcons.Basic.Close
     (带 contentDescription);只有 state.error 那条传 viewModel::dismissError,分享 / 附件 /
     定位那几条如实告知保持原样(它们自己会收走)。
   - SessionDrawerContent.kt 的错误行改成行内布局 + 右侧关闭按钮,样式随它已有的行(footnote2、
     同一水平内边距、同款 IconButton 灰度);新增参数 onDismissError,由 ChatHomeScreen 接
     sessionsViewModel::dismissError。
   - 错误内容本身、错误出现的时机、是否自动消失,一概未动。

3) 多图分享不再互相打断
   - 根因:W25 把「她正忙时按发送」接到发送路径后,deliver 每次投递前都会发一条 interrupt 帧
     (scope=current);而分享 N 张图 = N 次投递,第 2 条起就把前一条刚起的回合取消掉了,
     最后只有最后一张被回答。
   - 改法:emitAndDeliver 增加 allowInterrupt: Boolean = true(默认值保持现状),deliver 按它决定
     是否调 interruptRunningTurn();sendShared 的两条投递都传 false。
   - 只有一张图也传 false:分享是「一次把东西倒进去」的批量动作,自己打断自己一定是错的;
     「分享从不打断」是一句话,「超过一张才不打断」得先解释那个分界。
   - 单条发送(send)的口径一字未动。

4) 两处死代码
   - 删掉 ChatViewModel.clearDraftDocuments()(复核为全仓零调用方)。没选「在输入栏加清空全部入口」:
     附件芯片本来就每枚带叉、上限只有 5 枚,逐个移除已经覆盖全部场景;输入栏里再加一枚清空按钮是
     噪音,而留着一个没人接的公开方法只会让人误以为它是活的。
   - 删掉 ChatScreen 的 onLogout 参数(函数体内一次都没用到)与 ChatHomeScreen 的对应传参。
     真正的登出路径(CyreneApp -> ChatHomeScreen -> SettingsScreen)原样可用。

验证:assembleDebug 与 testDebugUnitTest 均 BUILD SUCCESSFUL;单测 892 条、0 失败 0 错误、0 跳过
(本次未新增测试:① 的判定是 when 的一个分支、抽不出自然的纯函数,不为测试把架构拧弯)。
This commit is contained in:
AskaEth committed 2026-10-04 22:36:47 +08:00
1 parent e4c6c7c72a
commit eacc65acc9
5 files changed
+137 -28

No files matched your search

@@ -100,7 +100,6 @@ fun ChatHomeScreen(
// 静默是为了不闪「同步中…」——屏幕上本来就有内容。
sessionsViewModel.refresh(silent = true)
},
onLogout = onLogout,
// 当前会话在别处被删了(`404 session_not_found`)⇒ 先立起那句如实告知,再重拉列表:
// `refresh` 里面会把「指向已删会话」的 activeSessionId 收敛回主会话
// (`resolveActiveSession`),抽屉里那条也跟着消失。所以这里不需要自己判「该切到哪儿」。
@@ -134,6 +133,7 @@ fun ChatHomeScreen(
drawerOpen = false
showSettings = true
},
onDismissError = sessionsViewModel::dismissError,
)
},
)
@@ -138,7 +138,6 @@ fun ChatScreen(
container: AppContainer,
sessionId: String,
onOpenDrawer: () -> Unit = {},
onLogout: () -> Unit = {},
onSessionGone: () -> Unit = {},
sessionNotice: String? = null,
) {
@@ -496,8 +495,15 @@ fun ChatScreen(
.align(Alignment.TopCenter)
.fillMaxWidth(),
) {
// 错误是这一排里**唯一**给关闭按钮的一条:它不会自己消失(别的几条是动作刚发生的
// 如实告知,挂了就收)。关掉的只是屏幕上这一行字,不代表服务端那边没事——
// 边界写在 ChatViewModel.dismissError 上。
state.error?.let { message ->
Banner(text = message, color = MiuixTheme.colorScheme.error)
Banner(
text = message,
color = MiuixTheme.colorScheme.error,
onDismiss = viewModel::dismissError,
)
}
// 「这个会话在服务端已经没了」那句解释:由上层注入(见函数头上那段说明),
// 因为承载它的会话 VM 在切走的那一刻就被销毁了。
@@ -610,18 +616,35 @@ private fun documentOpenType(context: Context, target: DocumentTarget): String {
/** 建议文件名兜底:理论上到不了(服务端的存储名恒非空),只是不让一个空名字进 SAF。 */
private const val FALLBACK_PREVIEW_FILE_NAME = "document.txt"
/** 消息区上方的一行提示:错误与分享告知共用同一形态(铺满宽度、卡片底色)。 */
/**
* 消息区上方的一行提示:错误与分享告知共用同一形态(铺满宽度、卡片底色)。
*
* [onDismiss] 给出时右侧多一个关闭按钮。**只有错误那条**给(它不会自己消失,见
* `ChatViewModel.dismissError`);分享/附件/定位那几条是**动作刚发生的如实告知**、挂了就自己
* 收走,给它们一个关闭按钮只是多一个没人按的图标。
*/
@Composable
private fun Banner(text: String, color: Color) {
Text(
text = text,
style = MiuixTheme.textStyles.footnote1,
color = color,
private fun Banner(text: String, color: Color, onDismiss: (() -> Unit)? = null) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(MiuixTheme.colorScheme.surfaceContainer)
.padding(12.dp),
)
.background(MiuixTheme.colorScheme.surfaceContainer),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = text,
style = MiuixTheme.textStyles.footnote1,
color = color,
modifier = Modifier
.weight(1f)
.padding(12.dp),
)
if (onDismiss != null) {
IconButton(onClick = onDismiss, modifier = Modifier.padding(end = 4.dp)) {
Icon(imageVector = MiuixIcons.Basic.Close, contentDescription = "关闭这条提示")
}
}
}
}
@Composable
@@ -678,8 +678,6 @@ class ChatViewModel(
fun removeDraftDocument(index: Int) =
_state.update { it.copy(draftDocuments = DocumentAttachments.removeAt(it.draftDocuments, index)) }
fun clearDraftDocuments() = _state.update { it.copy(draftDocuments = emptyList()) }
// —— D3:单条消息的编辑 / 删除 ——
/**
@@ -868,9 +866,20 @@ class ChatViewModel(
* 消息上(`chat.documents` 本就是数组,≤5 条),因此只随第一条走。
* 顺序由这里串起来(同一个协程里依次投递)——各投各的会让它们互相赛跑,落库顺序反过来。
*
* 两处刻意的取舍:
* 三处刻意的取舍:
* - **不动草稿**:分享不是用户按的发送键,不能把他正在输入的内容顶掉;
* - **空内容也照常处理**:[notice] 该说就说,静默什么都不发生比报一句更糟。
* - **空内容也照常处理**:[notice] 该说就说,静默什么都不发生比报一句更糟;
* - **一次都不打断**:见下面「分享不发打断」一段。
*
* ## 分享不发打断(`allowInterrupt` 恒为 false)
* 分享是**一次把东西倒进去**的批量动作:N 张图 = N 条消息,上面那个 `forEach` 会逐条投递,
* 而「她正忙时按发送」那个偏好(协同 A2)默认会在**每条**投递前发一条 `interrupt{scope:"current"}`。
* 照原样接上去,第 2 条起就是在**打断自己刚起的上一轮**——最后屏幕上只剩最后一张图的回答,
* 前面几条被取消(契约里被取消的回合不落库)。自己打断自己一定是错的。
*
* 只有一张图时同样不发:这条规则要**易解释**——「分享从不打断」是一句话,
* 「分享超过一张时才不打断」得先跟人解释清为什么是一张的分界。
* 用户真的想打断,发送键那条路([send])的口径一字未动。
*
* @param notice 如实告知(截断 / 读不到 / 空内容),由渲染层组织文案;null = 没什么要说的
*/
@@ -882,8 +891,10 @@ class ChatViewModel(
}
if (body.isEmpty() && share.images.isEmpty() && documents.isEmpty()) return
viewModelScope.launch {
emitAndDeliver(body, share.images.firstOrNull(), documents, clearDraft = false)
share.images.drop(1).forEach { emitAndDeliver("", it, emptyList(), clearDraft = false) }
emitAndDeliver(body, share.images.firstOrNull(), documents, clearDraft = false, allowInterrupt = false)
share.images.drop(1).forEach {
emitAndDeliver("", it, emptyList(), clearDraft = false, allowInterrupt = false)
}
}
}
@@ -910,12 +921,16 @@ class ChatViewModel(
*
* @param clearDraft 只有「用户按了发送键」才清草稿——那时草稿正是刚发出去的这一条;
* 分享落地是替用户发一条他自己录进来的内容,草稿不是它。
* @param allowInterrupt 这条投递要不要按「她正忙时按发送」那个偏好先发打断帧。**默认 true
* = 用户按发送键的既有口径**;只有 [sendShared] 传 false——它是批量动作,批量里每条都打断
* 等于自己杀自己(理由见 [sendShared])。
*/
private suspend fun emitAndDeliver(
text: String,
imageUri: String?,
documents: List<DraftDocument>,
clearDraft: Boolean,
allowInterrupt: Boolean = true,
) {
if (text.isEmpty() && imageUri == null && documents.isEmpty()) return
@@ -943,7 +958,7 @@ class ChatViewModel(
error = null,
)
}
deliver(localId, text, imageUri, documents)
deliver(localId, text, imageUri, documents, allowInterrupt)
}
private suspend fun deliver(
@@ -951,6 +966,7 @@ class ChatViewModel(
text: String,
imageUri: String?,
documents: List<DraftDocument>,
allowInterrupt: Boolean,
) {
// 判定矩阵([DocumentDelivery]):附件 >5 **直接拒绝,绝不裁剪**。这里用只需份数的
// [DocumentDelivery.planForCount]——字节还没读(最多 5×20 MiB 一次性读进内存不可接受),
@@ -988,7 +1004,11 @@ class ChatViewModel(
// 失败 / 附件上传失败 / 附件引用不合法)都会「回滚、什么都不发」——先把她正在说的那一轮
// 打断掉、再什么都不发,是凭空的破坏,而且用户看不到任何理由。
// 每次投递只经过这里一次 ⇒ 打断帧**最多一条**,下面的通道分支与任何后续重试都不会再发。
interruptRunningTurn()
//
// 只有 [allowInterrupt] 为 true 才发(= 用户按发送键那条路,默认档)。[ChatViewModel.sendShared]
// 传 false:它是**批量**投递,每条都打断就会让第 2 条起把前一条刚起的回合取消掉(理由与
// 取舍见 [sendShared] 的「分享不发打断」一段)。
if (allowInterrupt) interruptRunningTurn()
when (ChatSendRouting.channelFor(text, hasImage = image != null, hasDocuments = payload.isNotEmpty())) {
SendChannel.Ws -> {
@@ -1009,7 +1029,8 @@ class ChatViewModel(
*
* 判据本身是纯函数 [interruptScopeForSend](默认档恒不发、忙且选了「打断」才发 `scope:"current"`,
* 命中空闲会话不发),这里只做两件事:**取当前偏好**、把帧发出去。它由 [deliver] 在**通道分支
* 之前**恰好调用一次,所以一次投递最多一条打断帧。
* 之前**调用,所以一次投递最多一条打断帧——且**只在 [deliver] 的 `allowInterrupt` 为 true 时**
* 才调(分享那条批量路径传 false,见 [sendShared])。
*
* ## 一条已知边界:调用顺序 ≠ 到达顺序(不在本端可控范围)
* 本端能保证的只有**调用顺序**(打断帧在这条消息之前发出去)。到达与服务端处理顺序取决于通道:
@@ -1427,6 +1448,29 @@ class ChatViewModel(
updateStreaming { it.copy(bubbles = bubbles, active = true) }
}
/**
* 契约 §3.1 的**非流式回退路径**(`Cyrene_v2/Docs/api-client.md`):「未映射/无法确定的
* 事件仍走 `stream_chunk` + `stream_end`」(§3.1 的「向后兼容」一条;v0.4「保留
* `stream_chunk`/`stream_end` 向后兼容」)。服务端一旦走这条路,**这一轮回复的整段正文就
* 只在这两帧里**——这里不接它,它会落进 `onEvent` 末尾的 `else -> Unit` 被静默丢掉:
* 屏幕上那一轮回答是空的,而且没有任何信号说明正文丢了。
*
* 「与结构化帧**互斥**」的依据就是上面那句话本身:回退路径的语义是「结构化流帧没能承载
* 这次事件」,所以同一轮里 `stream_chunk` 与 `message.*` 不会并存。因此这一帧可以照
* [WsEvent.MessageDelta] 的同一形态喂进同一个 `streamParser`,不必去分辨「它属于哪条流」。
*
* 一处必须注意的差别:`stream_chunk` **不带 `index`**(那是 [WsEvent.MessageStart] 的字段),
* 所以这里不读它——它只是「当前这条流」的增量;它也不负责开一轮(回退路径里没有
* `message.start`),解析器因此按需创建,与上面 `MessageDelta` 分支逐字一致。
*
* 会话归属照旧由 `onEvent` 开头那道闸收敛(本帧带 `sessionId`,见 [isForSession])。
*/
is WsEvent.StreamChunk -> {
val parser = streamParser ?: StreamingContentParser().also { streamParser = it }
val bubbles = parser.onDelta(event.delta)
updateStreaming { it.copy(bubbles = bubbles, active = true) }
}
// —— 思维链 ——
is WsEvent.ThinkingStart -> {
if (event.index == 0) freshTurnIfFinished()
@@ -1656,6 +1700,15 @@ class ChatViewModel(
}
}
/**
* 收起**本端已经显示出来**的那条错误横幅。
*
* 它清的只是屏幕上那一行字:**不代表服务端那边的事已经好了**。比如一次失败的上拉分页,
* 用户关掉这条提示并不会让那几页消息回来——再上拉一次照样会失败、照样会重新挂出这条错误。
* 所以这里刻意**不动任何重试、也不动错误本身**:关掉只是把那句已经读过的告知收走。
*
* **不做自动消失**:错误不该自己走掉(分享/附件那几条告知才挂一会儿就收)。
*/
fun dismissError() = _state.update { it.copy(error = null) }
override fun onCleared() {
@@ -40,6 +40,7 @@ import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TextButton
import top.yukonga.miuix.kmp.basic.TextField
import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.basic.Close
import top.yukonga.miuix.kmp.icon.extended.Add
import top.yukonga.miuix.kmp.icon.extended.Delete
import top.yukonga.miuix.kmp.icon.extended.Pin
@@ -59,6 +60,8 @@ import top.yukonga.miuix.kmp.theme.MiuixTheme
* @param onCreate 参数是这次要绑定的角色 id:**null = 「默认角色」**(不绑,跟随全局默认角色)。
* 类型必须是 `(String?) -> Unit`:调用方传的是 `SessionsViewModel::createSession` 这个方法引用,
* 两边签名逐字对应。
* @param onDismissError 收起列表下方那条错误([SessionsUiState.error])。它不会自己消失,
* 所以必须有个关闭入口;关掉的只是**本端已经显示的**这一条(边界见 `SessionsViewModel.dismissError`)。
*/
@Composable
fun ColumnScope.SessionDrawerContent(
@@ -71,6 +74,7 @@ fun ColumnScope.SessionDrawerContent(
onOpenIot: () -> Unit,
onOpenAdmin: () -> Unit,
onOpenSettings: () -> Unit,
onDismissError: () -> Unit,
) {
var pendingDelete by remember { mutableStateOf<SessionItem?>(null) }
var pendingRename by remember { mutableStateOf<SessionItem?>(null) }
@@ -116,13 +120,32 @@ fun ColumnScope.SessionDrawerContent(
}
}
if (state.error != null) {
Text(
text = state.error.orEmpty(),
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.error,
modifier = Modifier.padding(horizontal = 20.dp, vertical = 6.dp),
)
// 这条错误也不会自己消失,所以右侧给一个关闭入口——样式随它上面那几行:同一份水平内边距、
// 同一档 `footnote2`;图标用同文件里置顶/删除那两个 `IconButton` 的同一档灰度。
// 关掉的只是本端已经显示的这条,不代表服务端那边没事(边界见 SessionsViewModel.dismissError)。
state.error?.let { message ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 20.dp, end = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = message,
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.error,
modifier = Modifier
.weight(1f)
.padding(vertical = 6.dp),
)
IconButton(onClick = onDismissError) {
Icon(
imageVector = MiuixIcons.Basic.Close,
contentDescription = "关闭这条错误提示",
tint = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
}
}
if (state.fromCache && state.syncedAt > 0) {
@@ -297,6 +297,16 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
}
}
/**
* 收起**本端已经显示出来**的那条错误(抽屉里列表下方那一行)。
*
* 它清的只是那一行字:**不代表服务端那边的事已经好了**。关掉一次失败的删除 / 重命名 / 同步的
* 提示,并不会让那件事成功——重来一次照样会失败、照样会重新挂出这条错误。所以这里刻意
* **不动任何重试、也不动错误本身**,也不做自动消失(错误不该自己走掉)。
*
* 角色目录那条 [SessionsUiState.personaError] 不在这儿:它只属于「新建会话」弹层,
* 关弹层就跟着没了。
*/
fun dismissError() = _state.update { it.copy(error = null) }
/**