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.kotlinx.coroutines.android)
implementation(libs.androidx.security.crypto) implementation(libs.androidx.security.crypto)
implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.datastore.preferences)
implementation(libs.coil.compose)
implementation(libs.coil.network.okhttp)
debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.tooling)
@@ -26,9 +26,21 @@ private const val DEVICE_ID = "voiceassist:cyrene:v1"
/** 乐观消息:已发出、还没等到 `message_persisted` 归一的本地消息。 */ /** 乐观消息:已发出、还没等到 `message_persisted` 归一的本地消息。 */
data class PendingMessage(val localId: String, val text: String) 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( data class StreamingState(
val bubbles: List<ParsedBubble> = emptyList(), val bubbles: List<ParsedBubble> = emptyList(),
val thinking: String = "",
val thinkingActive: Boolean = false,
val tools: List<ToolCallState> = emptyList(),
val active: Boolean = true, val active: Boolean = true,
) )
@@ -73,13 +85,13 @@ data class ChatUiState(
} }
/** /**
* M2 + M3:只读历史 + 发送 + 流式。 * M2 + M3 + M4:只读历史 + 发送 + 流式 + 思维链/工具卡。
* *
* 数据来源四路,全部按 `id` 去重收敛: * 数据来源四路,全部按 `id` 去重收敛:
* 1. REST 首屏 / 上拉分页(权威,带游标) * 1. REST 首屏 / 上拉分页(权威,带游标)
* 2. WS 首帧 `history` * 2. WS 首帧 `history`
* 3. WS 增量 `message_persisted`(含乐观消息归一) * 3. WS 增量 `message_persisted`(含乐观消息归一)
* 4. WS 流式 `message.start/delta/end` + `stream_end`(临时气泡,落库后由 3 取代) * 4. WS 流式 `thinking.* / tool_call.* / message.* / stream_end`(临时气泡,落库后由 3 取代)
*/ */
class ChatViewModel( class ChatViewModel(
private val container: AppContainer, private val container: AppContainer,
@@ -173,23 +185,70 @@ class ChatViewModel(
} }
} }
// —— 流式正文 ——
is WsEvent.MessageStart -> { is WsEvent.MessageStart -> {
if (event.index == 0) streamParser = StreamingContentParser() if (event.index == 0) {
streamParser = StreamingContentParser()
freshTurnIfFinished()
}
val parser = streamParser ?: StreamingContentParser().also { streamParser = it } val parser = streamParser ?: StreamingContentParser().also { streamParser = it }
val bubbles = parser.onDelta("") val bubbles = parser.onDelta("")
_state.update { it.copy(streaming = StreamingState(bubbles, active = true)) } updateStreaming { it.copy(bubbles = bubbles, active = true) }
} }
is WsEvent.MessageDelta -> { is WsEvent.MessageDelta -> {
val parser = streamParser ?: StreamingContentParser().also { streamParser = it } val parser = streamParser ?: StreamingContentParser().also { streamParser = it }
val bubbles = parser.onDelta(event.delta) 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 -> { is WsEvent.StreamEnd -> {
val bubbles = streamParser?.finish().orEmpty() val bubbles = streamParser?.finish().orEmpty()
_state.update { updateStreaming {
it.copy(streaming = if (bubbles.isEmpty()) null else StreamingState(bubbles, active = false)) it.copy(bubbles = bubbles, thinkingActive = false, active = false)
} }
} }
@@ -217,7 +276,21 @@ class ChatViewModel(
is WsEvent.Error -> _state.update { it.copy(error = event.message) } 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
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.layout.widthIn
@@ -12,8 +13,10 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import top.yeij.cyrene.data.api.dto.HistoryMessageDto import top.yeij.cyrene.data.api.dto.HistoryMessageDto
import top.yeij.cyrene.domain.parse.ContentPart import top.yeij.cyrene.domain.parse.ContentPart
import top.yeij.cyrene.domain.parse.ParsedBubble import top.yeij.cyrene.domain.parse.ParsedBubble
@@ -28,70 +31,74 @@ private const val CURSOR = "▍"
* 一条落库消息渲染成一个或多个气泡: * 一条落库消息渲染成一个或多个气泡:
* - `mode` 为 `chat`(或缺失——后端当前不落库 mode,一律回落 chat)时按 `<p>` 切分, * - `mode` 为 `chat`(或缺失——后端当前不落库 mode,一律回落 chat)时按 `<p>` 切分,
* `<action>` / `<narration>` 解析成灰斜体小字,依附到所属气泡的上方/下方; * `<action>` / `<narration>` 解析成灰斜体小字,依附到所属气泡的上方/下方;
* - 其它 mode 原样整条渲染,不解析标签。 * - 其它 mode 原样整条渲染,不解析标签;
* - `thinking` 非空时(来自 REST `/messages`,owner 可见)在气泡上方显示思维链卡片;
* - `image` 非空时在气泡上方显示图片。
*/ */
@Composable @Composable
fun MessageBubble(message: HistoryMessageDto) { fun MessageBubble(message: HistoryMessageDto) {
val isUser = message.role == "user"
val isChat = message.mode.isNullOrBlank() || message.mode == "chat" val isChat = message.mode.isNullOrBlank() || message.mode == "chat"
val bubbles = remember(message.content, isChat) { val bubbles = remember(message.content, isChat) {
val parsed = if (isChat) parse(message.content).filterNot { it.isEmpty } else emptyList() val parsed = if (isChat) parse(message.content).filterNot { it.isEmpty } else emptyList()
parsed.ifEmpty { listOf(plainBubble(message.content)) } parsed.ifEmpty { listOf(plainBubble(message.content)) }
} }
BubbleGroup( MessageGroup(isUser = isUser, footer = messageSourceLabel(message)) {
bubbles = bubbles, message.thinking?.takeIf { it.isNotBlank() }?.let { ThinkingCard(it) }
isUser = message.role == "user", message.image?.takeIf { it.isNotBlank() }?.let { ChatImage(it) }
footer = messageSourceLabel(message), bubbles.forEachIndexed { index, bubble ->
) Bubble(bubble, isUser, isLast = index == bubbles.lastIndex)
}
}
} }
/** 乐观发送中的用户消息(还没落库)。 */ /** 乐观发送中的用户消息(还没落库)。 */
@Composable @Composable
fun PendingBubble(item: PendingMessage) { fun PendingBubble(item: PendingMessage) {
BubbleGroup( MessageGroup(isUser = true, footer = "发送中…") {
bubbles = listOf(plainBubble(item.text)), Bubble(plainBubble(item.text), isUser = true, isLast = true)
isUser = true, }
footer = "发送中…",
)
} }
/** 正在流式的助手回复;`active` 时在末尾追加光标。 */ /** 正在流式的助手回复:思维链 → 工具卡 → 气泡(末尾带光标)。 */
@Composable @Composable
fun StreamingBubble(streaming: StreamingState) { fun StreamingBubble(streaming: StreamingState) {
BubbleGroup( MessageGroup(isUser = false) {
bubbles = streaming.bubbles, if (streaming.thinking.isNotBlank() || streaming.thinkingActive) {
isUser = false, ThinkingCard(text = streaming.thinking, active = streaming.thinkingActive)
footer = null, }
showCursor = streaming.active, 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 @Composable
private fun BubbleGroup( private fun MessageGroup(
bubbles: List<ParsedBubble>,
isUser: Boolean, isUser: Boolean,
footer: String? = null, footer: String? = null,
showCursor: Boolean = false, content: @Composable ColumnScope.() -> Unit,
) { ) {
val rendered = if (bubbles.isEmpty() && showCursor) listOf(plainBubble(CURSOR)) else bubbles
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 14.dp), .padding(horizontal = 14.dp),
horizontalAlignment = if (isUser) Alignment.End else Alignment.Start, horizontalAlignment = if (isUser) Alignment.End else Alignment.Start,
verticalArrangement = Arrangement.spacedBy(6.dp), 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) { if (footer != null) {
Text( 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 @Composable
private fun AnnotationLine(parts: List<ContentPart>) { 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( private fun plainBubble(text: String) = ParsedBubble(
body = text, body = text,
parts = emptyList(), 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" securityCrypto = "1.1.0"
junit = "4.13.2" junit = "4.13.2"
datastore = "1.2.1" datastore = "1.2.1"
coil = "3.6.2"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } 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" } 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-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" }
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } 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" } junit = { group = "junit", name = "junit", version.ref = "junit" }
[plugins] [plugins]