feat(chat): 思维链保留 10 分钟并自动折叠;输入栏下方留白承载忙碌提示

流式结束后把思维链挂到落库消息上,10 分钟后自动清除;ThinkingCard 在 active 变 false 时自动折叠,历史思维链默认折叠。输入栏底部预留 22dp 避开屏幕圆角,顶栏副标题只留连接状态,忙碌提示移到输入栏下方(带左缩进)。
This commit is contained in:
AskaEth committed 2026-09-08 21:56:55 +08:00
1 parent 5975827aea
commit 5a5b02f6fb
3 files changed
+80 -27

No files matched your search

@@ -3,11 +3,13 @@ package top.yeij.cyrene.ui.chat
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
@@ -45,6 +47,9 @@ import top.yukonga.miuix.kmp.theme.MiuixTheme
/** 判定「贴着底部」的容差(px)。 */ /** 判定「贴着底部」的容差(px)。 */
private const val BOTTOM_TOLERANCE = 64 private const val BOTTOM_TOLERANCE = 64
/** 输入栏下方预留的高度:避开屏幕圆角,同时承载忙碌提示。 */
private val InputBottomSpacer = 22.dp
@Composable @Composable
fun ChatScreen( fun ChatScreen(
container: AppContainer, container: AppContainer,
@@ -96,7 +101,7 @@ fun ChatScreen(
topBar = { topBar = {
SmallTopAppBar( SmallTopAppBar(
title = "与昔涟", title = "与昔涟",
subtitle = statusLabel(state), subtitle = connectionLabel(state),
navigationIcon = { navigationIcon = {
IconButton(onClick = onOpenDrawer) { IconButton(onClick = onOpenDrawer) {
Icon(imageVector = MiuixIcons.Basic.Sidebar, contentDescription = "菜单") Icon(imageVector = MiuixIcons.Basic.Sidebar, contentDescription = "菜单")
@@ -107,6 +112,7 @@ fun ChatScreen(
bottomBar = { bottomBar = {
InputBar( InputBar(
value = state.draft, value = state.draft,
busyText = busyLabel(state),
onValueChange = viewModel::onDraftChange, onValueChange = viewModel::onDraftChange,
onSend = viewModel::send, onSend = viewModel::send,
) )
@@ -172,30 +178,46 @@ fun ChatScreen(
@Composable @Composable
private fun InputBar( private fun InputBar(
value: String, value: String,
busyText: String,
onValueChange: (String) -> Unit, onValueChange: (String) -> Unit,
onSend: () -> Unit, onSend: () -> Unit,
) { ) {
Row( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.imePadding() .imePadding()
.padding(horizontal = 12.dp, vertical = 8.dp), .padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 4.dp),
verticalAlignment = Alignment.Bottom,
) { ) {
TextField( Row(verticalAlignment = Alignment.Bottom) {
value = value, TextField(
onValueChange = onValueChange, value = value,
label = "和昔涟说点什么…", onValueChange = onValueChange,
useLabelAsPlaceholder = true, label = "和昔涟说点什么…",
maxLines = 5, useLabelAsPlaceholder = true,
modifier = Modifier.weight(1f), maxLines = 5,
) modifier = Modifier.weight(1f),
Spacer(Modifier.width(8.dp)) )
Button( Spacer(Modifier.width(8.dp))
onClick = onSend, Button(
enabled = value.isNotBlank(), 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 && state.queueDepth > 0 -> "正在思考… 已排队 ${state.queueDepth} 条"
state.busyMode != null -> "正在思考…" state.busyMode != null -> "正在思考…"
else -> when (state.connection) { else -> ""
WsState.Connected -> "已连接"
WsState.Connecting -> "连接中…"
WsState.Reconnecting -> "重连中…"
WsState.Offline -> "离线"
WsState.Idle -> ""
}
} }
@@ -304,6 +304,13 @@ class ChatViewModel(
if (msg.sessionId != sessionId) return if (msg.sessionId != sessionId) return
val clearStreaming = msg.role == "assistant" 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 if (clearStreaming) streamParser = null
_state.update { state -> _state.update { state ->
@@ -314,11 +321,24 @@ class ChatViewModel(
state.pending state.pending
} }
state.copy( state.copy(
messages = mergeMessages(state.messages, listOf(msg)), messages = mergeMessages(state.messages, listOf(persisted)),
pending = pending, pending = pending,
streaming = if (clearStreaming) null else state.streaming, 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 = private fun Throwable.userMessage(): String =
(this as? CyreneException)?.error?.userMessage ?: "加载失败,请稍后重试" (this as? CyreneException)?.error?.userMessage ?: "加载失败,请稍后重试"
/** 思维链在流式结束后继续保留的时长。 */
private const val THINKING_LINGER_MILLIS = 10 * 60 * 1000L
@@ -33,7 +33,11 @@ fun ThinkingCard(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
if (text.isBlank() && !active) return 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 }) { FoldCard(modifier = modifier, onClick = { expanded = !expanded }) {
FoldCardHeader( FoldCardHeader(