From 805d8b530a95b81b051a94501ceddf18a2ae4ea4 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 4 Oct 2026 22:35:20 +0800 Subject: [PATCH] =?UTF-8?q?refactor+docs(W27):=20=E5=88=A0=E6=8E=89?= =?UTF-8?q?=E5=8F=AA=E8=A2=AB=E6=B5=8B=E8=AF=95=E5=85=BB=E7=9D=80=E7=9A=84?= =?UTF-8?q?=20AdminAccess.availablePages=EF=BC=8C=E8=AE=A9=20ChatImageBudg?= =?UTF-8?q?et=20=E7=9A=84=E5=85=AC=E5=BC=8F=E5=8F=AA=E7=95=99=E4=B8=80?= =?UTF-8?q?=E5=A4=84=EF=BC=8C=E5=B9=B6=E5=B0=B1=E5=9C=B0=E6=9B=B4=E6=AD=A3?= =?UTF-8?q?=20devdocs/03=20=E5=9B=9B=E5=A4=84=E8=BF=87=E6=97=B6=E8=A1=A8?= =?UTF-8?q?=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 清理(零行为变化) - 删 domain/admin/AdminAccess.kt 的 availablePages():全仓(含 app/src/test)调用点 0 处 —— main 里只有它自己的声明、测试里 2 处(AdminAccessTest 那 1 条用例一并删掉)。它早已被 ui/admin/AdminHomeScreen.kt 的做法取代:遍历 AdminPage.entries + 每行 canAccess 决定是否禁用。 - ChatImageBudget.SERVER_MAX_CHARS 原来把 base64Chars 的 ((n+2)/3)*4 又内联重写了一遍, 改为 base64Chars(SERVER_MAX_DECODED_BYTES) + PREFIX_ALLOWANCE_CHARS(一处公式、一处真相)。 代价:该常量由 const val 变 val(const 不允许调用函数)。取值逐位不变 = 6990572,由 ChatSendPolicyTest 的独立重算断言与 ChatDtoRealPayloadTest 对服务端真实文案的断言继续钉住。 文档(devdocs/03,一律删除线保留原文 + 更正括注,不动别人的回执段落) - §4 重连补全:02/A5 的 after 正向增量已接入(loadAfter + ChatViewModel.catchUp,28c3142)。 - §4 忙碌/队列 UI 映射:客户端只发 scope:"current",该档 dropped 恒为 0、all 从不发, 所以「已打断,丢弃 N 条」在今天的客户端里不可达。 - §5 mode 回落:mode 已落库(后端 b686d5f),MessageBubble 按 mode 分支。 - §4 打断模式:补上 W25 接线的依据 cfdc094 / feat/W25-interrupt-scope。 devdocs/05 追加 W27 小节;另记下 data/ws/WsEvent.kt:272 的同类过时 KDoc(不在本轮允许改动的 文件清单里,未动)。 验证:assembleDebug 通过;testDebugUnitTest 891 条 / 0 失败 / 0 错误(改动前 892,删掉 1 条)。 --- .../yeij/cyrene/domain/admin/AdminAccess.kt | 3 --- .../cyrene/domain/chat/ChatImageBudget.kt | 16 ++++++----- .../cyrene/domain/admin/AdminAccessTest.kt | 9 ------- devdocs/03-客户端细化开发方案.md | 15 ++++++++--- devdocs/05-开发进度与交接.md | 27 +++++++++++++++++++ 5 files changed, 49 insertions(+), 21 deletions(-) diff --git a/app/src/main/kotlin/top/yeij/cyrene/domain/admin/AdminAccess.kt b/app/src/main/kotlin/top/yeij/cyrene/domain/admin/AdminAccess.kt index c078638..f386704 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/domain/admin/AdminAccess.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/domain/admin/AdminAccess.kt @@ -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.entries.filter { canAccess(role, it) } diff --git a/app/src/main/kotlin/top/yeij/cyrene/domain/chat/ChatImageBudget.kt b/app/src/main/kotlin/top/yeij/cyrene/domain/chat/ChatImageBudget.kt index ec641a9..2d17ce6 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/domain/chat/ChatImageBudget.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/domain/chat/ChatImageBudget.kt @@ -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 diff --git a/app/src/test/kotlin/top/yeij/cyrene/domain/admin/AdminAccessTest.kt b/app/src/test/kotlin/top/yeij/cyrene/domain/admin/AdminAccessTest.kt index 000b5fe..7edebc5 100644 --- a/app/src/test/kotlin/top/yeij/cyrene/domain/admin/AdminAccessTest.kt +++ b/app/src/test/kotlin/top/yeij/cyrene/domain/admin/AdminAccessTest.kt @@ -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) diff --git a/devdocs/03-客户端细化开发方案.md b/devdocs/03-客户端细化开发方案.md index deed89d..622e3dc 100644 --- a/devdocs/03-客户端细化开发方案.md +++ b/devdocs/03-客户端细化开发方案.md @@ -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 @@ - `` / `` → 灰斜体旁白,依附气泡:首部连续块在上、尾部连续块在下、中间块上提到 head。 - 兼容漂移:全角 `*…*`(短于 40 字符且无换行 → action);半角 `*…*` **仅当以 动作/旁白/内心/神态/轻声 开头**才算旁白,否则保持普通文本。 - **流式增量解析**:delta 会把标签切半(``)→ 带缓冲的增量解析器,保留尾部未完成片段,只在能确定边界时产出;未闭合 `

` 不显示直到闭合。行为对齐 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 解析 `

` 切气泡;其余 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 不渲染,代码只给等宽字体 + 横向滚动。 diff --git a/devdocs/05-开发进度与交接.md b/devdocs/05-开发进度与交接.md index 83b7252..d8e836d 100644 --- a/devdocs/05-开发进度与交接.md +++ b/devdocs/05-开发进度与交接.md @@ -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 条同一个过时前提。 +> 它不在本轮允许改动的文件清单里,留给下一个动那个文件的会话顺手更正。