diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/ws/ChatSocket.kt b/app/src/main/kotlin/top/yeij/cyrene/data/ws/ChatSocket.kt index 9b0d93f..0f52501 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/data/ws/ChatSocket.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/data/ws/ChatSocket.kt @@ -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?, diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt index c1313cd..837e3f2 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt @@ -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) } diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt index 7046a82..64ab393 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt @@ -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 = emptyList(), val pending: List = emptyList(), + /** + * 「还没连上、留在本端等连接」的那几条待发消息,**有序**:顺序就是**用户发送的先后顺序**, + * 连接恢复后照这个顺序补发(2026-10-05 主人裁决:未连接就进队列)。 + * + * 队列里只有键与并行声明([QueuedSend]),正文 / 图片 / 附件仍在 [pending] 那条气泡上—— + * 所以这些气泡**照旧在屏幕上**,只是气泡下方的小标说「排队中 · 还没连上」 + * ([ChatUiState.isAwaitingConnection] / `pendingBubbleFooter`),与「服务端忙时排队」区分得开。 + * + * 空 = 没有等着补发的(绝大多数时候)。它**不持久化**:内容只在进程内存里,进程被杀就丢 + * (今天既有的语义,未变)。 + */ + val queuedForConnection: List = 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 = 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])。 * diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageBubble.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageBubble.kt index 73e3131..c157c83 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageBubble.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageBubble.kt @@ -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), diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/SendQueue.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/SendQueue.kt new file mode 100644 index 0000000..70271b3 --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/SendQueue.kt @@ -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, +): 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 +} diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/TurnStreams.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/TurnStreams.kt index c53c62e..01061cf 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/TurnStreams.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/TurnStreams.kt @@ -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, ) diff --git a/app/src/test/kotlin/top/yeij/cyrene/ui/chat/SendFailureMessageTest.kt b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/SendFailureMessageTest.kt deleted file mode 100644 index 6e5ec8d..0000000 --- a/app/src/test/kotlin/top/yeij/cyrene/ui/chat/SendFailureMessageTest.kt +++ /dev/null @@ -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) - } -} diff --git a/app/src/test/kotlin/top/yeij/cyrene/ui/chat/SendQueueTest.kt b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/SendQueueTest.kt new file mode 100644 index 0000000..0357eda --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/SendQueueTest.kt @@ -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 = 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))) + } +}