From 5a5b02f6fba5c4a8a8e8b653a102157d21e1a2c0 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 8 Sep 2026 21:56:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(chat):=20=E6=80=9D=E7=BB=B4=E9=93=BE?= =?UTF-8?q?=E4=BF=9D=E7=95=99=2010=20=E5=88=86=E9=92=9F=E5=B9=B6=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E6=8A=98=E5=8F=A0=EF=BC=9B=E8=BE=93=E5=85=A5=E6=A0=8F?= =?UTF-8?q?=E4=B8=8B=E6=96=B9=E7=95=99=E7=99=BD=E6=89=BF=E8=BD=BD=E5=BF=99?= =?UTF-8?q?=E7=A2=8C=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 流式结束后把思维链挂到落库消息上,10 分钟后自动清除;ThinkingCard 在 active 变 false 时自动折叠,历史思维链默认折叠。输入栏底部预留 22dp 避开屏幕圆角,顶栏副标题只留连接状态,忙碌提示移到输入栏下方(带左缩进)。 --- .../top/yeij/cyrene/ui/chat/ChatScreen.kt | 76 +++++++++++++------ .../top/yeij/cyrene/ui/chat/ChatViewModel.kt | 25 +++++- .../yeij/cyrene/ui/chat/StreamingBlocks.kt | 6 +- 3 files changed, 80 insertions(+), 27 deletions(-) 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 ad68f3c..768418c 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 @@ -3,11 +3,13 @@ package top.yeij.cyrene.ui.chat import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width @@ -45,6 +47,9 @@ import top.yukonga.miuix.kmp.theme.MiuixTheme /** 判定「贴着底部」的容差(px)。 */ private const val BOTTOM_TOLERANCE = 64 +/** 输入栏下方预留的高度:避开屏幕圆角,同时承载忙碌提示。 */ +private val InputBottomSpacer = 22.dp + @Composable fun ChatScreen( container: AppContainer, @@ -96,7 +101,7 @@ fun ChatScreen( topBar = { SmallTopAppBar( title = "与昔涟", - subtitle = statusLabel(state), + subtitle = connectionLabel(state), navigationIcon = { IconButton(onClick = onOpenDrawer) { Icon(imageVector = MiuixIcons.Basic.Sidebar, contentDescription = "菜单") @@ -107,6 +112,7 @@ fun ChatScreen( bottomBar = { InputBar( value = state.draft, + busyText = busyLabel(state), onValueChange = viewModel::onDraftChange, onSend = viewModel::send, ) @@ -172,30 +178,46 @@ fun ChatScreen( @Composable private fun InputBar( value: String, + busyText: String, onValueChange: (String) -> Unit, onSend: () -> Unit, ) { - Row( + Column( modifier = Modifier .fillMaxWidth() .imePadding() - .padding(horizontal = 12.dp, vertical = 8.dp), - verticalAlignment = Alignment.Bottom, + .padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 4.dp), ) { - TextField( - value = value, - onValueChange = onValueChange, - label = "和昔涟说点什么…", - useLabelAsPlaceholder = true, - maxLines = 5, - modifier = Modifier.weight(1f), - ) - Spacer(Modifier.width(8.dp)) - Button( - onClick = onSend, - enabled = value.isNotBlank(), + Row(verticalAlignment = Alignment.Bottom) { + TextField( + value = value, + onValueChange = onValueChange, + label = "和昔涟说点什么…", + useLabelAsPlaceholder = true, + maxLines = 5, + modifier = Modifier.weight(1f), + ) + Spacer(Modifier.width(8.dp)) + Button( + onClick = onSend, + enabled = value.isNotBlank(), + ) { + Text("发送") + } + } + // 输入栏下方预留的空间:让输入框/按钮避开屏幕圆角,同时放忙碌提示 + Box( + modifier = Modifier + .fillMaxWidth() + .height(InputBottomSpacer) + .padding(start = 12.dp, top = 4.dp), + contentAlignment = Alignment.CenterStart, ) { - Text("发送") + Text( + text = busyText, + style = MiuixTheme.textStyles.footnote2, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary, + ) } } } @@ -211,14 +233,18 @@ private fun CenterHint(text: String) { } } -private fun statusLabel(state: ChatUiState): String = when { +/** 顶栏副标题:只反映连接状态。 */ +private fun connectionLabel(state: ChatUiState): String = when (state.connection) { + WsState.Connected -> "已连接" + WsState.Connecting -> "连接中…" + WsState.Reconnecting -> "重连中…" + WsState.Offline -> "离线" + WsState.Idle -> "" +} + +/** 输入栏下方的忙碌提示。 */ +private fun busyLabel(state: ChatUiState): String = when { state.busyMode != null && state.queueDepth > 0 -> "正在思考… 已排队 ${state.queueDepth} 条" state.busyMode != null -> "正在思考…" - else -> when (state.connection) { - WsState.Connected -> "已连接" - WsState.Connecting -> "连接中…" - WsState.Reconnecting -> "重连中…" - WsState.Offline -> "离线" - WsState.Idle -> "" - } + else -> "" } 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 a2cb3ab..6d5da62 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 @@ -304,6 +304,13 @@ class ChatViewModel( if (msg.sessionId != sessionId) return val clearStreaming = msg.role == "assistant" + // 流式结束:把这一轮的思维链挂到落库消息上,保留 10 分钟后再自动消失 + val streamedThinking = if (clearStreaming) { + _state.value.streaming?.thinking?.takeIf { it.isNotBlank() } + } else { + null + } + val persisted = if (streamedThinking != null) msg.copy(thinking = streamedThinking) else msg if (clearStreaming) streamParser = null _state.update { state -> @@ -314,11 +321,24 @@ class ChatViewModel( state.pending } state.copy( - messages = mergeMessages(state.messages, listOf(msg)), + messages = mergeMessages(state.messages, listOf(persisted)), pending = pending, streaming = if (clearStreaming) null else state.streaming, ) } + + if (streamedThinking != null) { + viewModelScope.launch { + kotlinx.coroutines.delay(THINKING_LINGER_MILLIS) + _state.update { state -> + state.copy( + messages = state.messages.map { + if (it.id == persisted.id) it.copy(thinking = null) else it + }, + ) + } + } + } } /** 上拉加载更早的一页。 */ @@ -358,3 +378,6 @@ class ChatViewModel( private fun Throwable.userMessage(): String = (this as? CyreneException)?.error?.userMessage ?: "加载失败,请稍后重试" + +/** 思维链在流式结束后继续保留的时长。 */ +private const val THINKING_LINGER_MILLIS = 10 * 60 * 1000L diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/StreamingBlocks.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/StreamingBlocks.kt index 59fe354..9e369f0 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/StreamingBlocks.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/StreamingBlocks.kt @@ -33,7 +33,11 @@ fun ThinkingCard( modifier: Modifier = Modifier, ) { if (text.isBlank() && !active) return - var expanded by remember { mutableStateOf(true) } + // 流式期间展开(方便观察),流式结束自动折叠;历史思维链默认折叠。 + var expanded by remember { mutableStateOf(active) } + LaunchedEffect(active) { + if (!active) expanded = false + } FoldCard(modifier = modifier, onClick = { expanded = !expanded }) { FoldCardHeader(