feat(chat): M4 思维链卡片 + 工具卡 + 图片
thinking.*/tool_call.*/tool_execution.update 累积进流式状态并渲染为可折叠卡片(思维链默认展开);历史消息的 thinking(来自 REST /messages)同样展示;image 用 Coil 3 加载。
This commit is contained in:
1 parent
5ac0ea3c3b
commit
7faa604fd0
5 files changed
+280
-42
No files matched your search
@@ -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,
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user