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.Box
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.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
@@ -24,12 +28,14 @@ import androidx.lifecycle.viewmodel.viewModelFactory
import kotlinx.coroutines.flow.distinctUntilChanged
import top.yeij.cyrene.app.AppContainer
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.IconButton
import top.yukonga.miuix.kmp.basic.Scaffold
import top.yukonga.miuix.kmp.basic.SmallTopAppBar
import top.yukonga.miuix.kmp.basic.Text
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.basic.Sidebar
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 ->
Box(
modifier = Modifier
@@ -79,8 +92,8 @@ fun ChatScreen(
.padding(padding),
) {
when {
state.loading && state.messages.isEmpty() -> CenterHint("加载中…")
state.messages.isEmpty() -> CenterHint("还没有消息")
state.loading && state.items.isEmpty() -> CenterHint("加载中…")
state.items.isEmpty() -> CenterHint("还没有消息")
else -> LazyColumn(
state = listState,
reverseLayout = true,
@@ -88,8 +101,12 @@ fun ChatScreen(
contentPadding = PaddingValues(vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
items(items = state.messages.asReversed(), key = { it.id }) { message ->
MessageBubble(message)
items(items = state.items.asReversed(), key = { it.key }) { item ->
when (item) {
is ChatDisplayItem.Committed -> MessageBubble(item.message)
is ChatDisplayItem.Pending -> PendingBubble(item.item)
ChatDisplayItem.Streaming -> state.streaming?.let { StreamingBubble(it) }
}
}
if (state.hasMore) {
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
private fun CenterHint(text: String) {
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.WsEvent
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"
/** 本端标识(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(
/** 升序(旧 → 新);渲染时反向喂给 reverseLayout 的列表。 */
/** 已落库消息,升序(旧 → 新)。 */
val messages: List<HistoryMessageDto> = emptyList(),
val pending: List<PendingMessage> = emptyList(),
val streaming: StreamingState? = null,
val draft: String = "",
val loading: Boolean = true,
val loadingOlder: Boolean = false,
val hasMore: Boolean = true,
val error: String? = null,
val connection: WsState = WsState.Idle,
/** 编排忙碌态(`idle`/`thinking`/`executing`/`subagent`),null = 空闲。 */
/** 编排忙碌态:`idle`/`thinking`/`executing`/`subagent`;null = 空闲。 */
val busyMode: String? = null,
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` 去重):
* 1. REST 首屏 `loadLatest`(权威、带分页游标)
* 2. WS 首帧 `history`(与 REST 重叠,靠 id 去重收敛)
* 3. WS 增量 `message_persisted`(其它端/本端落库后的消息)
*
* 注意(D14):结构化流帧不带 `sessionId`,同一时刻只允许一个会话在流式;
* 当前只有主会话,暂不涉及归属问题。
* 数据来源四路,全部按 `id` 去重收敛:
* 1. REST 首屏 / 上拉分页(权威,带游标)
* 2. WS 首帧 `history`
* 3. WS 增量 `message_persisted`(含乐观消息归一)
* 4. WS 流式 `message.start/delta/end` + `stream_end`(临时气泡,落库后由 3 取代)
*/
class ChatViewModel(
private val container: AppContainer,
@@ -53,6 +92,8 @@ class ChatViewModel(
private var socket: ChatSocket? = null
private var repository: MessageRepository? = null
private var oldestLoadedAt: Long? = null
private var streamParser: StreamingContentParser? = null
private var pendingSeq = 0
init {
start()
@@ -85,12 +126,38 @@ class ChatViewModel(
viewModelScope.launch {
ws.state.collect { connection -> _state.update { it.copy(connection = connection) } }
}
viewModelScope.launch {
ws.events.collect(::onEvent)
}
viewModelScope.launch { ws.events.collect(::onEvent) }
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) {
when (event) {
is WsEvent.History -> {
@@ -106,12 +173,28 @@ class ChatViewModel(
}
}
is WsEvent.MessagePersisted -> {
val message = event.message ?: return
if (message.sessionId != sessionId) return
_state.update { it.copy(messages = mergeMessages(it.messages, listOf(message))) }
is WsEvent.MessageStart -> {
if (event.index == 0) streamParser = StreamingContentParser()
val parser = streamParser ?: StreamingContentParser().also { streamParser = it }
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) {
_state.update { it.copy(busyMode = event.mode) }
}
@@ -134,11 +217,38 @@ class ChatViewModel(
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() {
val repo = repository ?: 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.theme.MiuixTheme
/** 流式进行中的光标。 */
private const val CURSOR = "▍"
/**
* 一条消息渲染成一个或多个气泡:
* 一条落库消息渲染成一个或多个气泡:
* - `mode` 为 `chat`(或缺失——后端当前不落库 mode,一律回落 chat)时按 `<p>` 切分,
* `<action>` / `<narration>` 解析成灰斜体小字,依附到所属气泡的上方/下方;
* - 其它 mode 原样整条渲染,不解析标签。
*/
@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(
ParsedBubble(
body = message.content,
parts = emptyList(),
head = emptyList(),
tail = emptyList(),
),
)
}
parsed.ifEmpty { listOf(plainBubble(message.content)) }
}
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(
modifier = Modifier
@@ -54,15 +82,20 @@ fun MessageBubble(message: HistoryMessageDto) {
horizontalAlignment = if (isUser) Alignment.End else Alignment.Start,
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
bubbles.forEach { bubble ->
rendered.forEachIndexed { index, bubble ->
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)
}
// 来源角标挂在消息组末尾,紧随它标注的那条消息(主人右下、昔涟左下)
if (source != null) {
if (footer != null) {
Text(
text = source,
text = footer,
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
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(),
)