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:
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user