Files
Cyrene-For-Android/devdocs/03-客户端细化开发方案.md
T
AskaEth cfdc094c30 fix(chat): 设置项「她正忙时按发送」不再空转 —— 发送前按偏好决定是否先打断
设置页那个开关此前没有任何发送路径读它:interruptMode 全仓只出现在 AppSettings
(定义 / 读写)与 SettingsScreen(渲染文案),于是把它切成「打断」后行为与「追加队列」
一模一样,ChatSocket.sendInterrupt 更是零调用方(死 API)。这正是一直挂在文档
devdocs/02 协同项 A2 上的欠账(后端 v0.56 已支持 scope,客户端没接)。

现在按契约 v0.56 §3.1 接上,判据抽成与发送路由同类位置的纯函数
interruptScopeForSend(mode, busy: Boolean):String?

1. 追加队列(默认档)⇒ 恒定返回 null,一个 interrupt 帧都不发,线上请求与今天逐字节相同;
2. 打断(wireScope = "current")且会话正忙 ⇒ 这条消息之前先发 interrupt{scope:"current"}
   (只取消在跑回合、保留待处理队列,队列里的消息按编排器续跑,一条都不丢);
3. 打断但会话空闲 ⇒ 不发(命中空闲会话是 no-op,白发一条帧只多一次往返,还会在 logcat
   里制造「这里打断过」的假象)。

「正忙」只认服务端 session_busy 帧给的 busyMode != null,不用 queueDepth——队列深度
非零不等于有回合在跑(pending_ack / pending_drain 也会动它)。

接线点在 ChatViewModel.deliver() 的通道分支之前(app/src/main/kotlin/top/yeij/cyrene/
ui/chat/ChatViewModel.kt)。放在那里而不是 emitAndDeliver 开头是刻意的:上面每一段本端
拒绝(附件超限 / 图片处理失败 / 附件上传失败 / 引用不合法)都会「回滚、什么都不发」,
先打断她的回合再什么都不发是凭空的破坏。每次投递只经过那里一次,所以一次投递最多一条
打断帧,任何后续分支与重试都不会重复发。

偏好每次发送现读(不缓存、不订阅 Flow):「在设置页切成打断、然后立刻发的那一条」正是
本次要修的场景,缓存值要等 Flow 推到才更新,冷启动那一下必然没到——那就等于留了一个
静默不生效的窗口。读失败(IO / 损坏)回落默认档,也就是最少动作的那一档,与 AppSettings
把未知取值解成默认档是同一条纪律。

打断帧发不出去(只有「WS 未连接」一种原因)静默、不阻断本次发送:本次消息的成败与它
无关(REST 通道不看 WS 连接),且一直有既有的回执 / 回滚路径如实上报;反过来「打断失败」
没有对应得上的动作(重试等于把消息再发一遍,正是幂等键在防的事),喊一声只会误导。
于是打断没生效 = 行为退回默认档,是用户看得懂的状态,不是被藏起来的事。

测试:新增 InterruptScopeTest 8 条(两档 × 忙闲四格穷举 + 默认档零行为变化 + 偏好回落
方向),全仓 884 → 892。assembleDebug 与 testDebugUnitTest 全绿(0 失败 0 错误)。
设置页那两条文案原本就与 scope:current 的真实语义相符(「已排队的消息保留,继续依次
回答」),未改一个字。

文档:02 的 A2 状态表、05 的 A2 条目都写着「客户端尚未接入该字段」;03 里「打断模式 →
UI 限制一次只发一条」的前提(interrupt 会清空 pending)也随之失效,三处一并补记更正。
2026-10-04 22:29:57 +08:00

17 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 正向增量端点,客户端尚未接入。
  • 事件路由:结构化流帧(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 是两个命名空间。
  • 打断模式:后端 interrupt 会清空 pending(02/A2)→ 该模式下 UI 限制一次只发一条并明确提示。 ⚠️ 补记(W25,2026-10-04):02/A2 已由后端 v0.56 修好——scope:"current" = 只取消在跑回合、 保留待处理队列,客户端也已在发送路径按偏好发这个 scope(见 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 渲染。
  • 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 的具体验证清单与机器。