设置页那个开关此前没有任何发送路径读它: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)也随之失效,三处一并补记更正。
17 KiB
17 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 不重置。⚠️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是两个命名空间。 - 打断模式:
后端⚠️ 补记(W25,2026-10-04):interrupt会清空 pending(02/A2)→ 该模式下 UI 限制一次只发一条并明确提示。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)→ 文本 → 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 的具体验证清单与机器。