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/ 收录设计文档、前后端协同事项与细化方案。
15 KiB
15 KiB
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)vsLocal(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)→ 文本 → WSchat(落库 + 流式 + 编排)→ 回复 →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. 待定项
- 消息历史快照是否改用
EncryptedFile(当前建议:私有目录 + 关备份,P0 够用)。 - 管理员页 P0 是否只占位(当前建议:是)。
CONTRACT.md是否现在就建(建议 M2 之前建)。- 语音 spike 的具体验证清单与机器。