feat(chat): 普通发送未连接时进队列(不再回滚),连接恢复后按发送顺序自动补发

主人 2026-10-05 裁决:普通发送在「未连接」时进队列。

此前 deliver 拿到 sent=false 就 rollback:气泡摘掉、正文退回输入栏,外加一句「还没连上,
这条没发出去,请稍后手动重试」。内容是没丢,但把「网络还没好」说成了一次要用户自己负责的
失败,而且他还得对着输入栏再发一遍。这一批按 W38「分享落地等连接」那套语义重做普通发送:
不报错、内容不丢、连接恢复后自动补发。W38 的落地时机、PendingShareHolder.take() 的原子语义、
ChatSocket 的连接状态机一个字都没动。

① 三档处置:未就绪就排队(新纯函数 wsSendOutcome,ui/chat/SendQueue.kt)
判据的输入只有两个:sendChat 的回值,以及这一刻 socket 自己的连接状态。sent=true ⇒ Dispatched
(帧已经交给 socket,成功路径一个字没动:不打标记、不说告知、不写任何状态);sent=false 且
连接就绪 ⇒ Failed(连接是好的,那 false 只可能来自 socket 对象已空 / 已被 cancel / 序列化抛
异常,「发送失败」在这一刻是事实 ⇒ 走今天那条 rollback + SEND_FAILED_MESSAGE);sent=false 且
未就绪 ⇒ Queued(ChatSocket.send 第一行就按状态挡掉,帧连序列化都没做、服务端一个字节都收不到,
所以既不回滚也不进 error 横幅)。未就绪四档(Idle / Connecting / Reconnecting / Offline)
一视同仁:主人的原话是「未连接就进队列」,Offline 只是不会自己变好,内容仍然一个字节都不会丢,
两档的差别只在告知措辞(这一点与 W39 给分享落地定的规矩一致)。

② 队列与补发:顺序与「只发一次」的保证点
deliver 的 WS 分支只改了「未就绪」这一支:queueForConnection 把这条留在待发态
(ChatUiState.queuedForConnection,条目只有 localId 与投递那一刻定下的并行声明;正文 / 图片 /
附件仍从那条气泡上现取,同一件事不留第二份真相源,也就不存在两条真相分叉)。触发点只有一个:
socket 状态变成 Connected 那一刻(onConnectionChanged,由状态流驱动、没有任何轮询),外加
入队之后的一次竞态兜底(连接恰好在投递与入队之间就绪时,那一次补发会扑空)。补发从队头开始,
FIFO 顺序 = 用户发送的先后顺序。
「只发一次」的保证点是「队头 + 出队恰好一次」:队头只在这一步有了结论(发出去了 / 真失败 /
气泡已经不在了)之后才被 dropQueuedSend 摘掉,所以重复的触发拿到的是同一个键、不会多出第二次
出队;flushQueuedSends 是同步函数(一个挂起点都没有),两个触发点在主线程上不可能交错。
第二道保证在服务端:补发复用同一个 clientMsgId(绝不铸第二个),真重复到达也只会被回
chat_duplicate,不会落成第二条消息。补发又失败时按今天的失败路径处理(回滚 + 文案),
绝不静默吞掉。已知边界(写在 KDoc 里):打断声明不随补发重放——补发时在跑的那一轮早就不是
用户按发送时看到的那一个了,隔半分钟再去打断可能毁掉他没见过的一轮。

③ 状态表达:仍是六态,③ 里多一句更准的话
不新增第七态:等连接的这条走六态里的 ③ 排队中(phaseOf 的判据从「收到过入队回执」扩为
「收到过回执,或本端因未连接把它留住了」),两类排队的区别由气泡小标说清——新纯函数
pendingBubbleFooter 给 ③ 加四个字:「排队中 · 还没连上」。为什么挂在气泡上而不只是那句告知:
告知八秒就收走,而这是个会持续几十秒的状态,牌子撤掉之后光看「排队中」分不出是「她已经收下、
前面还排着队」还是「连都没连上、一个字节都没上路」。柔和告知走新通道 ChatUiState.sendQueueNotice
(与 shareNotice 分开:两条描述的是不同的动作,可以同时挂着),文案 SEND_QUEUED_NOTICE 是
「还没连上,这条排队等着,连上后自动发出」;Offline 那一档换 SEND_QUEUED_OFFLINE_NOTICE
「现在处于离线,需要重新登录或检查服务器地址;这条还没发出去,已排队不会丢」——不提「连上」
「自动发」,那一档没有会自动到来的连接。不刷屏:一轮「队列从空到空」只说一次(连续几条离线
消息不会每按一次发送就弹一遍),记账在 queueForConnection。

④ 未覆盖:REST 通道(长文 / 图片 / 附件)保持今天的行为
POST /api/v1/chat 那条路仍是失败回滚 + 既有网络错误文案,本批不动。两条理由:一是它给不出
「未连接」这个信号(拿到的只是一次网络错误,而请求可能已被服务端收到、也可能没有),
判据不可靠;二是要补发就得重跑图片重编码与附件上传换存储名,而本地 URI 的读授权可能已经失效。
代价:未连接时发长文 / 图片 / 附件仍要用户手动重发一次;影响面仅这一条通道,WS 那条(短文本,
日常绝大多数消息)已经覆盖。

⑤ 未连接时不再有的那句话
「还没连上,这条没发出去,请稍后手动重试」(W39 加的 SEND_NOT_CONNECTED_MESSAGE)与它的判据
sendFailureMessage 一并删除:回滚那条路现在只在连接就绪时才走,这句话已经没有出口,留着就是
一段描述旧行为的死文案。SendFailureMessageTest 的断言改成新事实(未就绪 ⇒ 排队,而不是回滚
加那句话)并连同新的队列判据并入 SendQueueTest,一条断言都没删。另把「空输入什么都不发生」
这条边界抽成纯函数 hasSendableContent,输入栏的 send 与投递口的 emitAndDeliver 共用同一条判据
(行为不变,只是原本写在两处的同一个 if 现在可单测,也不会再分叉)。

测试:新增 SendQueueTest(21 条:三档判据穷举 WsState.entries、两档告知措辞、队列顺序与出队
恰好一次、气泡小标与六态、空输入边界)。assembleDebug 成功;testDebugUnitTest 1118 条
(1101 减 4 加 21),failures 0 / errors 0。
This commit is contained in:
AskaEth committed 2026-10-05 13:57:54 +08:00
1 parent e60ae7aecc
commit df9fc85f76
8 files changed
+817 -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)))
}
}