merge: W32 —— 忙时被拒那条横幅调柔和(只调那一档,真错误仍红)+ 并行额度落回排队时如实说一句

This commit is contained in:
AskaEth committed 2026-10-05 09:56:22 +08:00
commit 79fcd803a0
3 files changed
+229 -14

No files matched your search

@@ -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) {
@@ -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 { "附件" }
@@ -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)
}
}