feat(chat): M3 发送与流式渲染
输入框 + 发送(WS chat 上行,deviceId=voiceassist:cyrene:v1);乐观消息先上屏,message_persisted 到达后按文本归一;message.start/delta/end + stream_end 走流式增量解析,末尾带光标;发送失败回滚文本到输入框。
This commit is contained in:
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(),
|
||||
)
|
||||
Reference in new issue
Block a user