# 03 — 客户端细化开发方案(Cyrene-For-Android) > 配套:`DESIGN.md`(总览)、`01-功能拆解与分步路线.md`(分步路线)、`02-前后端协同事项.md`(后端缺口登记) > 本文是**决策固化 + 可施工的细化方案**,随讨论更新。 > 最后更新:2026-09-08 --- ## 0. 已定决策(截至 2026-09-08) | # | 决策 | 备注 | |---|---|---| | D1 | 主界面 = 常规 AI APP(会话列表 + 聊天流) | | | D2 | 长按电源键 = 系统默认语音助手(`VOICE_INTERACTION_SERVICE`),全屏、无悬浮球 | **需先验 ROM 能否把本 App 设为默认助手** | | D3 | UI 严格 Miuix;**气泡自研、侧边栏自研** | Miuix 无 Drawer / 气泡组件 | | D4 | **minSdk 33** | 解锁 `miuix-blur`(硬下限 33) | | D5 | 先纯文本 P0,语音 P1 | | | D6 | 顶栏汉堡 → 侧边栏;含 IoT、App 设置、管理入口 | | | D7 | 服务器地址可配;单管理员登录;自动登录;401 登出 | | | D8 | 会话列表 = **本地回退快照 + 服务端良好时自动刷新** | §3.1 | | D9 | 消息历史缓存 = **方案 B**(每会话最近 50 条快照,LRU 5 个会话) | §3.2 | | D10 | 发图先做(REST `chat.image`,客户端压缩) | 后端无上传端点 → `02/B1` | | D11 | 历史思维链做(按需 REST `/messages`) | | | D12 | 静默重登 | 需存密码;后端无 refresh → `02/B2` | | D13 | 打断语义待后端适配 | `02/A2` | | D14 | ~~接受「同一时刻只有激活会话在流式」~~ **已退役** | 流帧缺 `sessionId` → `02/A1`;客户端透传 + 按会话过滤后不再需要此约束 | | D15 | 管理功能独立管理员页,**不进聊天界面** | 渠道配置等归 Web 后台 | | D16 | 管理员页只做**只读监控**(概览 / 后台思考 / 记忆 / 人格) | 其余全部归 Web 后台 | | D17 | 会话标题本地生成「会话 N」+ 序号持久化 | 后端 `title` 恒空 → `02/A4` | | D18 | 会话列表回退时**显示上次同步时间** | §3.1 | | D19 | 配色用 **Miuix 默认配色**(`ColorSchemeMode.System`,跟随系统深浅色) | 不用 Monet 动态取色 | --- ## 1. 工程骨架 - **版本钉死**(Miuix 0.9.3 牵引):Kotlin 2.4.0 / Compose Multiplatform 1.11.1 / AGP 9.2.1 / JDK 21。Miuix 以 `api` 暴露 `compose.foundation`,版本会被它牵制,不要自行升级。 - **单 module `app`**,包结构 `com.askaeth.cyrene.{app,core,data,domain,platform,ui}`;包内分层,为 P2 拆 `:core:ui` / `:feature:chat` 留边界。 - **DI:手写 `AppContainer`**(不引 Hilt;`miuix-nav` 与 Hilt 有已知冲突 issue #407)。 - **minSdk 33 / compileSdk & targetSdk 跟 Miuix(37)**。 - **网络**:Retrofit + OkHttp + kotlinx-serialization。 - `X-API-Version: 1` 照发(服务端不校验,CORS 也未放开该头 → `02/E1`)。 - debug 用 `networkSecurityConfig` 放行明文 HTTP;自部署支持**用户显式信任单个自签服务器**,绝不全局信任。 - 超时:连接 10s / 读 60s(LLM 慢);WS `pingInterval(30s)`。 - `HttpLoggingInterceptor` 仅 debug,**脱敏 `Authorization` 与 image base64**。 - **R8**:`@Serializable` 类与 Retrofit 泛型签名保留规则。 - **`BASE_URL` 只是默认值**,真实服务器地址运行时用户可改(D7)。 - **主题**:`MiuixTheme(controller)`,默认 `MonetSystem`;设置页提供 跟随系统 / 浅色 / 深色 / 取色。 --- ## 2. 数据层与契约封装 - **DTO 不用 enum**:`mode` / `platform` / `mood` / `role` / `stopReason` / `error.code` 后端全部「未穷举」。用 `String` + 客户端映射表,未知值走默认(`mode` → `chat`)。`ignoreUnknownKeys = true`,可选字段全 nullable。 - **统一错误模型**:`Server(code, message, httpStatus)` vs `Local(kind)`(无网 / DNS / TLS / 超时 / 解析)。S1.1 已定「已建连接用服务端文案,本地错误用本地文案」;401 单独一类,触发全局登出。 - **分页与去重**:WS 首帧 `history` 与 REST 首屏**必然重叠**,且文档未规定 `history` 排序。规则:以 `id` 为唯一键 upsert;本地按 `createdAt` 排序(同毫秒用 `id` 兜底);`history` 到达时只 merge 不重置。⚠️ `history` 帧是**主会话**的回放,接入时必须**按消息自身的 `sessionId` 过滤**,否则会串进次级会话(2026-10-04 实测踩过,`2a49c25` 修)。 - **乐观消息合并**:上行 `chat` **无 clientMsgId**、`reqId` 服务端不回填 → 归一化靠「**待发列表里第一条文本完全相等**」把本地临时气泡换成后端 `id`;匹配不上则保留两条(宁重复不丢)。⚠️ **没有 role 判断、没有时间窗**——本文早期写的「同 role + 时间窗 + 文本匹配」与实现不符(2026-10-04 以代码为准)。 - **deviceId**:首次生成后持久化,形如 `voiceassist:cyrene:v1`,所有 `chat` / `voice_input` 携带。 - **token**:存 `refreshToken` / `expiresAt` / `role` / `baseUrl` 到 EncryptedSharedPreferences,**不再存登录口令**。`expiresAt` 是 **Unix 秒**。访问令牌 24h + refresh 720h(`02/B2` **已闭环**,2026-10-04);临近过期主动 skew 刷新 + 401 兜底,且必须**单飞 + 代际守卫**(服务端的 refresh 是**单次使用旋转、无宽限期**);终态 401 才清会话、断 WS、回登录。 - **契约漂移防护**:维护 `CONTRACT.md` 记录「客户端实际依赖的字段与行为 + 对应后端版本」;关键 JSON 用**真实抓取的响应体**写解码测试(本项目既有做法:`AdminDtoRealPayloadTest` / `MetaDtoRealPayloadTest` / `AuthDtoRealPayloadTest` / `SessionDtoRealPayloadTest`)。 --- ## 3. 本地持久化 ### 3.1 会话列表回退快照(D8) **目标**:网络波动时列表不白屏;服务端可用时以服务端为准。 - **快照内容**:`List`(`id` / `type` / `title` / `createdAt`)+ `fetchedAt` + **指纹**(`baseUrl` + `username`)。 - **存储**:app 私有目录单文件 `sessions.json`(或 DataStore)。 - **启动流程**:读快照 → 立即渲染 → 并发 `GET /sessions` → 成功后整体替换并回写快照。 - **失败流程**:保留快照,列表顶部显示轻量条「网络异常,显示上次结果 · 同步于 <相对时间>」+ 重试。相对时间由本地 `fetchedAt` 算(刚刚 / N 分钟前 / N 小时前 / 日期),不依赖服务端时间。 - **合并策略**:**服务端结果整体替换,不做 merge**(避免已删除会话变幽灵)。服务端请求失败时**不清空快照**。 - **指纹不匹配**(换了服务器地址 / 账号)→ 丢弃快照。 - **排序**:主会话恒首位,次级按 `createdAt ASC`(与后端一致)。 - **当前会话 id** 一并持久化,冷启动直接进上次会话。 - **序号映射** `sessionId → N` 独立持久化:新建时取「当前最大序号 + 1」,删除会话时同步删映射。 - **写入时机**:每次成功拉取后;新建 / 删除后以服务端为准回写。 ### 3.2 消息历史快照 —— 方案 B(D9) - **粒度**:每会话一个文件,文件名用 `sessionId` 的 hash(`web:` 不能直接做文件名)。 - **内容**:最近 50 条 `historyMessage`(`id`/`role`/`content`/`mode?`/`mood?`/`image?`/`platform?`/`deviceId?`/`senderName?`/`createdAt`)。**不含 `thinking`**(WS 与列表接口都拿不到)。 - **LRU**:最多保留 **5 个会话** 的文件,按最后访问时间淘汰。 - **读取**:进会话 → 立即渲染快照 → 并发拉 REST 最新 50 条 → 按 `id` 合并(服务端优先)→ 回写。 - **写入**:收到 `message_persisted` / 分页结果后**节流写**(500ms debounce),避免流式期间频繁 IO;流式中的临时气泡不落盘,只落 `message_persisted` 的最终态。 - **大小上限**:单文件 2MB,超出只保留最近 20 条。 - **图片字段**:**base64 data URI 不落盘**(体积不可控),只保留 http(s) URL;内存中可正常显示。 - **清除时机**:登出、切换服务器地址、指纹不匹配时清空全部缓存。 - **隐私**:文件放 app 私有目录 + `android:allowBackup="false"`(或 backup rules 排除)。若要更强,用与 EncryptedSharedPreferences 同族的 `EncryptedFile`。 ### 3.3 其他 DataStore 项 | 数据 | 用途 | |---|---| | `deviceId` | 跨会话稳定标识 | | 会话序号映射 | D17 | | 用户偏好 | 主题、旁白开关、mood 显示、思维链默认展开、打断模式、通知开关 | | 每会话草稿 | 防进程被杀丢输入 | | 上次会话 id | 冷启动直达 | ### 3.4 不做的事 - **离线待发队列**:P0 不做,发送失败标红重试、内容留在输入框。 - **WS `seq` 游标持久化**:后端无续传机制,存了没用。 --- ## 4. WS 实时通道 - **连接状态机**:`Idle → Connecting → Connected → Reconnecting(退避 1/2/4…30s + 抖动) → Offline(需登录)`。握手 401 不再重试,直接登出。 - **重连补全**:主会话用新连接的 `history` + REST 补差(`before = 本地最后一条 createdAt`),次级会话走 REST,全程按 `id` 去重。⚠️ `02/A5` 已提供 `after` 正向增量端点,**客户端尚未接入**。 - **事件路由**:结构化流帧(`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` 至今无调用方)。另注意:广播是发给**每条连接**、**不按会话订阅**,且每个 `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` 是两个命名空间。 - **打断模式**:后端 `interrupt` 会清空 pending(`02/A2`)→ 该模式下 UI 限制一次只发一条并明确提示。 --- ## 5. 渲染管线 - **解析器**:纯函数、可单测,`parse(raw): List`。权威规则在 `configs/personas/cyrene.yaml`。 - `

…

` 每段一个气泡;无 `

` 时按空行 `\n\n` 回退切分,单个 `\n` 不切。 - `` / `` → 灰斜体旁白,依附气泡:首部连续块在上、尾部连续块在下、中间块上提到 head。 - 兼容漂移:全角 `*…*`(短于 40 字符且无换行 → action);半角 `*…*` **仅当以 动作/旁白/内心/神态/轻声 开头**才算旁白,否则保持普通文本。 - **流式增量解析**:delta 会把标签切半(``)→ 带缓冲的增量解析器,保留尾部未完成片段,只在能确定边界时产出;未闭合 `

` 不显示直到闭合。行为对齐 Web 端 `parseContent.ts` + `chat.tsx`。 - **mode 回落**:后端不落库(`02/A3`)→ P0 全部按 `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 不渲染,代码只给等宽字体 + 横向滚动。 --- ## 6. UI / Miuix 落地 | 需求 | 方案 | |---|---| | 顶栏 + 汉堡 | `SmallTopAppBar(navigationIcon = { IconButton { Icon(MiuixIcons.Sidebar) } })` | | 侧边栏 | **自研 `MiuixDrawer`**:`AnchoredDraggableState` + scrim + `Modifier.offset` lambda 版;汉堡开、抽屉内拖拽关;**不做左边缘滑出**(与系统返回手势冲突);`BackHandler` 关抽屉 | | 气泡 | **自研**:`Surface` / `Card` + `RoundedCornerShape`,最大宽度 `min(0.78 × 屏宽, 600dp)` | | 列表 | `LazyColumn`(Foundation)+ `overScrollVertical()` + `VerticalScrollBar`,`key = id` | | 输入 / 按钮 / 设置 / 弹窗 / 提示 / 加载 | `TextField` / `IconButton`+`FAB` / 全套 `*Preference` / `OverlayDialog`(须在 `Scaffold` 内)/ `Snackbar`+Host / `ProgressIndicator` | | IME | Miuix `Scaffold` 默认只处理 `systemBars ∪ displayCutout`,需自行 `imePadding()` | | 毛玻璃 | 只用在顶栏 / 底栏,不大面积铺;<33 已在 D4 排除 | **侧边栏信息架构** ``` ├─ 会话(主 / 次级) ├─ IoT(预留) ├─ App 设置 ├─ 管理(owner 可见)→ 独立导航图 + 独立 Scaffold,只读监控 │ ├─ 概览(metrics / trace) │ ├─ 后台思考 │ ├─ 记忆 │ └─ 人格 └─ 关于 / 登出 ``` - **管理员页范围**(D15/D16):只做**只读监控**;渠道配置、LLM、RAG、插件、用户、自动化、系统危险操作全部归 **Web 后台**。危险操作即使将来做也必须 `OverlayDialog` 二次确认。 - P0 管理员页只放入口占位(显示「规划中」),P2 再实现。 --- ## 7. P1 语音(依赖后端) - **第一件事是 spike,不是写 UI**:`VOICE_INTERACTION_SERVICE` 只保证系统「助手入口」能指向本 App;**MIUI 是否允许把第三方设为默认助手、长按电源键能否唤起**必须真机验证(不写代码,10 分钟)。验不过就换触发:系统助手手势 / 桌面快捷方式 / 通知磁贴 / 蓝牙耳机键 / App 内按钮。 - **链路(已按后端现状修正)**:`AudioRecord` 录音 → WAV → `POST /audio/transcribe`(REST,绕 4KB)→ 文本 → **WS `chat`**(落库 + 流式 + 编排)→ 回复 → `POST /audio/speech`(**返回裸音频字节,非 `{data}` 信封**)→ `AudioTrack`/ExoPlayer 播放,支持打断。 - **不用 `voice_input` 提交对话**:该路径不落库、不过编排器、无 `message_persisted`(`02/C1`)。 - 权限:`RECORD_AUDIO` 运行时申请;录音期间可选前台服务。 --- ## 8. 里程碑与验收 | 里程碑 | 内容 | 验收 | |---|---|---| | **M0 骨架** | Gradle + 依赖 + Miuix 主题 + 空壳首页 | `./gradlew :app:assembleDebug` 通过,App 起得来 | | **M1 登录** | 服务器地址可配 + 错误分类 + EncryptedPrefs + 自动登录 + 401 登出 | 错地址有本地文案、对地址能进;杀进程重开免登;token 失效自动回登录 | | **M2 只读主会话** | WS 连接 + `history` + REST 分页 + 倒序渲染 + 来源角标 + 会话列表回退 | 断网重开列表不白屏;进主会话看到历史;上拉翻页无重复;每条标来源 | | **M3 发送 + 流式** | `chat` 上行 + 乐观消息 + 流式气泡 + 忙碌/队列状态 + 失败重试 | 发消息立即上屏;流式逐字出;Web 端同步可见;忙时提示排队 | | **M4 富渲染** | `

` 拆气泡 + 旁白 + thinking/工具卡 + 图片 | 多气泡与旁白位置正确;流式半截标签不崩;图片正常显示 | | **M5 多会话 + 侧边栏** | 自研 Drawer + 新建/切换/删除 + 序号持久化 | 汉堡开合顺滑;次级可建可删;主会话禁删;重启记得上次会话 | | **M6 打磨** | 设置页、空态/错误态、无障碍、性能 | 字体放大不炸;冷启动 < 1.5s;无内存泄漏 | **P1**:语音 spike → 全屏助手 UI → 语音闭环。 **P2**:管理员页只读监控、IoT、主动推送(依赖 `02/D1`)、多角色。 --- ## 9. 待定项 1. 消息历史快照是否改用 `EncryptedFile`(当前建议:私有目录 + 关备份,P0 够用)。 2. 管理员页 P0 是否只占位(当前建议:是)。 3. `CONTRACT.md` 是否现在就建(建议 M2 之前建)。 4. 语音 spike 的具体验证清单与机器。