diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt index b2e62d6..7f4bec4 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt @@ -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) { 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 2ae4ccb..9732477 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 @@ -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 = 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 = emptyList(), + val pending: List = 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 + 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 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 2b89bc4..0cbd579 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 @@ -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)时按 `

` 切分, * `` / `` 解析成灰斜体小字,依附到所属气泡的上方/下方; * - 其它 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, + 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(), +)