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.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 -> ""
}
@@ -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
@@ -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(