Compare commits
4
Commits
e4c6c7c72a
...
b7ac6b8853
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7ac6b8853 | ||
|
|
3c0ed16ee7 | ||
|
|
eacc65acc9 | ||
|
|
805d8b530a |
No files matched your search
@@ -89,6 +89,3 @@ fun canReceiveObservabilityFrames(role: AdminRole): Boolean =
|
||||
* 而不是散在各处 `if` 里靠人记。
|
||||
*/
|
||||
fun canReadObservabilityLogHistory(role: AdminRole): Boolean = canAccess(role, AdminPage.Overview)
|
||||
|
||||
/** 当前角色可用的页,顺序与 [AdminPage] 声明一致。 */
|
||||
fun availablePages(role: AdminRole): List<AdminPage> = AdminPage.entries.filter { canAccess(role, it) }
|
||||
@@ -29,9 +29,16 @@ object ChatImageBudget {
|
||||
/** `data:` URI 前缀(`data:image/jpeg;base64,`)的字符余量,与服务端同一取值。 */
|
||||
const val PREFIX_ALLOWANCE_CHARS = 64
|
||||
|
||||
/** 服务端 `image` 字符串长度上限(= base64 膨胀 4/3 + 前缀余量)。 */
|
||||
const val SERVER_MAX_CHARS =
|
||||
((SERVER_MAX_DECODED_BYTES + 2) / 3) * 4 + PREFIX_ALLOWANCE_CHARS
|
||||
/** [byteCount] 字节 base64 编码后的字符数(每 3 字节 → 4 字符,末组补满)。 */
|
||||
fun base64Chars(byteCount: Int): Int = ((byteCount + 2) / 3) * 4
|
||||
|
||||
/**
|
||||
* 服务端 `image` 字符串长度上限(= [base64Chars] 的 4/3 膨胀 + 前缀余量)。
|
||||
*
|
||||
* 刻意复用 [base64Chars] 而不是把公式再内联写一遍:公式只留一处。代价是它只能是 `val`
|
||||
* ——`const val` 不允许调用函数;取值在对象初始化时算出,与内联写法一致。
|
||||
*/
|
||||
val SERVER_MAX_CHARS: Int = base64Chars(SERVER_MAX_DECODED_BYTES) + PREFIX_ALLOWANCE_CHARS
|
||||
|
||||
/** 本端目标:重编码后的**字节**上限,远低于服务端硬顶(见类注释)。 */
|
||||
const val MAX_DECODED_BYTES = 1 * 1024 * 1024
|
||||
@@ -42,9 +49,6 @@ object ChatImageBudget {
|
||||
*/
|
||||
const val MAX_DIMENSION_PX = 1280
|
||||
|
||||
/** [byteCount] 字节 base64 编码后的字符数(每 3 字节 → 4 字符,末组补满)。 */
|
||||
fun base64Chars(byteCount: Int): Int = ((byteCount + 2) / 3) * 4
|
||||
|
||||
/** 字符串长度是否在服务端上限内(入口层同一判据:`<=` 才算通过)。 */
|
||||
fun withinServerLimit(dataUriChars: Int): Boolean = dataUriChars <= SERVER_MAX_CHARS
|
||||
|
||||
|
||||
@@ -100,7 +100,6 @@ fun ChatHomeScreen(
|
||||
// 静默是为了不闪「同步中…」——屏幕上本来就有内容。
|
||||
sessionsViewModel.refresh(silent = true)
|
||||
},
|
||||
onLogout = onLogout,
|
||||
// 当前会话在别处被删了(`404 session_not_found`)⇒ 先立起那句如实告知,再重拉列表:
|
||||
// `refresh` 里面会把「指向已删会话」的 activeSessionId 收敛回主会话
|
||||
// (`resolveActiveSession`),抽屉里那条也跟着消失。所以这里不需要自己判「该切到哪儿」。
|
||||
@@ -134,6 +133,7 @@ fun ChatHomeScreen(
|
||||
drawerOpen = false
|
||||
showSettings = true
|
||||
},
|
||||
onDismissError = sessionsViewModel::dismissError,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -138,7 +138,6 @@ fun ChatScreen(
|
||||
container: AppContainer,
|
||||
sessionId: String,
|
||||
onOpenDrawer: () -> Unit = {},
|
||||
onLogout: () -> Unit = {},
|
||||
onSessionGone: () -> Unit = {},
|
||||
sessionNotice: String? = null,
|
||||
) {
|
||||
@@ -496,8 +495,15 @@ fun ChatScreen(
|
||||
.align(Alignment.TopCenter)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
// 错误是这一排里**唯一**给关闭按钮的一条:它不会自己消失(别的几条是动作刚发生的
|
||||
// 如实告知,挂了就收)。关掉的只是屏幕上这一行字,不代表服务端那边没事——
|
||||
// 边界写在 ChatViewModel.dismissError 上。
|
||||
state.error?.let { message ->
|
||||
Banner(text = message, color = MiuixTheme.colorScheme.error)
|
||||
Banner(
|
||||
text = message,
|
||||
color = MiuixTheme.colorScheme.error,
|
||||
onDismiss = viewModel::dismissError,
|
||||
)
|
||||
}
|
||||
// 「这个会话在服务端已经没了」那句解释:由上层注入(见函数头上那段说明),
|
||||
// 因为承载它的会话 VM 在切走的那一刻就被销毁了。
|
||||
@@ -610,18 +616,35 @@ private fun documentOpenType(context: Context, target: DocumentTarget): String {
|
||||
/** 建议文件名兜底:理论上到不了(服务端的存储名恒非空),只是不让一个空名字进 SAF。 */
|
||||
private const val FALLBACK_PREVIEW_FILE_NAME = "document.txt"
|
||||
|
||||
/** 消息区上方的一行提示:错误与分享告知共用同一形态(铺满宽度、卡片底色)。 */
|
||||
/**
|
||||
* 消息区上方的一行提示:错误与分享告知共用同一形态(铺满宽度、卡片底色)。
|
||||
*
|
||||
* [onDismiss] 给出时右侧多一个关闭按钮。**只有错误那条**给(它不会自己消失,见
|
||||
* `ChatViewModel.dismissError`);分享/附件/定位那几条是**动作刚发生的如实告知**、挂了就自己
|
||||
* 收走,给它们一个关闭按钮只是多一个没人按的图标。
|
||||
*/
|
||||
@Composable
|
||||
private fun Banner(text: String, color: Color) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MiuixTheme.textStyles.footnote1,
|
||||
color = color,
|
||||
private fun Banner(text: String, color: Color, onDismiss: (() -> Unit)? = null) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MiuixTheme.colorScheme.surfaceContainer)
|
||||
.padding(12.dp),
|
||||
)
|
||||
.background(MiuixTheme.colorScheme.surfaceContainer),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MiuixTheme.textStyles.footnote1,
|
||||
color = color,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(12.dp),
|
||||
)
|
||||
if (onDismiss != null) {
|
||||
IconButton(onClick = onDismiss, modifier = Modifier.padding(end = 4.dp)) {
|
||||
Icon(imageVector = MiuixIcons.Basic.Close, contentDescription = "关闭这条提示")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -678,8 +678,6 @@ class ChatViewModel(
|
||||
fun removeDraftDocument(index: Int) =
|
||||
_state.update { it.copy(draftDocuments = DocumentAttachments.removeAt(it.draftDocuments, index)) }
|
||||
|
||||
fun clearDraftDocuments() = _state.update { it.copy(draftDocuments = emptyList()) }
|
||||
|
||||
// —— D3:单条消息的编辑 / 删除 ——
|
||||
|
||||
/**
|
||||
@@ -868,9 +866,20 @@ class ChatViewModel(
|
||||
* 消息上(`chat.documents` 本就是数组,≤5 条),因此只随第一条走。
|
||||
* 顺序由这里串起来(同一个协程里依次投递)——各投各的会让它们互相赛跑,落库顺序反过来。
|
||||
*
|
||||
* 两处刻意的取舍:
|
||||
* 三处刻意的取舍:
|
||||
* - **不动草稿**:分享不是用户按的发送键,不能把他正在输入的内容顶掉;
|
||||
* - **空内容也照常处理**:[notice] 该说就说,静默什么都不发生比报一句更糟。
|
||||
* - **空内容也照常处理**:[notice] 该说就说,静默什么都不发生比报一句更糟;
|
||||
* - **一次都不打断**:见下面「分享不发打断」一段。
|
||||
*
|
||||
* ## 分享不发打断(`allowInterrupt` 恒为 false)
|
||||
* 分享是**一次把东西倒进去**的批量动作:N 张图 = N 条消息,上面那个 `forEach` 会逐条投递,
|
||||
* 而「她正忙时按发送」那个偏好(协同 A2)默认会在**每条**投递前发一条 `interrupt{scope:"current"}`。
|
||||
* 照原样接上去,第 2 条起就是在**打断自己刚起的上一轮**——最后屏幕上只剩最后一张图的回答,
|
||||
* 前面几条被取消(契约里被取消的回合不落库)。自己打断自己一定是错的。
|
||||
*
|
||||
* 只有一张图时同样不发:这条规则要**易解释**——「分享从不打断」是一句话,
|
||||
* 「分享超过一张时才不打断」得先跟人解释清为什么是一张的分界。
|
||||
* 用户真的想打断,发送键那条路([send])的口径一字未动。
|
||||
*
|
||||
* @param notice 如实告知(截断 / 读不到 / 空内容),由渲染层组织文案;null = 没什么要说的
|
||||
*/
|
||||
@@ -882,8 +891,10 @@ class ChatViewModel(
|
||||
}
|
||||
if (body.isEmpty() && share.images.isEmpty() && documents.isEmpty()) return
|
||||
viewModelScope.launch {
|
||||
emitAndDeliver(body, share.images.firstOrNull(), documents, clearDraft = false)
|
||||
share.images.drop(1).forEach { emitAndDeliver("", it, emptyList(), clearDraft = false) }
|
||||
emitAndDeliver(body, share.images.firstOrNull(), documents, clearDraft = false, allowInterrupt = false)
|
||||
share.images.drop(1).forEach {
|
||||
emitAndDeliver("", it, emptyList(), clearDraft = false, allowInterrupt = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -910,12 +921,16 @@ class ChatViewModel(
|
||||
*
|
||||
* @param clearDraft 只有「用户按了发送键」才清草稿——那时草稿正是刚发出去的这一条;
|
||||
* 分享落地是替用户发一条他自己录进来的内容,草稿不是它。
|
||||
* @param allowInterrupt 这条投递要不要按「她正忙时按发送」那个偏好先发打断帧。**默认 true
|
||||
* = 用户按发送键的既有口径**;只有 [sendShared] 传 false——它是批量动作,批量里每条都打断
|
||||
* 等于自己杀自己(理由见 [sendShared])。
|
||||
*/
|
||||
private suspend fun emitAndDeliver(
|
||||
text: String,
|
||||
imageUri: String?,
|
||||
documents: List<DraftDocument>,
|
||||
clearDraft: Boolean,
|
||||
allowInterrupt: Boolean = true,
|
||||
) {
|
||||
if (text.isEmpty() && imageUri == null && documents.isEmpty()) return
|
||||
|
||||
@@ -943,7 +958,7 @@ class ChatViewModel(
|
||||
error = null,
|
||||
)
|
||||
}
|
||||
deliver(localId, text, imageUri, documents)
|
||||
deliver(localId, text, imageUri, documents, allowInterrupt)
|
||||
}
|
||||
|
||||
private suspend fun deliver(
|
||||
@@ -951,6 +966,7 @@ class ChatViewModel(
|
||||
text: String,
|
||||
imageUri: String?,
|
||||
documents: List<DraftDocument>,
|
||||
allowInterrupt: Boolean,
|
||||
) {
|
||||
// 判定矩阵([DocumentDelivery]):附件 >5 **直接拒绝,绝不裁剪**。这里用只需份数的
|
||||
// [DocumentDelivery.planForCount]——字节还没读(最多 5×20 MiB 一次性读进内存不可接受),
|
||||
@@ -988,7 +1004,11 @@ class ChatViewModel(
|
||||
// 失败 / 附件上传失败 / 附件引用不合法)都会「回滚、什么都不发」——先把她正在说的那一轮
|
||||
// 打断掉、再什么都不发,是凭空的破坏,而且用户看不到任何理由。
|
||||
// 每次投递只经过这里一次 ⇒ 打断帧**最多一条**,下面的通道分支与任何后续重试都不会再发。
|
||||
interruptRunningTurn()
|
||||
//
|
||||
// 只有 [allowInterrupt] 为 true 才发(= 用户按发送键那条路,默认档)。[ChatViewModel.sendShared]
|
||||
// 传 false:它是**批量**投递,每条都打断就会让第 2 条起把前一条刚起的回合取消掉(理由与
|
||||
// 取舍见 [sendShared] 的「分享不发打断」一段)。
|
||||
if (allowInterrupt) interruptRunningTurn()
|
||||
|
||||
when (ChatSendRouting.channelFor(text, hasImage = image != null, hasDocuments = payload.isNotEmpty())) {
|
||||
SendChannel.Ws -> {
|
||||
@@ -1009,7 +1029,8 @@ class ChatViewModel(
|
||||
*
|
||||
* 判据本身是纯函数 [interruptScopeForSend](默认档恒不发、忙且选了「打断」才发 `scope:"current"`,
|
||||
* 命中空闲会话不发),这里只做两件事:**取当前偏好**、把帧发出去。它由 [deliver] 在**通道分支
|
||||
* 之前**恰好调用一次,所以一次投递最多一条打断帧。
|
||||
* 之前**调用,所以一次投递最多一条打断帧——且**只在 [deliver] 的 `allowInterrupt` 为 true 时**
|
||||
* 才调(分享那条批量路径传 false,见 [sendShared])。
|
||||
*
|
||||
* ## 一条已知边界:调用顺序 ≠ 到达顺序(不在本端可控范围)
|
||||
* 本端能保证的只有**调用顺序**(打断帧在这条消息之前发出去)。到达与服务端处理顺序取决于通道:
|
||||
@@ -1427,6 +1448,29 @@ class ChatViewModel(
|
||||
updateStreaming { it.copy(bubbles = bubbles, active = true) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 契约 §3.1 的**非流式回退路径**(`Cyrene_v2/Docs/api-client.md`):「未映射/无法确定的
|
||||
* 事件仍走 `stream_chunk` + `stream_end`」(§3.1 的「向后兼容」一条;v0.4「保留
|
||||
* `stream_chunk`/`stream_end` 向后兼容」)。服务端一旦走这条路,**这一轮回复的整段正文就
|
||||
* 只在这两帧里**——这里不接它,它会落进 `onEvent` 末尾的 `else -> Unit` 被静默丢掉:
|
||||
* 屏幕上那一轮回答是空的,而且没有任何信号说明正文丢了。
|
||||
*
|
||||
* 「与结构化帧**互斥**」的依据就是上面那句话本身:回退路径的语义是「结构化流帧没能承载
|
||||
* 这次事件」,所以同一轮里 `stream_chunk` 与 `message.*` 不会并存。因此这一帧可以照
|
||||
* [WsEvent.MessageDelta] 的同一形态喂进同一个 `streamParser`,不必去分辨「它属于哪条流」。
|
||||
*
|
||||
* 一处必须注意的差别:`stream_chunk` **不带 `index`**(那是 [WsEvent.MessageStart] 的字段),
|
||||
* 所以这里不读它——它只是「当前这条流」的增量;它也不负责开一轮(回退路径里没有
|
||||
* `message.start`),解析器因此按需创建,与上面 `MessageDelta` 分支逐字一致。
|
||||
*
|
||||
* 会话归属照旧由 `onEvent` 开头那道闸收敛(本帧带 `sessionId`,见 [isForSession])。
|
||||
*/
|
||||
is WsEvent.StreamChunk -> {
|
||||
val parser = streamParser ?: StreamingContentParser().also { streamParser = it }
|
||||
val bubbles = parser.onDelta(event.delta)
|
||||
updateStreaming { it.copy(bubbles = bubbles, active = true) }
|
||||
}
|
||||
|
||||
// —— 思维链 ——
|
||||
is WsEvent.ThinkingStart -> {
|
||||
if (event.index == 0) freshTurnIfFinished()
|
||||
@@ -1656,6 +1700,15 @@ class ChatViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 收起**本端已经显示出来**的那条错误横幅。
|
||||
*
|
||||
* 它清的只是屏幕上那一行字:**不代表服务端那边的事已经好了**。比如一次失败的上拉分页,
|
||||
* 用户关掉这条提示并不会让那几页消息回来——再上拉一次照样会失败、照样会重新挂出这条错误。
|
||||
* 所以这里刻意**不动任何重试、也不动错误本身**:关掉只是把那句已经读过的告知收走。
|
||||
*
|
||||
* **不做自动消失**:错误不该自己走掉(分享/附件那几条告知才挂一会儿就收)。
|
||||
*/
|
||||
fun dismissError() = _state.update { it.copy(error = null) }
|
||||
|
||||
override fun onCleared() {
|
||||
|
||||
@@ -40,6 +40,7 @@ import top.yukonga.miuix.kmp.basic.Text
|
||||
import top.yukonga.miuix.kmp.basic.TextButton
|
||||
import top.yukonga.miuix.kmp.basic.TextField
|
||||
import top.yukonga.miuix.kmp.icon.MiuixIcons
|
||||
import top.yukonga.miuix.kmp.icon.basic.Close
|
||||
import top.yukonga.miuix.kmp.icon.extended.Add
|
||||
import top.yukonga.miuix.kmp.icon.extended.Delete
|
||||
import top.yukonga.miuix.kmp.icon.extended.Pin
|
||||
@@ -59,6 +60,8 @@ import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||
* @param onCreate 参数是这次要绑定的角色 id:**null = 「默认角色」**(不绑,跟随全局默认角色)。
|
||||
* 类型必须是 `(String?) -> Unit`:调用方传的是 `SessionsViewModel::createSession` 这个方法引用,
|
||||
* 两边签名逐字对应。
|
||||
* @param onDismissError 收起列表下方那条错误([SessionsUiState.error])。它不会自己消失,
|
||||
* 所以必须有个关闭入口;关掉的只是**本端已经显示的**这一条(边界见 `SessionsViewModel.dismissError`)。
|
||||
*/
|
||||
@Composable
|
||||
fun ColumnScope.SessionDrawerContent(
|
||||
@@ -71,6 +74,7 @@ fun ColumnScope.SessionDrawerContent(
|
||||
onOpenIot: () -> Unit,
|
||||
onOpenAdmin: () -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
onDismissError: () -> Unit,
|
||||
) {
|
||||
var pendingDelete by remember { mutableStateOf<SessionItem?>(null) }
|
||||
var pendingRename by remember { mutableStateOf<SessionItem?>(null) }
|
||||
@@ -116,13 +120,32 @@ fun ColumnScope.SessionDrawerContent(
|
||||
}
|
||||
}
|
||||
|
||||
if (state.error != null) {
|
||||
Text(
|
||||
text = state.error.orEmpty(),
|
||||
style = MiuixTheme.textStyles.footnote2,
|
||||
color = MiuixTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 6.dp),
|
||||
)
|
||||
// 这条错误也不会自己消失,所以右侧给一个关闭入口——样式随它上面那几行:同一份水平内边距、
|
||||
// 同一档 `footnote2`;图标用同文件里置顶/删除那两个 `IconButton` 的同一档灰度。
|
||||
// 关掉的只是本端已经显示的这条,不代表服务端那边没事(边界见 SessionsViewModel.dismissError)。
|
||||
state.error?.let { message ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 20.dp, end = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = message,
|
||||
style = MiuixTheme.textStyles.footnote2,
|
||||
color = MiuixTheme.colorScheme.error,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(vertical = 6.dp),
|
||||
)
|
||||
IconButton(onClick = onDismissError) {
|
||||
Icon(
|
||||
imageVector = MiuixIcons.Basic.Close,
|
||||
contentDescription = "关闭这条错误提示",
|
||||
tint = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.fromCache && state.syncedAt > 0) {
|
||||
|
||||
@@ -297,6 +297,16 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 收起**本端已经显示出来**的那条错误(抽屉里列表下方那一行)。
|
||||
*
|
||||
* 它清的只是那一行字:**不代表服务端那边的事已经好了**。关掉一次失败的删除 / 重命名 / 同步的
|
||||
* 提示,并不会让那件事成功——重来一次照样会失败、照样会重新挂出这条错误。所以这里刻意
|
||||
* **不动任何重试、也不动错误本身**,也不做自动消失(错误不该自己走掉)。
|
||||
*
|
||||
* 角色目录那条 [SessionsUiState.personaError] 不在这儿:它只属于「新建会话」弹层,
|
||||
* 关弹层就跟着没了。
|
||||
*/
|
||||
fun dismissError() = _state.update { it.copy(error = null) }
|
||||
|
||||
/**
|
||||
|
||||
@@ -53,15 +53,6 @@ class AdminAccessTest {
|
||||
assertEquals(AdminRole.Viewer, AdminRole.of("viewer"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `available pages follow the declared order`() {
|
||||
assertEquals(
|
||||
listOf(AdminPage.Overview, AdminPage.Personas),
|
||||
availablePages(AdminRole.Viewer),
|
||||
)
|
||||
assertEquals(AdminPage.entries.toList(), availablePages(AdminRole.Owner))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `monitor tier is weaker than owner admin tier`() {
|
||||
assertTrue(AdminTier.Monitor.rank < AdminTier.OwnerAdmin.rank)
|
||||
|
||||
+12
-3
@@ -110,13 +110,19 @@
|
||||
## 4. WS 实时通道
|
||||
|
||||
- **连接状态机**:`Idle → Connecting → Connected → Reconnecting(退避 1/2/4…30s + 抖动) → Offline(需登录)`。握手 401 不再重试,直接登出。
|
||||
- **重连补全**:主会话用新连接的 `history` + REST 补差(`before = 本地最后一条 createdAt`),次级会话走 REST,全程按 `id` 去重。⚠️ `02/A5` 已提供 `after` 正向增量端点,**客户端尚未接入**。
|
||||
- **重连补全**:主会话用新连接的 `history` + REST 补差(`before = 本地最后一条 createdAt`),次级会话走 REST,全程按 `id` 去重。~~⚠️ `02/A5` 已提供 `after` 正向增量端点,**客户端尚未接入**。~~
|
||||
⚠️ **2026-10-04 更正:已接入**——`data/repository/MessageRepository.kt:92` 的 `loadAfter` +
|
||||
`ui/chat/ChatViewModel.kt:1303` 的 `catchUp`(掉线记游标 / 重连正向追),落地 `28c3142`(2026-10-04 深夜)。
|
||||
- **事件路由**:结构化流帧(`message.*` / `thinking.*` / `tool_call.*` / `tool_execution.*`)**后端 v0.36 起已带 `sessionId` + `turnId`** → **必须先按 `sessionId` 过滤再分发**。⚠️ 历史上本端 `WsEvent.parse` 把这两个字段丢掉了,才产生「不带 `sessionId` 的帧归属当前 active turn」这一假设与 **D14 硬约束**;**D14 已退役**(2026-10-04:`WsEvent` 已透传帧壳上的 `sessionId`,`ChatViewModel.onEvent` 用 `isForSession` 过滤;**这条约束从未被任何代码强制过**,`ChatSocket.sendInterrupt` 在 W25 之前也一条帧都没发过——现已接上唯一调用方,见 `05` 的 A2 补记)。另注意:广播是发给**每条连接**、**不按会话订阅**,且每个 `ChatScreen` 的 VM **各建一条 `ChatSocket`**——所以带着旧 socket 的旧 VM 也会收到新会话的帧,**过滤是必需的,不是优化**(已落地)。遗留问题:旧 VM 的 socket 仍**不会**随切会话关闭(每个 VM 一条,且 VM 存在 Activity 级 `ViewModelStore` 里,删会话也不回收)。
|
||||
- **流式段落状态机**:`index` 编号规则文档未定义 → 按首次出现顺序建段,`start` 建 / `delta` 追加 / `end` 收口;`stream_end` 才算整轮结束。要容忍丢帧(服务端发送缓冲 32,满则丢)。
|
||||
- **忙碌/队列 UI 映射**:`session_busy{mode}` → 轻状态条;`pending_ack{queueDepth}` → 「已排队 N 条」;`pending_full{max}` → 队列满(默认 5);`session_interrupted{dropped}` → 「已打断,丢弃 N 条」。注意编排 `mode`(`idle/thinking/executing/subagent`)与消息渲染 `mode` 是两个命名空间。
|
||||
⚠️ **2026-10-04 更正**:客户端**只发** `scope:"current"`(`core/prefs/AppSettings.kt:39`、`data/ws/WsOutbound.kt:63`),
|
||||
该档下 `dropped` **恒为 `0`**(契约 v0.56 明文);`dropped > 0` 只会在 `scope:"all"`(清空 pending)时出现,
|
||||
而**客户端从不发 `all`** ⇒ 上面那句「已打断,丢弃 N 条」在**今天的客户端里不可达**,留作契约面备忘即可。
|
||||
- **打断模式**:~~后端 `interrupt` 会清空 pending(`02/A2`)→ 该模式下 UI 限制一次只发一条并明确提示。~~
|
||||
⚠️ **补记(W25,2026-10-04)**:`02/A2` 已由后端 v0.56 修好——`scope:"current"` = 只取消在跑回合、
|
||||
**保留待处理队列**,客户端也已在发送路径按偏好发这个 `scope`(见 `05` 的 A2 补记)⇒ 「清空 pending」
|
||||
**保留待处理队列**,客户端也已在发送路径按偏好发这个 `scope`(`feat/W25-interrupt-scope`,`cfdc094`;
|
||||
见 `05` 的 A2 补记)⇒ 「清空 pending」
|
||||
这个前提**不再成立**,上面那条「限制一次只发一条」**不必做**:队列里的消息一条都不会丢。
|
||||
|
||||
---
|
||||
@@ -128,7 +134,10 @@
|
||||
- `<action>` / `<narration>` → 灰斜体旁白,依附气泡:首部连续块在上、尾部连续块在下、中间块上提到 head。
|
||||
- 兼容漂移:全角 `*…*`(短于 40 字符且无换行 → action);半角 `*…*` **仅当以 动作/旁白/内心/神态/轻声 开头**才算旁白,否则保持普通文本。
|
||||
- **流式增量解析**:delta 会把标签切半(`<act` + `ion>`)→ 带缓冲的增量解析器,保留尾部未完成片段,只在能确定边界时产出;未闭合 `<p>` 不显示直到闭合。行为对齐 Web 端 `parseContent.ts` + `chat.tsx`。
|
||||
- **mode 回落**:后端不落库(`02/A3`)→ P0 全部按 `chat` 渲染。
|
||||
- **mode 回落**:~~后端不落库(`02/A3`)→ P0 全部按 `chat` 渲染。~~
|
||||
⚠️ **2026-10-04 更正:已落库**(后端 `b686d5f`,见 `02` 的 `A3` 行与「回执五」补记第 5 条)——客户端按 `mode` 分支:
|
||||
`ui/chat/MessageBubble.kt:110` 的 `val isChat = message.mode.isNullOrBlank() || message.mode == "chat"`
|
||||
(只有 `chat` 才按 markup 解析 `<p>` 切气泡;其余 mode 走单块纯文本兜底,`null` / 空白同样回落 `chat`)。
|
||||
- **thinking / tool_call**:仅 WS 实时有;历史思维链按需请求 REST `/messages`(D11)。工具卡把 `tool_call.delta`(参数 JSON)与 `tool_execution.update` 的 `delta`(局部输出)分两块渲染;执行状态由 `tool_execution.start/update/end` 的 **`phase`**(`running`/`done`/`error`)驱动,三帧按 **`id`** 关联到对应的 `tool_call.*`(契约 v0.50;这三帧不带 `index`)。
|
||||
- **图片**:Coil 加载;base64 先读尺寸再决定是否解码,避免 OOM。
|
||||
- **Markdown**:P0 不渲染,代码只给等宽字体 + 横向滚动。
|
||||
|
||||
@@ -812,3 +812,30 @@ PDF · 563 B · 24 字 · 已读入
|
||||
> `89776fe3…` 那条会话里亲眼看到);并行 `tool_use` 配对的 P0 在修,**客户端不需要适配**。
|
||||
> 7. **`.env` 新增 `LLM_MULTIMODAL`**(主人加的)⇒ 主模型能识图,`H1` 那条结构上消失。
|
||||
> **我们还没做「如果主模型本身能识图」的客户端侧跟进**,等主人指路。
|
||||
|
||||
---
|
||||
|
||||
## W27(2026-10-04 深夜 · 死代码清理 + `03` 四处过时表述更正)
|
||||
|
||||
**两处清理(都不改行为)**
|
||||
|
||||
- **删 `AdminAccess.availablePages()`**(`domain/admin/AdminAccess.kt`):全仓(含 `app/src/test`)**调用点 0 处**——
|
||||
`app/src/main` 里只有它自己的声明,测试里 2 处(`AdminAccessTest` 的那 1 条用例,一并删掉)。
|
||||
它已被 UI 那套做法取代:`ui/admin/AdminHomeScreen.kt:101,116` 遍历 `AdminPage.entries` + 每行 `canAccess` 决定是否禁用。
|
||||
- **`ChatImageBudget.SERVER_MAX_CHARS` 不再自己重写膨胀公式**:原来它把 `base64Chars` 的
|
||||
`((n + 2) / 3) * 4` 又内联写了一遍(同一公式两份真相)⇒ 改为
|
||||
`val SERVER_MAX_CHARS = base64Chars(SERVER_MAX_DECODED_BYTES) + PREFIX_ALLOWANCE_CHARS`。
|
||||
⚠️ 代价:该常量由 `const val` 变 `val`(`const` 不允许调用函数)。**取值逐位不变 = `6990572`**
|
||||
(5 MiB ⇒ base64 膨胀 6990508 字符 + 前缀余量 64),由 `ChatSendPolicyTest` 的「独立重算 → 相等」断言继续钉住。
|
||||
|
||||
**`devdocs/03` 四处就地更正**(一律 ~~删除线~~ 保留原文 + 「⚠️ 更正」括注,不动别人的回执段落)
|
||||
|
||||
1. §4「重连补全」——`02/A5` 的 `after` 正向增量**早已接入**(`loadAfter` + `ChatViewModel.catchUp`,`28c3142`)。
|
||||
2. §4「忙碌/队列 UI 映射」——`session_interrupted{dropped}` 那句:客户端**只发** `scope:"current"`,
|
||||
该档 `dropped` **恒为 0**、`all` **从不发** ⇒「丢弃 N 条」在今天的客户端里**不可达**。
|
||||
3. §5「mode 回落」——`mode` **已落库**(后端 `b686d5f`),`MessageBubble.kt:110` 按 `mode` 分支。
|
||||
4. §4「打断模式」——「限制一次只发一条」**已由 W25 修掉**(补记本就写在案,本轮补上依据 `cfdc094` / `feat/W25-interrupt-scope`)。
|
||||
|
||||
> ⚠️ **顺手查到但本轮没动**:`data/ws/WsEvent.kt:272` 的 `SessionInterrupted` KDoc 也写着
|
||||
> 「当前任务取消、pending 清空,`dropped` 为丢弃条数」——与上面第 2 条同一个过时前提。
|
||||
> 它不在本轮允许改动的文件清单里,留给下一个动那个文件的会话顺手更正。
|
||||
Reference in new issue
Block a user