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.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(),
|
||||||
|
)
|
||||||
Reference in new issue
Block a user