Files
Cyrene-For-Android/devdocs/03-客户端细化开发方案.md
T
AskaEth 805d8b530a refactor+docs(W27): 删掉只被测试养着的 AdminAccess.availablePages,让 ChatImageBudget 的公式只留一处,并就地更正 devdocs/03 四处过时表述
清理(零行为变化)
- 删 domain/admin/AdminAccess.kt 的 availablePages():全仓(含 app/src/test)调用点 0 处 ——
  main 里只有它自己的声明、测试里 2 处(AdminAccessTest 那 1 条用例一并删掉)。它早已被
  ui/admin/AdminHomeScreen.kt 的做法取代:遍历 AdminPage.entries + 每行 canAccess 决定是否禁用。
- ChatImageBudget.SERVER_MAX_CHARS 原来把 base64Chars 的 ((n+2)/3)*4 又内联重写了一遍,
  改为 base64Chars(SERVER_MAX_DECODED_BYTES) + PREFIX_ALLOWANCE_CHARS(一处公式、一处真相)。
  代价:该常量由 const val 变 val(const 不允许调用函数)。取值逐位不变 = 6990572,由
  ChatSendPolicyTest 的独立重算断言与 ChatDtoRealPayloadTest 对服务端真实文案的断言继续钉住。

文档(devdocs/03,一律删除线保留原文 + 更正括注,不动别人的回执段落)
- §4 重连补全:02/A5 的 after 正向增量已接入(loadAfter + ChatViewModel.catchUp,28c3142)。
- §4 忙碌/队列 UI 映射:客户端只发 scope:"current",该档 dropped 恒为 0、all 从不发,
  所以「已打断,丢弃 N 条」在今天的客户端里不可达。
- §5 mode 回落:mode 已落库(后端 b686d5f),MessageBubble 按 mode 分支。
- §4 打断模式:补上 W25 接线的依据 cfdc094 / feat/W25-interrupt-scope。

devdocs/05 追加 W27 小节;另记下 data/ws/WsEvent.kt:272 的同类过时 KDoc(不在本轮允许改动的
文件清单里,未动)。

验证:assembleDebug 通过;testDebugUnitTest 891 条 / 0 失败 / 0 错误(改动前 892,删掉 1 条)。
2026-10-04 22:35:20 +08:00

18 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 不重置。⚠️ 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 正向增量端点,客户端尚未接入。 ⚠️ 2026-10-04 更正:已接入——data/repository/MessageRepository.kt:92 的 loadAfter + ui/chat/ChatViewModel.kt:1303 的 catchUp(掉线记游标 / 重连正向追),落地 28c3142(2026-10-04 深夜)。
  • 事件路由:结构化流帧(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 在 W25 之前也一条帧都没发过——现已接上唯一调用方,见 05 的 A2 补记)。另注意:广播是发给每条连接、不按会话订阅,且每个 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 是两个命名空间。 ⚠️ 2026-10-04 更正:客户端只发 scope:"current"(core/prefs/AppSettings.kt:39、data/ws/WsOutbound.kt:63), 该档下 dropped 恒为 0(契约 v0.56 明文);dropped > 0 只会在 scope:"all"(清空 pending)时出现, 而客户端从不发 all ⇒ 上面那句「已打断,丢弃 N 条」在今天的客户端里不可达,留作契约面备忘即可。
  • 打断模式:后端 interrupt 会清空 pending(02/A2)→ 该模式下 UI 限制一次只发一条并明确提示。 ⚠️ 补记(W25,2026-10-04):02/A2 已由后端 v0.56 修好——scope:"current" = 只取消在跑回合、 保留待处理队列,客户端也已在发送路径按偏好发这个 scope(feat/W25-interrupt-scope,cfdc094; 见 05 的 A2 补记)⇒ 「清空 pending」 这个前提不再成立,上面那条「限制一次只发一条」不必做:队列里的消息一条都不会丢。

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 渲染。 ⚠️ 2026-10-04 更正:已落库(后端 b686d5f,见 02 的 A3 行与「回执五」补记第 5 条)——客户端按 mode 分支: ui/chat/MessageBubble.kt:110 的 val isChat = message.mode.isNullOrBlank() || message.mode == "chat" (只有 chat 才按 markup 解析 <p> 切气泡;其余 mode 走单块纯文本兜底,null / 空白同样回落 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 的具体验证清单与机器。