feat(settings): 设置页「她忙时发消息」追加第三档「并行」——忙时不打断、另起一路(chat.parallel=true)
主人要求:在既有的「追加队列 / 打断」之下再追加一档「并行」。三档语义互补——追加队列进队列等 (默认档,逐字节不变)、打断先发 interrupt 再发这条、并行不打断、另起一路同时处理。 改了什么: - AppSettings:InterruptMode 加第三个值 Parallel(wireScope = null, wireParallel = true),并给枚举 补出第二根线 wireParallel(wireScope 只管 interrupt 的 scope,表达不了「要声明并行」这件事)。 既有两个值的 name 与落盘键 interrupt_mode 一个字未动——改名会让已存偏好读不出来并静默回落默认档。 落盘字符串 → 档位的映射抽成顶层纯函数 toInterruptMode(AppSettings 本体要 Android + DataStore, 单测里起不来),新增的第三个名字能正确读回,未知字符串仍回落 AppendQueue(既有行为未变)。 - 判据抽成纯函数 parallelForSend(mode, busy) 与 parallelWireValue(declare):前者与 interruptScopeForSend 共用同一个忙判据(ChatUiState.busyMode != null),只在会话正忙时为真。 规范 §2.2 没写空闲时要不要也声明——空闲时这条本来就是它自己的回合,声明与不声明在服务端是同一 件事,故取与打断同一个判据(最小惊讶:空闲时三档逐字节完全相同)。 - 两条互斥由枚举取值本身保证,不是靠约定:并行档 wireScope 是 null ⇒ 绝不发 interrupt;另外两档 wireParallel 是 false ⇒ 绝不带 parallel 键。默认档「追加队列」线上零差异由此结构性成立。 - 发送路径接线:ChatViewModel.deliver 只读一次偏好、同时喂给两个判据(读两次会在用户切档的瞬间 出现「既打断又并行」);ChatSocket.sendChat 与 ChatRequest 各加一个 parallel 参数,false 一律 翻成整键省略——显式 false 会被序列化成 parallel:false,那是「显式否认」而不是「未声明」, 默认档就不再逐字节不变。 - 设置页:新选项标题「并行」,summary 说清后果——不打断她正在说的这一句、两路各算一遍会多花一份 算力与 token(主人裁决:峰值 token 近翻倍可接受,但用户有权知道)。UI 仍是 Miuix,与同栏另外 两条逐字同形,勾选标记位置相同。 - 分享路径同样不套用这一档偏好(W26 的 allowInterrupt 因此更名为 applySendPreference,它现在管 的是整档偏好):分享是批量投递,逐条索要并行会被服务端 2 路额度把同一次分享劈成两种待遇,且每 条各算一遍;用户也没按过发送键说这条要并行。单张图时同样不声明,规则才能一句话说清。 为什么:主人 2026-10-05 的裁决(真两路并行)需要客户端有一个显式声明入口;W30 已备好 chat.parallel 的线上形状(默认 null = 整键省略),本次只把设置页这一档与发送路径接通,上行仍是纯增量——没选 「并行」的用户线上一个字节都不变。 验收:assembleDebug 通过;testDebugUnitTest 通过(1022 测试 / 0 失败 / 0 错误;基线 1004,新增 18: ParallelForSendTest 13 条 + InterruptScopeTest 1 条 + InterruptModeTest 4 条)。
This commit is contained in:
1 parent
82d3ca3953
commit
a776e9f608
11 files changed
+462
-92
No files matched your search
@@ -16,27 +16,60 @@ private val Context.appDataStore: DataStore<Preferences> by preferencesDataStore
|
||||
enum class ThemeMode { System, Light, Dark }
|
||||
|
||||
/**
|
||||
* 她正忙时按发送的行为(协同项 A2,契约 v0.56 §3.1 的 `interrupt.scope`)。
|
||||
* 她忙时发消息的行为(协同项 A2,契约 v0.56 §3.1 的 `interrupt.scope`;忙时并行规范 §2.2 的
|
||||
* `chat.parallel`)。
|
||||
*
|
||||
* @property wireScope 上行 `interrupt` 帧的 `scope` 取值;`null` = **整键省略**
|
||||
* (服务端视同 `"all"`,与加入 `scope` 之前那条帧逐字节相同)。
|
||||
* 类型名与落盘键(`interrupt_mode`)都是**历史名字**:第三档「并行」接上来之后它管的不再只是打断,
|
||||
* 但改类型名 / 键名 / 既有档位的 `name` 都会让已存偏好读不出来并**静默回落**默认档——所以三个名字
|
||||
* 一个都不动(见 [toInterruptMode] 与 `InterruptModeTest` 的「落盘名稳定」那几条)。
|
||||
*
|
||||
* 一档同时表达**两个上行声明**:[wireScope] 与 [wireParallel]。三档各占一种组合——打断占前者、
|
||||
* 并行占后者、追加队列两个都不占,**没有任何一档同时给两个**。
|
||||
*
|
||||
* @property wireScope 上行 `interrupt` 帧的 `scope` 取值;`null` = **不发打断帧**。注意它不是
|
||||
* 「发一条省略 scope 的帧」——那在服务端是 `"all"`(连待处理队列一起清),是完全不同的语义。
|
||||
* @property wireParallel 这条 `chat` 要不要声明 `parallel:true`;`false` = **一个字节都不带**。
|
||||
*/
|
||||
enum class InterruptMode(val wireScope: String?) {
|
||||
enum class InterruptMode(val wireScope: String?, val wireParallel: Boolean) {
|
||||
|
||||
/**
|
||||
* 追加队列(默认):忙时只把消息排进队列,不打断在跑回合。
|
||||
* 追加队列(默认):忙时只把消息排进队列,不打断也不并行。
|
||||
*
|
||||
* 该模式**从不产生 `interrupt` 帧**(判据 `interruptScopeForSend` 的结论恒为 `null`):
|
||||
* 默认档的线上请求与本偏好存在之前**逐字节相同**。它的 `wireScope` 是 `null`,正是
|
||||
* 「整键省略」那一形状——它在这里表达的是**没有要发的东西**,不是「发一条省略 scope 的帧」。
|
||||
* 该模式**两个声明都不发**(`interruptScopeForSend` 恒为「不发」、`parallelForSend` 恒为
|
||||
* `false`):默认档的线上请求与本偏好存在之前**逐字节相同**。
|
||||
*/
|
||||
AppendQueue(null),
|
||||
AppendQueue(wireScope = null, wireParallel = false),
|
||||
|
||||
/**
|
||||
* 打断:只取消在跑回合、**保留**待处理队列(`scope:"current"`)。
|
||||
* 队列里的消息按编排器正常续跑规则继续执行,一条都不丢。
|
||||
*/
|
||||
Interrupt("current"),
|
||||
Interrupt(wireScope = "current", wireParallel = false),
|
||||
|
||||
/**
|
||||
* 并行:忙时**不打断**在跑的那一路,本条**另起一路**同时处理(`chat.parallel=true`);
|
||||
* 服务端并发额度满时**不报错**,这一条落回排队。
|
||||
*
|
||||
* [wireScope] 为 `null` 不是因为「这一档没什么要发的东西」(它当然要发东西,发的是
|
||||
* `chat.parallel`),而是因为**并行与打断互斥**:这一档**绝不允许**产生 `interrupt` 帧——
|
||||
* 一产生就等于先把要并行的那一路砍掉。
|
||||
*
|
||||
* 代价:两路各算一遍 ⇒ 峰值算力 / token 近翻倍(主人 2026-10-05 裁决 4:不分摊、优先性能),
|
||||
* 所以设置页这一栏的 summary 把这句代价明说出来。
|
||||
*/
|
||||
Parallel(wireScope = null, wireParallel = true),
|
||||
}
|
||||
|
||||
/**
|
||||
* 落盘字符串 → 档位(`AppSettings` 的 `interrupt_mode` 键读取口;抽成顶层函数是为了能单测——
|
||||
* `AppSettings` 本体要 Android + DataStore,单测里起不来)。
|
||||
*
|
||||
* 缺省 / 旧版本遗留的未知值一律解成默认档(追加队列),绝不悄悄换成更激进的档位。
|
||||
*/
|
||||
fun String?.toInterruptMode(): InterruptMode = when (this) {
|
||||
InterruptMode.Interrupt.name -> InterruptMode.Interrupt
|
||||
InterruptMode.Parallel.name -> InterruptMode.Parallel
|
||||
else -> InterruptMode.AppendQueue
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,12 +125,6 @@ class AppSettings(context: Context) {
|
||||
else -> ThemeMode.System
|
||||
}
|
||||
|
||||
/** 缺省 / 旧版本遗留的未知值一律解成默认档(追加队列),绝不悄悄换成更激进的档位。 */
|
||||
private fun String?.toInterruptMode(): InterruptMode = when (this) {
|
||||
InterruptMode.Interrupt.name -> InterruptMode.Interrupt
|
||||
else -> InterruptMode.AppendQueue
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val KEY_THEME_MODE = stringPreferencesKey("theme_mode")
|
||||
val KEY_SHOW_NARRATION = booleanPreferencesKey("show_narration")
|
||||
|
||||
@@ -49,8 +49,9 @@ data class ChatRequest(
|
||||
/**
|
||||
* **请求把本条作为独立一路处理**(忙时并行规范 §2.2,REST 与 WS `chat` **同形**)。
|
||||
*
|
||||
* `null` = **整键省略**(`encodeDefaults = false`),与加入该字段之前**逐字节相同**。
|
||||
* 本端今天**不发它**:真并行要用户显式声明,而声明入口不在本次范围内(客户端只备好形状)。
|
||||
* `null` = **整键省略**(`encodeDefaults = false`),与加入该字段之前**逐字节相同**:
|
||||
* 用户把设置页那栏「她忙时发消息」选成「**并行**」**且会话正忙**时才给 `true`(判据是纯函数
|
||||
* `parallelForSend`;另外两档、以及空闲时都不带这个键)。
|
||||
* 服务端照准与否都不报错——额度满时落回排队并回 `202 {queued:true}`(伴一条
|
||||
* `pending_ack{reason:"parallel_limit"}`),已被在飞路 steer 吃进时回 `200 {stream:true}`。
|
||||
*/
|
||||
|
||||
@@ -2,6 +2,7 @@ package top.yeij.cyrene.data.ws
|
||||
|
||||
import top.yeij.cyrene.core.auth.AuthRecovery
|
||||
import top.yeij.cyrene.data.api.ApiClientFactory
|
||||
import top.yeij.cyrene.domain.chat.parallelWireValue
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -166,6 +167,9 @@ class ChatSocket(
|
||||
/**
|
||||
* 发送 `chat`(api-reference §6.3)。
|
||||
* @param clientMsgId 幂等键(契约 v0.56 §3.13);重复到达服务端回 `chat_duplicate`,不落库。
|
||||
* @param parallel 要不要声明 `parallel:true`(忙时并行规范 §2.2)——**默认 `false` = 一个字节
|
||||
* 都不带该键**,线上帧与加入本参数之前**逐字节相同**。调用方只有一处:`ChatViewModel.deliver`,
|
||||
* 它按 App 偏好(`InterruptMode`)与「会话是否正忙」算出来(判据是纯函数 `parallelForSend`)。
|
||||
* @return 未连接或写失败返回 `false`(本层不做离线队列,由上层标红重试)。
|
||||
*/
|
||||
fun sendChat(
|
||||
@@ -173,12 +177,16 @@ class ChatSocket(
|
||||
text: String,
|
||||
deviceId: String?,
|
||||
clientMsgId: String? = null,
|
||||
parallel: Boolean = false,
|
||||
): Boolean = send(
|
||||
WsOutbound.Chat(
|
||||
text = text,
|
||||
sessionId = sessionId,
|
||||
deviceId = deviceId,
|
||||
clientMsgId = clientMsgId,
|
||||
// 直接传 `false` 会序列化成 `"parallel":false`(`WsOutbound.Chat` 只在 `null` 时省略该键),
|
||||
// 那不是「未声明」而是**显式否认**——默认两档必须逐字节不变 ⇒ 只有 true 才带上该键。
|
||||
parallel = parallelWireValue(parallel),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -191,7 +199,8 @@ class ChatSocket(
|
||||
*
|
||||
* **调用方只有一处**:`ChatViewModel.interruptRunningTurn()`——它在一条消息真的要走上去**之前**
|
||||
* 按 App 偏好(`InterruptMode`)决定要不要先打断,且**只在会话正忙时**发(判据是纯函数
|
||||
* `interruptScopeForSend`)。默认档「追加队列」恒不发,所以本方法对线上流量零影响。
|
||||
* `interruptScopeForSend`)。默认档「追加队列」与「并行」这一档都恒不发(后者的 `wireScope`
|
||||
* 就是 `null`:并行与打断互斥),所以本方法只服务「打断」这一档。
|
||||
* 返回 false 只有一种原因(WS 未连接):调用方**静默**并不阻断本次发送,理由见那里的 KDoc。
|
||||
*/
|
||||
fun sendInterrupt(sessionId: String, scope: String? = null): Boolean =
|
||||
|
||||
@@ -47,9 +47,9 @@ sealed interface WsOutbound {
|
||||
/**
|
||||
* **请求把本条作为独立一路处理**(忙时并行规范 §2.2,**纯增量**)。
|
||||
*
|
||||
* `null` = **整键省略**:行为与加入该字段之前**逐字相同**(忙时落回排队 + 回合内 steer)。
|
||||
* 本端**今天一个字节都不发它**——真并行需要用户显式声明,而声明入口(UI)不在本次范围内;
|
||||
* 这里只把线上形状备好,好让「接上它」是一次纯增量而不是一次协议改动。
|
||||
* `null`(默认)= **整键省略**:行为与加入该字段之前**逐字相同**(忙时落回排队 + 回合内 steer)。
|
||||
* 用户把「她忙时发消息」那栏选成「并行」**且会话正忙**时才给 `true`(判据是纯函数
|
||||
* `parallelForSend`;另外两档与空闲时都不带这个键——线上取值由 `parallelWireValue` 翻)。
|
||||
*
|
||||
* 服务端**照准也不报错**(额度满 ⇒ 落回排队并回 `pending_ack{reason:"parallel_limit"}`;
|
||||
* 已被在飞路 steer 吃进 ⇒ 回 `steer_ack`),所以这个字段永远不该引来一个错误帧。
|
||||
|
||||
@@ -2,6 +2,15 @@ package top.yeij.cyrene.domain.chat
|
||||
|
||||
import top.yeij.cyrene.core.prefs.InterruptMode
|
||||
|
||||
/**
|
||||
* 「她忙时发消息」这一档偏好在上行的**两个声明**(纯函数,VM 与测试共用):
|
||||
* 协同项 A2 的 `interrupt.scope`([interruptScopeForSend])与忙时并行规范 §2.2 的
|
||||
* `chat.parallel`([parallelForSend] / [parallelWireValue])。
|
||||
*
|
||||
* 文件名沿用了先有的那个(`InterruptScope`):并行那一半接上来时,两者是**同一个判据的两个方向**
|
||||
* (打断 vs 不打断),放在同一处比拆成两个文件更容易看出它们互斥。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 发这条消息**之前**要不要先发一条 `interrupt`:返回要发的作用域,`null` = 一个字节都不发
|
||||
* (纯函数,VM 与测试共用)。
|
||||
@@ -9,13 +18,16 @@ import top.yeij.cyrene.core.prefs.InterruptMode
|
||||
* ## 四条判据(协同 A2 的客户端半边,契约 v0.56 §3.1)
|
||||
*
|
||||
* 1. [InterruptMode.AppendQueue](**默认档**)⇒ 恒为 `null`,线上请求与接入本函数之前**逐字节相同**。
|
||||
* 这是最重要的一条:默认档不许有任何行为变化。[InterruptMode.AppendQueue] 的 `wireScope`
|
||||
* 本来就是 `null`(见那里的 KDoc),所以这一档连「线上取值是什么」都不必判——它天生发不出东西。
|
||||
* 这是最重要的一条:默认档不许有任何行为变化。
|
||||
* 2. [InterruptMode.Interrupt](`wireScope = "current"`)**且会话正忙** ⇒ `"current"`:
|
||||
* 只取消在跑回合、**保留待处理队列**(队列里的消息按编排器正常续跑,一条都不丢)。
|
||||
* 3. [InterruptMode.Interrupt] 但会话**空闲** ⇒ `null`:命中空闲会话在服务端是 no-op,白发一条帧
|
||||
* 只是多一次往返,还会在 logcat 里制造「这里打断过」的假象。
|
||||
* 4. 判据里**没有**「队列有多深」这一项,见下。
|
||||
* 4. [InterruptMode.Parallel] ⇒ **恒为 `null`**(它的 `wireScope` 就是 `null`):并行与打断互斥,
|
||||
* 并行档先打断就恰好砍掉它要并行的那一路。这一条不是「这一档没得发」,而是**结构性保证**——
|
||||
* 谓词只读 `wireScope`,并行档不可能发出打断帧。
|
||||
*
|
||||
* 判据里**没有**「队列有多深」这一项,见下。
|
||||
*
|
||||
* ## 「正忙」由谁判
|
||||
* 只认**服务端给的** `session_busy{mode}`(调用方传 `ChatUiState.busyMode != null`)。
|
||||
@@ -26,3 +38,39 @@ import top.yeij.cyrene.core.prefs.InterruptMode
|
||||
*/
|
||||
fun interruptScopeForSend(mode: InterruptMode, busy: Boolean): String? =
|
||||
if (busy) mode.wireScope else null
|
||||
|
||||
/**
|
||||
* 发这条消息时要不要声明 `parallel:true`(忙时并行规范 §2.2,[interruptScopeForSend] 的兄弟)。
|
||||
*
|
||||
* ## 三条判据
|
||||
*
|
||||
* 1. [InterruptMode.Parallel] **且会话正忙** ⇒ `true`:不打断在跑的那一路,让本条**另起一路**
|
||||
* 同时处理(服务端额度满时不报错,这一条落回排队)。
|
||||
* 2. 另外两档 ⇒ **恒为 `false`**:`parallel` 是纯增量键,省略它线上请求与接入本判据之前**逐字节相同**
|
||||
* ([InterruptMode.AppendQueue] 与 [InterruptMode.Interrupt] 的 `wireParallel` 都是 `false`)。
|
||||
* 3. 并行的同时**绝不打断**:两件事是一对互斥语义(打断 = 先砍掉她正在说的这一句,并行 = 不打断、
|
||||
* 另起一路),所以 [InterruptMode.Parallel] 的 `wireScope` 是 `null` ⇒ 它在
|
||||
* [interruptScopeForSend] 里恒为「不发」。反过来,另外两档的 `wireParallel` 是 `false` ⇒
|
||||
* 它们永远不带 `parallel` 键。「绝不既打断又并行」不是约定,是这两处属性取值本身
|
||||
* (`ParallelForSendTest` 把它钉成穷举断言)。
|
||||
*
|
||||
* ## 为什么空闲时不声明
|
||||
* 规范 §2.2 只说 `parallel:true` 是「**请求**把本条作为独立一路处理」,**没写**空闲时要不要照发。
|
||||
* 空闲时这一条**本来就是它自己的回合**,声明并行与不声明在服务端是同一件事;而多带一个键就多一处
|
||||
* 线上形状差异。于是本端取**与 [interruptScopeForSend] 同一个判据**(忙才声明)——最小惊讶:
|
||||
* 空闲时三档**逐字节**完全相同,切档不会改变一条普通发送的任何字节。
|
||||
*
|
||||
* @param busy 这条消息要发出去的那一刻,该会话是否有回合在跑(与 [interruptScopeForSend] 同一个来源)。
|
||||
*/
|
||||
fun parallelForSend(mode: InterruptMode, busy: Boolean): Boolean = busy && mode.wireParallel
|
||||
|
||||
/**
|
||||
* 把 [parallelForSend] 的结论翻成帧 / DTO 那个可选字段的取值:`false` ⇒ `null`(**整键省略**)。
|
||||
*
|
||||
* 为什么必须翻一道:`WsOutbound.Chat.parallel` 与 `ChatRequest.parallel` 的默认值都是 `null`,
|
||||
* 而「省略」与「显式 `false`」在服务端**不是一回事**(前者 = 未声明,后者 = 明确不要并行);
|
||||
* kotlinx 的 `encodeDefaults = false` 只省略「等于默认值」的字段 ⇒ 把 `false` 直接传下去,会在
|
||||
* **每一条**请求上多出一个 `"parallel":false` 键,默认档就不再逐字节不变了。两个发送层共用这一个
|
||||
* 转换点,免得哪天有人在其中一处「顺手」把布尔原样传下去。
|
||||
*/
|
||||
fun parallelWireValue(declare: Boolean): Boolean? = if (declare) true else null
|
||||
@@ -59,6 +59,8 @@ import top.yeij.cyrene.domain.chat.closingBucketOf
|
||||
import top.yeij.cyrene.domain.chat.closingTurn
|
||||
import top.yeij.cyrene.domain.chat.editableMessageOf
|
||||
import top.yeij.cyrene.domain.chat.interruptScopeForSend
|
||||
import top.yeij.cyrene.domain.chat.parallelForSend
|
||||
import top.yeij.cyrene.domain.chat.parallelWireValue
|
||||
import top.yeij.cyrene.domain.chat.readBounded
|
||||
import top.yeij.cyrene.domain.chat.toDocumentFailure
|
||||
import top.yeij.cyrene.domain.chat.turnBucketOf
|
||||
@@ -962,17 +964,25 @@ class ChatViewModel(
|
||||
* 三处刻意的取舍:
|
||||
* - **不动草稿**:分享不是用户按的发送键,不能把他正在输入的内容顶掉;
|
||||
* - **空内容也照常处理**:[notice] 该说就说,静默什么都不发生比报一句更糟;
|
||||
* - **一次都不打断**:见下面「分享不发打断」一段。
|
||||
* - **一次都不套用「她忙时发消息」那个偏好**:见下面「分享不声明」一段。
|
||||
*
|
||||
* ## 分享不发打断(`allowInterrupt` 恒为 false)
|
||||
* ## 分享不声明(`applySendPreference` 恒为 false)
|
||||
* 分享是**一次把东西倒进去**的批量动作:N 张图 = N 条消息,上面那个 `forEach` 会逐条投递,
|
||||
* 而「她正忙时按发送」那个偏好(协同 A2)默认会在**每条**投递前发一条 `interrupt{scope:"current"}`。
|
||||
* 照原样接上去,第 2 条起就是在**打断自己刚起的上一轮**——最后屏幕上只剩最后一张图的回答,
|
||||
* 前面几条被取消(契约里被取消的回合不落库)。自己打断自己一定是错的。
|
||||
* 而「她忙时发消息」那个偏好(协同 A2 + 忙时并行规范 §2.2)在**每条**投递前都会声明点什么。
|
||||
* 两种声明在这里**都**是错的:
|
||||
*
|
||||
* 只有一张图时同样不发:这条规则要**易解释**——「分享从不打断」是一句话,
|
||||
* 「分享超过一张时才不打断」得先跟人解释清为什么是一张的分界。
|
||||
* 用户真的想打断,发送键那条路([send])的口径一字未动。
|
||||
* - **打断**(`interrupt{scope:"current"}`):第 2 条起就是在**打断自己刚起的上一轮**——最后屏幕上
|
||||
* 只剩最后一张图的回答,前面几条被取消(契约里被取消的回合不落库)。自己打断自己一定是错的。
|
||||
* - **并行**(`chat.parallel=true`):它没有破坏性,但同一次分享里的 N 条会**互相索要新的一路**,
|
||||
* 而服务端单会话额度只有 2 路(规范 §0 的 `CHAT_MAX_PARALLEL_TURNS` 默认档)⇒ 前两条各占一路、
|
||||
* 其余落回排队——**同一次分享被劈成两种待遇**,用户也没按过发送键说「这条我要并行」。
|
||||
* 批量的语义是「把这些都交给她」,不是「每一条都要抢一路」;而且每条各算一遍就是 N 份算力。
|
||||
*
|
||||
* 只有一张图时同样不声明:这条规则要**易解释**——「分享从不声明」是一句话,
|
||||
* 「分享超过一张时才不声明」得先跟人解释清为什么是一张的分界。
|
||||
* 用户真的想打断 / 想并行,发送键那条路([send])的口径一字未动。
|
||||
* (并行那一路的**降级**后果轻得多:额度满时只是落回排队,不会像打断那样毁掉已排队的消息——
|
||||
* 所以这条取舍是「更可解释 + 不多花算力」,不是「不做就会坏」。)
|
||||
*
|
||||
* ## 超长正文走文件(主人 2026-10-05 拍板)
|
||||
* 分享进来的正文超过 [DocumentDelivery.TEXT_AS_DOCUMENT_RUNE_LIMIT] 时,**落成一份 `.txt`**
|
||||
@@ -1012,9 +1022,9 @@ class ChatViewModel(
|
||||
showShareNotice(SHARE_TEXT_NOT_STAGED)
|
||||
}
|
||||
}
|
||||
emitAndDeliver(text, share.images.firstOrNull(), files, clearDraft = false, allowInterrupt = false)
|
||||
emitAndDeliver(text, share.images.firstOrNull(), files, clearDraft = false, applySendPreference = false)
|
||||
share.images.drop(1).forEach {
|
||||
emitAndDeliver("", it, emptyList(), clearDraft = false, allowInterrupt = false)
|
||||
emitAndDeliver("", it, emptyList(), clearDraft = false, applySendPreference = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1042,16 +1052,16 @@ class ChatViewModel(
|
||||
*
|
||||
* @param clearDraft 只有「用户按了发送键」才清草稿——那时草稿正是刚发出去的这一条;
|
||||
* 分享落地是替用户发一条他自己录进来的内容,草稿不是它。
|
||||
* @param allowInterrupt 这条投递要不要按「她正忙时按发送」那个偏好先发打断帧。**默认 true
|
||||
* = 用户按发送键的既有口径**;只有 [sendShared] 传 false——它是批量动作,批量里每条都打断
|
||||
* 等于自己杀自己(理由见 [sendShared])。
|
||||
* @param applySendPreference 这条投递要不要套用「她忙时发消息」那个偏好(两种声明:
|
||||
* `interrupt` 与 `parallel`)。**默认 true = 用户按发送键的既有口径**;只有 [sendShared]
|
||||
* 传 false——它是批量动作,批量里逐条声明只会自相干扰(两种声明各自的理由见 [sendShared])。
|
||||
*/
|
||||
private suspend fun emitAndDeliver(
|
||||
text: String,
|
||||
imageUri: String?,
|
||||
documents: List<DraftDocument>,
|
||||
clearDraft: Boolean,
|
||||
allowInterrupt: Boolean = true,
|
||||
applySendPreference: Boolean = true,
|
||||
) {
|
||||
if (text.isEmpty() && imageUri == null && documents.isEmpty()) return
|
||||
|
||||
@@ -1079,7 +1089,7 @@ class ChatViewModel(
|
||||
error = null,
|
||||
)
|
||||
}
|
||||
deliver(localId, text, imageUri, documents, allowInterrupt)
|
||||
deliver(localId, text, imageUri, documents, applySendPreference)
|
||||
}
|
||||
|
||||
private suspend fun deliver(
|
||||
@@ -1087,7 +1097,7 @@ class ChatViewModel(
|
||||
text: String,
|
||||
imageUri: String?,
|
||||
documents: List<DraftDocument>,
|
||||
allowInterrupt: Boolean,
|
||||
applySendPreference: Boolean,
|
||||
) {
|
||||
// 判定矩阵([DocumentDelivery]):附件 >5 **直接拒绝,绝不裁剪**。这里用只需份数的
|
||||
// [DocumentDelivery.planForCount]——字节还没读(最多 5×20 MiB 一次性读进内存不可接受),
|
||||
@@ -1120,27 +1130,33 @@ class ChatViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
// 偏好「她正忙时按发送的行为」(协同 A2):**这条消息真的要走上去之前**,先把在跑回合打断。
|
||||
// 放在这里而不是 [emitAndDeliver] 开头,是刻意的:上面每一段本端拒绝(附件超限 / 图片处理
|
||||
// 失败 / 附件上传失败 / 附件引用不合法)都会「回滚、什么都不发」——先把她正在说的那一轮
|
||||
// 打断掉、再什么都不发,是凭空的破坏,而且用户看不到任何理由。
|
||||
// 偏好「她忙时发消息」(协同 A2 + 忙时并行规范 §2.2):**这条消息真的要走上去之前**,
|
||||
// 按档位决定要不要额外声明什么。放在这里而不是 [emitAndDeliver] 开头,是刻意的:上面每一段
|
||||
// 本端拒绝(附件超限 / 图片处理失败 / 附件上传失败 / 附件引用不合法)都会「回滚、什么都不发」
|
||||
// ——先把她正在说的那一轮打断掉、再什么都不发,是凭空的破坏,而且用户看不到任何理由。
|
||||
//
|
||||
// 偏好**只在这里读一次**,两个声明(打断 / 并行)必然出自同一个档位:读两次的话,用户在两读
|
||||
// 之间切档就会让一条消息既打断又并行——那正是这两档的互斥语义要排除的组合(见 [InterruptMode])。
|
||||
// 每次投递只经过这里一次 ⇒ 打断帧**最多一条**,下面的通道分支与任何后续重试都不会再发。
|
||||
//
|
||||
// 只有 [allowInterrupt] 为 true 才发(= 用户按发送键那条路,默认档)。[ChatViewModel.sendShared]
|
||||
// 传 false:它是**批量**投递,每条都打断就会让第 2 条起把前一条刚起的回合取消掉(理由与
|
||||
// 取舍见 [sendShared] 的「分享不发打断」一段)。
|
||||
if (allowInterrupt) interruptRunningTurn()
|
||||
// 只有 [applySendPreference] 为 true 才套用偏好(= 用户按发送键那条路,默认档)。
|
||||
// [ChatViewModel.sendShared] 传 false:它是**批量**投递,两种声明在那儿都是错的(理由与取舍
|
||||
// 见 [sendShared] 的「分享不声明」一段)。
|
||||
val mode = currentInterruptMode()
|
||||
val busy = _state.value.busyMode != null
|
||||
if (applySendPreference) interruptRunningTurn(mode, busy)
|
||||
val parallel = applySendPreference && parallelForSend(mode, busy)
|
||||
|
||||
when (ChatSendRouting.channelFor(text, hasImage = image != null, hasDocuments = payload.isNotEmpty())) {
|
||||
SendChannel.Ws -> {
|
||||
val sent = socket?.sendChat(sessionId, text, DEVICE_ID, localId) ?: false
|
||||
val sent = socket?.sendChat(sessionId, text, DEVICE_ID, localId, parallel) ?: false
|
||||
if (!sent) rollback(localId, "发送失败,请重试")
|
||||
}
|
||||
|
||||
SendChannel.Rest -> if (image == null) {
|
||||
sendViaRest(localId, text, image = null, documents = payload)
|
||||
sendViaRest(localId, text, image = null, documents = payload, parallel = parallel)
|
||||
} else {
|
||||
deliverImage(localId, text, image, payload)
|
||||
deliverImage(localId, text, image, payload, parallel)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1148,11 +1164,14 @@ class ChatViewModel(
|
||||
/**
|
||||
* 发这条消息之前,按偏好决定要不要先打断在跑回合(协同 A2 / 契约 v0.56 §3.1)。
|
||||
*
|
||||
* 判据本身是纯函数 [interruptScopeForSend](默认档恒不发、忙且选了「打断」才发 `scope:"current"`,
|
||||
* 命中空闲会话不发),这里只做两件事:**取当前偏好**、把帧发出去。它由 [deliver] 在**通道分支
|
||||
* 之前**调用,所以一次投递最多一条打断帧——且**只在 [deliver] 的 `allowInterrupt` 为 true 时**
|
||||
* 判据本身是纯函数 [interruptScopeForSend](默认档与「并行」档恒不发、忙且选了「打断」才发
|
||||
* `scope:"current"`,命中空闲会话不发),这里只做一件事:把帧发出去。它由 [deliver] 在**通道分支
|
||||
* 之前**调用,所以一次投递最多一条打断帧——且**只在 [deliver] 的 `applySendPreference` 为 true 时**
|
||||
* 才调(分享那条批量路径传 false,见 [sendShared])。
|
||||
*
|
||||
* [mode] / [busy] 由 [deliver] 读一次偏好后传进来,与 [parallelForSend] 共用**同一次读**:
|
||||
* 本函数自己再读一次偏好就会有一个窗口——用户在两读之间切档,一条消息既打断又并行。
|
||||
*
|
||||
* ## 一条已知边界:调用顺序 ≠ 到达顺序(不在本端可控范围)
|
||||
* 本端能保证的只有**调用顺序**(打断帧在这条消息之前发出去)。到达与服务端处理顺序取决于通道:
|
||||
* WS 那条路两帧同连接,REST 那条路是「WS 发打断 + HTTP 发消息」两条道。极端时序下打断可能落在
|
||||
@@ -1171,27 +1190,25 @@ class ChatViewModel(
|
||||
*
|
||||
* 于是这一条的结果是:**打断没生效 = 行为退回默认档(追加队列)**——那是用户能看懂的状态
|
||||
* (消息排进队列、她先说完当前这一轮),不是一件被藏起来的事。
|
||||
*
|
||||
* 偏好读不出来(DataStore IO 出错)同样**回落到默认档**(`AppPrefs` 的缺省值 = [InterruptMode.AppendQueue]
|
||||
* = 不发任何帧):偏好读不到不该让一次发送失败,而回落方向必须是**最少动作**的那一档——
|
||||
* 与 AppSettings 把未知取值解成默认档是同一条纪律(「绝不悄悄换成更激进的档位」)。
|
||||
* (并行的**失败方向**不同也更轻:它没有「发不出去」这一支——那个键就在 `chat` 帧 / 请求体里,
|
||||
* 跟着消息一起走;WS 没连着时这条消息整个发不出去,走的是既有的回滚与提示。)
|
||||
*/
|
||||
private suspend fun interruptRunningTurn() {
|
||||
val scope = interruptScopeForSend(
|
||||
mode = currentInterruptMode(),
|
||||
busy = _state.value.busyMode != null,
|
||||
) ?: return
|
||||
private fun interruptRunningTurn(mode: InterruptMode, busy: Boolean) {
|
||||
val scope = interruptScopeForSend(mode, busy) ?: return
|
||||
socket?.sendInterrupt(sessionId, scope)
|
||||
}
|
||||
|
||||
/**
|
||||
* 取当前的「她正忙时按发送的行为」档位(每次发送现读,不缓存、不订阅)。
|
||||
* 取当前的「她忙时发消息」档位(每次发送现读,不缓存、不订阅;[interruptRunningTurn] 与
|
||||
* [parallelForSend] 共用这一次读的结果)。
|
||||
*
|
||||
* 现读的理由:偏好是**用户在设置页里当场切**的东西,而上一个会话里读进字段的值要等 Flow 推到才更新
|
||||
* (冷启动那一下必然没到)——「切成打断后立刻发的那一条」正是这次要修的场景,缓存住就等于留一个
|
||||
* 静默不生效的窗口。DataStore 的 `data` 首次收集后走内存缓存,这里不会每发一条就碰一次磁盘。
|
||||
*
|
||||
* 读失败(IO / 损坏)回落 `AppPrefs` 的缺省值:理由见 [interruptRunningTurn] 的「偏好读不出来」一段。
|
||||
* 读失败(IO / 损坏)回落 `AppPrefs` 的缺省值 = [InterruptMode.AppendQueue](两个声明都不发):
|
||||
* 偏好读不到不该让一次发送失败,而回落方向必须是**最少动作**的那一档——与 AppSettings 把未知
|
||||
* 取值解成默认档是同一条纪律(「绝不悄悄换成更激进的档位」)。
|
||||
* `CancellationException` 原样抛出(它是取消,不是「读失败」,不能吞成默认档)。
|
||||
*/
|
||||
private suspend fun currentInterruptMode(): InterruptMode = try {
|
||||
@@ -1306,13 +1323,14 @@ class ChatViewModel(
|
||||
text: String,
|
||||
image: PreparedImage,
|
||||
documents: List<DocumentRefDto>,
|
||||
parallel: Boolean,
|
||||
) {
|
||||
when (ChatImageDelivery.forPreparedBytes(image.bytes.size)) {
|
||||
ImageDelivery.Inline -> sendViaRest(localId, text, image.dataUri, documents)
|
||||
ImageDelivery.Inline -> sendViaRest(localId, text, image.dataUri, documents, parallel)
|
||||
|
||||
ImageDelivery.Upload -> container.uploadRepository
|
||||
.uploadImage(image.bytes, image.mime)
|
||||
.onSuccess { uploaded -> sendViaRest(localId, text, uploaded.url, documents) }
|
||||
.onSuccess { uploaded -> sendViaRest(localId, text, uploaded.url, documents, parallel) }
|
||||
.onFailure { e -> rollback(localId, uploadFailureMessage(e)) }
|
||||
}
|
||||
}
|
||||
@@ -1320,12 +1338,16 @@ class ChatViewModel(
|
||||
/**
|
||||
* 走 REST 发出这一轮(`stream:true`)。[documents] 是**已上传**的附件引用(空 = 没附件):
|
||||
* 空列表在线上整键省略(`encodeDefaults = false`),所以不带附件的请求与加入该字段之前逐字节相同。
|
||||
*
|
||||
* [parallel] 与 [documents] 同一条纪律:`false` **整键省略**(只有用户选了「并行」且会话正忙时
|
||||
* 才是 `true`,判据见 [parallelForSend])——默认档的请求体因此逐字节不变。
|
||||
*/
|
||||
private suspend fun sendViaRest(
|
||||
localId: String,
|
||||
text: String,
|
||||
image: String?,
|
||||
documents: List<DocumentRefDto> = emptyList(),
|
||||
parallel: Boolean = false,
|
||||
) {
|
||||
val request = ChatRequest(
|
||||
sessionId = sessionId,
|
||||
@@ -1337,6 +1359,9 @@ class ChatViewModel(
|
||||
stream = true,
|
||||
clientMsgId = localId,
|
||||
documents = documents,
|
||||
// 直接传 `false` 会序列化成 `"parallel":false`(字段默认值是 `null`,而
|
||||
// `encodeDefaults = false` 只省略「等于默认值」的字段)——那不是「未声明」而是**显式否认**。
|
||||
parallel = parallelWireValue(parallel),
|
||||
)
|
||||
container.chatRepository
|
||||
.send(request)
|
||||
|
||||
@@ -190,15 +190,21 @@ private fun ThemeMode.label(): String = when (this) {
|
||||
private fun InterruptMode.label(): String = when (this) {
|
||||
InterruptMode.AppendQueue -> "追加队列"
|
||||
InterruptMode.Interrupt -> "打断"
|
||||
InterruptMode.Parallel -> "并行"
|
||||
}
|
||||
|
||||
/**
|
||||
* 两档的差别只有一处:**忙时那条排队消息还在不在**。文案必须说清这一点——
|
||||
* 一个用户看不懂效果的偏好,比没有这个偏好更糟。
|
||||
* 三档的差别只有一处:**忙时这条新消息与在跑的那一句是什么关系**(排队等着 / 把她打断 / 另起一路)。
|
||||
* 文案必须说清这一点——一个用户看不懂效果的偏好,比没有这个偏好更糟。
|
||||
*
|
||||
* 「并行」那一条还多说半句**代价**(两路各算一遍 ⇒ 多花一份算力与 token):那是主人 2026-10-05
|
||||
* 裁决里接受了的成本(不分摊、优先性能),不是缺陷,但**用户有权知道**他花的这份钱买到了什么。
|
||||
*/
|
||||
private fun InterruptMode.summary(): String = when (this) {
|
||||
InterruptMode.AppendQueue ->
|
||||
"她正忙时只把消息排进队列,等她做完当前回复再依次回;不打断(默认)"
|
||||
InterruptMode.Interrupt ->
|
||||
"她正忙时先打断当前这一句;已排队的消息保留,继续依次回答"
|
||||
InterruptMode.Parallel ->
|
||||
"她正忙时不打断当前这一句,另起一路同时处理这条;两路各算一遍,会多花一份算力与 token"
|
||||
}
|
||||
@@ -1,16 +1,24 @@
|
||||
package top.yeij.cyrene.core.prefs
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.data.ws.WsOutbound
|
||||
|
||||
/**
|
||||
* A2 偏好 → 线上取值 的映射(契约 v0.56 §3.1)。
|
||||
* 「她忙时发消息」档位 → 线上取值 的映射(契约 v0.56 §3.1 的 `interrupt.scope`;忙时并行规范
|
||||
* §2.2 的 `chat.parallel`)。
|
||||
*
|
||||
* 这一层只负责一件事:把用户选的档位翻成 `interrupt` 帧的 `scope`。默认档必须翻成
|
||||
* **整键省略**——服务端视同 `"all"`,帧因此与加入 `scope` 之前**逐字节相同**(兼容保证)。
|
||||
* 这一层负责三件事:
|
||||
* 1. 把用户选的档位翻成 `interrupt` 帧的 `scope`——**默认档与「并行」档都必须是「不发」**
|
||||
* (默认档在服务端视同 `"all"`,帧因此与加入 `scope` 之前逐字节相同;并行档则压根不该有这条帧,
|
||||
* 它与打断是互斥语义);
|
||||
* 2. 两个声明**没有哪一档同时给**(`wireScope` 与 `wireParallel` 永不同时成立);
|
||||
* 3. 落盘那一侧(`AppSettings` 的 `interrupt_mode` 键,按 `enum.name` 存)三档都能存能读、
|
||||
* 未知值仍回落默认档。`AppSettings` 本体要 Android + DataStore、单测里起不来,映射本身是顶层
|
||||
* 纯函数 [toInterruptMode]。
|
||||
*/
|
||||
class InterruptModeTest {
|
||||
|
||||
@@ -22,7 +30,7 @@ class InterruptModeTest {
|
||||
val mode = AppPrefs().interruptMode
|
||||
|
||||
assertEquals(InterruptMode.AppendQueue, mode)
|
||||
assertNull("默认档必须是「省略 scope」(服务端 = all)", mode.wireScope)
|
||||
assertNull("默认档必须是「不发打断帧」", mode.wireScope)
|
||||
assertEquals("""{"type":"interrupt","data":{"sessionId":"web"}}""", frameOf(mode))
|
||||
}
|
||||
|
||||
@@ -36,23 +44,63 @@ class InterruptModeTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the preference only ever produces the two documented wire values`() {
|
||||
// 服务端取值域是 {"", "all", "current"};客户端只发「省略」与 "current" 两种,
|
||||
// ""/"all" 已由省略表达——不自己补一串等价别名,少一种线上形状就少一类歧义。
|
||||
assertEquals(listOf(null, "current"), InterruptMode.entries.map { it.wireScope })
|
||||
fun `the parallel mode sends no interrupt frame at all`() {
|
||||
// 并行与打断互斥:并行档先打断就恰好砍掉它要并行的那一路。所以这里不是「省略 scope」
|
||||
// 而是「这条路根本不发这条帧」——`wireScope` 就是 `null`,帧形状与默认档那个「不发」一致。
|
||||
assertNull(InterruptMode.Parallel.wireScope)
|
||||
assertEquals("""{"type":"interrupt","data":{"sessionId":"web"}}""", frameOf(InterruptMode.Parallel))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the preference only ever produces the documented interrupt wire values`() {
|
||||
// 服务端取值域是 {"", "all", "current"};客户端只发「不发打断帧」与 "current" 两种,
|
||||
// ""/"all" 由「不发打断帧」表达——不自己补一串等价别名,少一种线上形状就少一类歧义。
|
||||
assertEquals(listOf(null, "current", null), InterruptMode.entries.map { it.wireScope })
|
||||
assertTrue(InterruptMode.entries.none { it.wireScope == "all" })
|
||||
assertTrue(InterruptMode.entries.none { it.wireScope == "" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `both modes stay distinguishable on the wire`() {
|
||||
// 打成同一个形状就等于「两种模式被当成一种」——正是 A2 要修掉的那类缺陷。
|
||||
assertTrue(frameOf(InterruptMode.AppendQueue) != frameOf(InterruptMode.Interrupt))
|
||||
fun `no mode ever asks for both an interrupt and a parallel leg`() {
|
||||
// 一对互斥语义:打断 = 先取消她正在说的这一句;并行 = 不打断、另起一路同时说。
|
||||
// 两件事同时声明是自相矛盾的(先砍掉要并行的那一路),三档各占一种组合,没有第三种。
|
||||
val names = InterruptMode.entries.map { it.name }
|
||||
|
||||
assertTrue(
|
||||
"没有任何一档能同时给两个声明:$names",
|
||||
InterruptMode.entries.none { it.wireScope != null && it.wireParallel },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only the parallel mode ever declares a parallel leg`() {
|
||||
assertEquals(listOf(InterruptMode.Parallel), InterruptMode.entries.filter { it.wireParallel })
|
||||
assertFalse("默认档一个声明都不发", AppPrefs().interruptMode.wireParallel)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the persisted enum names are stable`() {
|
||||
// AppSettings 按 enum 名落盘:改名 = 把所有已存偏好静默重置回默认档。
|
||||
assertEquals(listOf("AppendQueue", "Interrupt"), InterruptMode.entries.map { it.name })
|
||||
// 第三档是**追加**上来的新名字;既有那两个名字一个字都不许动。
|
||||
assertEquals(listOf("AppendQueue", "Interrupt", "Parallel"), InterruptMode.entries.map { it.name })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every mode survives a round trip through the persisted name`() {
|
||||
InterruptMode.entries.forEach { mode ->
|
||||
assertEquals("落盘名 ${mode.name} 读不回原来的档", mode, mode.name.toInterruptMode())
|
||||
}
|
||||
// 三个落盘名必须互不相同,否则有档位会被读成另一档。
|
||||
assertEquals(3, InterruptMode.entries.map { it.name }.toSet().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unknown persisted value still falls back to the default mode`() {
|
||||
// 缺省 / 旧版本遗留 / 被手改过 / 大小写不同 / 带了空格的值,一律解成默认档:回落方向必须是
|
||||
// 「最少动作」的那一档,**绝不**悄悄换成更激进的「打断」或「并行」。
|
||||
// 注意 "current" 是 interrupt 帧的 scope 取值、不是档位名——它也必须回落。
|
||||
listOf<String?>(null, "", "all", "current", "Parallelism", "parallel", "AppendQueue ", "3").forEach { raw ->
|
||||
assertEquals("落盘值 ${raw?.let { "「$it」" } ?: "空"} 应回落默认档", InterruptMode.AppendQueue, raw.toInterruptMode())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,10 @@ import org.junit.Test
|
||||
/**
|
||||
* 忙着并行规范的上行两处**纯增量**(§2.2 `chat.parallel` / §2.3 `interrupt.turnId`)。
|
||||
*
|
||||
* 本层只负责一件事:**省略时整键不进线上、给值时原样发出**。本端今天一个字节都不发这两个字段
|
||||
* (真并行要用户显式声明,声明入口不在本次范围内),所以这一组测试真正钉住的是
|
||||
* 「老形状的那条帧仍然逐字节相同」——那是与老服务端兼容的全部依据。
|
||||
* 本层只负责一件事:**省略时整键不进线上、给值时原样发出**。`parallel` 现在有调用方了——
|
||||
* 设置页那栏选「并行」且会话正忙时由发送路径置 `true`(判据是纯函数 `parallelForSend`,线上取值
|
||||
* 经 `parallelWireValue` 翻,`false` 一律翻成「整键省略」);`interrupt.turnId` 本端仍一个字节都不发。
|
||||
* 所以这一组测试真正钉住的是「老形状的那条帧仍然逐字节相同」——那是与老服务端兼容的全部依据。
|
||||
*/
|
||||
class WsOutboundParallelTest {
|
||||
|
||||
|
||||
@@ -11,7 +11,8 @@ import top.yeij.cyrene.core.prefs.InterruptMode
|
||||
* A2 客户端半边:**发这条消息之前要不要先打断**(契约 v0.56 §3.1 的 `interrupt.scope`)。
|
||||
*
|
||||
* 这个判据此前**根本不存在**——设置页那个开关没有任何发送路径读它,于是「打断」与「追加队列」
|
||||
* 行为完全一样,`ChatSocket.sendInterrupt` 一条帧都没发过。这里把它钉成纯函数,四条一次钉全:
|
||||
* 行为完全一样,`ChatSocket.sendInterrupt` 一条帧都没发过。这里把它钉成纯函数,六条一次钉全
|
||||
* (并行那一档的**另一半**——`chat.parallel`——见 [ParallelForSendTest]):
|
||||
*
|
||||
* | 档位 | 会话正忙 | 该发什么 |
|
||||
* |---|---|---|
|
||||
@@ -19,12 +20,16 @@ import top.yeij.cyrene.core.prefs.InterruptMode
|
||||
* | 追加队列(默认) | 空闲 | **什么都不发** |
|
||||
* | 打断 | 忙 | `interrupt{scope:"current"}` |
|
||||
* | 打断 | 空闲 | **什么都不发** |
|
||||
* | 并行 | 忙 | **什么都不发**(它与打断互斥;那一档声明的是 `chat.parallel`) |
|
||||
* | 并行 | 空闲 | **什么都不发** |
|
||||
*
|
||||
* 两条最容易写错的边界各占一列:
|
||||
* 三条最容易写错的边界各占一列:
|
||||
* - **默认档不许有任何行为变化**(第一行、第二行):线上请求必须与接入本判据之前逐字节相同;
|
||||
* - **打断但空闲不发帧**(第四行):命中空闲会话在服务端是 no-op,白发一条帧只是多一次往返、
|
||||
* 还会在 logcat 里制造「这里打断过」的假象——所以「正忙」必须是**服务端说的**
|
||||
* (`session_busy` ⇒ `ChatUiState.busyMode != null`),不是队列深度非零推出来的。
|
||||
* (`session_busy` ⇒ `ChatUiState.busyMode != null`),不是队列深度非零推出来的;
|
||||
* - **并行档整列都不发**(第五行、第六行):并行与打断互斥,先打断就恰好砍掉要并行的那一路。
|
||||
* 这不是「这一档碰巧没得发」,是 `Parallel.wireScope == null` 的结构性结论。
|
||||
*/
|
||||
class InterruptScopeTest {
|
||||
|
||||
@@ -52,8 +57,8 @@ class InterruptScopeTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the whole matrix of both modes against both busy states is pinned`() {
|
||||
// 2 档 × 2 忙闲 = 4 格,一次穷举钉死;将来任何人动这个判据,这里先红。
|
||||
fun `the whole matrix of all three modes against both busy states is pinned`() {
|
||||
// 3 档 × 2 忙闲 = 6 格,一次穷举钉死;将来任何人动这个判据,这里先红。
|
||||
val matrix = InterruptMode.entries.flatMap { mode ->
|
||||
listOf(true, false).map { busy ->
|
||||
"${mode.name}/busy=$busy" to interruptScopeForSend(mode, busy)
|
||||
@@ -66,6 +71,8 @@ class InterruptScopeTest {
|
||||
"AppendQueue/busy=false" to null,
|
||||
"Interrupt/busy=true" to "current",
|
||||
"Interrupt/busy=false" to null,
|
||||
"Parallel/busy=true" to null,
|
||||
"Parallel/busy=false" to null,
|
||||
),
|
||||
matrix,
|
||||
)
|
||||
@@ -85,7 +92,7 @@ class InterruptScopeTest {
|
||||
// 发送路径取偏好读失败时回落 `AppPrefs()` 的缺省值(见 ChatViewModel.currentInterruptMode 的 KDoc)。
|
||||
// 这里钉住那个缺省值本身是**追加队列** = 不发任何帧 —— 读不到偏好时回落的方向必须是
|
||||
// 「最少动作」的那一档,绝不能悄悄变成更激进的「打断」。
|
||||
// (落盘字符串 → 枚举的映射另有 AppSettings.toInterruptMode 与 InterruptModeTest 钉着。)
|
||||
// (落盘字符串 → 枚举的映射另有顶层纯函数 toInterruptMode 与 InterruptModeTest 钉着。)
|
||||
val fallback = AppPrefs().interruptMode
|
||||
|
||||
assertEquals(InterruptMode.AppendQueue, fallback)
|
||||
@@ -94,7 +101,7 @@ class InterruptScopeTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the two modes only differ while she is busy`() {
|
||||
fun `the interrupt mode only differs from the default while she is busy`() {
|
||||
// 空闲那一列两种档位必须**看起来一样**(都不发);差异只允许出现在「忙」这一列,
|
||||
// 否则默认档就不是零行为变化了。
|
||||
assertEquals(
|
||||
@@ -106,4 +113,17 @@ class InterruptScopeTest {
|
||||
interruptScopeForSend(InterruptMode.Interrupt, busy = true),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the parallel mode is indistinguishable from the default on this axis however busy she is`() {
|
||||
// 并行与打断互斥,所以这一档在**这条**轴上一个字节都不该多出来:忙闲两种情形都必须
|
||||
// 与默认档完全一致(它的声明在另一条轴上,见 ParallelForSendTest)。
|
||||
listOf(true, false).forEach { busy ->
|
||||
assertEquals(
|
||||
"busy=$busy 时并行档发出了打断帧——那会砍掉它自己要并行的那一路",
|
||||
interruptScopeForSend(InterruptMode.AppendQueue, busy),
|
||||
interruptScopeForSend(InterruptMode.Parallel, busy),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package top.yeij.cyrene.domain.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
|
||||
import top.yeij.cyrene.core.prefs.AppPrefs
|
||||
import top.yeij.cyrene.core.prefs.InterruptMode
|
||||
import top.yeij.cyrene.data.ws.WsOutbound
|
||||
|
||||
/**
|
||||
* 忙时并行的客户端半边:**发这条消息时要不要声明 `parallel:true`**(忙时并行规范 §2.2),
|
||||
* 也就是 [interruptScopeForSend] 的兄弟 [parallelForSend]([InterruptScopeTest] 钉的是另一条轴)。
|
||||
*
|
||||
* 设置页那栏「她忙时发消息」现在是三选一,两条铁律在这里穷举钉死:
|
||||
*
|
||||
* | 档位 | 会话正忙时这一条怎么发 | `interrupt` | `chat.parallel` |
|
||||
* |---|---|---|---|
|
||||
* | 追加队列(默认) | 排队等(今天的行为) | **不发** | **不带**(线上逐字节不变) |
|
||||
* | 打断 | 先取消在跑的那一轮,再发 | `scope:"current"` | **不带** |
|
||||
* | 并行 | **不打断**,本条另起一路 | **绝不发** | `true` |
|
||||
*
|
||||
* 顺手把「空闲时三档逐字节完全相同」钉住:规范 §2.2 只写了 `parallel:true` 是「请求把本条作为
|
||||
* 独立一路处理」,**没写**空闲时要不要照发。空闲时这条本来就是它自己的回合,声明与不声明在服务端
|
||||
* 是同一件事 ⇒ 本端取与打断**同一个判据**(只在忙时声明),空闲时切档不会改变一个字节。
|
||||
*/
|
||||
class ParallelForSendTest {
|
||||
|
||||
/** 加入 `parallel` / 加入本档位之前那条 `chat` 帧的逐字形状。 */
|
||||
private val legacyChat =
|
||||
"""{"type":"chat","data":{"sessionId":"web","text":"你好","deviceId":"d","clientMsgId":"c-1"}}"""
|
||||
|
||||
/** 同上,但带上了 `parallel:true`(只有「并行」档在忙时才是这个形状)。 */
|
||||
private val parallelChat =
|
||||
"""{"type":"chat","data":{"sessionId":"web","text":"你好","deviceId":"d","clientMsgId":"c-1","parallel":true}}"""
|
||||
|
||||
/** 「打断」档在忙时那条帧。 */
|
||||
private val interruptCurrent =
|
||||
"""{"type":"interrupt","data":{"sessionId":"web","scope":"current"}}"""
|
||||
|
||||
/**
|
||||
* 按偏好把**实际会铺到线上的帧序列**铺出来:`interrupt` 只在 scope 非 `null` 时才发,
|
||||
* `chat` 永远发一条、`parallel` 键按判定带——与 `ChatViewModel.deliver` 接上
|
||||
* `ChatSocket.sendChat` / `ChatRequest` 之后的行为同形(那边把这两步接在一起,这里把它摊平)。
|
||||
*/
|
||||
private fun wireOf(mode: InterruptMode, busy: Boolean): List<String> = buildList {
|
||||
interruptScopeForSend(mode, busy)?.let {
|
||||
add(WsOutbound.Interrupt(sessionId = "web", scope = it).toJson())
|
||||
}
|
||||
add(
|
||||
WsOutbound.Chat(
|
||||
text = "你好",
|
||||
sessionId = "web",
|
||||
deviceId = "d",
|
||||
clientMsgId = "c-1",
|
||||
parallel = parallelWireValue(parallelForSend(mode, busy)),
|
||||
).toJson(),
|
||||
)
|
||||
}
|
||||
|
||||
// —— 判据本身 ——
|
||||
|
||||
@Test
|
||||
fun `the default mode declares nothing even while she is busy`() {
|
||||
// 最重要的一条:默认档是绝大多数用户手上的状态,它必须零行为变化。
|
||||
assertFalse(parallelForSend(InterruptMode.AppendQueue, busy = true))
|
||||
assertEquals(AppPrefs().interruptMode, InterruptMode.AppendQueue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the default mode declares nothing while she is idle either`() {
|
||||
assertFalse(parallelForSend(InterruptMode.AppendQueue, busy = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the parallel mode asks for its own leg while she is busy`() {
|
||||
assertTrue(parallelForSend(InterruptMode.Parallel, busy = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the parallel mode asks for nothing while she is idle`() {
|
||||
// 空闲时这条本来就是它自己的回合:声明了服务端也没第二路可并。
|
||||
assertFalse(parallelForSend(InterruptMode.Parallel, busy = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the interrupt mode never declares a parallel leg`() {
|
||||
// 「打断」与「并行」互斥:选了打断就是要她**先停**,同时再要一路并行是自相矛盾。
|
||||
assertFalse(parallelForSend(InterruptMode.Interrupt, busy = true))
|
||||
assertFalse(parallelForSend(InterruptMode.Interrupt, busy = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the whole matrix of all three modes against both busy states is pinned`() {
|
||||
// 3 档 × 2 忙闲 = 6 格,一次穷举钉死;将来任何人动这个判据,这里先红。
|
||||
val matrix = InterruptMode.entries.flatMap { mode ->
|
||||
listOf(true, false).map { busy -> "${mode.name}/busy=$busy" to parallelForSend(mode, busy) }
|
||||
}
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
"AppendQueue/busy=true" to false,
|
||||
"AppendQueue/busy=false" to false,
|
||||
"Interrupt/busy=true" to false,
|
||||
"Interrupt/busy=false" to false,
|
||||
"Parallel/busy=true" to true,
|
||||
"Parallel/busy=false" to false,
|
||||
),
|
||||
matrix,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only the busy parallel cell ever declares a leg`() {
|
||||
val declaring = InterruptMode.entries.filter { mode ->
|
||||
listOf(true, false).any { busy -> parallelForSend(mode, busy) }
|
||||
}
|
||||
|
||||
assertEquals(listOf(InterruptMode.Parallel), declaring)
|
||||
}
|
||||
|
||||
// —— 两条铁律:并行绝不打断;另外两档绝不并行 ——
|
||||
|
||||
@Test
|
||||
fun `the parallel mode never sends an interrupt frame however busy she is`() {
|
||||
listOf(true, false).forEach { busy ->
|
||||
assertNull("并行档发出打断帧就会砍掉它自己要并行的那一路", interruptScopeForSend(InterruptMode.Parallel, busy))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no mode ever declares both an interrupt and a parallel leg`() {
|
||||
InterruptMode.entries.forEach { mode ->
|
||||
listOf(true, false).forEach { busy ->
|
||||
assertFalse(
|
||||
"${mode.name}/busy=$busy 同时要了打断与并行",
|
||||
interruptScopeForSend(mode, busy) != null && parallelForSend(mode, busy),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// —— 铺到线上的形状 ——
|
||||
|
||||
@Test
|
||||
fun `the whole wire shape of all three modes is pinned frame by frame`() {
|
||||
assertEquals(listOf(legacyChat), wireOf(InterruptMode.AppendQueue, busy = true))
|
||||
assertEquals(listOf(legacyChat), wireOf(InterruptMode.AppendQueue, busy = false))
|
||||
|
||||
assertEquals(listOf(interruptCurrent, legacyChat), wireOf(InterruptMode.Interrupt, busy = true))
|
||||
assertEquals(listOf(legacyChat), wireOf(InterruptMode.Interrupt, busy = false))
|
||||
|
||||
// 并行:**没有** interrupt 帧,只有一条多带 `parallel:true` 的 chat 帧。
|
||||
assertEquals(listOf(parallelChat), wireOf(InterruptMode.Parallel, busy = true))
|
||||
assertEquals(listOf(legacyChat), wireOf(InterruptMode.Parallel, busy = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `while she is idle all three modes put exactly the same bytes on the wire`() {
|
||||
// 空闲是绝大多数发送的情形;切档在这里必须**零差异**(三档的唯一差别是「她正忙时」)。
|
||||
val wires = InterruptMode.entries.map { wireOf(it, busy = false) }
|
||||
|
||||
assertEquals(3, wires.size)
|
||||
assertTrue("空闲时三档的帧不一致:$wires", wires.all { it == listOf(legacyChat) })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the default mode keeps today's bytes on the wire while she is busy`() {
|
||||
// 「一个字节都不变」的正面陈述:默认档忙时那条 chat 帧就是加入 `parallel` 之前的字面量。
|
||||
assertEquals(listOf(legacyChat), wireOf(AppPrefs().interruptMode, busy = true))
|
||||
}
|
||||
|
||||
// —— 判定 → 字段取值的那一步 ——
|
||||
|
||||
@Test
|
||||
fun `a false judgement must omit the key rather than send an explicit false`() {
|
||||
// `false` 与「省略」在服务端同义(都落回排队),但**线上形状不同**:带上 `"parallel":false`
|
||||
// 就不再是「与加入该字段之前逐字节相同」。只有 true 才带上该键。
|
||||
assertNull(parallelWireValue(false))
|
||||
assertEquals(true, parallelWireValue(true))
|
||||
assertEquals(legacyChat, wireOf(InterruptMode.AppendQueue, busy = true).last())
|
||||
assertFalse(wireOf(InterruptMode.AppendQueue, busy = true).last().contains("parallel"))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user