merge: W40 —— 普通发送未连接时进队列(不再回滚),连接恢复后按发送顺序自动补发
This commit is contained in:
commit
31e8e31679
9 files changed
+845
-102
No files matched your search
@@ -170,7 +170,9 @@ class ChatSocket(
|
||||
* @param parallel 要不要声明 `parallel:true`(忙时并行规范 §2.2)——**默认 `false` = 一个字节
|
||||
* 都不带该键**,线上帧与加入本参数之前**逐字节相同**。调用方只有一处:`ChatViewModel.deliver`,
|
||||
* 它按 App 偏好(`InterruptMode`)与「会话是否正忙」算出来(判据是纯函数 `parallelForSend`)。
|
||||
* @return 未连接或写失败返回 `false`(本层不做离线队列,由上层标红重试)。
|
||||
* @return 未连接或写失败返回 `false`(本层不做离线队列;上层按**这一刻的连接状态**分档处置:
|
||||
* 未就绪 ⇒ 记住这条、连接恢复后按序补发,连接就绪却仍发不出去 ⇒ 回滚 + 诚实文案——
|
||||
* 判据是 `ChatViewModel.deliver` 里的纯函数 `wsSendOutcome`)。
|
||||
*/
|
||||
fun sendChat(
|
||||
sessionId: String?,
|
||||
|
||||
@@ -520,6 +520,9 @@ fun ChatScreen(
|
||||
is ChatDisplayItem.Pending -> PendingBubble(
|
||||
item = item.item,
|
||||
phase = state.phaseOf(item.item),
|
||||
// ③ 里更细的那一档(W40):**本端**因为没连上把这条留住了。
|
||||
// 判据是纯函数(队列成员关系),接线层不自己猜。
|
||||
awaitingConnection = state.isAwaitingConnection(item.item.localId),
|
||||
onDocumentClick = { draft ->
|
||||
previewNotice = null
|
||||
previewTarget = DocumentTarget.of(draft)
|
||||
@@ -609,6 +612,12 @@ fun ChatScreen(
|
||||
state.parallelLimitNotice?.let { message ->
|
||||
Banner(text = message, color = MiuixTheme.colorScheme.onSurfaceVariantSummary)
|
||||
}
|
||||
// 「这条还没连上、已排队等连接」(W40):与上面三条同档的如实告知(**不是错误**,
|
||||
// 所以不用红色)。它与 `shareNotice` 分开两块牌子,因为说的是不同的动作;
|
||||
// 而「这条还在等连接」这个**持续**状态由气泡上的小标一直挂着(牌子会到点收走)。
|
||||
state.sendQueueNotice?.let { message ->
|
||||
Banner(text = message, color = MiuixTheme.colorScheme.onSurfaceVariantSummary)
|
||||
}
|
||||
searchNotice?.let { message ->
|
||||
Banner(text = message, color = MiuixTheme.colorScheme.onSurfaceVariantSummary)
|
||||
}
|
||||
|
||||
@@ -154,37 +154,37 @@ const val QUEUE_FULL_UNKNOWN_MESSAGE = "她忙不过来,这条可能没发出
|
||||
const val SEND_FAILED_MESSAGE = "发送失败,请重试"
|
||||
|
||||
/**
|
||||
* 一条消息**一次都没尝试发出去**(连接还没就绪)时的如实文案。
|
||||
* 一条消息**还没连上、已经排进补发队列**时的如实告知(2026-10-05 主人裁决:未连接就进队列)。
|
||||
*
|
||||
* 它与 [SEND_FAILED_MESSAGE] 分开,因为那是两件事,而用户只能从这句话里分辨它们:
|
||||
* 「还没连上」意味着这条消息**根本没上路**(网络还没好),「发送失败」却暗示它已经出去过、只是
|
||||
* 没成功——后者在连接未就绪时**根本不是事实**(`ChatSocket.send` 连帧都没序列化,服务端一个
|
||||
* 请求都收不到)。措辞照 [QUEUE_FULL_MESSAGE] 那一族的惯例:「这条没发出去」说清没发生什么,
|
||||
* 「手动重试」说清要用户做什么——内容已经被 [ChatUiState.rollbackPending] 退回输入栏,而全链路
|
||||
* 没有任何自动重发,所以那一步只能由用户来。
|
||||
* 说清三件事:**还没连上**(所以一个字节都还没发出去)、**这条已排队**(内容一条不丢,
|
||||
* 留在屏幕上那条气泡里)、**连上后自动发出**(用户不需要做任何事,也不必重打一遍)。
|
||||
* 刻意不写「失败」、更不写「重试」:失败的是连接,而这条消息一次都没上过路;把它说成
|
||||
* 「发送失败,请重试」既不是事实,也会把用户推向「重新打一遍」这种多余动作——W39 给分享落地
|
||||
* 定过同一条规矩([SHARE_NOT_CONNECTED_NOTICE]),这里逐字沿用同一套措辞结构。
|
||||
*
|
||||
* 走 `ChatUiState.sendQueueNotice` 这条柔和通道,**不是**带 X 的 error 横幅(error 的语义是
|
||||
* 「出错了」)。它与分享那条告知**分开两块牌子**:两条说的是不同的动作(一条是输入栏发的、
|
||||
* 一条是分享进来的),可以同时挂着;判据见 [queuedSendNoticeFor]。
|
||||
*
|
||||
* **不刷屏**:一轮「队列从空到空」只说一次(第一条进队列时说,队列全部补发出去之后才复位),
|
||||
* 连续发几条时不会每按一次发送就弹一遍——记账在 [ChatViewModel.queueForConnection]。
|
||||
*/
|
||||
const val SEND_NOT_CONNECTED_MESSAGE = "还没连上,这条没发出去,请稍后手动重试"
|
||||
const val SEND_QUEUED_NOTICE = "还没连上,这条排队等着,连上后自动发出"
|
||||
|
||||
/**
|
||||
* 「`sendChat` 回了 `false`」时该显示哪一句(**纯函数**;输入只有失败那一刻的连接状态)。
|
||||
* 同一条事实,而这一刻的未就绪是**不可恢复**的那一档([WsState.Offline])时的如实告知。
|
||||
*
|
||||
* `ChatSocket.send` 回 `false` 有三种成因:(1) **连接状态不是** [WsState.Connected]、
|
||||
* (2) `webSocket` 对象已空或已被 cancel、(3) `toJson()` / `send()` 抛了异常。三者在返回值上分不开,
|
||||
* 但**连接状态这一格分得开**:只有 (1) 是「这条压根没上路」,那就不能说成发送失败——用户看到
|
||||
* 「发送失败」会以为东西已经出去了、以为「重试」是把同一件东西再送一次,而实际上下一次是按
|
||||
* 幂等键的一条新尝试。(2)(3) 以及已经是 `Connected` 时的任何失败照旧说 [SEND_FAILED_MESSAGE],
|
||||
* 那时候「发送失败」是事实。
|
||||
* 与 [SEND_QUEUED_NOTICE] 分开的**唯一**理由:[WsState.Offline] 是终态(握手 401 且续不上、
|
||||
* 或缺少 token / 服务器地址),它**不会自动重连**——在那句「连上后自动发出」里,用户等的是一个
|
||||
* **永远不会到来**的连接,那是拿一句不会兑现的承诺糊住一个需要他动手的状况(W39 给分享落地
|
||||
* 定过同一条规矩,文案见 [SHARE_OFFLINE_NOTICE])。
|
||||
*
|
||||
* 判据只认 [WsState.Connected] **一个值**(不是「不等于 `Offline`」之类的近似):连接状态的另外四档
|
||||
* ([WsState.Idle] / [WsState.Connecting] / [WsState.Reconnecting] / [WsState.Offline])都意味着帧
|
||||
* 没有被交给任何服务端。socket 为 null(本 VM 还没接上那条共享连接)在这里按 [WsState.Idle] 处理:
|
||||
* 「未启动」正是它的语义,而且确实没连上。
|
||||
*
|
||||
* ⚠️ 这一条**只换句子、不换行为**:回滚仍是同一条回滚(气泡摘掉、内容退回输入栏、记下幂等键),
|
||||
* 没有自动重发,普通发送的其它语义一个字没动。
|
||||
* 这一档要说的是他能做的事:**重新登录**(会话过期的路)或**检查服务器地址**(配置不对的路)。
|
||||
* 内容呢?照样一个字节都没发出去、已经排进本端的补发队列——这一点必须说清,否则用户会以为
|
||||
* 东西丢了、重新打一遍(那就真发两遍了);也**不提**任何「连上」「自动发」:这一档没有会自动
|
||||
* 到来的连接(用户重新登录之后连接一旦就绪,队列照常按序补发,那不是这句承诺兑现了)。
|
||||
*/
|
||||
fun sendFailureMessage(connection: WsState): String =
|
||||
if (connection == WsState.Connected) SEND_FAILED_MESSAGE else SEND_NOT_CONNECTED_MESSAGE
|
||||
const val SEND_QUEUED_OFFLINE_NOTICE = "现在处于离线,需要重新登录或检查服务器地址;这条还没发出去,已排队不会丢"
|
||||
|
||||
/** 队列满的机器可读码(契约 v0.59/G5,与 REST `409` 同码)。 */
|
||||
const val CHAT_BUSY_CODE = "chat_busy"
|
||||
@@ -554,6 +554,18 @@ data class ChatUiState(
|
||||
/** 已落库消息,升序(旧 → 新)。 */
|
||||
val messages: List<HistoryMessageDto> = emptyList(),
|
||||
val pending: List<PendingMessage> = emptyList(),
|
||||
/**
|
||||
* 「还没连上、留在本端等连接」的那几条待发消息,**有序**:顺序就是**用户发送的先后顺序**,
|
||||
* 连接恢复后照这个顺序补发(2026-10-05 主人裁决:未连接就进队列)。
|
||||
*
|
||||
* 队列里只有键与并行声明([QueuedSend]),正文 / 图片 / 附件仍在 [pending] 那条气泡上——
|
||||
* 所以这些气泡**照旧在屏幕上**,只是气泡下方的小标说「排队中 · 还没连上」
|
||||
* ([ChatUiState.isAwaitingConnection] / `pendingBubbleFooter`),与「服务端忙时排队」区分得开。
|
||||
*
|
||||
* 空 = 没有等着补发的(绝大多数时候)。它**不持久化**:内容只在进程内存里,进程被杀就丢
|
||||
* (今天既有的语义,未变)。
|
||||
*/
|
||||
val queuedForConnection: List<QueuedSend> = emptyList(),
|
||||
/**
|
||||
* 流式中的助手回复,**按 `(sessionId, turnId)` 两级分桶**(忙时并行规范 §1.4 / §1.7 第 1 条)。
|
||||
*
|
||||
@@ -620,6 +632,19 @@ data class ChatUiState(
|
||||
* 同样到期自动消失([PARALLEL_LIMIT_NOTICE_MILLIS])。
|
||||
*/
|
||||
val parallelLimitNotice: String? = null,
|
||||
/**
|
||||
* 「这条**还没连上、已排队等连接**」的如实告知(2026-10-05 主人裁决:未连接就进队列);
|
||||
* null = 没有要说的。判据是 [queuedSendNoticeFor],说哪一句由它按连接状态分两档
|
||||
* ([SEND_QUEUED_NOTICE] / [SEND_QUEUED_OFFLINE_NOTICE])。
|
||||
*
|
||||
* 与 [error] 分开:这条**不是错误**——一次发送都没发生过,内容就在待发气泡里等着,
|
||||
* 连接一到就自动补发。也不与 [shareNotice] 共用一块牌子:两条描述的是**不同的动作**
|
||||
* (输入栏发的这条 / 分享进来的那条),可以同时挂着,各自到期消失([SEND_QUEUED_NOTICE_MILLIS])。
|
||||
*
|
||||
* **不刷屏**:一轮「队列从空到空」只说一次,连续几条离线消息不会每按一次发送就弹一遍
|
||||
* (记账在 [ChatViewModel.queueForConnection],不是按时间窗判的)。
|
||||
*/
|
||||
val sendQueueNotice: String? = null,
|
||||
val connection: WsState = WsState.Idle,
|
||||
/** 编排忙碌态:`idle`/`thinking`/`executing`/`subagent`;null = 空闲。 */
|
||||
val busyMode: String? = null,
|
||||
@@ -642,9 +667,11 @@ data class ChatUiState(
|
||||
* 收到过「这条进队列了」回执的键:本地乐观气泡的 `localId`,以及落库后的服务端 `messageId`。
|
||||
*
|
||||
* 两种写法都要认,因为 `pending_ack`(广播、**不带消息身份**)与 `message_persisted`
|
||||
* (带 `clientMsgId` = 本端 `localId`)的先后顺序不确定。它是六态里 ③ 排队中的唯一判据
|
||||
* (带 `clientMsgId` = 本端 `localId`)的先后顺序不确定。它是六态里 ③ 排队中的判据**之一**
|
||||
* (规范 §1.1 ③ 的进入条件是「现状帧,形状不变」——服务端没说被拒的是哪一条,所以本端只按
|
||||
* 「我发出去之后收到了入队回执」认,**不猜**)。
|
||||
* 「我发出去之后收到了入队回执」认,**不猜**);另一个判据是**本端自己**因为没连上把这条留住
|
||||
* ([queuedForConnection],W40)——两者都是「在排队」,但来源不同,界面上靠气泡小标分开
|
||||
* (见 [ChatUiState.isAwaitingConnection])。
|
||||
*/
|
||||
val queuedKeys: Set<String> = emptySet(),
|
||||
/**
|
||||
@@ -712,8 +739,10 @@ data class ChatUiState(
|
||||
* 消费者)——服务端据此判重(`chat_duplicate` / `{duplicate:true}`)而不是落成第二条消息;
|
||||
* 内容改过(哪怕只多一个空格)就铸新键,因为那是另一条消息。记录**不是永久的**:每条只活
|
||||
* [RetryKeyMemory.SLOT_TTL_MILLIS](10 分钟,见那里「时间上界」)——多槽让记录跨很多次发送存活,
|
||||
* 太久以前那条失败过的内容再打一遍是**新**消息,不能被当成重试吞掉。**始终没有自动重试**:
|
||||
* 键的复用只发生在用户手动重发的那一刻。
|
||||
* 太久以前那条失败过的内容再打一遍是**新**消息,不能被当成重试吞掉。**没有「失败之后自动重试」**:
|
||||
* 键的复用只发生在用户手动重发的那一刻。唯一会自动重发的是**一次都还没上过路**的那几条
|
||||
* (未连接 ⇒ 进 [ChatUiState.queuedForConnection],连接恢复后按发送顺序补发)——那不是重试,
|
||||
* 是这条消息的**第一次**投递,用的还是同一个键,所以「补发」与「手动重发」在服务端是同一种判重。
|
||||
*
|
||||
* ## 单条消息的编辑 / 删除(D3)
|
||||
* 编辑**只对 `user` 消息开放**([canEditMessage]),删除对 `user` 与 `assistant` 都开放
|
||||
@@ -804,6 +833,19 @@ class ChatViewModel(
|
||||
*/
|
||||
private var retryKey = RetryKeyMemory()
|
||||
|
||||
/**
|
||||
* 「这一轮还没连上」的告知说过了没有(**不刷屏**的记账)。
|
||||
*
|
||||
* 一轮的定义是「补发队列**从空到空**」:进第一条时说一次,队列全部补发出去(或全部了结)
|
||||
* 之后归零。所以连续发几条离线消息只说一次——不是按时间窗判的,隔十秒再发也不会再说一遍;
|
||||
* 而连接恢复、队列清空之后**再断线**,那是一个新的一轮,又会说一次(那时候用户确实需要
|
||||
* 重新知道这件事)。
|
||||
*
|
||||
* 它与队列本身同生共死:这个位只由 [queueForConnection] 置真、由 [forgetQueuedNoticeIfDrained]
|
||||
* 置假,两个调用点都在这一处,没有第三个地方碰它。
|
||||
*/
|
||||
private var queuedNoticeTold = false
|
||||
|
||||
init {
|
||||
start()
|
||||
}
|
||||
@@ -865,6 +907,10 @@ class ChatViewModel(
|
||||
gapCursor = null
|
||||
val repo = repository
|
||||
if (cursor != null && repo != null) viewModelScope.launch { catchUp(repo, cursor) }
|
||||
// 连接恢复 ⇒ 把没连上时留下的那几条按**用户发送的先后顺序**补发(W40 主人裁决)。
|
||||
// 触发点就是这里:连接状态流(`socket.state`,进程内共享的那一条)一变就重来一遍判据,
|
||||
// **没有任何轮询**(与 W38 分享落地的纪律一致:状态驱动,不按时间轮)。
|
||||
flushQueuedSends()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1056,7 +1102,8 @@ class ChatViewModel(
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送:先乐观上屏,再按路由决定走哪条上行通道;失败回滚并把文本 / 图片 / 附件还给输入框。
|
||||
* 发送:先乐观上屏,再按路由决定走哪条上行通道;**WS 未就绪时留在待发态等连接**
|
||||
* ([ChatUiState.queuedForConnection],这不算失败),其余失败回滚并把文本 / 图片 / 附件还给输入框。
|
||||
*
|
||||
* 路由 [ChatSendRouting]:**短文本且无图无附件 → WS**(直达、绕过有损总线,今天就能用);
|
||||
* **长文本、任何图片、任何附件 → REST `stream:true`**。REST 起的回合**同样在已连接的 WS 上
|
||||
@@ -1075,7 +1122,9 @@ class ChatViewModel(
|
||||
val text = snapshot.draft.trim()
|
||||
val imageUri = snapshot.draftImage
|
||||
val documents = snapshot.draftDocuments
|
||||
if (text.isEmpty() && imageUri == null && documents.isEmpty()) return
|
||||
// 空输入的四件事都不发生(不上屏、不排队、不发帧、不说话)——判据与投递口**同一个函数**
|
||||
// ([hasSendableContent]),免得入口与投递口对「有没有东西可发」各有一套说法。
|
||||
if (!hasSendableContent(text, imageUri, documents)) return
|
||||
viewModelScope.launch { emitAndDeliver(text, imageUri, documents, clearDraft = true) }
|
||||
}
|
||||
|
||||
@@ -1222,6 +1271,24 @@ class ChatViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记一条「这条还没连上、已排队等连接」的告知,到期自动收走(与 [showShareNotice] 同一形态,
|
||||
* 第四块牌子)。
|
||||
*
|
||||
* 与那三条一样:它描述的是**刚刚发生的一次动作**(用户按了发送、而连接还没就绪),
|
||||
* 所以到点自己收走;它**不是错误**,不给关闭按钮、也不进 `error` 通道。
|
||||
*
|
||||
* 要不要说、以及一轮说几次的判据都不在这里,在 [queueForConnection]:那一处按
|
||||
* [queuedNoticeTold] 一轮只说一次(连续几条离线消息不刷屏)。
|
||||
*/
|
||||
private fun showQueuedSendNotice(text: String) {
|
||||
_state.update { it.copy(sendQueueNotice = text) }
|
||||
viewModelScope.launch {
|
||||
kotlinx.coroutines.delay(SEND_QUEUED_NOTICE_MILLIS)
|
||||
_state.update { if (it.sendQueueNotice == text) it.copy(sendQueueNotice = null) else it }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一条消息乐观上屏并投递出去([send] 与 [sendShared] 共用)。
|
||||
*
|
||||
@@ -1245,7 +1312,8 @@ class ChatViewModel(
|
||||
applySendPreference: Boolean = true,
|
||||
localIdPrefix: String = LOCAL_CLIENT_MSG_ID_PREFIX,
|
||||
) {
|
||||
if (text.isEmpty() && imageUri == null && documents.isEmpty()) return
|
||||
// 与输入栏那条入口**同一个判据**([hasSendableContent]):空内容不上屏、不排队、不发帧。
|
||||
if (!hasSendableContent(text, imageUri, documents)) return
|
||||
|
||||
// `localId` 即幂等键。它与「某条没发出去的记录」内容一致时**复用当时的键**:
|
||||
// 那次失败可能是「服务端已经收下、客户端判成失败」(队列满 / 超时 / 回执丢失),
|
||||
@@ -1335,10 +1403,18 @@ class ChatViewModel(
|
||||
// 而界面那一格(`ChatUiState.connection`)是经 collect 镜像过来的,可能晚一帧。
|
||||
val ws = socket
|
||||
val sent = ws?.sendChat(sessionId, text, DEVICE_ID, localId, parallel) ?: false
|
||||
// 该说哪句按**失败那一刻的连接状态**分开(判据是纯函数 [sendFailureMessage]):
|
||||
// 连接没就绪时这条消息一个字节都没上路,说成「发送失败」不是事实。回滚本身照旧,
|
||||
// 一个字没改。socket 为 null 按 Idle 处理(「未启动」正是它的语义,也确实是没连上)。
|
||||
if (!sent) rollback(localId, sendFailureMessage(ws?.state?.value ?: WsState.Idle))
|
||||
// 处置按**纯函数** [wsSendOutcome] 分三档(成功 / 排队 / 真失败):
|
||||
// 连接没就绪时这条消息一个字节都没上路(`ChatSocket.send` 第一行就挡掉、服务端
|
||||
// 一个请求都收不到)⇒ 主人 2026-10-05 的裁决是**进队列**:不摘气泡、不报横幅,
|
||||
// 连接恢复后按发送顺序自动补发([flushQueuedSends])。只有「连接就绪却仍然发不出去」
|
||||
// 那一档走今天那条回滚 + 文案。socket 为 null 按 Idle 处理(「未启动」正是它的语义,
|
||||
// 也确实是没连上)。
|
||||
when (wsSendOutcome(sent, ws?.state?.value ?: WsState.Idle)) {
|
||||
// 成功路径**一个字都没变**:帧交给 socket 之后这里什么都不做。
|
||||
WsSendOutcome.Dispatched -> Unit
|
||||
WsSendOutcome.Queued -> queueForConnection(localId, parallel)
|
||||
WsSendOutcome.Failed -> rollback(localId, SEND_FAILED_MESSAGE)
|
||||
}
|
||||
}
|
||||
|
||||
SendChannel.Rest -> if (image == null) {
|
||||
@@ -1349,6 +1425,104 @@ class ChatViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记下「这条没连上、留在本端等连接」,并在该说的时候说一句(W40 主人裁决:未连接就进队列)。
|
||||
*
|
||||
* 三件事,顺序也就是优先级:
|
||||
* 1. **入队**([ChatUiState.withQueuedSend])——气泡照旧留在屏幕上(它本来就已经是乐观气泡了),
|
||||
* 只是多一个「排队中 · 还没连上」的小标([ChatUiState.isAwaitingConnection])。**不回滚、
|
||||
* 不报错误横幅**:`error` 的语义是「出错了」,而这条消息一次都没上过路。
|
||||
* 2. **该说就说一句**([queuedSendNoticeFor] 定哪句、走 [showQueuedSendNotice] 这条柔和通道):
|
||||
* **一轮「队列从空到空」只说一次**([queuedNoticeTold])——连续发几条离线消息只说一次,
|
||||
* 不是每按一次发送就弹一遍。
|
||||
* 3. **竞态兜底**:投递那一刻读到的「未就绪」与现在可能已经不是同一帧(连接恰在前面几百微秒里
|
||||
* 就绪,而那时这条还没进队列、[onConnectionChanged] 那次补发正好扑空)⇒ 入队之后再看一眼
|
||||
* socket 自己的状态,已经就绪就立刻补一次。这**不是轮询**:只在队列刚刚多出一条时做一次。
|
||||
*
|
||||
* ## 一条已知边界:打断声明不随补发重放
|
||||
* `deliver` 里那个打断声明(`interrupt{scope:"current"}`,见 [interruptRunningTurn])发生在
|
||||
* **通道分支之前**:未连接时它同样发不出去(`sendInterrupt` 静默回 false),而补发只重发
|
||||
* `chat` 这一帧 ⇒ 这条消息最终以**默认档**(追加队列)被受理。今天(回滚)那条路也是同一个结果:
|
||||
* 用户手动重发时若连接已就绪,打断会重新算一次,但那取决于用户什么时候按——两条路都不保证
|
||||
* 「发的时候的那次打断」一定落地。不在这里补打断帧,是因为补发时那个「在跑的回合」早就不是
|
||||
* 用户按发送时看到的那一个了:隔了半分钟再去打断,可能毁掉他没见过的一轮。
|
||||
*
|
||||
* @param parallel 投递那一刻算出的并行声明,随这条消息一起排队(见 [QueuedSend])。
|
||||
*/
|
||||
private fun queueForConnection(localId: String, parallel: Boolean) {
|
||||
_state.update { it.withQueuedSend(localId, parallel) }
|
||||
if (!queuedNoticeTold) {
|
||||
queuedNoticeTold = true
|
||||
queuedSendNoticeFor(socket?.state?.value ?: WsState.Idle)?.let(::showQueuedSendNotice)
|
||||
}
|
||||
if (socket?.state?.value == WsState.Connected) flushQueuedSends()
|
||||
}
|
||||
|
||||
/**
|
||||
* 把补发队列按**用户发送的先后顺序**发出去(队头开始,一条一条走)。
|
||||
*
|
||||
* 触发点只有一个:连接状态变成 [WsState.Connected]([onConnectionChanged],由进程内共享的
|
||||
* `socket.state` 那条流驱动),外加 [queueForConnection] 末尾那次竞态兜底。**没有轮询**。
|
||||
*
|
||||
* 每一步都问纯函数 [ChatUiState.nextQueuedSend]:队头这一条该发、该丢(气泡已经不在了)、
|
||||
* 还是补发不了(带着图 / 附件)。三档的落地:
|
||||
* - 真发出去了([WsSendOutcome.Dispatched])⇒ **出队**,接着看下一条;
|
||||
* - 又没连上([WsSendOutcome.Queued])⇒ **留在队列里**(顺序一动不动)并停手,
|
||||
* 等下一次 Connected 再来——一条都不发出去,也一条都不丢;
|
||||
* - 连接就绪却仍发不出去([WsSendOutcome.Failed],只剩 socket 对象已空 / 序列化抛异常那两种成因)
|
||||
* 或这条补发不了([QueuedSendStep.Failed])⇒ 出队 + 走今天那条回滚 + 文案:**绝不静默吞掉**。
|
||||
*
|
||||
* ## 「同一条不会被补发两次」的保证点
|
||||
* **队头 + 出队**:每一步只处理队头,而队头只在这一步有了结论之后才被摘掉([dropQueuedSend]),
|
||||
* 所以同一批消息被多次触发(连接抖动、重组、刚连上又断)时,重复的触发拿到的是**同一个键**、
|
||||
* 不会多出第二次出队。本函数是同步的(一个挂起点都没有),两个触发点在主线程上因此不可能交错,
|
||||
* 不需要锁;若将来在这里加挂起点,必须重新想一遍这件事。第二道保证在服务端:补发用的还是
|
||||
* 原来那个 `clientMsgId`,真重复到达也只会被回 `chat_duplicate`(契约 v0.56 §3.13)。
|
||||
*/
|
||||
private fun flushQueuedSends() {
|
||||
while (true) {
|
||||
when (val step = _state.value.nextQueuedSend()) {
|
||||
QueuedSendStep.Idle -> return
|
||||
is QueuedSendStep.Drop -> dropQueuedSend(step.localId)
|
||||
is QueuedSendStep.Failed -> {
|
||||
dropQueuedSend(step.localId)
|
||||
rollback(step.localId, SEND_FAILED_MESSAGE)
|
||||
}
|
||||
|
||||
is QueuedSendStep.Send -> {
|
||||
val ws = socket
|
||||
val sent = ws?.sendChat(sessionId, step.text, DEVICE_ID, step.localId, step.parallel) ?: false
|
||||
when (wsSendOutcome(sent, ws?.state?.value ?: WsState.Idle)) {
|
||||
WsSendOutcome.Dispatched -> dropQueuedSend(step.localId)
|
||||
// 又断回去了:这条仍是「还没上路」,留在队列里等下一次 Connected(顺序不动)。
|
||||
// 这一刻**不再说一遍**告知:气泡上的小标一直挂着,刷第二遍只是噪声。
|
||||
WsSendOutcome.Queued -> return
|
||||
WsSendOutcome.Failed -> {
|
||||
dropQueuedSend(step.localId)
|
||||
rollback(step.localId, SEND_FAILED_MESSAGE)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 出队(**唯一入口**):补发成功了、这条以别的方式了结了、或它补发不了时调用。
|
||||
*
|
||||
* 队列空了就把「这一轮还没连上」的告知记账归零([forgetQueuedNoticeIfDrained]):下一轮
|
||||
* 断线时用户确实需要重新知道一次这件事。
|
||||
*/
|
||||
private fun dropQueuedSend(localId: String) {
|
||||
_state.update { it.withoutQueuedSend(localId) }
|
||||
forgetQueuedNoticeIfDrained()
|
||||
}
|
||||
|
||||
/** 队列空了 ⇒ 这一轮的告知记账归零(见 [queuedNoticeTold] 的「一轮 = 队列从空到空」)。 */
|
||||
private fun forgetQueuedNoticeIfDrained() {
|
||||
if (_state.value.queuedForConnection.isEmpty()) queuedNoticeTold = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 发这条消息之前,按偏好决定要不要先打断在跑回合(协同 A2 / 契约 v0.56 §3.1)。
|
||||
*
|
||||
@@ -1371,7 +1545,8 @@ class ChatViewModel(
|
||||
* [ChatSocket.sendInterrupt] 返回 false 只有一种原因:WS 此刻没连着(`send` 在非 Connected 时直接
|
||||
* 返回 false,见 [ChatSocket])。此时刻意什么都不说,理由三条:
|
||||
* 1. **本次消息的成败与它无关**:REST 那条通道与 WS 连不连着无关(长文 / 图片 / 附件照样发得出去),
|
||||
* 而走 WS 那条自己会失败并走既有的回滚与提示——「发出去没有」这件事一直有唯一且如实的出口;
|
||||
* 而走 WS 那条要么进补发队列(未连接,W40)、要么走既有的回滚与提示(连接就绪却发不出去)
|
||||
* ——「发出去没有」这件事一直有唯一且如实的出口;
|
||||
* 2. **没有什么动作与之对应**:用户看到「打断失败」能做的只有「再发一遍这条消息」,那等于把一条
|
||||
* 可能已经发出去的消息重发一次(正是幂等键在防的事),比不说更坏;
|
||||
* 3. WS 断开本身另有可见信号(连接状态),不需要在一个尽力而为的动作上再喊一次。
|
||||
@@ -1722,7 +1897,11 @@ class ChatViewModel(
|
||||
*/
|
||||
private fun rollback(localId: String, reason: String, queueFullNotice: Boolean = false) {
|
||||
rememberRetryKey(_state.value.pending, localId)
|
||||
_state.update { it.rollbackPending(localId, reason, queueFullNotice) }
|
||||
// 顺带出队:回滚的意思是「这条了结、内容退回输入栏」,补发队列里若还留着它,
|
||||
// 连接恢复时就会对着一条**已经退回给用户**的消息再发一次(用户可能已经改过、又发了一遍)。
|
||||
// 队列通常本来就是空的,[ChatUiState.withoutQueuedSend] 那时原样返回同一个实例。
|
||||
_state.update { it.rollbackPending(localId, reason, queueFullNotice).withoutQueuedSend(localId) }
|
||||
forgetQueuedNoticeIfDrained()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2358,6 +2537,15 @@ private const val SESSION_NOT_FOUND_CODE = "session_not_found"
|
||||
/** 分享提示在界面上停留的时长(它描述的是一次刚发生的动作,不是持续状态)。 */
|
||||
private const val SHARE_NOTICE_MILLIS = 8 * 1000L
|
||||
|
||||
/**
|
||||
* 「这条还没连上、已排队等连接」告知在界面上停留的时长(同上:描述动作,不描述持续状态)。
|
||||
*
|
||||
* 「这条还在等连接」本身是个**持续**状态,但那条事实由气泡上的小标一直挂着
|
||||
* ([QUEUED_BADGE] · [AWAITING_CONNECTION_BADGE]);这块牌子描述的只是**用户刚按了发送的那一刻**,
|
||||
* 所以照另外三条的惯例到点收走,也取同一个值。
|
||||
*/
|
||||
private const val SEND_QUEUED_NOTICE_MILLIS = 8 * 1000L
|
||||
|
||||
/**
|
||||
* 超长分享正文**没能落成文件**时的如实告知(见 [ChatViewModel.sendShared])。
|
||||
*
|
||||
|
||||
@@ -228,6 +228,39 @@ fun MessageBubble(
|
||||
*/
|
||||
const val SHARED_MESSAGE_BADGE = "[用户分享]"
|
||||
|
||||
/** 六态 ③ 的角标文案(规范 §1.1:「排队 ≠ 被受理」,语气与「发送中…」并列、不与「已读」混同)。 */
|
||||
const val QUEUED_BADGE = "排队中"
|
||||
|
||||
/**
|
||||
* 「这条还没连上、留在本端等连接」的小标(W40):接在 [QUEUED_BADGE] 后面,合成
|
||||
* 「排队中 · 还没连上」。
|
||||
*
|
||||
* 为什么要在气泡上(而不只是那一句柔和告知):告知八秒就收走了,而「这条还在等连接」是个会持续
|
||||
* 几十秒的**状态**——牌子撤掉之后,光看「排队中」分不出这是「她已经收下、只是前面还排着队」
|
||||
* 还是「连都没连上、一个字节都没上路」。两者对用户意味着不同的事(前者会照常回复,后者要等网络),
|
||||
* 所以状态本身要一直挂在气泡上。
|
||||
*
|
||||
* 语气刻意与 [QUEUED_BADGE] 一致(都是陈述,不是警告):没连上不是错误,内容也一条没丢。
|
||||
*/
|
||||
const val AWAITING_CONNECTION_BADGE = "还没连上"
|
||||
|
||||
/**
|
||||
* 乐观气泡下方那一行小标的文案(**纯函数**,可单测)。
|
||||
*
|
||||
* [awaitingConnection] 只在 ③ 上成句,而且是**加在既有小标之后**的四个字(见
|
||||
* [AWAITING_CONNECTION_BADGE]):这条消息在服务端**还没有任何身份**(没入队、没落库),
|
||||
* 与「她忙、这条已受理排队」必须看得出区别。刻意**不新增第七态**([TurnPhase] 的六个取值是
|
||||
* W30 定下的口径,见 `ChatUiState.phaseOf`),所以这里只是给同一个 ③ 补一句更准的话。
|
||||
*
|
||||
* ① 发送中时它一定是 false(没连上就不可能是「发送中」),这里也按 `when` 的顺序把它挡在
|
||||
* ③ 之外:真的传进 `true` 也只会说「发送中…」——不为了一个不该发生的组合编一句话。
|
||||
*/
|
||||
fun pendingBubbleFooter(phase: TurnPhase, awaitingConnection: Boolean): String = when {
|
||||
phase != TurnPhase.Queued -> "发送中…"
|
||||
awaitingConnection -> "$QUEUED_BADGE · $AWAITING_CONNECTION_BADGE"
|
||||
else -> QUEUED_BADGE
|
||||
}
|
||||
|
||||
/**
|
||||
* 气泡下方那一行角标的文案(纯函数,可单测)。
|
||||
*
|
||||
@@ -256,7 +289,7 @@ fun bubbleFooter(
|
||||
val parts = buildList {
|
||||
legBadge?.let { add(it) }
|
||||
when (phase) {
|
||||
TurnPhase.Queued -> add("排队中")
|
||||
TurnPhase.Queued -> add(QUEUED_BADGE)
|
||||
TurnPhase.Answering -> add("已读,正在回答…")
|
||||
else -> Unit
|
||||
}
|
||||
@@ -277,17 +310,22 @@ fun bubbleFooter(
|
||||
* [phase] 只会是 ① 发送中或 ③ 排队中(乐观气泡还没有服务端 id,绑不到任何一路——
|
||||
* 见 [ChatUiState.phaseOf])。③ 的角标文案按规范 §1.1 用「排队中」,语气与「发送中…」并列而不
|
||||
* 与「已读」混同:**排队 ≠ 被受理**(规范 §1.3)。
|
||||
*
|
||||
* [awaitingConnection] 是 ③ 里更细的那一档(W40:**本端**因为没连上把这条留住了,一个字节都还
|
||||
* 没上路),文案由纯函数 [pendingBubbleFooter] 给:接上「还没连上」四个字。判据在 VM 状态里
|
||||
* ([ChatUiState.isAwaitingConnection]),这里只负责画。
|
||||
*/
|
||||
@Composable
|
||||
fun PendingBubble(
|
||||
item: PendingMessage,
|
||||
phase: TurnPhase = TurnPhase.Sending,
|
||||
awaitingConnection: Boolean = false,
|
||||
onDocumentClick: (DraftDocument) -> Unit = {},
|
||||
onImageClick: (String) -> Unit = {},
|
||||
) {
|
||||
MessageGroup(
|
||||
isUser = true,
|
||||
footer = if (phase == TurnPhase.Queued) "排队中" else "发送中…",
|
||||
footer = pendingBubbleFooter(phase, awaitingConnection),
|
||||
) {
|
||||
DocumentChipRow(
|
||||
chips = item.documents.map(DocumentChips::of),
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
package top.yeij.cyrene.ui.chat
|
||||
|
||||
import top.yeij.cyrene.data.ws.WsState
|
||||
import top.yeij.cyrene.domain.chat.ChatSendRouting
|
||||
import top.yeij.cyrene.domain.chat.DraftDocument
|
||||
import top.yeij.cyrene.domain.chat.SendChannel
|
||||
|
||||
/**
|
||||
* 「没连上、还没发出去」的一条待发消息([ChatUiState.queuedForConnection] 的元素)。
|
||||
*
|
||||
* 队列里只放两样东西:那条乐观气泡的 `localId`(= 上行幂等键 `clientMsgId`)与投递那一刻定下的
|
||||
* 并行声明。正文 / 图片 / 附件**不在这里再存一份**——它们已经在 [ChatUiState.pending] 那条气泡上,
|
||||
* 补发时从那里现取([ChatUiState.nextQueuedSend])。同一件事只留一个存放处:队列里再存一份正文,
|
||||
* 就多了一个会跟气泡分叉的真相源。
|
||||
*
|
||||
* [parallel] 是**投递那一刻**按「她忙时发消息」偏好算出来的
|
||||
* ([top.yeij.cyrene.domain.chat.parallelForSend]),补发照它发出、**不重读偏好**:补发的是同一条
|
||||
* 消息的**同一次投递**,排队途中用户切档不该让这条变成另一种投递,而重读还会让「这条消息的投递
|
||||
* 参数」随重连的时刻漂移。
|
||||
*/
|
||||
data class QueuedSend(
|
||||
/**
|
||||
* 乐观气泡的 `localId`,同时就是**上行幂等键** `clientMsgId`:补发**复用同一个键**,
|
||||
* 绝不铸第二个(契约 v0.56 §3.13,服务端按它判重、重复到达不会落成第二条消息)。
|
||||
*/
|
||||
val localId: String,
|
||||
/** 并行声明:只有 `true` 才会在帧上带 `parallel` 键([top.yeij.cyrene.domain.chat.parallelWireValue])。 */
|
||||
val parallel: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* 这次发送**有没有东西可发**(**纯函数**,输入栏的 `send()` 与投递口 `emitAndDeliver` 共用同一次判定)。
|
||||
*
|
||||
* 三样里任意一样在就算有:正文(按 `isNotBlank`——调用方喂进来的本来就是 trim 过的,
|
||||
* 空白串与空串是同一件事)、图片、附件。三样都没有 ⇒ **什么都不发生**:不上屏乐观气泡、
|
||||
* 不进补发队列、不发任何帧、也不说一句话(用户按了个空发送键,没什么可说的)。
|
||||
*
|
||||
* 为什么把它从两处 `if` 里提出来做成一个函数:这条边界此前写在两个地方(输入栏的 `send()` 与
|
||||
* 投递口的 `emitAndDeliver`),两处写法一致但**没有一处能单测**——而「空输入不该发生任何事」正是
|
||||
* 一条要一直成立的边界。合成一个判据之后,入口与投递口用的**必须是同一条**:否则会出现
|
||||
* 「入口认为有内容、投递口认为没有」(或反过来)这种分叉。
|
||||
*/
|
||||
fun hasSendableContent(
|
||||
text: String,
|
||||
imageUri: String?,
|
||||
documents: List<DraftDocument>,
|
||||
): Boolean = text.isNotBlank() || imageUri != null || documents.isNotEmpty()
|
||||
|
||||
/** 一次 WS 投递的结论([wsSendOutcome] 的取值)。 */
|
||||
enum class WsSendOutcome {
|
||||
/** 帧已经交给 socket(`sendChat` 回 `true`):照今天的样子继续等回执,这里什么都不做。 */
|
||||
Dispatched,
|
||||
|
||||
/** 未就绪 ⇒ 这条**不是失败**:留在待发态,等连接恢复后按发送顺序补发。 */
|
||||
Queued,
|
||||
|
||||
/** 没发出去,而这一刻连接**就绪** ⇒ 真失败:走今天那条回滚 + 文案。 */
|
||||
Failed,
|
||||
}
|
||||
|
||||
/**
|
||||
* 「`sendChat` 的回值 + 这一刻的连接状态」⇒ 这条消息该怎么处置(**纯函数**,VM 与测试共用)。
|
||||
*
|
||||
* ## 为什么主人要的是队列而不是回滚(2026-10-05 裁决)
|
||||
* W39 之前,`deliver` 在拿到 `sent == false` 时一律 `rollback(...)`:气泡摘掉、正文退回输入栏,
|
||||
* 而全链路没有自动重发 ⇒ 用户看到的是一句「这条没发出去,请稍后手动重试」。断网 / 弱网下那句话
|
||||
* 把「网络还没好」说成了一次要用户负责的失败,而内容还得他自己看着输入栏再发一遍。主人的裁决是
|
||||
* **未连接就进队列**:不报错、内容不丢、连接恢复后自动补发。W38 给「分享落地」做的正是同一件事
|
||||
* ([ShareLanding]:等连接就绪才取走、恢复后自动补发)——这里沿用同一套语义,只是对象从
|
||||
* 「分享进来的内容」换成了「输入栏发出的这条」,所以两处的措辞与纪律也刻意保持一致。
|
||||
*
|
||||
* ## 判据与优先级
|
||||
* 1. `sent == true` ⇒ [WsSendOutcome.Dispatched]。帧已经交给 socket,连接状态那一格不再参与判断
|
||||
* (`sendChat` 判的就是它自己的状态)。**成功路径一个字都没动**:不打标记、不说告知、不写任何
|
||||
* 状态——回执(`pending_ack` / REST ack)、落库(`message_persisted`)、队列满(`pending_full`)
|
||||
* 各有各的路。
|
||||
* 2. `sent == false` 且连接**就绪**([WsState.Connected])⇒ [WsSendOutcome.Failed]。连接是好的,
|
||||
* 那 `false` 只可能来自另外两种成因(`webSocket` 对象已空 / 已被 cancel,或 `toJson()` / `send()`
|
||||
* 抛了异常),这时候「发送失败」是事实 ⇒ 走 [SEND_FAILED_MESSAGE] 那条既有回滚。
|
||||
* 3. 其余四档([WsState.Idle] / [WsState.Connecting] / [WsState.Reconnecting] / [WsState.Offline])
|
||||
* ⇒ [WsSendOutcome.Queued]。`ChatSocket.send` 的**第一行**就按连接状态挡掉,帧连序列化都没做、
|
||||
* 服务端一个字节都收不到 ⇒ 这不是「发送失败」,是**还没上路**:既不回滚,也不进 `error` 横幅。
|
||||
*
|
||||
* ## 为什么 Offline 也进队列(而不像告知那样另分一档)
|
||||
* 主人的原话是「未连接就进队列」,四档一视同仁:[WsState.Offline] 只是**不会自己变好**,
|
||||
* 内容仍然一个字节没发出去、也一个字都不会丢;两档的差别只在**告知措辞**
|
||||
* ([queuedSendNoticeFor]:这一档说的是「重新登录 / 检查服务器地址」,而不是一个永远等不到的
|
||||
* 「连上后自动发出」)。队列照旧留着:用户重新登录 / 修好地址之后连接一旦就绪,它与其余三档
|
||||
* 一样按序补发。
|
||||
*
|
||||
* 判据只认 `Connected` **一个值**(不是「不等于 `Offline`」之类的近似)——见第 3 条:连接状态的
|
||||
* 另外四档都意味着帧没有被交给任何服务端。
|
||||
*/
|
||||
fun wsSendOutcome(sent: Boolean, connection: WsState): WsSendOutcome = when {
|
||||
sent -> WsSendOutcome.Dispatched
|
||||
connection == WsState.Connected -> WsSendOutcome.Failed
|
||||
else -> WsSendOutcome.Queued
|
||||
}
|
||||
|
||||
/**
|
||||
* 记下「这条没发出去、留在本端等连接」(**纯函数**,返回新状态)。
|
||||
*
|
||||
* 队列是**有序**的:新条目追加在末尾,顺序就是**用户发送的先后顺序**,补发照这个顺序走
|
||||
* ([ChatUiState.nextQueuedSend] 只看队头)。调用点只有一处(`deliver` 的「未就绪」分支),
|
||||
* 一条消息只会走到那里一次,所以不需要额外的去重表。
|
||||
*/
|
||||
fun ChatUiState.withQueuedSend(localId: String, parallel: Boolean): ChatUiState =
|
||||
copy(queuedForConnection = queuedForConnection + QueuedSend(localId, parallel))
|
||||
|
||||
/**
|
||||
* 出队(**纯函数**)。出队**恰好一次**正是「同一条不会被补发两次」的结构性保证:补发的每一步都
|
||||
* 只看队头,而队头只在这次投递有了结论(发出去了 / 真失败 / 气泡已经不在)之后才被摘掉
|
||||
* (见 [ChatUiState.nextQueuedSend] 与 `ChatViewModel.flushQueuedSends`)。
|
||||
*
|
||||
* 键不在队列里时**原样返回同一个实例**(Compose 的 key 稳定性靠它):回滚、收编、落库归一路过
|
||||
* 这里时,队列通常本来就是空的。
|
||||
*/
|
||||
fun ChatUiState.withoutQueuedSend(localId: String): ChatUiState =
|
||||
if (queuedForConnection.none { it.localId == localId }) {
|
||||
this
|
||||
} else {
|
||||
copy(queuedForConnection = queuedForConnection.filterNot { it.localId == localId })
|
||||
}
|
||||
|
||||
/** 这条待发气泡是不是「还没连上、留在本端等连接」的那一类(判据 = 它在补发队列里)。 */
|
||||
fun ChatUiState.isAwaitingConnection(localId: String?): Boolean =
|
||||
!localId.isNullOrBlank() && queuedForConnection.any { it.localId == localId }
|
||||
|
||||
/** 补发的下一步([ChatUiState.nextQueuedSend] 的取值)。 */
|
||||
sealed interface QueuedSendStep {
|
||||
/** 就发这条:键仍是 [localId](幂等键,不铸第二个),正文从那条气泡上现取。 */
|
||||
data class Send(val localId: String, val text: String, val parallel: Boolean) : QueuedSendStep
|
||||
|
||||
/** 气泡已经以别的路径了结(回滚 / 落库归一 / 收编都会把它带走)⇒ **只出队、不发**。 */
|
||||
data class Drop(val localId: String) : QueuedSendStep
|
||||
|
||||
/** 这条补发不了(带着图片或附件)⇒ 走今天那条失败路径:回滚 + 文案,内容一条不丢。 */
|
||||
data class Failed(val localId: String) : QueuedSendStep
|
||||
|
||||
/** 队列空:没什么要补的。 */
|
||||
data object Idle : QueuedSendStep
|
||||
}
|
||||
|
||||
/**
|
||||
* 补发的**下一步**(纯函数,VM 与测试共用):只看队头,**不出队**。
|
||||
*
|
||||
* 出队由调用方在这条有了结论之后做([ChatUiState.withoutQueuedSend]),于是「重复触发 ⇒ 不重复
|
||||
* 补发」落在结构上,而不是落在某一次调用的运气上:
|
||||
* - 同一批消息被**多次触发**(连接抖动、重组、重连之后立刻又断)时,每一步拿到的都是同一个键、
|
||||
* 同一段正文——触发器重复**不会**产生第二次出队;
|
||||
* - 出队之后再看,队头就是**下一条**,顺序因此严格等于入队顺序(用户发送的先后顺序);
|
||||
* - 就算某一帧真的被重复交出去(本端判据失效、进程被杀前重放之类),服务端仍按同一个
|
||||
* `clientMsgId` 判重(`chat_duplicate`,契约 v0.56 §3.13)⇒ 不会落成第二条消息。
|
||||
* 两道保证各管一头:**出队**管本端不重发,`clientMsgId` 管服务端不落第二条。
|
||||
*
|
||||
* 两种边界也要说清:
|
||||
* - 队头的键在 [ChatUiState.pending] 里找不到 ⇒ [QueuedSendStep.Drop]:气泡已经以别的路径了结
|
||||
* (回滚把它退回了输入栏、落库归一把它换成了权威消息),这时候再补发就是凭空多出一条。
|
||||
* - 气泡在、但它不是**纯文本**(带图 / 带附件)⇒ [QueuedSendStep.Failed]:WS 那条路只走纯文本
|
||||
* (有图 / 有附件一律 REST,见 [ChatSendRouting.channelFor]),只发正文会把图与附件**静默丢掉**。
|
||||
* 今天不可能出现(能进队列的只有已经走到 WS 分支的那几条),这道闸是给将来路由变化留的:
|
||||
* 真出现了宁可回滚、让用户自己看见并重发,也不要发出一条缺了图的消息。
|
||||
*/
|
||||
fun ChatUiState.nextQueuedSend(): QueuedSendStep {
|
||||
val head = queuedForConnection.firstOrNull() ?: return QueuedSendStep.Idle
|
||||
val item = pending.firstOrNull { it.localId == head.localId }
|
||||
?: return QueuedSendStep.Drop(head.localId)
|
||||
val channel = ChatSendRouting.channelFor(
|
||||
text = item.text,
|
||||
hasImage = !item.imageUri.isNullOrBlank(),
|
||||
hasDocuments = item.documents.isNotEmpty(),
|
||||
)
|
||||
return if (channel == SendChannel.Ws) {
|
||||
QueuedSendStep.Send(head.localId, item.text, head.parallel)
|
||||
} else {
|
||||
QueuedSendStep.Failed(head.localId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 这一刻的未就绪该说哪句话(**纯函数**;`null` = 这一刻没什么要说的)。
|
||||
*
|
||||
* 两档**只有措辞不同**:都只在「这条刚进了补发队列」时说一次(判据与不刷屏的记账在
|
||||
* `ChatViewModel.queueForConnection`),队列、补发时机、出队规则一个字都没改。
|
||||
*
|
||||
* - [WsState.Offline] ⇒ [SEND_QUEUED_OFFLINE_NOTICE]:这一档**不会自动重连**,对用户说「连上后
|
||||
* 自动发出」等的是一个**永远不会到来**的连接——那是拿一句不会兑现的承诺糊住一个需要他动手的
|
||||
* 状况(W39 给分享落地定过同一条规矩,见 [SHARE_OFFLINE_NOTICE])。所以这一档说的是他**能做的
|
||||
* 事**:重新登录(会话过期的路)或检查服务器地址(配置不对的路)。
|
||||
* - [WsState.Connected] ⇒ `null`:那一刻「排队等连接」这件事根本不成立——连接就绪时要么发出去
|
||||
* 要么走真失败那条路(见 [wsSendOutcome]),没有第三档。
|
||||
* - 其余三档([WsState.Idle] / [WsState.Connecting] / [WsState.Reconnecting])⇒ [SEND_QUEUED_NOTICE]:
|
||||
* 只是还没连上,连上就会自动发出。
|
||||
*
|
||||
* 用 `when` 穷举,而不是「不是 Offline 就用另一句」:连接状态将来多一档时,这里必须有人做决定
|
||||
* (编不出文案就不该静默继承前一句)。
|
||||
*/
|
||||
fun queuedSendNoticeFor(connection: WsState): String? = when (connection) {
|
||||
WsState.Offline -> SEND_QUEUED_OFFLINE_NOTICE
|
||||
WsState.Connected -> null
|
||||
WsState.Idle, WsState.Connecting, WsState.Reconnecting -> SEND_QUEUED_NOTICE
|
||||
}
|
||||
@@ -131,13 +131,25 @@ fun ChatUiState.phaseOf(message: HistoryMessageDto): TurnPhase = turnPhaseOf(
|
||||
* 这条**乐观气泡**处在哪一态(六态判定)。
|
||||
*
|
||||
* 乐观气泡还没有服务端 id,所以绑定的路**永远查不到**(`turn_begin` 的 `messageIds` 是服务端 id)
|
||||
* ——它要么是 ① 发送中,要么(收到过入队回执)是 ③ 排队中。这正是规范 §1.1 里 ① / ③ 的
|
||||
* 那两种视觉:半透明「发送中…」与浅灰「排队中」。
|
||||
* ——它要么是 ① 发送中,要么是 ③ 排队中。这正是规范 §1.1 里 ① / ③ 的那两种视觉:
|
||||
* 半透明「发送中…」与浅灰「排队中」。
|
||||
*
|
||||
* ## ③ 的两个来源(W40 起)
|
||||
* 「排队」这件事在本端有**两个来源**,都算 ③,但**来源不同、要看得出区别**:
|
||||
* 1. 收到过**服务端**的入队回执([ChatUiState.queuedKeys])——她忙,这条已受理、排在别人后面;
|
||||
* 2. **本端**因为没连上把这条留住了([ChatUiState.queuedForConnection],主人 2026-10-05 裁决)——
|
||||
* 这条**一个字节都还没上路**,连接恢复后会自动补发。
|
||||
*
|
||||
* 两者的差别由气泡小标在**文案上**说清(`pendingBubbleFooter`:③+等连接 = 「排队中 · 还没连上」),
|
||||
* 而**不新增第七态**:六态模型(规范 §1.1)是 W30 定下的口径,`TurnPhase` 的每一个取值都在
|
||||
* 界面与测试里有明确形状——为「另一种排队」再加一态,等于让所有按六态穷举的地方(渲染、测试、
|
||||
* 并行规范)都跟着改一遍,而它回答的问题(「这条还没送到她手上」)与 ③ 本来就是同一个。
|
||||
*/
|
||||
fun ChatUiState.phaseOf(item: PendingMessage): TurnPhase = turnPhaseOf(
|
||||
local = true,
|
||||
binding = null,
|
||||
queued = isQueuedMarked(item.localId),
|
||||
// ③ 的两个来源(见上):入队回执,或本端因未连接把它留住了。
|
||||
queued = isQueuedMarked(item.localId) || isAwaitingConnection(item.localId),
|
||||
rejected = false,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
package top.yeij.cyrene.ui.chat
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.data.ws.WsState
|
||||
|
||||
/**
|
||||
* 「`sendChat` 回了 `false`」之后该说的那句话([sendFailureMessage])。
|
||||
*
|
||||
* 背景:WS 那条路上 `sendChat` 有三种失败成因(连接状态不是 `Connected` / socket 对象已空或已被
|
||||
* cancel / 序列化抛了异常),此前共用一句「发送失败,请重试」。2026-10-05 的实机取证(原文见
|
||||
* `ShareLandingTest` 的类注释)说明代价:连接还没就绪时那条消息**一个字节都没上路**(后端零请求、
|
||||
* 也没有任何 error 帧),而横幅说成「发送失败」——用户于是去怀疑网络与对方,而事实是连帧都没发出去。
|
||||
*
|
||||
* 本测试钉住三件事:
|
||||
*
|
||||
* 1. 连接**没就绪**的每一档([WsState.Idle] / [WsState.Connecting] / [WsState.Reconnecting] /
|
||||
* [WsState.Offline])都说「还没连上」那句,**不是**「发送失败」;
|
||||
* 2. 连接状态**仍是** `Connected` 时逐字保持今天那句(那种失败里「发送失败」是事实);
|
||||
* 3. 两句文案各说各的:新那句里不许出现「发送失败」。
|
||||
*/
|
||||
class SendFailureMessageTest {
|
||||
|
||||
@Test
|
||||
fun `a failure with the connection still up keeps today's wording byte for byte`() {
|
||||
assertEquals("发送失败,请重试", sendFailureMessage(WsState.Connected))
|
||||
assertEquals(SEND_FAILED_MESSAGE, sendFailureMessage(WsState.Connected))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every state that is not connected says the message never left`() {
|
||||
WsState.entries.filter { it != WsState.Connected }.forEach { connection ->
|
||||
val said = sendFailureMessage(connection)
|
||||
|
||||
assertEquals("$connection 该说「还没连上」那句", SEND_NOT_CONNECTED_MESSAGE, said)
|
||||
assertNotEquals("$connection 上不该说成发送失败", SEND_FAILED_MESSAGE, said)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the not connected wording never claims the send failed`() {
|
||||
assertTrue(SEND_NOT_CONNECTED_MESSAGE.contains("还没连上"))
|
||||
// 说清**没发生什么**:这条消息压根没上路(否则用户会以为东西已经出去了)。
|
||||
assertTrue(SEND_NOT_CONNECTED_MESSAGE.contains("没发出去"))
|
||||
assertFalse(SEND_NOT_CONNECTED_MESSAGE.contains("发送失败"))
|
||||
// 也别说成「失败了」——失败的是连接,不是这次发送。而「重试」是唯一该留给用户的动作:
|
||||
// 内容已经被回滚退回输入栏,全链路没有任何自动重发。
|
||||
assertFalse(SEND_NOT_CONNECTED_MESSAGE.contains("失败"))
|
||||
assertTrue(SEND_NOT_CONNECTED_MESSAGE.contains("重试"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the two sentences are really two`() {
|
||||
assertNotEquals(SEND_FAILED_MESSAGE, SEND_NOT_CONNECTED_MESSAGE)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
package top.yeij.cyrene.ui.chat
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.data.ws.WsState
|
||||
import top.yeij.cyrene.domain.chat.DraftDocument
|
||||
import top.yeij.cyrene.domain.chat.TurnPhase
|
||||
|
||||
/**
|
||||
* 「普通发送在未连接时**进队列**而不是回滚」的判据([wsSendOutcome] / [queuedSendNoticeFor] /
|
||||
* 补发队列本身)。
|
||||
*
|
||||
* ## 背景:主人 2026-10-05 的裁决
|
||||
* W39 之前,输入栏发一条消息而 WS 未就绪时,`deliver` 拿到 `sent == false` 就 `rollback(...)`:
|
||||
* 气泡摘掉、正文退回输入栏,外加一句「这条没发出去,请稍后手动重试」——**内容不丢,但发送的
|
||||
* 责任被推回给了用户**,而那一刻的事实只是「网络还没好」。主人的裁决是:**未连接就进队列**,
|
||||
* 不报错、内容留着,连接恢复后自动补发(W38 给「分享落地」做的正是同一件事,见 `ShareLandingTest`)。
|
||||
*
|
||||
* ## 本测试钉住四件事
|
||||
* 1. **三档判据**([wsSendOutcome]):已连接 ⇒ 真的发(`Dispatched`,连接那一格不再参与判断);
|
||||
* 连接就绪却发不出去 ⇒ 今天那条失败路径(`Failed`);**未连接的每一档**(`Idle` / `Connecting` /
|
||||
* `Reconnecting` / `Offline`)⇒ 排队(`Queued`),**绝不**变成 `Failed`(那是唯一会回滚 + 报
|
||||
* 横幅的一档)。
|
||||
* 2. **告知措辞**([queuedSendNoticeFor]):说清「还没连上、已排队、连上后自动发出」,**不说**
|
||||
* 「失败」「重试」;而 `Offline` 那一档换一句**可执行**的(重新登录 / 检查服务器地址),
|
||||
* 且一个字都不提「连上」「自动发」——那一档没有会自动到来的连接。
|
||||
* 3. **队列本身**:有序(= 用户发送的先后顺序)、出队**恰好一次**、重复触发不重复补发、
|
||||
* 补发用的仍是原来那个键(`clientMsgId`,绝不铸第二个)。
|
||||
* 4. **状态表达**:等连接的这条读作 ③ 排队中(**不新增第七态**),小标比「服务端忙时排队」
|
||||
* 多一句「还没连上」。
|
||||
*/
|
||||
class SendQueueTest {
|
||||
|
||||
private fun pending(
|
||||
localId: String = "local-a",
|
||||
text: String = "还没连上时发的那条",
|
||||
imageUri: String? = null,
|
||||
documents: List<DraftDocument> = emptyList(),
|
||||
) = PendingMessage(localId = localId, text = text, imageUri = imageUri, documents = documents)
|
||||
|
||||
private fun QueuedSendStep.asSend(): QueuedSendStep.Send = this as QueuedSendStep.Send
|
||||
|
||||
// —— 一、这一刻该发、该排队、还是该走失败那条路 ——
|
||||
|
||||
@Test
|
||||
fun `a frame handed to the socket keeps today's path whatever the connection column says`() {
|
||||
// `sendChat` 回 true 就是「帧交给 socket 了」:连接状态那一格不再参与判断(它判的就是自己
|
||||
// 那个状态),所以六档都得是 Dispatched——这里**不许**有「虽然有回值但连接不对所以算排队」
|
||||
// 这种加戏。
|
||||
WsState.entries.forEach { connection ->
|
||||
assertEquals(
|
||||
"$connection 上帧已经交出去了,不该改成别的处置",
|
||||
WsSendOutcome.Dispatched,
|
||||
wsSendOutcome(sent = true, connection),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a send that failed while the connection is up stays a failure`() {
|
||||
// 连接是好的,那 false 只可能来自「socket 对象已空 / 已 cancel」或「序列化抛异常」
|
||||
// ⇒ 「发送失败」在这一刻是事实,照 W39 的口径回滚 + 文案。
|
||||
assertEquals(WsSendOutcome.Failed, wsSendOutcome(sent = false, WsState.Connected))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every state that is not connected queues instead of failing`() {
|
||||
WsState.entries.filter { it != WsState.Connected }.forEach { connection ->
|
||||
val outcome = wsSendOutcome(sent = false, connection)
|
||||
|
||||
assertEquals("$connection 上该排队", WsSendOutcome.Queued, outcome)
|
||||
// 排队这一档**不是失败**:`Failed` 是唯一会回滚 + 报横幅的一档,未连接时那两件事
|
||||
// 一件都不许发生(这正是主人这次裁决要改的地方)。
|
||||
assertNotEquals("$connection 上不该走回滚那条路", WsSendOutcome.Failed, outcome)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `offline queues like the others, because only the wording differs`() {
|
||||
// Offline 只是**不会自己变好**(终态、不自动重连),内容仍是一个字节都没发出去、也一个字
|
||||
// 都不会丢 ⇒ 照主人「未连接就进队列」的原话一视同仁:队列里留着,用户重新登录之后
|
||||
// 连接一旦就绪照常补发。差别只在告知措辞(下面那一组测试)。
|
||||
assertEquals(WsSendOutcome.Queued, wsSendOutcome(sent = false, WsState.Offline))
|
||||
}
|
||||
|
||||
// —— 二、该说哪一句话 ——
|
||||
|
||||
@Test
|
||||
fun `the queued notice says not connected, queued, and automatic`() {
|
||||
assertEquals(SEND_QUEUED_NOTICE, queuedSendNoticeFor(WsState.Idle))
|
||||
assertEquals(SEND_QUEUED_NOTICE, queuedSendNoticeFor(WsState.Connecting))
|
||||
assertEquals(SEND_QUEUED_NOTICE, queuedSendNoticeFor(WsState.Reconnecting))
|
||||
|
||||
assertTrue(SEND_QUEUED_NOTICE.contains("还没连上"))
|
||||
assertTrue(SEND_QUEUED_NOTICE.contains("排队"))
|
||||
assertTrue(SEND_QUEUED_NOTICE.contains("自动发出"))
|
||||
// 「失败」「重试」这一族措辞会把用户推向「重新打一遍」——内容明明就留在气泡里等着。
|
||||
assertFalse(SEND_QUEUED_NOTICE.contains("失败"))
|
||||
assertFalse(SEND_QUEUED_NOTICE.contains("重试"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the offline wording says what the user can do and promises nothing`() {
|
||||
assertEquals(SEND_QUEUED_OFFLINE_NOTICE, queuedSendNoticeFor(WsState.Offline))
|
||||
|
||||
assertTrue(SEND_QUEUED_OFFLINE_NOTICE.contains("离线"))
|
||||
// 说清用户**能做的事**:会话过期那条路(重新登录)与配置不对那条路(服务器地址)。
|
||||
assertTrue(SEND_QUEUED_OFFLINE_NOTICE.contains("重新登录"))
|
||||
assertTrue(SEND_QUEUED_OFFLINE_NOTICE.contains("服务器地址"))
|
||||
// 内容一个字节都没发出去、但排进了本端的队列——后一半不说清,用户会以为东西丢了、
|
||||
// 重新打一遍(那就真发两遍了)。
|
||||
assertTrue(SEND_QUEUED_OFFLINE_NOTICE.contains("还没发出去"))
|
||||
assertTrue(SEND_QUEUED_OFFLINE_NOTICE.contains("排队"))
|
||||
assertTrue(SEND_QUEUED_OFFLINE_NOTICE.contains("不会丢"))
|
||||
// 「连上后自动发出」在 Offline 上永远不会兑现 ⇒ 这一档一个字都不许提。
|
||||
assertFalse(SEND_QUEUED_OFFLINE_NOTICE.contains("连上"))
|
||||
assertFalse(SEND_QUEUED_OFFLINE_NOTICE.contains("自动发"))
|
||||
assertFalse(SEND_QUEUED_OFFLINE_NOTICE.contains("失败"))
|
||||
assertNotEquals(SEND_QUEUED_NOTICE, SEND_QUEUED_OFFLINE_NOTICE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the connected instant has nothing to say`() {
|
||||
// 连接就绪时「排队等连接」这件事根本不成立:要么发出去、要么走真失败那条路
|
||||
// (见 [wsSendOutcome] 的前两档),没有第三档。
|
||||
assertNull(queuedSendNoticeFor(WsState.Connected))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every state that can queue has something to say`() {
|
||||
WsState.entries.forEach { connection ->
|
||||
if (connection == WsState.Connected) return@forEach
|
||||
assertNotNull("$connection 上该有一句话", queuedSendNoticeFor(connection))
|
||||
}
|
||||
}
|
||||
|
||||
// —— 三、队列:顺序、出队恰好一次、补发用的还是原来那个键 ——
|
||||
|
||||
@Test
|
||||
fun `the queue keeps the order the user sent in`() {
|
||||
val state = ChatUiState(
|
||||
pending = listOf(pending("local-1", "第一条"), pending("local-2", "第二条"), pending("local-3", "第三条")),
|
||||
)
|
||||
.withQueuedSend("local-1", parallel = false)
|
||||
.withQueuedSend("local-2", parallel = true)
|
||||
.withQueuedSend("local-3", parallel = false)
|
||||
|
||||
assertEquals(
|
||||
listOf("local-1", "local-2", "local-3"),
|
||||
state.queuedForConnection.map { it.localId },
|
||||
)
|
||||
// 补发照这个顺序走:出队头一条之后,队头就是**下一条**(不是重新排序、也不是最后一条)。
|
||||
assertEquals("local-2", state.withoutQueuedSend("local-1").nextQueuedSend().asSend().localId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a repeated trigger steps towards the same head instead of a second dequeue`() {
|
||||
val state = ChatUiState(
|
||||
pending = listOf(pending("local-a", "第一条"), pending("local-b", "第二条")),
|
||||
)
|
||||
.withQueuedSend("local-a", parallel = false)
|
||||
.withQueuedSend("local-b", parallel = false)
|
||||
|
||||
// 同一批消息被**多次触发**(连接抖动、重组、刚连上又断)时,每一步看到的都是同一个队头:
|
||||
// 触发器重复**不会**多出一次出队,也就不会多出一次补发。
|
||||
repeat(3) {
|
||||
val step = state.nextQueuedSend().asSend()
|
||||
assertEquals("local-a", step.localId)
|
||||
assertEquals("第一条", step.text)
|
||||
}
|
||||
// 出队不是「步进」的一部分:nextQueuedSend 只读,队列一动不动。
|
||||
assertEquals(listOf("local-a", "local-b"), state.queuedForConnection.map { it.localId })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dequeuing the head exactly once is what stops a second send`() {
|
||||
val queued = ChatUiState(
|
||||
pending = listOf(pending("local-a", "第一条"), pending("local-b", "第二条")),
|
||||
)
|
||||
.withQueuedSend("local-a", parallel = false)
|
||||
.withQueuedSend("local-b", parallel = false)
|
||||
|
||||
val afterFirst = queued.withoutQueuedSend("local-a")
|
||||
|
||||
assertEquals(listOf("local-b"), afterFirst.queuedForConnection.map { it.localId })
|
||||
// 同一条再出队一次**什么都不变**(幂等),也不会顺手把第二条带走。
|
||||
assertEquals(afterFirst, afterFirst.withoutQueuedSend("local-a"))
|
||||
assertSame(afterFirst, afterFirst.withoutQueuedSend("local-a"))
|
||||
// 补发用的键就是气泡的 `localId`(= 上行幂等键 `clientMsgId`):补发**不铸第二个键**,
|
||||
// 所以就算某一帧真的被重复交出去,服务端仍按同一个键判重、不会落成第二条消息。
|
||||
assertEquals("local-b", afterFirst.nextQueuedSend().asSend().localId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the replayed text comes from the bubble itself`() {
|
||||
// 队列里只有键与并行声明,正文从那条气泡上现取 ⇒ 两者不可能分叉,也就不存在
|
||||
// 「补发的是旧文案」这种第二份真相。
|
||||
val state = ChatUiState(pending = listOf(pending("local-a", "改过之后的正文")))
|
||||
.withQueuedSend("local-a", parallel = false)
|
||||
|
||||
assertEquals("改过之后的正文", state.nextQueuedSend().asSend().text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the parallel declaration travels with the message`() {
|
||||
// 并行这一位在**投递那一刻**按偏好定下,随消息一起排队:补发不重读偏好(见 QueuedSend)。
|
||||
val state = ChatUiState(pending = listOf(pending("local-a", "并行档发出去的")))
|
||||
.withQueuedSend("local-a", parallel = true)
|
||||
|
||||
assertEquals(QueuedSendStep.Send("local-a", "并行档发出去的", true), state.nextQueuedSend())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a queued key whose bubble is gone is dropped, not sent`() {
|
||||
// 回滚 / 落库归一都会把气泡带走(回滚那一处也顺手出队,这里是兜底那一层):
|
||||
// 气泡不在,就没有任何要补发的东西——凭空发一条才是真的多出来。
|
||||
val state = ChatUiState().withQueuedSend("local-a", parallel = false)
|
||||
|
||||
assertEquals(QueuedSendStep.Drop("local-a"), state.nextQueuedSend())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a queued bubble that is not plain text is never sent as text only`() {
|
||||
// WS 那条路只走纯文本(有图 / 有附件一律 REST,见 ChatSendRouting.channelFor)。今天不可能
|
||||
// 有这种条目进队列;这道闸是给将来路由变化留的:宁可回滚让用户看见(内容一条不丢、
|
||||
// 图与附件一起退回输入栏),也不要发出一条缺了图的消息。
|
||||
val withImage = ChatUiState(pending = listOf(pending("local-a", "带图的", imageUri = "content://img")))
|
||||
.withQueuedSend("local-a", parallel = false)
|
||||
val withDocument = ChatUiState(
|
||||
pending = listOf(
|
||||
pending("local-b", "带附件的", documents = listOf(DraftDocument("content://doc", "a.txt"))),
|
||||
),
|
||||
).withQueuedSend("local-b", parallel = false)
|
||||
|
||||
assertEquals(QueuedSendStep.Failed("local-a"), withImage.nextQueuedSend())
|
||||
assertEquals(QueuedSendStep.Failed("local-b"), withDocument.nextQueuedSend())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty queue has nothing to do`() {
|
||||
assertEquals(QueuedSendStep.Idle, ChatUiState().nextQueuedSend())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only the keys in the queue count as waiting for the connection`() {
|
||||
val state = ChatUiState(pending = listOf(pending("local-a"), pending("local-b")))
|
||||
.withQueuedSend("local-a", parallel = false)
|
||||
|
||||
assertTrue(state.isAwaitingConnection("local-a"))
|
||||
assertFalse(state.isAwaitingConnection("local-b"))
|
||||
// 空白键与 null 一律不算(与队列里其余判据一致:空白等同缺席)。
|
||||
assertFalse(state.isAwaitingConnection(null))
|
||||
assertFalse(state.isAwaitingConnection(" "))
|
||||
}
|
||||
|
||||
// —— 四、状态表达:仍是六态,只是 ③ 里多一句更准的话 ——
|
||||
|
||||
@Test
|
||||
fun `a message waiting for the connection reads as queued plus not connected`() {
|
||||
val state = ChatUiState(pending = listOf(pending("local-a"), pending("local-b")))
|
||||
.withQueuedSend("local-a", parallel = false)
|
||||
|
||||
// 等连接的这条走 ③ 排队中——**不新增第七态**(六态是 W30 定下的口径,见 ChatUiState.phaseOf)。
|
||||
assertEquals(TurnPhase.Queued, state.phaseOf(state.pending[0]))
|
||||
assertEquals(
|
||||
"排队中 · 还没连上",
|
||||
pendingBubbleFooter(state.phaseOf(state.pending[0]), state.isAwaitingConnection("local-a")),
|
||||
)
|
||||
// 没进队列的(真的交出去了、还没等到回执)仍是 ① 发送中,一个字都没变。
|
||||
assertEquals(TurnPhase.Sending, state.phaseOf(state.pending[1]))
|
||||
assertEquals(
|
||||
"发送中…",
|
||||
pendingBubbleFooter(state.phaseOf(state.pending[1]), state.isAwaitingConnection("local-b")),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the two kinds of queueing look different on the bubble`() {
|
||||
// ③ 的两个来源:服务端入队回执(她忙、这条已受理)与**本端留住**(一个字节都没上路)。
|
||||
// 两者都是「在排队」,但用户要能分辨——差别就挂在小标上。
|
||||
val told = ChatUiState(pending = listOf(pending("local-a")), queuedKeys = setOf("local-a"))
|
||||
val waiting = ChatUiState(pending = listOf(pending("local-a")))
|
||||
.withQueuedSend("local-a", parallel = false)
|
||||
|
||||
assertEquals(TurnPhase.Queued, told.phaseOf(told.pending.single()))
|
||||
assertEquals(TurnPhase.Queued, waiting.phaseOf(waiting.pending.single()))
|
||||
assertEquals(
|
||||
"排队中",
|
||||
pendingBubbleFooter(told.phaseOf(told.pending.single()), told.isAwaitingConnection("local-a")),
|
||||
)
|
||||
assertEquals(
|
||||
"排队中 · 还没连上",
|
||||
pendingBubbleFooter(waiting.phaseOf(waiting.pending.single()), waiting.isAwaitingConnection("local-a")),
|
||||
)
|
||||
}
|
||||
|
||||
// —— 五、空输入的边界(与今天一致:什么都不发生) ——
|
||||
|
||||
@Test
|
||||
fun `an empty input has nothing to send in any shape of empty`() {
|
||||
assertFalse(hasSendableContent("", null, emptyList()))
|
||||
// 调用方喂进来的本来就是 trim 过的:空白串与空串是同一件事。
|
||||
assertFalse(hasSendableContent(" ", null, emptyList()))
|
||||
assertFalse(hasSendableContent("\n\t ", null, emptyList()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `any one of the three things counts as content`() {
|
||||
val document = DraftDocument(uri = "content://doc", displayName = "a.txt")
|
||||
|
||||
assertTrue(hasSendableContent("你好", null, emptyList()))
|
||||
assertTrue(hasSendableContent("", "content://img", emptyList()))
|
||||
assertTrue(hasSendableContent("", null, listOf(document)))
|
||||
// 判据是「任意一样在」,不是「三样都得在」。
|
||||
assertTrue(hasSendableContent("你好", "content://img", listOf(document)))
|
||||
}
|
||||
}
|
||||
@@ -870,3 +870,31 @@ curl -s -X PATCH "$BASE/api/v1/sessions/$SID" -H "Authorization: Bearer $TOK" \
|
||||
> 3. `Offline` 立刻告知(不等 3 秒)。
|
||||
> 4. **W38/W39 的真机回归**:W38 已验(补记九);W39 这三条只在真机上扫一眼即可(触发条件都少见)。
|
||||
> 5. 旧账:子会话流程(主人搁置)、图片/工具卡实机验证(后端未配工具)、**相册多选→分享**(图片路径仍需真机;App 一个权限都没声明 ⇒ adb 造不出可读 URI,这条**只能**走真机或驱动相册 UI)。
|
||||
|
||||
> ## ★★ 锚点补记十一(2026-10-05 · W40:普通发送「未连接 ⇒ 进队列」,不再回滚)
|
||||
>
|
||||
> ### 状态
|
||||
> - worktree 分支 `feat/W40-send-queue-offline`(基线 `e60ae7a`),分支提交 **`df9fc85`**;**未合并、未推送**(主树与 `origin/android` 都没动)。
|
||||
> - 本批证据:`assembleDebug` 成功;`testDebugUnitTest` **1118 测试 / 0 失败 / 0 错误 / 0 跳过**(83 套件)。**没有真机回归**(这一批只有单测与编译证据)。
|
||||
> - 净变化:W39 加的 `SendFailureMessageTest`(4 条)**删掉**,其断言改成新事实后并入新的 `SendQueueTest`(21 条)⇒ 1101 − 4 + 21 = 1118。
|
||||
>
|
||||
> ### ① 主人的裁决落地:未连接 ⇒ 进队列(上一批的「待办 1」已定案并做完)
|
||||
> - 新纯函数 `wsSendOutcome(sent, connection)`(`ui/chat/SendQueue.kt`)三档:`Dispatched`(帧交给 socket;**成功路径一个字没动**)/ `Queued`(未就绪四档:Idle / Connecting / Reconnecting / Offline)/ `Failed`(连接就绪却仍发不出去 ⇒ 今天那条 `rollback` + `SEND_FAILED_MESSAGE`)。
|
||||
> - `deliver` 的 WS 分支**只改「未就绪」这一支**:不回滚、不进 `error` 横幅,改由 `queueForConnection` 留在待发态(`ChatUiState.queuedForConnection`:条目只有键与并行声明,正文仍从气泡上现取)。
|
||||
> - **触发点**:`onConnectionChanged` 里 `connection == WsState.Connected` 那一格(由 `socket.state` 驱动,**无轮询**)+入队后的竞态兜底。
|
||||
> - **顺序**:FIFO = 用户发送的先后顺序;补发只看队头。**只发一次**:队头 + 出队恰好一次(`dropQueuedSend`);第二道是补发复用同一个 `clientMsgId`(服务端 `chat_duplicate` 判重)。`flushQueuedSends` 是同步函数(无挂起点),两个触发点不会交错。
|
||||
> - ⚠️ **已知边界**:打断声明(`interrupt{scope:"current"}`)不随补发重放(补发时在跑的那一轮早已不是用户按发送时看到的那一个);内容仍只在**进程内存**里,一直连不上又杀进程就丢(既有语义)。
|
||||
>
|
||||
> ### ② 状态表达:③ 排队中 + 小标「还没连上」,**不新增第七态**
|
||||
> - `phaseOf(待发气泡)` 的 ③ 判据扩为「收到过入队回执 **或** 本端因未连接留住」;气泡小标由新纯函数 `pendingBubbleFooter` 给:`排队中 · 还没连上`(服务端忙时排队仍是 `排队中`)——挂在气泡上而不是只靠告知,因为告知 8 秒就收走,而这是会持续几十秒的状态。
|
||||
> - 柔和告知走**新通道** `ChatUiState.sendQueueNotice`(与 `shareNotice` 分开:两条说不同的动作):`SEND_QUEUED_NOTICE = "还没连上,这条排队等着,连上后自动发出"`;`Offline` 那一档换 `SEND_QUEUED_OFFLINE_NOTICE = "现在处于离线,需要重新登录或检查服务器地址;这条还没发出去,已排队不会丢"`(**不提**「连上」「自动发」)。**不刷屏**:一轮「队列从空到空」只说一次。
|
||||
>
|
||||
> ### ③ REST 通道(长文 / 图片 / 附件)**未覆盖**,仍是回滚 + 网络错误文案
|
||||
> 理由:它给不出「未连接」这个信号(只有一次网络错误,请求可能已被服务端收到),且补发要重跑图片重编码与附件上传(本地 URI 授权可能已失效)。影响面仅这一条通道。
|
||||
>
|
||||
> ### 待办(更新)
|
||||
> 1. **W40 的真机回归**:断网发普通消息 ⇒ 应看到「排队中 · 还没连上」+ 柔和告知(**没有**红色横幅、输入栏不退回)⇒ 恢复连接 ⇒ 自动补发且只落库一条。
|
||||
> 2. REST 通道(长文 / 图片 / 附件)未连接时要不要也排队——需主人定案(代价见 ③)。
|
||||
> 3. `Offline` 立刻告知(不等 3 秒)——W39 遗留,未动。
|
||||
> 4. **未纳入新判据的一处字面量**:REST 回执被拒那条路仍是 `rejected.message.ifBlank { "发送失败,请重试" }`,刻意未动。
|
||||
> 5. 旧账:子会话流程(主人搁置)、图片/工具卡实机验证(后端未配工具)、**相册多选→分享**(只能走真机或驱动相册 UI)。
|
||||
Reference in new issue
Block a user