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

15 KiB
Raw Blame History

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 的具体验证清单与机器。