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 fc386dc..88968e4 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 @@ -526,10 +526,21 @@ fun ChatScreen( // 错误是这一排里**唯一**给关闭按钮的一条:它不会自己消失(别的几条是动作刚发生的 // 如实告知,挂了就收)。关掉的只是屏幕上这一行字,不代表服务端那边没事—— // 边界写在 ChatViewModel.dismissError 上。 + // + // 颜色分两档,判据是状态里那一位(**不按文案猜**): + // - 忙时被拒那句(「她正忙且队列已满…」)是**降级告知**,主人裁决「红色不要太醒目」 + // ⇒ 用下面几条告知同一档的柔和色; + // - 其余任何 error(登录失效 / 网络失败 / 服务端 4xx / 图片与附件处理失败…)都是 + // **真错误**,照旧红色——显眼是对的。**不要**顺手把它们一起调柔和。 + // 见 ChatUiState.errorIsQueueFullNotice。 state.error?.let { message -> Banner( text = message, - color = MiuixTheme.colorScheme.error, + color = if (state.errorIsQueueFullNotice) { + MiuixTheme.colorScheme.onSurfaceVariantSummary + } else { + MiuixTheme.colorScheme.error + }, onDismiss = viewModel::dismissError, ) } @@ -544,6 +555,11 @@ fun ChatScreen( state.attachmentNotice?.let { message -> Banner(text = message, color = MiuixTheme.colorScheme.onSurfaceVariantSummary) } + // 「并行没排上、这条按普通排队走了」——与上面两条同档的如实告知(**不是错误**, + // 所以不用红色):开关 ON ≠ 一定起第二路,这条说的就是那个差别。 + state.parallelLimitNotice?.let { message -> + Banner(text = message, color = MiuixTheme.colorScheme.onSurfaceVariantSummary) + } searchNotice?.let { message -> Banner(text = message, color = MiuixTheme.colorScheme.onSurfaceVariantSummary) } @@ -645,11 +661,15 @@ private fun documentOpenType(context: Context, target: DocumentTarget): String { private const val FALLBACK_PREVIEW_FILE_NAME = "document.txt" /** - * 消息区上方的一行提示:错误与分享告知共用同一形态(铺满宽度、卡片底色)。 + * 消息区上方的一行提示:错误与各条告知共用同一形态(铺满宽度、卡片底色)。 * * [onDismiss] 给出时右侧多一个关闭按钮。**只有错误那条**给(它不会自己消失,见 - * `ChatViewModel.dismissError`);分享/附件/定位那几条是**动作刚发生的如实告知**、挂了就自己 - * 收走,给它们一个关闭按钮只是多一个没人按的图标。 + * `ChatViewModel.dismissError`);分享/附件/定位/并行落回排队那几条是**动作刚发生的如实告知**、 + * 挂了就自己收走,给它们一个关闭按钮只是多一个没人按的图标。 + * + * [color] 由**调用点**给,本函数不设默认色:真错误红(`colorScheme.error`),降级告知用柔和档 + * (`colorScheme.onSurfaceVariantSummary`)。哪一条算哪一档写在调用处那段注释里—— + * **忙时被拒那条不是错误**,别顺手把它调回红色,也别把真错误调柔和。 */ @Composable private fun Banner(text: String, color: Color, onDismiss: (() -> Unit)? = null) { 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 221b95e..854e84a 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 @@ -148,6 +148,34 @@ const val QUEUE_FULL_UNKNOWN_MESSAGE = "她忙不过来,这条可能没发出 /** 队列满的机器可读码(契约 v0.59/G5,与 REST `409` 同码)。 */ const val CHAT_BUSY_CODE = "chat_busy" +/** `pending_ack.reason` 的已知取值之一(契约 v0.65 忙时并行规范 §2.6)。 */ +const val PARALLEL_LIMIT_REASON = "parallel_limit" + +/** + * 「**并行没排上、已按普通排队走**」的如实文案(降级告知,**不是错误**)。 + * + * 措辞刻意不吓人:这条消息服务端**收下了**、也照常会回,只是没能另起一路。 + * 三件事都要说清——(1) 没排上并行、(2) 这条并没丢(落回普通排队)、(3) 什么时候轮到它 + * (前面那句做完就轮到)。少了第 (2)(3) 句,用户会把「降级」读成「失败」。 + */ +const val PARALLEL_LIMIT_NOTICE = "她正忙,这条没能另起一路,已按普通排队排上;她做完手上这句就轮到它" + +/** + * `pending_ack.reason` ⇒ 该对用户说的那句降级告知(**纯函数**,`null` = 没什么要说的)。 + * + * ## 判据(穷举) + * - [PARALLEL_LIMIT_REASON] ⇒ [PARALLEL_LIMIT_NOTICE]:声明了并行但服务端额度已满,落回排队。 + * - 其它任何字符串 ⇒ `null`:**开放集合**(服务端明写「已知取值 `"parallel_limit"`」,将来可能 + * 再加),本端**不为不认识的 reason 编文案**——说不出的话就不说。这不是静默: + * `reason` 本身在解析层**原样保留**(`WsEvent.PendingAck.reason`,未知值不丢也不改写), + * 而「这条在排队」这件事照旧由既有的排队态与队列深度如实呈现。 + * - `null` / 空白 ⇒ `null`:「整键缺席」= 服务端没给理由(绝大部分情况),本来就没话说。 + * + * 不做 trim 之外的归一:reason 是**机器可读码**,大小写与拼写都以服务端为准,本端不猜同义词。 + */ +fun parallelLimitNoticeFor(reason: String?): String? = + if (reason?.trim() == PARALLEL_LIMIT_REASON) PARALLEL_LIMIT_NOTICE else null + /** * 一帧「队列满」该对本地乐观气泡做什么(纯函数的结果,见 [queueFullOutcome])。 * @@ -237,8 +265,12 @@ fun queueFullOutcome( */ fun ChatUiState.applyQueueFull(outcome: QueueFullOutcome): ChatUiState = when (outcome) { is QueueFullOutcome.Adopt -> copy(pending = pending.filterNot { it.localId == outcome.localId }) - is QueueFullOutcome.Rollback -> rollbackPending(outcome.localId, outcome.reason) - is QueueFullOutcome.Ambiguous -> copy(error = outcome.reason) + // 回滚 / 「说不准」两档说的都是**忙时被拒**那一句:横幅照旧走 `error` 通道,但颜色按降级 + // 告知走(`errorIsQueueFullNotice = true`,主人裁决「红色不要太醒目」)。 + is QueueFullOutcome.Rollback -> + rollbackPending(outcome.localId, outcome.reason, queueFullNotice = true) + + is QueueFullOutcome.Ambiguous -> copy(error = outcome.reason, errorIsQueueFullNotice = true) QueueFullOutcome.Ignore -> this } @@ -251,8 +283,16 @@ fun ChatUiState.applyQueueFull(outcome: QueueFullOutcome): ChatUiState = when (o * 但本地引用仍然有效、可直接重发); * - 哪一条都没命中(气泡已被别的路径收走)时,输入栏与原因照旧,只是没有气泡可摘—— * 同一件事被两条帧各报一次(如广播 `pending_full` 与定向 `error{chat_busy}`)时不会重复归还。 + * + * @param queueFullNotice 这次回滚的原因是**忙时被拒**(而不是图片处理失败、服务端 4xx 之类): + * 只影响那条横幅的**配色**。默认 `false` = 真错误,红色;唯一的 `true` 调用点在 + * [applyQueueFull] 与 `ChatAck.QueueFull`(见 [ChatUiState.errorIsQueueFullNotice] 的边界)。 */ -fun ChatUiState.rollbackPending(localId: String, reason: String): ChatUiState { +fun ChatUiState.rollbackPending( + localId: String, + reason: String, + queueFullNotice: Boolean = false, +): ChatUiState { val failed = pending.firstOrNull { it.localId == localId } return copy( pending = pending.filterNot { it.localId == localId }, @@ -260,6 +300,7 @@ fun ChatUiState.rollbackPending(localId: String, reason: String): ChatUiState { draftImage = failed?.imageUri ?: draftImage, draftDocuments = failed?.documents?.takeIf { it.isNotEmpty() } ?: draftDocuments, error = reason, + errorIsQueueFullNotice = queueFullNotice, ) } @@ -424,7 +465,8 @@ fun ChatUiState.applyMessageActionFailure( } copy(messageAction = updated) } else { - copy(error = reason) + // 一次没生效的编辑是**真错误** ⇒ 位写 false,横幅照旧红色(见 [ChatUiState.errorIsQueueFullNotice])。 + copy(error = reason, errorIsQueueFullNotice = false) } } @@ -479,6 +521,20 @@ data class ChatUiState( val loadingOlder: Boolean = false, val hasMore: Boolean = true, val error: String? = null, + /** + * [error] 里这句话是不是**忙时被拒**的降级告知(而不是真错误)——**只决定配色**。 + * + * `error` 是一条**通道**,两种东西共用它:真正的失败(登录失效 / 网络失败 / 服务端 4xx / + * 图片与附件处理失败……)与忙时被拒那一句([QUEUE_FULL_MESSAGE] / + * [QUEUE_FULL_UNKNOWN_MESSAGE])。主人裁决(`02` 回执十六):「形态不变,但红色不要太醒目」 + * ⇒ 只有**后者**改用同页那几条告知的柔和档([ChatScreen] 那条横幅按这一位选色, + * 判据就是这个位本身,**不按文案猜**)。 + * + * ⚠️ **边界**:除忙时被拒以外的任何 `error` 都必须保持红色——那些是真错误,显眼是对的。 + * 因此**写 [error] 的每一处都要把这个位一起写清楚**(默认 `false`):`copy` 会继承上一位值, + * 一条接着柔和告知之后的真错误若不写它,就会顶着一身柔和的颜色出现。 + */ + val errorIsQueueFullNotice: Boolean = false, /** * 「分享到 App」落地的如实告知(例如有几张图没能发送);null = 没有要说的。 * @@ -493,6 +549,17 @@ data class ChatUiState( * 也不一样(换文件 / 先发一批)。同样到期自动消失([ATTACHMENT_NOTICE_MILLIS])。 */ val attachmentNotice: String? = null, + /** + * 「**并行没排上**」的如实告知(契约 v0.65 忙时并行规范 §2.6:`pending_ack{reason:"parallel_limit"}`); + * null = 没有要说的。 + * + * 与 [error] 分开:这条**不是错误**——消息服务端收下了、也照常会回,只是**没能另起一路**, + * 按普通排队排在现有那句之后(设置页那栏「并行」开关 ON ≠ 一定起第二路: + * `CHAT_MAX_PARALLEL_TURNS < 2` 或额度已满时服务端让它落回排队)。不与 [shareNotice] / + * [attachmentNotice] 共用同一个位,是因为它们各自描述**不同的动作**、可以同时挂着。 + * 同样到期自动消失([PARALLEL_LIMIT_NOTICE_MILLIS])。 + */ + val parallelLimitNotice: String? = null, val connection: WsState = WsState.Idle, /** 编排忙碌态:`idle`/`thinking`/`executing`/`subagent`;null = 空闲。 */ val busyMode: String? = null, @@ -699,7 +766,8 @@ class ChatViewModel( } } .onFailure { e -> - _state.update { it.copy(loading = false, error = e.userMessage()) } + // 首屏拉取失败是**真错误** ⇒ 位写 false(红色),别继承上一条柔和告知。 + _state.update { it.copy(loading = false, error = e.userMessage(), errorIsQueueFullNotice = false) } markSessionGoneIfNeeded(e) } } @@ -1047,6 +1115,20 @@ class ChatViewModel( } } + /** + * 记一条「并行没排上」的告知,到期自动收走(与 [showShareNotice] 同一形态,第三块牌子)。 + * + * 它描述的是**刚刚发生的那一次发送**([WsEvent.PendingAck] 到达的那一刻),所以到点自己收走; + * 它**不是错误**,不给关闭按钮、也不进 `error` 通道。 + */ + private fun showParallelLimitNotice(text: String) { + _state.update { it.copy(parallelLimitNotice = text) } + viewModelScope.launch { + kotlinx.coroutines.delay(PARALLEL_LIMIT_NOTICE_MILLIS) + _state.update { if (it.parallelLimitNotice == text) it.copy(parallelLimitNotice = null) else it } + } + } + /** * 把一条消息乐观上屏并投递出去([send] 与 [sendShared] 共用)。 * @@ -1397,7 +1479,8 @@ class ChatViewModel( // 409 队列满:这条**没有被受理、没有落库**(服务端在入队判定处直接拒绝), // 所以不能当成「已排队」——回滚气泡并把文本还回输入框。文案与 WS 路径的定向 // `error{chat_busy}` 共用一句(契约把两者定为同一个 code;见 [QUEUE_FULL_MESSAGE])。 - ChatAck.QueueFull -> rollback(localId, QUEUE_FULL_MESSAGE) + // 这是**忙时被拒**那一档 ⇒ 柔和配色(真错误才红,见 [ChatUiState.errorIsQueueFullNotice])。 + ChatAck.QueueFull -> rollback(localId, QUEUE_FULL_MESSAGE, queueFullNotice = true) is ChatAck.Unavailable -> rollback(localId, "服务端暂时接不住这一轮,请稍后重试") @@ -1526,10 +1609,14 @@ class ChatViewModel( * * 所有「没发出去」都汇到这一处(WS 发送失败 / REST 各种失败 / `409 chat_busy` / 附件与图片 * 上传失败 / 图片处理失败 / 附件超限…),所以重试记忆也只需要这一个写入口。 + * + * 这一处也汇聚了**两种温度**的原因:真错误(默认,红色)与忙时被拒的降级告知 + * ([queueFullNotice] = `true`,柔和档)。**只有 `409 chat_busy` 那一档是后者**——它是 + * 「她忙,请稍后再试」,不是坏了;调色板见 [ChatUiState.errorIsQueueFullNotice] 的边界。 */ - private fun rollback(localId: String, reason: String) { + private fun rollback(localId: String, reason: String, queueFullNotice: Boolean = false) { rememberRetryKey(_state.value.pending, localId) - _state.update { it.rollbackPending(localId, reason) } + _state.update { it.rollbackPending(localId, reason, queueFullNotice) } } /** @@ -1761,6 +1848,11 @@ class ChatViewModel( queuedKeys = state.queuedKeys + state.pending.map { it.localId }, ) } + // 「并行没排上」(契约 v0.65 §2.6):开关 ON ≠ 一定起第二路——服务端额度满时 + // 让它落回排队,界面上与普通排队**毫无区别**。用户选了「并行」却看不出区别就是 + // 静默,所以这里如实说一句。判据与文案都是纯函数 [parallelLimitNoticeFor] + // (未知 reason = `null`,不编文案);这条**不是错误**,配色用告知档。 + parallelLimitNoticeFor(event.reason)?.let(::showParallelLimitNotice) } is WsEvent.PendingDrain -> if (event.sessionId == sessionId) { @@ -1804,7 +1896,10 @@ class ChatViewModel( // 其余错误码原样透出,未知码也不吞:没有文案可说的(如 `bad_request` 只带 code) // 就不摆一个空气泡,但事件本身已经完整解码、没有被丢弃。 - event.message.isNotBlank() -> _state.update { it.copy(error = event.message) } + // 服务端给的是**真错误**(登录失效 / 4xx / 5xx……)⇒ 位写 false,红色不动 + // (柔和档**只**给忙时被拒那一条,见 [ChatUiState.errorIsQueueFullNotice])。 + event.message.isNotBlank() -> + _state.update { it.copy(error = event.message, errorIsQueueFullNotice = false) } else -> Unit } @@ -2025,7 +2120,8 @@ class ChatViewModel( } } .onFailure { e -> - _state.update { it.copy(loadingOlder = false, error = e.userMessage()) } + // 上拉分页失败是**真错误** ⇒ 位写 false(红色)。 + _state.update { it.copy(loadingOlder = false, error = e.userMessage(), errorIsQueueFullNotice = false) } markSessionGoneIfNeeded(e) } } @@ -2155,6 +2251,14 @@ private const val SHARE_TEXT_NOT_STAGED = "这段内容没能存成文件,已 /** 附件提示在界面上停留的时长(同上:描述动作,不描述持续状态)。 */ private const val ATTACHMENT_NOTICE_MILLIS = 8 * 1000L +/** + * 「并行没排上」告知在界面上停留的时长(同上:描述刚发生的那一次发送)。 + * + * 与另两条告知取同一个值:三条都是「动作刚发生的如实告知」,停留时长不同只会让同一列横幅 + * 显得各行其是。 + */ +private const val PARALLEL_LIMIT_NOTICE_MILLIS = 8 * 1000L + /** 附件的可读名字:显示名拿不到时用 URI,再拿不到就只说「附件」(不编造一个名字)。 */ private fun documentLabel(document: DraftDocument): String = document.displayName.ifBlank { document.uri }.ifBlank { "附件" } diff --git a/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ParallelLimitNoticeTest.kt b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ParallelLimitNoticeTest.kt new file mode 100644 index 0000000..312dd80 --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ParallelLimitNoticeTest.kt @@ -0,0 +1,91 @@ +package top.yeij.cyrene.ui.chat + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * 「并行没排上」的如实告知与顶部横幅的**两档配色**(契约 v0.65 §2.6)。 + * + * 两件事各测各的: + * + * 1. [parallelLimitNoticeFor] 的判据(`pending_ack.reason` ⇒ 文案或 `null`):已知值一句话、 + * 未知值**不编**、缺席与空白没话说。开关 ON ≠ 一定起第二路,这一句就是那个差别的全部告知。 + * 2. [ChatUiState.errorIsQueueFullNotice] 那条**边界**:忙时被拒(降级告知)为 `true`, + * 真错误为 `false`。形状仍是既有的顶部横幅,变的只是颜色——所以判据必须可测,且必须 + * 只对忙时被拒那一档成立。配色本身(哪一档用哪个色)在 `ChatScreen` 里,不进单测。 + */ +class ParallelLimitNoticeTest { + + private fun pending(localId: String) = PendingMessage(localId = localId, text = "这条") + + // —— 一、reason ⇒ 文案(纯函数,穷举三档) —— + + @Test + fun `the known reason says parallel did not fit and the message queued normally`() { + assertEquals(PARALLEL_LIMIT_NOTICE, parallelLimitNoticeFor("parallel_limit")) + } + + @Test + fun `the known reason is tolerant of surrounding whitespace`() { + // 机器码两侧的空格只可能是传输噪声:认它比因为一个空格而**静默**更安全。 + assertEquals(PARALLEL_LIMIT_NOTICE, parallelLimitNoticeFor(" parallel_limit ")) + } + + @Test + fun `an unknown reason invents nothing`() { + // 开放集合:将来服务端可能再加取值。不为不认识的 reason 编文案——说不出的话就不说, + // 而「这条在排队」仍由既有的排队态与队列深度如实呈现(reason 本身也没被丢掉)。 + assertNull(parallelLimitNoticeFor("some_future_reason")) + assertNull(parallelLimitNoticeFor("PARALLEL_LIMIT")) + } + + @Test + fun `an absent or blank reason has nothing to say`() { + assertNull(parallelLimitNoticeFor(null)) + assertNull(parallelLimitNoticeFor("")) + assertNull(parallelLimitNoticeFor(" ")) + } + + // —— 二、横幅温度:降级告知柔和、真错误照旧红 —— + + @Test + fun `a queue-full rejection is marked as a degradation notice`() { + val state = ChatUiState(pending = listOf(pending("local-a"))) + + val after = state.applyQueueFull(QueueFullOutcome.Rollback("local-a", QUEUE_FULL_MESSAGE)) + + assertEquals(QUEUE_FULL_MESSAGE, after.error) + assertTrue(after.errorIsQueueFullNotice) + } + + @Test + fun `an unlocatable queue-full rejection is marked as a degradation notice too`() { + val state = ChatUiState(pending = listOf(pending("local-a"), pending("local-b"))) + + val after = state.applyQueueFull(QueueFullOutcome.Ambiguous(QUEUE_FULL_UNKNOWN_MESSAGE)) + + assertEquals(QUEUE_FULL_UNKNOWN_MESSAGE, after.error) + assertTrue(after.errorIsQueueFullNotice) + } + + @Test + fun `a genuine error stays red even right after a degradation notice`() { + // 边界:柔和那份**不继承**。一条接着忙时被拒之后的真错误必须照旧红色。 + val afterNotice = ChatUiState(pending = listOf(pending("local-a"))) + .applyQueueFull(QueueFullOutcome.Rollback("local-a", QUEUE_FULL_MESSAGE)) + + val afterError = afterNotice + .applyMessageActionFailure(MessageActionKind.Edit, "m-1", "登录状态已失效,请重新登录后再编辑") + + assertEquals("登录状态已失效,请重新登录后再编辑", afterError.error) + assertFalse(afterError.errorIsQueueFullNotice) + } + + @Test + fun `a fresh state has no degradation marker`() { + assertFalse(ChatUiState().errorIsQueueFullNotice) + } +}