feat(chat): M3 发送与流式渲染

输入框 + 发送(WS chat 上行,deviceId=voiceassist:cyrene:v1);乐观消息先上屏,message_persisted 到达后按文本归一;message.start/delta/end + stream_end 走流式增量解析,末尾带光标;发送失败回滚文本到输入框。
This commit is contained in:
AskaEth committed 2026-09-08 21:09:37 +08:00
1 parent a8a422c4e1
commit 67ed0479e4
3 files changed
+240 -42

No files matched your search

@@ -4,9 +4,13 @@ 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.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.imePadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
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
@@ -24,12 +28,14 @@ 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.ws.WsState import top.yeij.cyrene.data.ws.WsState
import top.yukonga.miuix.kmp.basic.Button
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
import top.yukonga.miuix.kmp.basic.Scaffold import top.yukonga.miuix.kmp.basic.Scaffold
import top.yukonga.miuix.kmp.basic.SmallTopAppBar import top.yukonga.miuix.kmp.basic.SmallTopAppBar
import top.yukonga.miuix.kmp.basic.Text import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TextButton import top.yukonga.miuix.kmp.basic.TextButton
import top.yukonga.miuix.kmp.basic.TextField
import top.yukonga.miuix.kmp.icon.MiuixIcons import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.basic.Sidebar import top.yukonga.miuix.kmp.icon.basic.Sidebar
import top.yukonga.miuix.kmp.theme.MiuixTheme import top.yukonga.miuix.kmp.theme.MiuixTheme
@@ -72,6 +78,13 @@ fun ChatScreen(
}, },
) )
}, },
bottomBar = {
InputBar(
value = state.draft,
onValueChange = viewModel::onDraftChange,
onSend = viewModel::send,
)
},
) { padding -> ) { padding ->
Box( Box(
modifier = Modifier modifier = Modifier
@@ -79,8 +92,8 @@ fun ChatScreen(
.padding(padding), .padding(padding),
) { ) {
when { when {
state.loading && state.messages.isEmpty() -> CenterHint("加载中…") state.loading && state.items.isEmpty() -> CenterHint("加载中…")
state.messages.isEmpty() -> CenterHint("还没有消息") state.items.isEmpty() -> CenterHint("还没有消息")
else -> LazyColumn( else -> LazyColumn(
state = listState, state = listState,
reverseLayout = true, reverseLayout = true,
@@ -88,8 +101,12 @@ fun ChatScreen(
contentPadding = PaddingValues(vertical = 12.dp), contentPadding = PaddingValues(vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp), verticalArrangement = Arrangement.spacedBy(10.dp),
) { ) {
items(items = state.messages.asReversed(), key = { it.id }) { message -> items(items = state.items.asReversed(), key = { it.key }) { item ->
MessageBubble(message) when (item) {
is ChatDisplayItem.Committed -> MessageBubble(item.message)
is ChatDisplayItem.Pending -> PendingBubble(item.item)
ChatDisplayItem.Streaming -> state.streaming?.let { StreamingBubble(it) }
}
} }
if (state.hasMore) { if (state.hasMore) {
item(key = "__load_older__") { item(key = "__load_older__") {
@@ -126,6 +143,37 @@ fun ChatScreen(
} }
} }
@Composable
private fun InputBar(
value: String,
onValueChange: (String) -> Unit,
onSend: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.imePadding()
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.Bottom,
) {
TextField(
value = value,
onValueChange = onValueChange,
label = "和昔涟说点什么…",
useLabelAsPlaceholder = true,
maxLines = 5,
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(8.dp))
Button(
onClick = onSend,
enabled = value.isNotBlank(),
) {
Text("发送")
}
}
}
@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) {
@@ -15,32 +15,71 @@ import top.yeij.cyrene.data.repository.mergeMessages
import top.yeij.cyrene.data.ws.ChatSocket import top.yeij.cyrene.data.ws.ChatSocket
import top.yeij.cyrene.data.ws.WsEvent import top.yeij.cyrene.data.ws.WsEvent
import top.yeij.cyrene.data.ws.WsState import top.yeij.cyrene.data.ws.WsState
import top.yeij.cyrene.domain.parse.ParsedBubble
import top.yeij.cyrene.domain.parse.StreamingContentParser
const val MAIN_SESSION_ID = "web" const val MAIN_SESSION_ID = "web"
/** 本端标识(design/16 §4.5 允许的可读前缀形态)。 */
private const val DEVICE_ID = "voiceassist:cyrene:v1"
/** 乐观消息:已发出、还没等到 `message_persisted` 归一的本地消息。 */
data class PendingMessage(val localId: String, val text: String)
/** 正在流式的助手回复(已按增量解析成气泡)。 */
data class StreamingState(
val bubbles: List<ParsedBubble> = emptyList(),
val active: Boolean = true,
)
sealed interface ChatDisplayItem {
val key: String
data class Committed(val message: HistoryMessageDto) : ChatDisplayItem {
override val key: String get() = "m:${message.id}"
}
data class Pending(val item: PendingMessage) : ChatDisplayItem {
override val key: String get() = "p:${item.localId}"
}
data object Streaming : ChatDisplayItem {
override val key: String get() = "s"
}
}
data class ChatUiState( data class ChatUiState(
/** 升序(旧 → 新);渲染时反向喂给 reverseLayout 的列表。 */ /** 已落库消息,升序(旧 → 新)。 */
val messages: List<HistoryMessageDto> = emptyList(), val messages: List<HistoryMessageDto> = emptyList(),
val pending: List<PendingMessage> = emptyList(),
val streaming: StreamingState? = null,
val draft: String = "",
val loading: Boolean = true, val loading: Boolean = true,
val loadingOlder: Boolean = false, val loadingOlder: Boolean = false,
val hasMore: Boolean = true, val hasMore: Boolean = true,
val error: String? = null, val error: String? = null,
val connection: WsState = WsState.Idle, val connection: WsState = WsState.Idle,
/** 编排忙碌态(`idle`/`thinking`/`executing`/`subagent`),null = 空闲。 */ /** 编排忙碌态:`idle`/`thinking`/`executing`/`subagent`;null = 空闲。 */
val busyMode: String? = null, val busyMode: String? = null,
val queueDepth: Int = 0, val queueDepth: Int = 0,
) ) {
/** 渲染顺序:已落库 → 乐观发送中 → 流式中(升序,UI 再反向喂给 reverseLayout)。 */
val items: List<ChatDisplayItem>
get() = buildList {
messages.forEach { add(ChatDisplayItem.Committed(it)) }
pending.forEach { add(ChatDisplayItem.Pending(it)) }
if (streaming != null) add(ChatDisplayItem.Streaming)
}
}
/** /**
* M2:只读主会话。 * M2 + M3:只读历史 + 发送 + 流式。
* *
* 数据来源三路合并(都走 `mergeMessages` 按 `id` 去重): * 数据来源四路,全部按 `id` 去重收敛:
* 1. REST 首屏 `loadLatest`(权威、带分页游标) * 1. REST 首屏 / 上拉分页(权威,带游标)
* 2. WS 首帧 `history`(与 REST 重叠,靠 id 去重收敛) * 2. WS 首帧 `history`
* 3. WS 增量 `message_persisted`(其它端/本端落库后的消息) * 3. WS 增量 `message_persisted`(含乐观消息归一)
* * 4. WS 流式 `message.start/delta/end` + `stream_end`(临时气泡,落库后由 3 取代)
* 注意(D14):结构化流帧不带 `sessionId`,同一时刻只允许一个会话在流式;
* 当前只有主会话,暂不涉及归属问题。
*/ */
class ChatViewModel( class ChatViewModel(
private val container: AppContainer, private val container: AppContainer,
@@ -53,6 +92,8 @@ class ChatViewModel(
private var socket: ChatSocket? = null private var socket: ChatSocket? = null
private var repository: MessageRepository? = null private var repository: MessageRepository? = null
private var oldestLoadedAt: Long? = null private var oldestLoadedAt: Long? = null
private var streamParser: StreamingContentParser? = null
private var pendingSeq = 0
init { init {
start() start()
@@ -85,12 +126,38 @@ class ChatViewModel(
viewModelScope.launch { viewModelScope.launch {
ws.state.collect { connection -> _state.update { it.copy(connection = connection) } } ws.state.collect { connection -> _state.update { it.copy(connection = connection) } }
} }
viewModelScope.launch { viewModelScope.launch { ws.events.collect(::onEvent) }
ws.events.collect(::onEvent)
}
ws.connect() ws.connect()
} }
fun onDraftChange(text: String) = _state.update { it.copy(draft = text) }
/** 发送:先乐观上屏,WS 上行失败则回滚并把文本还给输入框。 */
fun send() {
val text = _state.value.draft.trim()
if (text.isEmpty()) return
val localId = "local-${pendingSeq++}"
_state.update {
it.copy(
pending = it.pending + PendingMessage(localId, text),
draft = "",
error = null,
)
}
val sent = socket?.sendChat(sessionId, text, DEVICE_ID) ?: false
if (!sent) {
_state.update {
it.copy(
pending = it.pending.filterNot { p -> p.localId == localId },
draft = text,
error = "发送失败,请重试",
)
}
}
}
private fun onEvent(event: WsEvent) { private fun onEvent(event: WsEvent) {
when (event) { when (event) {
is WsEvent.History -> { is WsEvent.History -> {
@@ -106,12 +173,28 @@ class ChatViewModel(
} }
} }
is WsEvent.MessagePersisted -> { is WsEvent.MessageStart -> {
val message = event.message ?: return if (event.index == 0) streamParser = StreamingContentParser()
if (message.sessionId != sessionId) return val parser = streamParser ?: StreamingContentParser().also { streamParser = it }
_state.update { it.copy(messages = mergeMessages(it.messages, listOf(message))) } val bubbles = parser.onDelta("")
_state.update { it.copy(streaming = StreamingState(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)) }
}
is WsEvent.StreamEnd -> {
val bubbles = streamParser?.finish().orEmpty()
_state.update {
it.copy(streaming = if (bubbles.isEmpty()) null else StreamingState(bubbles, active = false))
}
}
is WsEvent.MessagePersisted -> onPersisted(event.message)
is WsEvent.SessionBusy -> if (event.sessionId == sessionId) { is WsEvent.SessionBusy -> if (event.sessionId == sessionId) {
_state.update { it.copy(busyMode = event.mode) } _state.update { it.copy(busyMode = event.mode) }
} }
@@ -134,11 +217,38 @@ class ChatViewModel(
is WsEvent.Error -> _state.update { it.copy(error = event.message) } is WsEvent.Error -> _state.update { it.copy(error = event.message) }
else -> Unit // 流式/思维链/工具卡等留到 M3、M4 else -> Unit // thinking / tool_call / 图片等留到后续
} }
} }
/** 上拉加载更早的一页(`before` 游标 = 当前已加载最旧一条的 createdAt)。 */ /**
* 落库消息到达:
* - user:按「同文本」把对应的乐观气泡去掉(协议没有 clientMsgId,只能这样匹配)
* - assistant:用落库内容取代流式临时气泡
*/
private fun onPersisted(message: HistoryMessageDto?) {
val msg = message ?: return
if (msg.sessionId != sessionId) return
val clearStreaming = msg.role == "assistant"
if (clearStreaming) streamParser = null
_state.update { state ->
val pending = if (msg.role == "user") {
val index = state.pending.indexOfFirst { it.text == msg.content }
if (index >= 0) state.pending.filterIndexed { i, _ -> i != index } else state.pending
} else {
state.pending
}
state.copy(
messages = mergeMessages(state.messages, listOf(msg)),
pending = pending,
streaming = if (clearStreaming) null else state.streaming,
)
}
}
/** 上拉加载更早的一页。 */
fun loadOlder() { fun loadOlder() {
val repo = repository ?: return val repo = repository ?: return
val before = oldestLoadedAt ?: return val before = oldestLoadedAt ?: return
@@ -21,31 +21,59 @@ import top.yeij.cyrene.domain.parse.parse
import top.yukonga.miuix.kmp.basic.Text import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.theme.MiuixTheme import top.yukonga.miuix.kmp.theme.MiuixTheme
/** 流式进行中的光标。 */
private const val CURSOR = "▍"
/** /**
* 一条消息渲染成一个或多个气泡: * 一条落库消息渲染成一个或多个气泡:
* - `mode` 为 `chat`(或缺失——后端当前不落库 mode,一律回落 chat)时按 `<p>` 切分, * - `mode` 为 `chat`(或缺失——后端当前不落库 mode,一律回落 chat)时按 `<p>` 切分,
* `<action>` / `<narration>` 解析成灰斜体小字,依附到所属气泡的上方/下方; * `<action>` / `<narration>` 解析成灰斜体小字,依附到所属气泡的上方/下方;
* - 其它 mode 原样整条渲染,不解析标签。 * - 其它 mode 原样整条渲染,不解析标签。
*/ */
@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 { parsed.ifEmpty { listOf(plainBubble(message.content)) }
listOf(
ParsedBubble(
body = message.content,
parts = emptyList(),
head = emptyList(),
tail = emptyList(),
),
)
}
} }
val source = messageSourceLabel(message)
BubbleGroup(
bubbles = bubbles,
isUser = message.role == "user",
footer = messageSourceLabel(message),
)
}
/** 乐观发送中的用户消息(还没落库)。 */
@Composable
fun PendingBubble(item: PendingMessage) {
BubbleGroup(
bubbles = listOf(plainBubble(item.text)),
isUser = true,
footer = "发送中…",
)
}
/** 正在流式的助手回复;`active` 时在末尾追加光标。 */
@Composable
fun StreamingBubble(streaming: StreamingState) {
BubbleGroup(
bubbles = streaming.bubbles,
isUser = false,
footer = null,
showCursor = streaming.active,
)
}
@Composable
private fun BubbleGroup(
bubbles: List<ParsedBubble>,
isUser: Boolean,
footer: String? = null,
showCursor: Boolean = false,
) {
val rendered = if (bubbles.isEmpty() && showCursor) listOf(plainBubble(CURSOR)) else bubbles
Column( Column(
modifier = Modifier modifier = Modifier
@@ -54,15 +82,20 @@ fun MessageBubble(message: HistoryMessageDto) {
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),
) { ) {
bubbles.forEach { bubble -> rendered.forEachIndexed { index, bubble ->
AnnotationLine(bubble.head) AnnotationLine(bubble.head)
if (bubble.body.isNotEmpty()) BubbleBody(bubble.body, isUser) 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) AnnotationLine(bubble.tail)
} }
// 来源角标挂在消息组末尾,紧随它标注的那条消息(主人右下、昔涟左下) // 来源角标挂在消息组末尾,紧随它标注的那条消息(主人右下、昔涟左下)
if (source != null) { if (footer != null) {
Text( Text(
text = source, text = footer,
style = MiuixTheme.textStyles.footnote2, style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary, color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
modifier = Modifier.padding(start = 6.dp, end = 6.dp), modifier = Modifier.padding(start = 6.dp, end = 6.dp),
@@ -107,3 +140,10 @@ private fun BubbleBody(text: String, isUser: Boolean) {
) )
} }
} }
private fun plainBubble(text: String) = ParsedBubble(
body = text,
parts = emptyList(),
head = emptyList(),
tail = emptyList(),
)