From 7faa604fd04f1028692de957efbec65da2f2cf92 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 8 Sep 2026 21:48:40 +0800 Subject: [PATCH] =?UTF-8?q?feat(chat):=20M4=20=E6=80=9D=E7=BB=B4=E9=93=BE?= =?UTF-8?q?=E5=8D=A1=E7=89=87=20+=20=E5=B7=A5=E5=85=B7=E5=8D=A1=20+=20?= =?UTF-8?q?=E5=9B=BE=E7=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit thinking.*/tool_call.*/tool_execution.update 累积进流式状态并渲染为可折叠卡片(思维链默认展开);历史消息的 thinking(来自 REST /messages)同样展示;image 用 Coil 3 加载。 --- app/build.gradle.kts | 2 + .../top/yeij/cyrene/ui/chat/ChatViewModel.kt | 91 ++++++++++-- .../top/yeij/cyrene/ui/chat/MessageBubble.kt | 97 ++++++++----- .../yeij/cyrene/ui/chat/StreamingBlocks.kt | 129 ++++++++++++++++++ gradle/libs.versions.toml | 3 + 5 files changed, 280 insertions(+), 42 deletions(-) create mode 100644 app/src/main/kotlin/top/yeij/cyrene/ui/chat/StreamingBlocks.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8312a16..4f3244c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt index 9732477..a2cb3ab 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt @@ -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 = emptyList(), + val thinking: String = "", + val thinkingActive: Boolean = false, + val tools: List = 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())) } } 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 index 0cbd579..1f01a47 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageBubble.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageBubble.kt @@ -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)时按 `

` 切分, * `` / `` 解析成灰斜体小字,依附到所属气泡的上方/下方; - * - 其它 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, +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) { @@ -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(), diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/StreamingBlocks.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/StreamingBlocks.kt new file mode 100644 index 0000000..59fe354 --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/StreamingBlocks.kt @@ -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, + ) +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ba6679d..11f1a3c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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]