feat(chat): 接入 <p> 多气泡与 <action>/<narration> 旁白渲染

chat 模式消息按 <p> 拆成多个气泡;<action>/<narration> 解析为灰斜体小字依附气泡上/下;来源角标只在最后一条气泡显示一次。非 chat 模式原样渲染。
This commit is contained in:
AskaEth committed 2026-09-08 21:01:48 +08:00
1 parent 171f6ff59a
commit 63fe9a12a6
2 files changed
+107 -39

No files matched your search

@@ -3,16 +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.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -26,7 +23,6 @@ import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory import androidx.lifecycle.viewmodel.viewModelFactory
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import top.yeij.cyrene.app.AppContainer import top.yeij.cyrene.app.AppContainer
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
import top.yeij.cyrene.data.ws.WsState import top.yeij.cyrene.data.ws.WsState
import top.yukonga.miuix.kmp.basic.Icon import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.IconButton 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 @Composable
private fun CenterHint(text: String) { private fun CenterHint(text: String) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
@@ -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)时按 `<p>` 切分,
* `<action>` / `<narration>` 解析成灰斜体小字,依附到所属气泡的上方/下方;
* - 其它 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<ContentPart>) {
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,
)
}
}