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 abb0dc8..b2e62d6 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,16 +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.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -26,7 +23,6 @@ import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory import kotlinx.coroutines.flow.distinctUntilChanged import top.yeij.cyrene.app.AppContainer -import top.yeij.cyrene.data.api.dto.HistoryMessageDto import top.yeij.cyrene.data.ws.WsState import top.yukonga.miuix.kmp.basic.Icon import top.yukonga.miuix.kmp.basic.IconButton @@ -130,41 +126,6 @@ fun ChatScreen( } } -@Composable -private fun MessageBubble(message: HistoryMessageDto) { - val isUser = message.role == "user" - val bubbleColor = if (isUser) MiuixTheme.colorScheme.primary else MiuixTheme.colorScheme.surfaceContainer - val textColor = if (isUser) MiuixTheme.colorScheme.onPrimary else MiuixTheme.colorScheme.onSurfaceContainer - - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 14.dp), - horizontalAlignment = if (isUser) Alignment.End else Alignment.Start, - ) { - Box( - modifier = Modifier - .widthIn(max = 300.dp) - .background(color = bubbleColor, shape = RoundedCornerShape(18.dp)) - .padding(horizontal = 14.dp, vertical = 10.dp), - ) { - Text( - text = message.content, - style = MiuixTheme.textStyles.main, - color = textColor, - ) - } - messageSourceLabel(message)?.let { source -> - Text( - text = source, - style = MiuixTheme.textStyles.footnote2, - color = MiuixTheme.colorScheme.onSurfaceVariantSummary, - modifier = Modifier.padding(top = 3.dp, start = 6.dp, end = 6.dp), - ) - } - } -} - @Composable private fun CenterHint(text: String) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageBubble.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageBubble.kt new file mode 100644 index 0000000..38fafce --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageBubble.kt @@ -0,0 +1,107 @@ +package top.yeij.cyrene.ui.chat + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.unit.dp +import top.yeij.cyrene.data.api.dto.HistoryMessageDto +import top.yeij.cyrene.domain.parse.ContentPart +import top.yeij.cyrene.domain.parse.ParsedBubble +import top.yeij.cyrene.domain.parse.parse +import top.yukonga.miuix.kmp.basic.Text +import top.yukonga.miuix.kmp.theme.MiuixTheme + +/** + * 一条消息渲染成一个或多个气泡: + * - `mode` 为 `chat`(或缺失——后端当前不落库 mode,一律回落 chat)时按 `

` 切分, + * `` / `` 解析成灰斜体小字,依附到所属气泡的上方/下方; + * - 其它 mode 原样整条渲染,不解析标签。 + */ +@Composable +fun MessageBubble(message: HistoryMessageDto) { + val isUser = message.role == "user" + val isChat = message.mode.isNullOrBlank() || message.mode == "chat" + + val bubbles = remember(message.content, isChat) { + val parsed = if (isChat) parse(message.content).filterNot { it.isEmpty } else emptyList() + parsed.ifEmpty { + listOf( + ParsedBubble( + body = message.content, + parts = emptyList(), + head = emptyList(), + tail = emptyList(), + ), + ) + } + } + val source = messageSourceLabel(message) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp), + horizontalAlignment = if (isUser) Alignment.End else Alignment.Start, + ) { + bubbles.forEachIndexed { index, bubble -> + AnnotationLine(bubble.head) + if (bubble.body.isNotEmpty()) BubbleBody(bubble.body, isUser) + AnnotationLine(bubble.tail) + // 来源角标只在最后一条气泡下方显示一次,避免多气泡消息重复刷屏 + if (index == bubbles.lastIndex && source != null) { + Text( + text = source, + style = MiuixTheme.textStyles.footnote2, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary, + modifier = Modifier.padding(top = 3.dp, start = 6.dp, end = 6.dp), + ) + } + } + } +} + +/** 旁白/动作:灰斜体小字,依附在气泡上/下。 */ +@Composable +private fun AnnotationLine(parts: List) { + val text = parts.joinToString(" ") { it.text }.trim() + if (text.isEmpty()) return + Text( + text = text, + style = MiuixTheme.textStyles.footnote1, + fontStyle = FontStyle.Italic, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp), + ) +} + +@Composable +private fun BubbleBody(text: String, isUser: Boolean) { + Box( + modifier = Modifier + .widthIn(max = 300.dp) + .background( + color = if (isUser) { + MiuixTheme.colorScheme.primary + } else { + MiuixTheme.colorScheme.surfaceContainer + }, + shape = RoundedCornerShape(18.dp), + ) + .padding(horizontal = 14.dp, vertical = 10.dp), + ) { + Text( + text = text, + style = MiuixTheme.textStyles.main, + color = if (isUser) MiuixTheme.colorScheme.onPrimary else MiuixTheme.colorScheme.onSurfaceContainer, + ) + } +}