feat(chat): M4 思维链卡片 + 工具卡 + 图片

thinking.*/tool_call.*/tool_execution.update 累积进流式状态并渲染为可折叠卡片(思维链默认展开);历史消息的 thinking(来自 REST /messages)同样展示;image 用 Coil 3 加载。
This commit is contained in:
AskaEth committed 2026-09-08 21:48:40 +08:00
1 parent 5ac0ea3c3b
commit 7faa604fd0
5 files changed
+280 -42

No files matched your search

+2
View File
@@ -68,6 +68,8 @@ dependencies {
implementation(libs.kotlinx.coroutines.android)
implementation(libs.androidx.security.crypto)
implementation(libs.androidx.datastore.preferences)
implementation(libs.coil.compose)
implementation(libs.coil.network.okhttp)
debugImplementation(libs.androidx.compose.ui.tooling)
@@ -26,9 +26,21 @@ private const val DEVICE_ID = "voiceassist:cyrene:v1"
/** 乐观消息:已发出、还没等到 `message_persisted` 归一的本地消息。 */
data class PendingMessage(val localId: String, val text: String)
/** 正在流式的助手回复(已按增量解析成气泡)。 */
/** 一次工具调用的中间态(参数增量累积 + 执行输出)。 */
data class ToolCallState(
val id: String,
val index: Int,
val arguments: String = "",
val output: String = "",
val done: Boolean = false,
)
/** 正在流式的助手回复。 */
data class StreamingState(
val bubbles: List<ParsedBubble> = emptyList(),
val thinking: String = "",
val thinkingActive: Boolean = false,
val tools: List<ToolCallState> = emptyList(),
val active: Boolean = true,
)
@@ -73,13 +85,13 @@ data class ChatUiState(
}
/**
* M2 + M3:只读历史 + 发送 + 流式。
* M2 + M3 + M4:只读历史 + 发送 + 流式 + 思维链/工具卡。
*
* 数据来源四路,全部按 `id` 去重收敛:
* 1. REST 首屏 / 上拉分页(权威,带游标)
* 2. WS 首帧 `history`
* 3. WS 增量 `message_persisted`(含乐观消息归一)
* 4. WS 流式 `message.start/delta/end` + `stream_end`(临时气泡,落库后由 3 取代)
* 4. WS 流式 `thinking.* / tool_call.* / message.* / stream_end`(临时气泡,落库后由 3 取代)
*/
class ChatViewModel(
private val container: AppContainer,
@@ -173,23 +185,70 @@ class ChatViewModel(
}
}
// —— 流式正文 ——
is WsEvent.MessageStart -> {
if (event.index == 0) streamParser = StreamingContentParser()
if (event.index == 0) {
streamParser = StreamingContentParser()
freshTurnIfFinished()
}
val parser = streamParser ?: StreamingContentParser().also { streamParser = it }
val bubbles = parser.onDelta("")
_state.update { it.copy(streaming = StreamingState(bubbles, active = true)) }
updateStreaming { it.copy(bubbles = bubbles, active = true) }
}
is WsEvent.MessageDelta -> {
val parser = streamParser ?: StreamingContentParser().also { streamParser = it }
val bubbles = parser.onDelta(event.delta)
_state.update { it.copy(streaming = StreamingState(bubbles, active = true)) }
updateStreaming { it.copy(bubbles = bubbles, active = true) }
}
// —— 思维链 ——
is WsEvent.ThinkingStart -> {
if (event.index == 0) freshTurnIfFinished()
updateStreaming { it.copy(thinkingActive = true) }
}
is WsEvent.ThinkingDelta -> updateStreaming {
it.copy(thinking = it.thinking + event.delta, thinkingActive = true)
}
is WsEvent.ThinkingEnd -> updateStreaming { it.copy(thinkingActive = false) }
// —— 工具调用 ——
is WsEvent.ToolCallStart -> updateStreaming { streaming ->
if (streaming.tools.any { it.id == event.id }) {
streaming
} else {
streaming.copy(tools = streaming.tools + ToolCallState(event.id, event.index))
}
}
is WsEvent.ToolCallDelta -> updateStreaming { streaming ->
streaming.copy(
tools = streaming.tools.map {
if (it.id == event.id) it.copy(arguments = it.arguments + event.delta) else it
},
)
}
is WsEvent.ToolCallEnd -> updateStreaming { streaming ->
streaming.copy(
tools = streaming.tools.map { if (it.id == event.id) it.copy(done = true) else it },
)
}
is WsEvent.ToolExecutionUpdate -> updateStreaming { streaming ->
streaming.copy(
tools = streaming.tools.map {
if (it.id == event.id) it.copy(output = it.output + event.delta) else it
},
)
}
is WsEvent.StreamEnd -> {
val bubbles = streamParser?.finish().orEmpty()
_state.update {
it.copy(streaming = if (bubbles.isEmpty()) null else StreamingState(bubbles, active = false))
updateStreaming {
it.copy(bubbles = bubbles, thinkingActive = false, active = false)
}
}
@@ -217,7 +276,21 @@ class ChatViewModel(
is WsEvent.Error -> _state.update { it.copy(error = event.message) }
else -> Unit // thinking / tool_call / 图片等留到后续
else -> Unit
}
}
/** 新一轮开始时,若上一轮的流式状态已结束(active=false)就清掉,避免思维链/工具卡串台。 */
private fun freshTurnIfFinished() {
_state.update { state ->
val streaming = state.streaming
if (streaming != null && !streaming.active) state.copy(streaming = StreamingState()) else state
}
}
private fun updateStreaming(block: (StreamingState) -> StreamingState) {
_state.update { state ->
state.copy(streaming = block(state.streaming ?: StreamingState()))
}
}
@@ -4,6 +4,7 @@ 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.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
@@ -12,8 +13,10 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
import top.yeij.cyrene.domain.parse.ContentPart
import top.yeij.cyrene.domain.parse.ParsedBubble
@@ -28,70 +31,74 @@ private const val CURSOR = "▍"
* 一条落库消息渲染成一个或多个气泡:
* - `mode` 为 `chat`(或缺失——后端当前不落库 mode,一律回落 chat)时按 `<p>` 切分,
* `<action>` / `<narration>` 解析成灰斜体小字,依附到所属气泡的上方/下方;
* - 其它 mode 原样整条渲染,不解析标签。
* - 其它 mode 原样整条渲染,不解析标签;
* - `thinking` 非空时(来自 REST `/messages`,owner 可见)在气泡上方显示思维链卡片;
* - `image` 非空时在气泡上方显示图片。
*/
@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(plainBubble(message.content)) }
}
BubbleGroup(
bubbles = bubbles,
isUser = message.role == "user",
footer = messageSourceLabel(message),
)
MessageGroup(isUser = isUser, footer = messageSourceLabel(message)) {
message.thinking?.takeIf { it.isNotBlank() }?.let { ThinkingCard(it) }
message.image?.takeIf { it.isNotBlank() }?.let { ChatImage(it) }
bubbles.forEachIndexed { index, bubble ->
Bubble(bubble, isUser, isLast = index == bubbles.lastIndex)
}
}
}
/** 乐观发送中的用户消息(还没落库)。 */
@Composable
fun PendingBubble(item: PendingMessage) {
BubbleGroup(
bubbles = listOf(plainBubble(item.text)),
isUser = true,
footer = "发送中…",
)
MessageGroup(isUser = true, footer = "发送中…") {
Bubble(plainBubble(item.text), isUser = true, isLast = true)
}
}
/** 正在流式的助手回复;`active` 时在末尾追加光标。 */
/** 正在流式的助手回复:思维链 → 工具卡 → 气泡(末尾带光标)。 */
@Composable
fun StreamingBubble(streaming: StreamingState) {
BubbleGroup(
bubbles = streaming.bubbles,
isUser = false,
footer = null,
showCursor = streaming.active,
)
MessageGroup(isUser = false) {
if (streaming.thinking.isNotBlank() || streaming.thinkingActive) {
ThinkingCard(text = streaming.thinking, active = streaming.thinkingActive)
}
streaming.tools.forEach { ToolCard(it) }
val bubbles = if (streaming.bubbles.isEmpty() && streaming.active) {
listOf(plainBubble(CURSOR))
} else {
streaming.bubbles
}
bubbles.forEachIndexed { index, bubble ->
Bubble(
bubble = bubble,
isUser = false,
isLast = index == bubbles.lastIndex,
showCursor = streaming.active,
)
}
}
}
@Composable
private fun BubbleGroup(
bubbles: List<ParsedBubble>,
private fun MessageGroup(
isUser: Boolean,
footer: String? = null,
showCursor: Boolean = false,
content: @Composable ColumnScope.() -> Unit,
) {
val rendered = if (bubbles.isEmpty() && showCursor) listOf(plainBubble(CURSOR)) else bubbles
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp),
horizontalAlignment = if (isUser) Alignment.End else Alignment.Start,
verticalArrangement = Arrangement.spacedBy(6.dp),
content = content,
) {
rendered.forEachIndexed { index, bubble ->
AnnotationLine(bubble.head)
val body = if (showCursor && index == rendered.lastIndex && bubble.body.isNotEmpty()) {
bubble.body + CURSOR
} else {
bubble.body
}
if (body.isNotEmpty()) BubbleBody(body, isUser)
AnnotationLine(bubble.tail)
}
// 来源角标挂在消息组末尾,紧随它标注的那条消息(主人右下、昔涟左下)
if (footer != null) {
Text(
@@ -104,6 +111,19 @@ private fun BubbleGroup(
}
}
@Composable
private fun Bubble(
bubble: ParsedBubble,
isUser: Boolean,
isLast: Boolean,
showCursor: Boolean = false,
) {
AnnotationLine(bubble.head)
val body = if (showCursor && isLast && bubble.body.isNotEmpty()) bubble.body + CURSOR else bubble.body
if (body.isNotEmpty()) BubbleBody(body, isUser)
AnnotationLine(bubble.tail)
}
/** 旁白/动作:灰斜体小字,依附在气泡上/下。 */
@Composable
private fun AnnotationLine(parts: List<ContentPart>) {
@@ -141,6 +161,17 @@ private fun BubbleBody(text: String, isUser: Boolean) {
}
}
@Composable
private fun ChatImage(model: String) {
AsyncImage(
model = model,
contentDescription = null,
modifier = Modifier
.widthIn(max = 240.dp)
.clip(RoundedCornerShape(14.dp)),
)
}
private fun plainBubble(text: String) = ParsedBubble(
body = text,
parts = emptyList(),
@@ -0,0 +1,129 @@
package top.yeij.cyrene.ui.chat
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.theme.MiuixTheme
/**
* 思维链卡片。默认展开(方便观察她的思考过程),可折叠。
* 流式期间标题显示「思考中…」。
*/
@Composable
fun ThinkingCard(
text: String,
active: Boolean = false,
modifier: Modifier = Modifier,
) {
if (text.isBlank() && !active) return
var expanded by remember { mutableStateOf(true) }
FoldCard(modifier = modifier, onClick = { expanded = !expanded }) {
FoldCardHeader(
title = if (active) "思考中…" else "思维链",
expanded = expanded,
)
if (expanded && text.isNotBlank()) {
Spacer(Modifier.height(6.dp))
FoldCardBody(text)
}
}
}
/** 工具调用卡片:参数与执行输出分开显示,默认折叠。 */
@Composable
fun ToolCard(
tool: ToolCallState,
modifier: Modifier = Modifier,
) {
var expanded by remember { mutableStateOf(false) }
FoldCard(modifier = modifier, onClick = { expanded = !expanded }) {
FoldCardHeader(
title = if (tool.done) "工具调用" else "调用工具…",
expanded = expanded,
)
if (expanded) {
if (tool.arguments.isNotBlank()) {
Spacer(Modifier.height(6.dp))
FoldCardLabel("参数")
FoldCardBody(tool.arguments)
}
if (tool.output.isNotBlank()) {
Spacer(Modifier.height(6.dp))
FoldCardLabel("结果")
FoldCardBody(tool.output)
}
}
}
}
@Composable
private fun FoldCard(
modifier: Modifier,
onClick: () -> Unit,
content: @Composable ColumnScope.() -> Unit,
) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 14.dp)
.background(MiuixTheme.colorScheme.surfaceContainerHigh, RoundedCornerShape(14.dp))
.clickable(onClick = onClick)
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
content = content,
)
}
@Composable
private fun FoldCardHeader(title: String, expanded: Boolean) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = title,
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
modifier = Modifier.weight(1f),
)
Text(
text = if (expanded) "收起" else "展开",
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.primary,
)
}
}
@Composable
private fun FoldCardLabel(text: String) {
Text(
text = text,
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.primary,
)
}
@Composable
private fun FoldCardBody(text: String) {
Text(
text = text,
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
+3
View File
@@ -13,6 +13,7 @@ coroutines = "1.11.0"
securityCrypto = "1.1.0"
junit = "4.13.2"
datastore = "1.2.1"
coil = "3.6.2"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -40,6 +41,8 @@ kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" }
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }
coil-network-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
[plugins]