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:
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) }
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user