主人 2026-10-04 指示「项目仓库也可以写进去」。因此把 devdocs/ 从 .gitignore 移除并首次入库: - `05-开发进度与交接.md` —— 跨会话交接锚点:当前状态、易忘的实现约定、踩过的坑、实机验收方法 - `02-前后端协同事项.md` —— 与后端会话的**交换文档**(两边都会读写:我们登记缺口,对面写回执) - `01/03/04/06/07` + `DESIGN.md` —— 路线、客户端细化方案、构建环境、迁移说明、给后端会话的交接 **凭据扫描已过**:无账号口令、无 token/refreshToken 明文、无 API key 或私钥。 文档里只记「登录凭据不写进文档,问主人」这条口径本身。 (此前忽略它的理由已不成立:文档现在承担跨会话交接与前后端协同两件正事, 只留在本机磁盘会随机器丢失,而这批内容的价值恰恰在「后来人读得到」。)
198 lines
17 KiB
Markdown
198 lines
17 KiB
Markdown
# 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<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 不再重试,直接登出。
|
||
- **重连补全**:主会话用新连接的 `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<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` 的 `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 富渲染** | `<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 的具体验证清单与机器。
|