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 a91bdd2..4a507f9 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 @@ -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, ) }, ) diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt index 50c232e..83c7f34 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt @@ -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 diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt index 122b215..e3a81ee 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt @@ -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, 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, + 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() { 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 c20d5e2..928f3b6 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 @@ -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(null) } var pendingRename by remember { mutableStateOf(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) { 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 b54ff1c..b8d428a 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 @@ -297,6 +297,16 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() { } } + /** + * 收起**本端已经显示出来**的那条错误(抽屉里列表下方那一行)。 + * + * 它清的只是那一行字:**不代表服务端那边的事已经好了**。关掉一次失败的删除 / 重命名 / 同步的 + * 提示,并不会让那件事成功——重来一次照样会失败、照样会重新挂出这条错误。所以这里刻意 + * **不动任何重试、也不动错误本身**,也不做自动消失(错误不该自己走掉)。 + * + * 角色目录那条 [SessionsUiState.personaError] 不在这儿:它只属于「新建会话」弹层, + * 关弹层就跟着没了。 + */ fun dismissError() = _state.update { it.copy(error = null) } /**