feat(ws): interrupt 支持 scope,并按偏好选「追加队列 / 打断」(协同 A2)
契约 v0.56/A2(api-client §3.1):interrupt{sessionId, scope?},取值域 {"", "all", "current"}——省略 / 空串 / "all" 同义(取消在跑回合 + 清空待处理队列,逐字不变的旧行为),"current" = 只取消在跑回合、保留待处理队列(队列消息按编排器续跑规则继续执行,一条不丢);其余取值服务端定向回 error{bad_request}、不打断任何会话。
- WsOutbound.Interrupt 增可选 scope:null = 整键省略(服务端视同 all),默认帧因此与今天逐字节相同;传输层不校验取值、不补默认、不改写别名(开放取值域由服务端裁决,客户端替它纠正只会换来一次没解释的打断)。
- ChatSocket.sendInterrupt(sessionId, scope = null):把 scope 交给调用方从偏好取值;今天仍无调用方——「打断」触发点在聊天区(属另一轮),本 API 先把传输面备好,KDoc 如实标注。
- 新偏好 InterruptMode(DataStore cyrene_app/interrupt_mode):AppendQueue(默认,省略 scope = all)与 Interrupt(scope:"current");未知/缺省值一律解回默认档,绝不悄悄换成更激进的一档。偏好经 AppPrefs → LocalAppPrefs 下发,聊天区按既有方式读取。
- 设置页新增「她忙时发消息」分组:沿用主题那套 Miuix PreferenceCard + BasicComponent + ✓;两行文案说清唯一差别——忙时那条排队消息还在不在(看不懂效果的偏好比没有更糟)。
- 测试新增 11 条(WsOutboundInterruptTest 6 / InterruptModeTest 5):钉死默认档与旧帧逐字节相同、current 原样透传、"all"/""/未知取值都原样发出、两档不在线上打成同一种形状。工作树 assembleDebug 绿,testDebugUnitTest 283 条 0 失败(基线 272 + 新增 11)。
This commit is contained in:
1 parent
feeb49ebeb
commit
b7806951eb
6 files changed
+233
-4
No files matched your search
@@ -15,17 +15,42 @@ private val Context.appDataStore: DataStore<Preferences> by preferencesDataStore
|
|||||||
|
|
||||||
enum class ThemeMode { System, Light, Dark }
|
enum class ThemeMode { System, Light, Dark }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 她正忙时按发送的行为(协同项 A2,契约 v0.56 §3.1 的 `interrupt.scope`)。
|
||||||
|
*
|
||||||
|
* @property wireScope 上行 `interrupt` 帧的 `scope` 取值;`null` = **整键省略**
|
||||||
|
* (服务端视同 `"all"`,与加入 `scope` 之前那条帧逐字节相同)。
|
||||||
|
*/
|
||||||
|
enum class InterruptMode(val wireScope: String?) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 追加队列(默认):忙时只把消息排进队列,不打断在跑回合。
|
||||||
|
*
|
||||||
|
* 该模式**不发** `interrupt`;一旦因别的触发点要发,就用省略 `scope` 的旧形状
|
||||||
|
* (服务端 = `all`:取消在跑回合 + 清空待处理队列)——这是与旧版逐字节兼容的那一档。
|
||||||
|
*/
|
||||||
|
AppendQueue(null),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打断:只取消在跑回合、**保留**待处理队列(`scope:"current"`)。
|
||||||
|
* 队列里的消息按编排器正常续跑规则继续执行,一条都不丢。
|
||||||
|
*/
|
||||||
|
Interrupt("current"),
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* App 级偏好(非机密)。
|
* App 级偏好(非机密)。
|
||||||
*
|
*
|
||||||
* @param themeMode 主题深浅
|
* @param themeMode 主题深浅
|
||||||
* @param showNarration 是否显示 `<action>` / `<narration>` 旁白
|
* @param showNarration 是否显示 `<action>` / `<narration>` 旁白
|
||||||
* @param thinkingExpanded 思维链卡片默认是否展开
|
* @param thinkingExpanded 思维链卡片默认是否展开
|
||||||
|
* @param interruptMode 她正忙时按发送的行为(默认追加队列)
|
||||||
*/
|
*/
|
||||||
data class AppPrefs(
|
data class AppPrefs(
|
||||||
val themeMode: ThemeMode = ThemeMode.System,
|
val themeMode: ThemeMode = ThemeMode.System,
|
||||||
val showNarration: Boolean = true,
|
val showNarration: Boolean = true,
|
||||||
val thinkingExpanded: Boolean = true,
|
val thinkingExpanded: Boolean = true,
|
||||||
|
val interruptMode: InterruptMode = InterruptMode.AppendQueue,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** 全局注入 App 偏好,供聊天渲染等深层组件读取。 */
|
/** 全局注入 App 偏好,供聊天渲染等深层组件读取。 */
|
||||||
@@ -40,6 +65,7 @@ class AppSettings(context: Context) {
|
|||||||
themeMode = prefs[KEY_THEME_MODE].toThemeMode(),
|
themeMode = prefs[KEY_THEME_MODE].toThemeMode(),
|
||||||
showNarration = prefs[KEY_SHOW_NARRATION] ?: true,
|
showNarration = prefs[KEY_SHOW_NARRATION] ?: true,
|
||||||
thinkingExpanded = prefs[KEY_THINKING_EXPANDED] ?: true,
|
thinkingExpanded = prefs[KEY_THINKING_EXPANDED] ?: true,
|
||||||
|
interruptMode = prefs[KEY_INTERRUPT_MODE].toInterruptMode(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,15 +81,26 @@ class AppSettings(context: Context) {
|
|||||||
store.edit { it[KEY_THINKING_EXPANDED] = enabled }
|
store.edit { it[KEY_THINKING_EXPANDED] = enabled }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun setInterruptMode(mode: InterruptMode) {
|
||||||
|
store.edit { it[KEY_INTERRUPT_MODE] = mode.name }
|
||||||
|
}
|
||||||
|
|
||||||
private fun String?.toThemeMode(): ThemeMode = when (this) {
|
private fun String?.toThemeMode(): ThemeMode = when (this) {
|
||||||
ThemeMode.Light.name -> ThemeMode.Light
|
ThemeMode.Light.name -> ThemeMode.Light
|
||||||
ThemeMode.Dark.name -> ThemeMode.Dark
|
ThemeMode.Dark.name -> ThemeMode.Dark
|
||||||
else -> ThemeMode.System
|
else -> ThemeMode.System
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 缺省 / 旧版本遗留的未知值一律解成默认档(追加队列),绝不悄悄换成更激进的档位。 */
|
||||||
|
private fun String?.toInterruptMode(): InterruptMode = when (this) {
|
||||||
|
InterruptMode.Interrupt.name -> InterruptMode.Interrupt
|
||||||
|
else -> InterruptMode.AppendQueue
|
||||||
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
val KEY_THEME_MODE = stringPreferencesKey("theme_mode")
|
val KEY_THEME_MODE = stringPreferencesKey("theme_mode")
|
||||||
val KEY_SHOW_NARRATION = booleanPreferencesKey("show_narration")
|
val KEY_SHOW_NARRATION = booleanPreferencesKey("show_narration")
|
||||||
val KEY_THINKING_EXPANDED = booleanPreferencesKey("thinking_expanded")
|
val KEY_THINKING_EXPANDED = booleanPreferencesKey("thinking_expanded")
|
||||||
|
val KEY_INTERRUPT_MODE = stringPreferencesKey("interrupt_mode")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -155,8 +155,18 @@ class ChatSocket(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
/** 发送 `interrupt`(取消在跑 turn + 清空 pending)。 */
|
/**
|
||||||
fun sendInterrupt(sessionId: String): Boolean = send(WsOutbound.Interrupt(sessionId))
|
* 发送 `interrupt`(api-client §3.1;契约 v0.56/A2 起支持 `scope`)。
|
||||||
|
*
|
||||||
|
* @param scope 打断范围:`null` = **省略该键**(服务端视同 `"all"`:取消在跑回合 + 清空待处理队列,
|
||||||
|
* 与加入 `scope` 之前**逐字节相同**);`"current"` = 只取消在跑回合、保留待处理队列。
|
||||||
|
* 其它取值原样发出,服务端**定向**回 `error{bad_request}` 且不打断任何会话(本层不预判取值域)。
|
||||||
|
*
|
||||||
|
* **今天没有任何调用方**:聊天的「打断」触发点属聊天区那一轮,本 API 先把传输面备好——
|
||||||
|
* 调用方从 App 偏好(`InterruptMode`)取 `scope` 后传进来即可。
|
||||||
|
*/
|
||||||
|
fun sendInterrupt(sessionId: String, scope: String? = null): Boolean =
|
||||||
|
send(WsOutbound.Interrupt(sessionId = sessionId, scope = scope))
|
||||||
|
|
||||||
private fun send(outbound: WsOutbound): Boolean {
|
private fun send(outbound: WsOutbound): Boolean {
|
||||||
if (_state.value != WsState.Connected) return false
|
if (_state.value != WsState.Connected) return false
|
||||||
|
|||||||
@@ -56,14 +56,25 @@ sealed interface WsOutbound {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `interrupt`:`{sessionId}`。命中空闲会话为 no-op。 */
|
/** `interrupt`:`{sessionId, scope?}`。命中空闲会话为 no-op。 */
|
||||||
data class Interrupt(
|
data class Interrupt(
|
||||||
val sessionId: String,
|
val sessionId: String,
|
||||||
|
/**
|
||||||
|
* 打断范围(契约 v0.56/A2,api-client §3.1):取值域 `{"", "all", "current"}`。
|
||||||
|
* `null` = **整键省略**——服务端视同 `"all"`(取消在跑回合 + 清空待处理队列),
|
||||||
|
* 也就是加入该字段之前那条帧**逐字节相同**;`"current"` = 只取消在跑回合、**保留**待处理队列。
|
||||||
|
* 其它取值服务端定向回 `error{bad_request}`(不打断任何会话)——故本层**原样透传**,
|
||||||
|
* 不做取值校验、不静默回退成默认(服务端的开放取值域由服务端裁决)。
|
||||||
|
*/
|
||||||
|
val scope: String? = null,
|
||||||
override val reqId: String? = null,
|
override val reqId: String? = null,
|
||||||
) : WsOutbound {
|
) : WsOutbound {
|
||||||
override val type: String get() = "interrupt"
|
override val type: String get() = "interrupt"
|
||||||
|
|
||||||
override fun data(): JsonElement = buildJsonObject { put("sessionId", sessionId) }
|
override fun data(): JsonElement = buildJsonObject {
|
||||||
|
put("sessionId", sessionId)
|
||||||
|
scope?.let { put("scope", it) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `voice_input`:`{audio?, format?, text?, sessionId?}`。 */
|
/** `voice_input`:`{audio?, format?, text?, sessionId?}`。 */
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import kotlinx.coroutines.launch
|
|||||||
import top.yeij.cyrene.BuildConfig
|
import top.yeij.cyrene.BuildConfig
|
||||||
import top.yeij.cyrene.app.AppContainer
|
import top.yeij.cyrene.app.AppContainer
|
||||||
import top.yeij.cyrene.core.prefs.AppPrefs
|
import top.yeij.cyrene.core.prefs.AppPrefs
|
||||||
|
import top.yeij.cyrene.core.prefs.InterruptMode
|
||||||
import top.yeij.cyrene.core.prefs.ThemeMode
|
import top.yeij.cyrene.core.prefs.ThemeMode
|
||||||
import top.yeij.cyrene.ui.common.PreferenceCard
|
import top.yeij.cyrene.ui.common.PreferenceCard
|
||||||
import top.yukonga.miuix.kmp.basic.BasicComponent
|
import top.yukonga.miuix.kmp.basic.BasicComponent
|
||||||
@@ -102,6 +103,27 @@ fun SettingsScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SectionTitle("她忙时发消息")
|
||||||
|
InterruptMode.entries.forEach { mode ->
|
||||||
|
PreferenceCard {
|
||||||
|
BasicComponent(
|
||||||
|
title = mode.label(),
|
||||||
|
summary = mode.summary(),
|
||||||
|
onClick = { scope.launch { settings.setInterruptMode(mode) } },
|
||||||
|
endActions = {
|
||||||
|
if (prefs.interruptMode == mode) {
|
||||||
|
Text(
|
||||||
|
text = "✓",
|
||||||
|
style = MiuixTheme.textStyles.main,
|
||||||
|
color = MiuixTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
|
||||||
SectionTitle("关于")
|
SectionTitle("关于")
|
||||||
PreferenceCard {
|
PreferenceCard {
|
||||||
BasicComponent(
|
BasicComponent(
|
||||||
@@ -155,3 +177,19 @@ private fun ThemeMode.label(): String = when (this) {
|
|||||||
ThemeMode.Light -> "浅色"
|
ThemeMode.Light -> "浅色"
|
||||||
ThemeMode.Dark -> "深色"
|
ThemeMode.Dark -> "深色"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun InterruptMode.label(): String = when (this) {
|
||||||
|
InterruptMode.AppendQueue -> "追加队列"
|
||||||
|
InterruptMode.Interrupt -> "打断"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 两档的差别只有一处:**忙时那条排队消息还在不在**。文案必须说清这一点——
|
||||||
|
* 一个用户看不懂效果的偏好,比没有这个偏好更糟。
|
||||||
|
*/
|
||||||
|
private fun InterruptMode.summary(): String = when (this) {
|
||||||
|
InterruptMode.AppendQueue ->
|
||||||
|
"她正忙时只把消息排进队列,等她做完当前回复再依次回;不打断(默认)"
|
||||||
|
InterruptMode.Interrupt ->
|
||||||
|
"她正忙时先打断当前这一句;已排队的消息保留,继续依次回答"
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package top.yeij.cyrene.core.prefs
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
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)。
|
||||||
|
*
|
||||||
|
* 这一层只负责一件事:把用户选的档位翻成 `interrupt` 帧的 `scope`。默认档必须翻成
|
||||||
|
* **整键省略**——服务端视同 `"all"`,帧因此与加入 `scope` 之前**逐字节相同**(兼容保证)。
|
||||||
|
*/
|
||||||
|
class InterruptModeTest {
|
||||||
|
|
||||||
|
private fun frameOf(mode: InterruptMode): String =
|
||||||
|
WsOutbound.Interrupt(sessionId = "web", scope = mode.wireScope).toJson()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the default mode serialises to today's interrupt frame byte for byte`() {
|
||||||
|
val mode = AppPrefs().interruptMode
|
||||||
|
|
||||||
|
assertEquals(InterruptMode.AppendQueue, mode)
|
||||||
|
assertNull("默认档必须是「省略 scope」(服务端 = all)", mode.wireScope)
|
||||||
|
assertEquals("""{"type":"interrupt","data":{"sessionId":"web"}}""", frameOf(mode))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the interrupt mode maps to scope current so the queue survives`() {
|
||||||
|
assertEquals("current", InterruptMode.Interrupt.wireScope)
|
||||||
|
assertEquals(
|
||||||
|
"""{"type":"interrupt","data":{"sessionId":"web","scope":"current"}}""",
|
||||||
|
frameOf(InterruptMode.Interrupt),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 })
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the persisted enum names are stable`() {
|
||||||
|
// AppSettings 按 enum 名落盘:改名 = 把所有已存偏好静默重置回默认档。
|
||||||
|
assertEquals(listOf("AppendQueue", "Interrupt"), InterruptMode.entries.map { it.name })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package top.yeij.cyrene.data.ws
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A2:`interrupt` 的可选 `scope`(契约 v0.56 §3.1,取值域 `{"", "all", "current"}`)。
|
||||||
|
*
|
||||||
|
* 两条要点:
|
||||||
|
* - **省略 `scope` 的帧必须与今天逐字节相同**——这是与老服务端 / 老行为的兼容保证;
|
||||||
|
* - 本层**不校验**取值(服务端开放取值域由服务端裁决):除了省略,其余一律原样透传,
|
||||||
|
* 既不自作主张补默认、也不静默改写成别名——服务端对非法值是**定向回 `bad_request`**。
|
||||||
|
*/
|
||||||
|
class WsOutboundInterruptTest {
|
||||||
|
|
||||||
|
/** 加入 `scope` 之前那条帧的逐字形状。 */
|
||||||
|
private val legacyFrame = """{"type":"interrupt","data":{"sessionId":"web"}}"""
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an interrupt without a scope is today's frame byte for byte`() {
|
||||||
|
assertEquals(legacyFrame, WsOutbound.Interrupt(sessionId = "web").toJson())
|
||||||
|
assertEquals(legacyFrame, WsOutbound.Interrupt(sessionId = "web", scope = null).toJson())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `omitting the scope omits the key itself rather than sending null`() {
|
||||||
|
val json = WsOutbound.Interrupt(sessionId = "web").toJson()
|
||||||
|
|
||||||
|
// `"scope":null` 与「整键省略」在服务端不是一回事:后者才是「未声明」。
|
||||||
|
assertFalse("省略时不该出现 scope 键:$json", json.contains("scope"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `scope current is carried verbatim next to the session`() {
|
||||||
|
assertEquals(
|
||||||
|
"""{"type":"interrupt","data":{"sessionId":"web","scope":"current"}}""",
|
||||||
|
WsOutbound.Interrupt(sessionId = "web", scope = "current").toJson(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `scope all and an empty scope are transmitted as-is`() {
|
||||||
|
// 三者(省略 / "" / "all")在服务端同义,但**同义不等于客户端该改写**:
|
||||||
|
// 调用方给了什么就发什么,服务端才是解释者。
|
||||||
|
assertEquals(
|
||||||
|
"""{"type":"interrupt","data":{"sessionId":"web","scope":"all"}}""",
|
||||||
|
WsOutbound.Interrupt(sessionId = "web", scope = "all").toJson(),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"""{"type":"interrupt","data":{"sessionId":"web","scope":""}}""",
|
||||||
|
WsOutbound.Interrupt(sessionId = "web", scope = "").toJson(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an unknown scope is sent verbatim and never silently downgraded to the default`() {
|
||||||
|
// 服务端大小写敏感:`"Current"` 是非法值,会定向回 bad_request 且**不打断任何会话**。
|
||||||
|
// 客户端若在此处「帮它纠正」成 "current",用户看到的就是一次没解释的打断。
|
||||||
|
val json = WsOutbound.Interrupt(sessionId = "web", scope = "Current").toJson()
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
"""{"type":"interrupt","data":{"sessionId":"web","scope":"Current"}}""",
|
||||||
|
json,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the session id stays first so older servers see the same key order`() {
|
||||||
|
assertEquals(
|
||||||
|
"""{"type":"interrupt","data":{"sessionId":"web","scope":"current"}}""",
|
||||||
|
WsOutbound.Interrupt("web", "current").toJson(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user