Files
Cyrene-For-Android/devdocs/03-客户端细化开发方案.md
T
AskaEth 84de51c7b7 feat: 初始化 Android 客户端(M0 骨架 + M1 登录 + M2 基础模块)
M0:Gradle 9.7.1 / AGP 9.2.1 / Kotlin 2.4 / Miuix 0.9.3,minSdk 33,compileSdk 37。
M1:登录页(服务器地址可配、服务端/本地错误分流、EncryptedSharedPreferences、自动登录与静默重登、401 登出)。
M2 基础模块:消息内容解析器(<p>/<action>/<narration> + 流式增量)、WebSocket 客户端、REST 数据层与分页去重。
45 个单元测试全绿,真机验证通过(登录页 + 启动图标)。
devdocs/ 收录设计文档、前后端协同事项与细化方案。
2026-09-08 20:40:58 +08:00

198 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 不重置;次级会话无回放,一律走 REST。
- **乐观消息合并**:上行 `chat` **无 clientMsgId**,`reqId` 服务端不回填 → 靠「同 role + 时间窗 + 文本匹配」把本地临时气泡归一为后端 `id`;匹配不上则保留两条(宁重复不丢)。
- **deviceId**:首次生成后持久化,形如 `voiceassist:cyrene:v1`,所有 `chat` / `voice_input` 携带。
- **token**:存 `token` / `expiresAt` / `role` / `baseUrl` 到 EncryptedSharedPreferences;24h 过期、无 refresh(`02/B2`),采用静默重登(D12);运行中 401 → 清 token、断 WS、回登录。
- **契约漂移防护**:维护 `CONTRACT.md` 记录「客户端实际依赖的字段与行为 + 对应后端版本」;关键 JSON 用 MockWebServer 写契约测试。
---
## 3. 本地持久化
### 3.1 会话列表回退快照(D8)
**目标**:网络波动时列表不白屏;服务端可用时以服务端为准。
- **快照内容**:`List<SessionSummary>`(`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:<uuid>` 不能直接做文件名)。
- **内容**:最近 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 不再重试,直接登出。
- **重连补全**:无 resume cursor(`02/A5`)→ 重连后主会话用新连接的 `history` + REST 补差(`before = 本地最后一条 createdAt`),次级会话走 REST,全程按 `id` 去重。
- **事件路由**:带 `sessionId` 的按会话分发;**不带 `sessionId` 的**(`message.*` / `thinking.*` / `tool_call.*`)归属「当前 active turn」→ 因此**硬约束同一时刻只有一个会话在流式**(D14)。切会话时若正在流式:等结束或 `interrupt`。
- **流式段落状态机**:`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<ContentPart>`。权威规则在 `configs/personas/cyrene.yaml`。
- `<p>…</p>` 每段一个气泡;无 `<p>` 时按空行 `\n\n` 回退切分,单个 `\n` 不切。
- `<action>` / `<narration>` → 灰斜体旁白,依附气泡:首部连续块在上、尾部连续块在下、中间块上提到 head。
- 兼容漂移:全角 `*…*`(短于 40 字符且无换行 → action);半角 `*…*` **仅当以 动作/旁白/内心/神态/轻声 开头**才算旁白,否则保持普通文本。
- **流式增量解析**:delta 会把标签切半(`<act` + `ion>`)→ 带缓冲的增量解析器,保留尾部未完成片段,只在能确定边界时产出;未闭合 `<p>` 不显示直到闭合。行为对齐 Web 端 `parseContent.ts` + `chat.tsx`。
- **mode 回落**:后端不落库(`02/A3`)→ P0 全部按 `chat` 渲染。
- **thinking / tool_call**:仅 WS 实时有;历史思维链按需请求 REST `/messages`(D11)。工具卡把 `tool_call.delta`(参数 JSON)与 `tool_execution.update`(输出)分两块渲染。
- **图片**: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 富渲染** | `<p>` 拆气泡 + 旁白 + 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 的具体验证清单与机器。