feat(chat): 思维链保留 10 分钟并自动折叠;输入栏下方留白承载忙碌提示
流式结束后把思维链挂到落库消息上,10 分钟后自动清除;ThinkingCard 在 active 变 false 时自动折叠,历史思维链默认折叠。输入栏底部预留 22dp 避开屏幕圆角,顶栏副标题只留连接状态,忙碌提示移到输入栏下方(带左缩进)。
This commit is contained in:
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(
|
||||||
|
|||||||
Reference in new issue
Block a user