feat(chat): 加 REST 发送路径 —— 长文与图片(含 clientMsgId 幂等与归一)

客户端此前**只有 WS** 发送,而 WS 帧硬上限 4096 字节、**超限是直接断连**(不是报个错),
所以长文发不出去、图完全发不了。v0.56 给 REST 流式补齐了明确响应面与 clientMsgId,
本次照它接入。

- data/api:ChatApi + ChatDto(窄请求体,不回环整条会话)+ ChatRepository
  —— **刻意不走 `apiCall`**:它把 4xx/5xx 一律折进 `Result.failure` 并丢掉状态码,
  那样 `202`(已排队)与 `409`(队满)的差别会在那一刻消失;改为映射成 ChatAck 密封结果
  (Accepted / Queued / QueueFull / Duplicate / Unavailable / Rejected)
- domain/chat/ChatSendRouting:纯函数路由 —— **UTF-8 字节数 ≤ 3584 且无图 → WS,否则 REST**。
  阈值按**字节**不按字符(中文 1 字 3 字节);预留 512 字节覆盖帧壳。短文本仍走 WS 是刻意的:
  那条路**绕开有损总线**,而超 WS 上限的代价是**断连**而非报错,两边风险不对称
- domain/chat/ChatImageBudget + data/image/ChatImagePreparer:解码 → 等比缩放 → 重编码为
  JPEG/PNG data URI;超预算自动降尺寸重来;**失败返回 null,绝不退回原图 base64**
- clientMsgId:每次发送 `"local-" + UUID`(**随机不自增**——服务端判重窗口是**整个会话历史
  且无过期清理**,进程重启后复用旧值会让那条消息永远发不出去);REST 与 WS 两路都带
- 归一:带 clientMsgId 的消息**只按 id 匹配**(文本不同也命中;匹配不到就什么都不吃,
  **绝不回落到文本**——那会吃掉一条无关气泡);不带键的老行才回落「第一条同文本」
- ack 分支:200 已受理(等 message_persisted)/ 202 已排队(出「已排队 N 条」)/
  **409 既未受理也未落库 → 回滚气泡并把文本与图片还回输入框**(不能当「已排队」,
  那会让气泡永远挂着「发送中…」)/ duplicate 不是错误(收气泡;本地没有该 messageId 就补拉一页)/
  500、503 回滚 / 400 image_too_large 换短文案
- 接下行帧 `chat_duplicate`;图片一律走 REST(`WsOutbound.Chat.image` 仍无生产者)

测试:269 全绿(218 → +51)。含真实抓包的四段响应(200 / 202 / duplicate / 400 image_too_large,
且服务端文案里的 6990572 与本端 SERVER_MAX_CHARS 做成对断言)、路由阈值边界
(1194 汉字 = 3582 字节走 WS、1195 = 3585 走 REST,并直接断言阈值处真实
`WsOutbound.Chat(...)` 序列化后 ≤ 4096 字节)、归一规则、六种 ack 映射。

真机(真后端 127.0.0.1:10942):**图片全链路通过** —— 选图 → 重编码(源 7.8 KB PNG →
1 KB JPEG,magic ffd8ffe0)→ `POST /api/v1/chat`(1558 字节体)→
`200 {"data":{"stream":true}}` → 气泡由「发送中…」归一为「来自 安卓」→ 昔涟回复**准确描述了那张图**。
另 WS 短文本路径未回归(无 POST,且 clientMsgId 被 v0.56 原样回填)。

未验证:`409 chat_busy` 与 `500` 非真实抓包(要填满主人后端的待处理队列、等于真跑 5 个回合,
判断不值得;形状与文案逐字取自后端源码,测试注释里已写明);UI 上的 202/409 未端到端跑过;
降尺寸「减半重来」那步未在设备上跑过(测试图 64×64 只走到重编码)。
This commit is contained in:
AskaEth committed 2026-10-04 16:57:22 +08:00
1 parent 4c14028c70
commit e642b0c18c
19 files changed
+1615 -35

No files matched your search

@@ -1,5 +1,8 @@
package top.yeij.cyrene.ui.chat
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -12,10 +15,12 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
@@ -28,11 +33,13 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import coil3.compose.AsyncImage
import kotlinx.coroutines.flow.distinctUntilChanged
import top.yeij.cyrene.app.AppContainer
import top.yeij.cyrene.data.ws.WsState
@@ -44,7 +51,9 @@ import top.yukonga.miuix.kmp.basic.SmallTopAppBar
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TextField
import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.basic.Close
import top.yukonga.miuix.kmp.icon.basic.Sidebar
import top.yukonga.miuix.kmp.icon.extended.Image
import top.yukonga.miuix.kmp.theme.MiuixTheme
/** 判定「贴着底部」的容差(px)。 */
@@ -67,6 +76,12 @@ fun ChatScreen(
val state by viewModel.state.collectAsState()
val listState = rememberLazyListState()
// 系统相册选择器(PickVisualMedia 走 Photo Picker,**不需要任何存储权限**)。
// 只把 URI 交给 VM:真正的解码 / 缩放 / base64 在发送时于 IO 线程做。
val imagePicker = rememberLauncherForActivityResult(
ActivityResultContracts.PickVisualMedia(),
) { uri -> uri?.let { viewModel.onImagePicked(it.toString()) } }
// 是否跟随底部(微信/QQ 式):只有用户主动滚到别处时才停止跟随。
// 只在「滚动结束」时更新,避免新消息插入导致的锚点位移把状态带偏。
var followBottom by remember { mutableStateOf(true) }
@@ -115,9 +130,16 @@ fun ChatScreen(
bottomBar = {
InputBar(
value = state.draft,
imageUri = state.draftImage,
busyText = busyLabel(state),
onValueChange = viewModel::onDraftChange,
onSend = viewModel::send,
onPickImage = {
imagePicker.launch(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly),
)
},
onClearImage = viewModel::clearDraftImage,
)
},
) { padding ->
@@ -181,9 +203,12 @@ fun ChatScreen(
@Composable
private fun InputBar(
value: String,
imageUri: String?,
busyText: String,
onValueChange: (String) -> Unit,
onSend: () -> Unit,
onPickImage: () -> Unit,
onClearImage: () -> Unit,
) {
Column(
modifier = Modifier
@@ -191,7 +216,19 @@ private fun InputBar(
.imePadding()
.padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 4.dp),
) {
imageUri?.let { DraftImagePreview(uri = it, onClear = onClearImage) }
Row(verticalAlignment = Alignment.Bottom) {
// 图片入口只在没有待发图片时出现:已经选好一张就没必要再添一张(一次一条消息一张图)。
if (imageUri == null) {
IconButton(onClick = onPickImage) {
Icon(
imageVector = MiuixIcons.Image,
contentDescription = "发送图片",
tint = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
Spacer(Modifier.width(4.dp))
}
TextField(
value = value,
onValueChange = onValueChange,
@@ -205,7 +242,8 @@ private fun InputBar(
Spacer(Modifier.width(8.dp))
Button(
onClick = onSend,
enabled = value.isNotBlank(),
// 图片可以单独发出(契约:text / image 至少一项非空)。
enabled = value.isNotBlank() || imageUri != null,
) {
Text("发送")
}
@@ -227,6 +265,37 @@ private fun InputBar(
}
}
/** 待发图片的本地预览(`content://` URI,直接由 Coil 读本地文件,不联网)。 */
@Composable
private fun DraftImagePreview(uri: String, onClear: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box {
AsyncImage(
model = uri,
contentDescription = "待发送的图片",
modifier = Modifier
.size(64.dp)
.clip(RoundedCornerShape(10.dp)),
)
IconButton(
onClick = onClear,
modifier = Modifier.align(Alignment.TopEnd),
) {
Icon(
imageVector = MiuixIcons.Basic.Close,
contentDescription = "移除图片",
tint = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
}
}
}
@Composable
private fun CenterHint(text: String) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
@@ -247,9 +316,17 @@ private fun connectionLabel(state: ChatUiState): String = when (state.connection
WsState.Idle -> ""
}
/** 输入栏下方的忙碌提示。 */
/**
* 输入栏下方的忙碌提示。
*
* 三个来源共用这一行:WS 的 `session_busy`([ChatUiState.busyMode])、WS 的
* `pending_ack`/`pending_drain` 与 REST `202 {queued:true,queueDepth}`([ChatUiState.queueDepth])。
* REST 的 202 只给得到条数、给不到忙碌通道,所以队列深度**单独也能成句**——否则 202 之后到
* `session_busy` 之前会有一小段什么都看不见的空档。
*/
private fun busyLabel(state: ChatUiState): String = when {
state.busyMode != null && state.queueDepth > 0 -> "正在思考… 已排队 ${state.queueDepth} 条"
state.busyMode != null -> "正在思考…"
state.queueDepth > 0 -> "已排队 ${state.queueDepth} 条"
else -> ""
}
@@ -1,15 +1,21 @@
package top.yeij.cyrene.ui.chat
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import top.yeij.cyrene.app.AppContainer
import top.yeij.cyrene.data.api.CyreneException
import top.yeij.cyrene.data.api.dto.ChatRequest
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
import top.yeij.cyrene.data.image.ChatImagePreparer
import top.yeij.cyrene.data.repository.ChatAck
import top.yeij.cyrene.data.repository.MessageRepository
import top.yeij.cyrene.data.repository.historyForSession
import top.yeij.cyrene.data.repository.mergeMessages
@@ -17,16 +23,58 @@ 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.data.ws.isForSession
import top.yeij.cyrene.domain.chat.ChatSendRouting
import top.yeij.cyrene.domain.chat.SendChannel
import top.yeij.cyrene.domain.parse.ParsedBubble
import top.yeij.cyrene.domain.parse.StreamingContentParser
import java.util.UUID
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)
/**
* 乐观消息:已发出、还没等到 `message_persisted` 归一的本地消息。
*
* [localId] 同时是**上行幂等键** `clientMsgId`(契约 v0.56 §3.13):同一会话内服务端只承认
* 第一个非空值,重复到达回 `chat_duplicate`(WS)或 `{duplicate:true,messageId}`(REST)而
* **不再落库、不再起回合**。所以手动重试可以**复用**同一个 id,弱网重发不会变成第二条消息。
* 它必须足够唯一:服务端的判重窗口是**整个会话历史**(键没有过期清理),进程重启后复用
* 旧值会被当成重复、消息永远发不出去——因此用随机值而不是自增序号。
*/
data class PendingMessage(
val localId: String,
val text: String,
/** 本次带的图片的**本地**预览(`content://` URI);null = 纯文本消息。 */
val imageUri: String? = null,
)
/**
* 落库的 user 消息到达时,该从待发列表里去掉哪一个乐观气泡(纯函数;返回新列表)。
*
* **首选 `clientMsgId`**(契约 v0.56 §3.13:带键发出的消息服务端会原样回填):键相等即同一条
* 消息,**文本不同**也绝不会认错(发送时被 trim、服务端将来会再加工)。这是 v0.56 专门为
* 「乐观气泡归一」加的通道,用来取代此前唯一的「同文本」匹配。
*
* 落库消息**没有键**时(老客户端发的、IM 镜像进来的、v0.56 之前的历史行)才回落到既有规则:
* 去掉**第一条同文本**的气泡——服务端若是旧版本,永远只走这条路,行为与加入 REST 之前一致。
*
* 命中**恰好一条**且不修改入参:同一帧被重放、或两条同文本消息分别落库时,都不会一次吃掉两个气泡。
*/
fun consumePendingFor(
pending: List<PendingMessage>,
message: HistoryMessageDto,
): List<PendingMessage> {
val key = message.clientMsgId?.takeIf { it.isNotBlank() }
val index = if (key != null) {
pending.indexOfFirst { it.localId == key }
} else {
pending.indexOfFirst { it.text == message.content }
}
if (index < 0) return pending
return pending.filterIndexed { i, _ -> i != index }
}
/**
* 工具执行的细粒度阶段,与后端流程图 tool 节点 status **同一套枚举**(契约 v0.50 §3.1 / §3.5)。
@@ -136,6 +184,8 @@ data class ChatUiState(
val pending: List<PendingMessage> = emptyList(),
val streaming: StreamingState? = null,
val draft: String = "",
/** 已选好、还没发出去的图片(本地 `content://` URI 字符串);null = 没有待发图片。 */
val draftImage: String? = null,
val loading: Boolean = true,
val loadingOlder: Boolean = false,
val hasMore: Boolean = true,
@@ -143,6 +193,10 @@ data class ChatUiState(
val connection: WsState = WsState.Idle,
/** 编排忙碌态:`idle`/`thinking`/`executing`/`subagent`;null = 空闲。 */
val busyMode: String? = null,
/**
* 待处理队列深度。两个来源:WS 的 `pending_ack` / `pending_drain`,
* 以及 REST `202 {queued:true,queueDepth:n}` 的受理回执(同一个队列,同一份语义)。
*/
val queueDepth: Int = 0,
) {
/** 渲染顺序:已落库 → 乐观发送中 → 流式中(升序,UI 再反向喂给 reverseLayout)。 */
@@ -160,9 +214,12 @@ data class ChatUiState(
* 数据来源四路,全部按 `id` 去重收敛:
* 1. REST 首屏 / 上拉分页(权威,带游标)
* 2. WS 首帧 `history`
* 3. WS 增量 `message_persisted`(含乐观消息归一)
* 3. WS 增量 `message_persisted`(含乐观消息归一:按 `clientMsgId` 首选、同文本兜底)
* 4. WS 流式 `thinking.* / tool_call.* / message.* / stream_end`(临时气泡,落库后由 3 取代)
*
* 上行两条通道(路由见 [ChatSendRouting]):短文本走 WS `chat` 帧,长文本 / 任何图片走
* REST `POST /api/v1/chat`(`stream:true`)——**收都在 WS 上**,所以两种发的体验一致。
*
* 本 VM 只收**属于 [sessionId] 的帧**(见 [onEvent] 的会话闸)。服务端广播不按会话订阅,
* 且每个会话各有一个 VM(各自的 socket 都还活着),没有这道闸就会串台。
*/
@@ -178,7 +235,6 @@ class ChatViewModel(
private var repository: MessageRepository? = null
private var oldestLoadedAt: Long? = null
private var streamParser: StreamingContentParser? = null
private var pendingSeq = 0
init {
start()
@@ -217,29 +273,165 @@ class ChatViewModel(
fun onDraftChange(text: String) = _state.update { it.copy(draft = text) }
/** 发送:先乐观上屏,WS 上行失败则回滚并把文本还给输入框。 */
fun send() {
val text = _state.value.draft.trim()
if (text.isEmpty()) return
/** 选好待发图片(本地 `content://` URI);传 null 或空白串即清除。 */
fun onImagePicked(uri: String?) =
_state.update { it.copy(draftImage = uri?.takeIf { value -> value.isNotBlank() }) }
val localId = "local-${pendingSeq++}"
fun clearDraftImage() = _state.update { it.copy(draftImage = null) }
/**
* 发送:先乐观上屏,再按路由决定走哪条上行通道;失败回滚并把文本 / 图片还给输入框。
*
* 路由 [ChatSendRouting]:**短文本且无图 → WS**(直达、绕过有损总线,今天就能用);
* **长文本或任何图片 → REST `stream:true`**。REST 起的回合**同样在已连接的 WS 上流式
* 回来**(同一个编排器 + 广播路径),所以「REST 发、WS 收」不牺牲流式体验。
*
* 图片先在本端解码 / 缩放 / 重编码([ChatImagePreparer]),这一步是重活,放在 IO 线程上;
* 气泡同步上屏,所以等待期间用户已经能看到它。
*/
fun send() {
val snapshot = _state.value
val text = snapshot.draft.trim()
val imageUri = snapshot.draftImage
if (text.isEmpty() && imageUri == null) return
// localId 即幂等键:重试复用同一个值,服务端不会把它当成第二条消息。
val localId = "local-${UUID.randomUUID()}"
_state.update {
it.copy(
pending = it.pending + PendingMessage(localId, text),
pending = it.pending + PendingMessage(localId, text, imageUri),
draft = "",
draftImage = null,
error = null,
)
}
viewModelScope.launch { deliver(localId, text, imageUri) }
}
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 suspend fun deliver(localId: String, text: String, imageUri: String?) {
val image = imageUri?.let { uri ->
withContext(Dispatchers.IO) { ChatImagePreparer.prepare(container.appContext, Uri.parse(uri)) }
}
// 图片处理失败就**不发**:绝不退回原图 base64(会撞服务端 5 MiB 上限、也会把历史背成几 MB)。
if (imageUri != null && image == null) {
rollback(localId, "图片处理失败,请换一张")
return
}
when (ChatSendRouting.channelFor(text, hasImage = image != null)) {
SendChannel.Ws -> {
val sent = socket?.sendChat(sessionId, text, DEVICE_ID, localId) ?: false
if (!sent) rollback(localId, "发送失败,请重试")
}
SendChannel.Rest -> sendViaRest(localId, text, image)
}
}
private suspend fun sendViaRest(localId: String, text: String, image: String?) {
val request = ChatRequest(
sessionId = sessionId,
text = text,
image = image,
deviceId = DEVICE_ID,
// 必须 true:`stream:false` 是同步的,服务端**没有写超时**而客户端读超时 60s——
// 慢回合会在客户端先超时、服务端还在跑,重发就是第二次对话(契约明确禁用)。
stream = true,
clientMsgId = localId,
)
container.chatRepository
.send(request)
.onSuccess { ack -> onAck(localId, ack) }
.onFailure { e -> rollback(localId, e.userMessage()) }
}
/** REST 受理回执的六个分支(契约 v0.56 的响应面;形状见 [ChatAck])。 */
private fun onAck(localId: String, ack: ChatAck) {
when (ack) {
// 200 {stream:true}:回合已起,气泡等 message_persisted 归一。
ChatAck.Accepted -> Unit
// 202 {queued:true}:已**落库并续接排队**。轻量态交给既有忙碌标签(busyLabel 认
// queueDepth);队列清空时 WS 的 `pending_drain` / `session_idle` 会把它归零。
is ChatAck.Queued -> _state.update { it.copy(queueDepth = ack.queueDepth) }
// 幂等重复:服务端没有重复落库、也没有起第二个回合——对齐既有消息,**不是错误**。
is ChatAck.Duplicate -> alignDuplicate(localId, ack.messageId)
// 409 队列满:这条**没有被受理、没有落库**(服务端在入队判定处直接拒绝),
// 所以不能当成「已排队」——回滚气泡并把文本还回输入框,用的是既有忙碌文案的口径。
ChatAck.QueueFull -> rollback(localId, "会话正忙,待处理消息已满,请稍后再发")
is ChatAck.Unavailable -> rollback(localId, "服务端暂时接不住这一轮,请稍后重试")
is ChatAck.Rejected -> rollback(localId, rejectMessage(ack))
}
}
private fun rejectMessage(rejected: ChatAck.Rejected): String = when (rejected.code) {
"image_too_large" -> "图片过大,请换一张更小的"
else -> rejected.message.ifBlank { "发送失败,请重试" }
}
/**
* 幂等重复的对齐(REST `{duplicate:true}` 与 WS `chat_duplicate` 共用):
* 去掉那条乐观气泡;本地若**没有**这条消息,再补拉最新一页。
*
* 复用同一个 `clientMsgId` 重试时,正常路径本端早就收到过 `message_persisted`(第一次发的
* 时候),但弱网 / 重连丢帧会留下「气泡没了、消息也没了」的空档——用 `messageId` 补一次
* 增量拉取才是真的「对齐到服务端真相」,而不是让气泡凭空消失。
*/
private fun alignDuplicate(clientMsgId: String, messageId: String?) {
_state.update { state ->
state.copy(pending = state.pending.filterNot { it.localId == clientMsgId })
}
val known = messageId != null && _state.value.messages.any { it.id == messageId }
if (messageId != null && !known) refreshLatest()
}
/** 静默补拉最新一页(成功才并进来;失败不改状态——这只是一次对齐尝试)。 */
private fun refreshLatest() {
val repo = repository ?: return
viewModelScope.launch {
repo.loadLatest(sessionId).onSuccess { page ->
page.oldestCreatedAt?.let { oldest -> oldestLoadedAt = minOf(oldestLoadedAt ?: oldest, oldest) }
_state.update {
it.copy(
messages = mergeMessages(it.messages, page.messages),
pending = reconcilePending(it.pending, page.messages),
)
}
}
}
}
/**
* 一批**服务端权威消息**到达时,把它们对应的乐观气泡一并收掉(按 `clientMsgId`)。
*
* 通路除了 `message_persisted`,还有两条同样会带来「这条早就落库了」的路径:
* WS 握手 `history` 回放(重连 / 冷启动)与 REST 补拉。少了这一步,
* 「发出去 → 丢帧 → 重连回放」会留下**乐观气泡与落库消息同屏重复**。
*/
private fun reconcilePending(
pending: List<PendingMessage>,
incoming: List<HistoryMessageDto>,
): List<PendingMessage> = incoming
.filter { it.role == "user" }
.fold(pending) { acc, message -> consumePendingFor(acc, message) }
/**
* 回滚乐观气泡:把文本 / 图片还给输入框并给出原因(挂掉的气泡本身就是权威副本)。
* 空文本(纯图片)不覆盖用户当前正在输入的内容;图片照原样还给待发位,不必让用户重挑。
*/
private fun rollback(localId: String, reason: String) {
_state.update { state ->
val failed = state.pending.firstOrNull { it.localId == localId }
state.copy(
pending = state.pending.filterNot { it.localId == localId },
draft = failed?.text?.ifBlank { state.draft } ?: state.draft,
draftImage = failed?.imageUri ?: state.draftImage,
error = reason,
)
}
}
@@ -261,8 +453,12 @@ class ChatViewModel(
} else {
val oldest = incoming.minOf { it.createdAt }
oldestLoadedAt = oldestLoadedAt?.let { minOf(it, oldest) } ?: oldest
_state.update {
it.copy(messages = mergeMessages(it.messages, incoming), loading = false)
_state.update { state ->
state.copy(
messages = mergeMessages(state.messages, incoming),
pending = reconcilePending(state.pending, incoming),
loading = false,
)
}
}
}
@@ -347,6 +543,12 @@ class ChatViewModel(
_state.update { it.copy(queueDepth = event.queueDepth) }
}
// 幂等重复(契约 v0.56 §3.13):这条消息**早已受理过**,本端重发(弱网 / 切后台)撞上了
// 服务端的部分唯一索引。它不是错误——按既有 messageId 对齐气泡即可,绝不弹报错。
is WsEvent.ChatDuplicate -> if (event.sessionId == sessionId) {
alignDuplicate(event.clientMsgId, event.messageId)
}
is WsEvent.SessionIdle -> if (event.sessionId == sessionId) {
_state.update { it.copy(busyMode = null, queueDepth = 0) }
}
@@ -387,7 +589,8 @@ class ChatViewModel(
/**
* 落库消息到达:
* - user:按「同文本」把对应的乐观气泡去掉(协议没有 clientMsgId,只能这样匹配)
* - user:把对应的乐观气泡去掉——**首选 `clientMsgId`**(v0.56 起服务端回填),
* 没有键时才回落到「同文本」匹配(见 [consumePendingFor])
* - assistant:用落库内容取代流式临时气泡
*/
private fun onPersisted(message: HistoryMessageDto?) {
@@ -405,15 +608,9 @@ class ChatViewModel(
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(persisted)),
pending = pending,
pending = if (msg.role == "user") consumePendingFor(state.pending, msg) else state.pending,
streaming = if (clearStreaming) null else state.streaming,
)
}
@@ -62,11 +62,17 @@ fun MessageBubble(message: HistoryMessageDto) {
}
}
/** 乐观发送中的用户消息(还没落库)。 */
/**
* 乐观发送中的用户消息(还没落库)。
*
* 图片显示的是**本地** `content://` URI(不是上行的那段 base64):预览不需要联网、
* 也不必把几 MB 的字符串塞进 UI 状态。落库后由 `message.image` 接管。
*/
@Composable
fun PendingBubble(item: PendingMessage) {
MessageGroup(isUser = true, footer = "发送中…") {
Bubble(plainBubble(item.text), isUser = true, isLast = true)
item.imageUri?.takeIf { it.isNotBlank() }?.let { ChatImage(it) }
if (item.text.isNotEmpty()) Bubble(plainBubble(item.text), isUser = true, isLast = true)
}
}