Operit 主题编辑器草稿机制重构:基于目标作用域的原子保存、WebChat 绑定与路由离开守卫实战指南
Operit 主题编辑器草稿机制重构:基于目标作用域的原子保存、WebChat 绑定与路由离开守卫实战指南
导读
本文围绕 Operit(Android 平台上的 AI Agent 与 AI 聊天应用)中 Issue 782 的主题编辑器(Theme Editor)草稿机制展开,完整讲解一次以“角色目标(Character Card / Group)作用域”为核心的主题持久化重构:编辑器如何在不切换聊天历史的前提下激活任意角色的已保存主题、如何以编辑器会话(Editor Session)承载未保存的草稿、如何通过一次 DataStore 事务原子提交完整主题作用域,以及 WebChat 如何为每个请求的聊天解析其独立主题。读完本文,你将掌握 Operit 主题系统中“共享投影 + 目标前缀”的双层存储模型、草稿提交/重置的边界设计、路由离开守卫的统一接入方式,以及对应的静态验证与手动回归清单。
背景:主题编辑器为何需要草稿机制
重构前的行为缺陷
在 Issue 782 之前的主题编辑器实现中,存在三个相互关联的结构性问题(详见 issue_782_theme_editor_drafts/index.md):
- 控件直写共享投影:发布的主题编辑器把每一个控件都绑定到当前激活的聊天提示词(active chat prompt)上。控件修改会立即写入共享的 Android 主题投影(shared Android theme projection),然后再被单独复制到激活目标的前缀(active target prefix)下。这导致用户尚未主动点击“保存”时,单个控件的部分状态就已落盘,无法编辑非激活目标。
- WebChat 主题串线:WebChat 在收到任意聊天的主题请求时,仍然读取激活提示词的主题,因此非激活聊天可能收到另一个聊天的角色主题。
- 保存/重置语义粗糙:一次保存先写投影、再在第二个 DataStore 事务中复制;一次重置会删除目标作用域下的所有键,包括属于目标展示元数据的 AI 头像(AI avatar)与自定义聊天标题(custom chat title)。
重构目标
重构的意图非常明确:编辑器可以切换激活提示词与其已保存主题,但不切换聊天历史;目标作用域的修改保留在内存中;通过一次保存动作持久化完整目标状态。同时保持已发布的卡与组前缀键不变,保留全局显示身份(global display identity)与近期颜色(recent colors),并让 WebChat 解析请求聊天的绑定关系。
主题作用域契约:完整快照与原子替换
存储模型:共享投影 + 目标前缀
从源码看,主题数据被组织成“共享投影”与“目标前缀”两层,这一模型由 ActivePromptManager.kt 与 UserPreferencesManager.kt(同目录)共同维护。激活提示词通过 activePromptFlow 以 Flow 形式暴露:
val activePromptFlow: Flow<ActivePrompt> =
combine(
characterGroupCardManager.observeActiveCharacterGroupId(),
characterCardManager.observeActiveCharacterCardId()
) { groupId, cardId ->
when {
!groupId.isNullOrBlank() -> ActivePrompt.CharacterGroup(groupId)
!cardId.isNullOrBlank() -> ActivePrompt.CharacterCard(cardId)
else -> ActivePrompt.CharacterCard(CharacterCardManager.DEFAULT_CHARACTER_CARD_ID)
}
}.distinctUntilChanged()
而主题快照通过 activeThemePreferenceSnapshotFlow 按激活目标实时重算:当激活目标是角色组时读取 characterGroupId 前缀,是角色卡时读取 characterCardId 前缀,未匹配任何卡/组时回退到 DEFAULT_CHARACTER_CARD_ID(默认角色)。
契约变更:目标完整快照与原子替换
1_ThemeScopeContract.md 定义了本次重构的契约核心:
- 完整解析快照:针对某个特定卡或组目标,引入一张完整的、已解析的主题快照。快照始终读取该目标的前缀;当该前缀没有视觉值时,使用与 Android 主题流程相同的默认值。
- 原子替换操作:一次操作内完成三件事——清空目标视觉键集合、写入完整草稿、仅当保存的目标仍是激活目标时才更新共享投影。这样保存操作不会让共享投影与目标作用域失去同步。
- 元数据与视觉键分离:AI 头像与自定义聊天标题不属于视觉重置键集合。一次完整草稿保存会在同一个 DataStore 事务中写入这些目标元数据;而实体删除则保留独立的完整清理操作,删除某个角色卡或组时移除该目标的所有已存数据。
用一句话概括契约:“保存 = 视觉键集合的原子替换 + 元数据同事务提交;重置 = 仅清除视觉配置;删除 = 清空目标全部数据”。
编辑器目标草稿:屏幕拥有的编辑会话
从“每设置一个 Flow”到“一个会话”
重构前,主题屏幕从 ActivePromptManager.activePromptFlow 推导目标,每个标签页各自收集共享偏好 Flow,并通过 saveThemeSettingsWithCharacterCard 单独持久化控件变更;切换标签页会销毁本地输入状态,选择不同编辑目标则需要先改变激活聊天提示词(详见 2_EditorTargetDrafts.md)。
重构后,编辑器改为屏幕拥有的编辑会话(screen-owned editor session),由所选目标快照支撑。从 ThemeSettingsContentEditor.kt 的源码结构可以看到,会话通过 ThemeSettingsShared 与 ThemeEditorSession 组织,主题各分区读取一个 values StateFlow 并同步更新;近期颜色委托给全局存储,角色绑定的主题键在屏幕提交草稿之前不会被写入。
紧凑目标选择器与激活语义
编辑器在标签页上方新增一个紧凑选择器(compact selector),列出默认角色、非默认角色卡与角色组:
- 接受选择即激活角色并投影其已保存主题,但不会切换或创建聊天历史——这一语义由
ActivePromptManager.setActivePrompt实现。从 ActivePromptManager.kt 可见,切换角色组会setActiveCharacterGroupCard并clearActiveCharacterCard,切换角色卡则相反,整个过程通过ThemeTargetOperationCoordinator的runTransition串行化,杜绝并发竞态。 - 脏草稿守卫:当当前草稿为脏(dirty)状态时,切换目标会先询问用户保存、放弃或取消。
- 保存时机与目标捕获:保存动作在异步工作开始前先捕获源目标(source target),避免异步期间目标被外部改变导致写入错误对象。
- 重置语义:底部操作栏拥有唯一的保存与重置动作;重置先变成“草稿重置”,直到用户保存后才真正生效。
- 选择器资源生命周期:文件选择器的结果在启动外部 Activity 前保留其源草稿;暂存文件在草稿保存期间保持可用,草稿被放弃或失败时删除。
目标切换与并发激活的确定性
setActivePrompt 内部通过 themeOperations.runTransition 包裹,保证并发激活请求被串行化;mutateActiveThemeForPrompt 还会在执行变换前校验 getActivePrompt() != target,一旦激活目标在等待期间被外部改变,旧目标的写入会被安全跳过,防止后台失败篡改前一个目标的状态。这正是“目标捕获 + 串行过渡”在源码层的落地。
WebChat 主题绑定:按请求聊天解析
修复前的串线问题
3_WebChatThemeBinding.md 指出:GET /chats/{id}/theme 只确认聊天存在,却从激活提示词解析主题;同时 WebChat 的若干字段直接读取共享的 Android Flow,导致这些字段可能与返回的快照来自不同目标。
修复后的解析规则
修复后,主题解析严格跟随请求聊天的元数据:
- 聊天元数据中若存在角色组 ID(group ID),则组 ID 优先;
- 否则使用存储的角色卡绑定(character-card binding)定位角色卡;
- 基于该目标构建完整主题快照,WebChat 的所有字段统一从同一张快照映射;
- 请求聊天的结构化渲染(structured rendering)也使用同一解析快照。
相关实现位于 WebChatHttpBridge.kt 与同目录的 WebChatModels.kt,其中 ActivePromptManager.activateForChatBinding 提供了按角色卡名/组 ID 解析并激活目标的基础能力(未命中时回退到默认角色卡)。
兼容性与已知数据约束
现有 JSON 字段保持可用,新增目标元数据仅在 Web 客户端需要处追加(additive)。已知约束:存量聊天记录以角色卡名称标识卡,当存在重名角色卡时,在聊天 schema 迁移引入稳定卡 ID 之前无法唯一确定卡主题。
修复后的预期效果:两个 WebChat 标签页分别请求不同角色卡/角色组的聊天主题时,各自收到自己的解析视觉设置,包括气泡玻璃效果(bubble glass)与字体启用状态(font enablement)。
路由离开守卫:所有路由出口统一拦截
修复前:只有返回键有守卫
4_RouteLeaveGuard.md 指出:此前路由守卫注册表只在返回导航(back navigation)时被调用;抽屉选择、快捷方式、外部路由请求与路由器网关调用都会绕过屏幕的未保存状态处理器,直接改变路由。
修复后:统一挂起的路由过渡闸门
修复方案是在 OperitApp 中为所有 AppRouterState.navigate、resetTo 与 pop 入口引入一个挂起的路由过渡闸门(suspended route-transition gate):
- 闸门捕获当前路由实例,等待其注册的处理器(即主题编辑器的保存/放弃/取消对话框),仅当该实例仍处于激活状态时才应用过渡——这保证了等待期间用户又导航到别处时,旧实例的守卫不会错误放行。
- 外部 Intent 保持现有 Compose 树存活,使已注册的路由守卫在请求期间不丢失。
- 闸门在 Compose 协程作用域内执行,从而串行化 UI、网关与外部路由请求;当守卫处于挂起状态时,最新到达的外部目标请求会被保留。
预期结果:无论用户通过返回键、抽屉项、快捷方式还是外部路由请求离开主题编辑器,脏草稿的保存/放弃/取消对话框都会一致地出现。
紧凑目标切换与清理:删除遗留的双重身份卡片
6_CompactTargetSwitchAndCleanup.md 记录了本次重构的 UI 收敛与 API 清理:
- 移除双重身份卡片:此前目标选择器与“基础”标签页都渲染所选角色身份,且选择器只改变编辑器本地状态,要应用该目标已保存的主题仍需在聊天对话框中重复选择同一角色。重构后,选择器与基础页身份卡合并为标签页上方的一个紧凑目标卡片。
- 激活提示词成为权威编辑目标:接受目标选择时调用
ActivePromptManager.setActivePrompt,激活角色并投影其已保存主题,不触发聊天历史自动切换行为。 - 脏草稿确认顺序:当草稿为脏时,保存、放弃、取消的顺序在目标激活之前强制执行,保证确认动作先于任何请求的激活完成。
- 移除管理器形状的草稿门面:用包含 values、baseline、dirty/reset 状态与暂存资产的单一编辑会话替换仿照持久化偏好管理器、按设置拆分的 Flow 与“大可选参数”保存方法。
- 删除过时 API:立即保存(immediate-save)、当前投影复制(current-projection copy)与仅声明包装器(declaration-only wrapper)三类 API 被删除,且静态检查确认仓库中已无源码引用。
兼容性边界
本次重构保留:已发布的卡/组主题前缀、持久化主题键名、遗留默认主题迁移、遗留纵向重复值(vertical repeat values)、WebChat 响应字段与既有聊天绑定解析。由于该精化版本尚未发布(refinement has not shipped),其内部草稿 API 无需兼容层。
验证清单:静态检查与手动场景
静态检查要点
5_Verification.md 给出了重构的验证边界,可在当前仓库中对照源码逐项核验:
- 逐一检查主题编辑器所有写路径,确认修改的是编辑器会话而非持久化管理器;
- 检查保存与重置路径,确认在异步工作前只捕获一个目标;
- 检查实体清理,区分目标删除与视觉主题重置;
- 检查 WebChat 主题与结构化渲染,确认从请求的聊天解析;
- 检查每个路由变更都经过路由离开守卫。
已完成并通过的静态检查包括:主题控件统一读取一个编辑会话 values Flow 并直接更新内存草稿;目标捕获、暂存资源清理、目标元数据提交与视觉重置边界;激活提示词在待确认期间被外部改变时的目标/会话配对;并发激活请求串行化且过期后台失败无法篡改先前目标;重置提交时保留 AI 头像与自定义聊天标题元数据;WebChat 在构建主题与结构化渲染响应前解析请求聊天;OperitApp 中所有路由变更进入共享离开闸门;以及 git diff --check 通过。
手动回归场景(六步)
- 选择另一张角色卡,确认其角色与已保存主题被激活,同时当前聊天 ID 保持不变;
- 编辑一个角色组,选择另一目标后放弃,确认该组已存主题未被改变;
- 重置一张卡的视觉主题,确认其 AI 头像与自定义聊天标题完整保留;
- 分别请求两个 WebChat 主题,确认每个响应都携带各自匹配的目标来源与玻璃设置;
- 编辑目标后分别通过抽屉、快捷方式与返回键离开,确认每次路由变更都等待同一个对话框;
- 编辑目标后选择另一目标,验证保存、放弃、取消各自在任何请求的激活之前完成。
未执行项与已知约束
按文档记录,本次未运行 Gradle、lint、单元测试与构建(未显式请求验证命令),xmllint 不可用导致未运行独立 XML 解析。存量聊天绑定存储角色卡名称,重名卡在聊天 schema 迁移引入稳定卡 ID 之前无法唯一识别卡主题——这是已知的数据层约束,不应在无迁移的情况下宣称已解决。
关键源码索引
| 关注点 | 源码位置 |
|---|---|
| 激活提示词、主题快照、目标切换与草稿提交 | ActivePromptManager.kt |
| 主题编辑器屏幕、编辑会话与紧凑目标卡片 | ThemeSettingsContentEditor.kt |
| WebChat 主题/结构化渲染解析 | WebChatHttpBridge.kt、WebChatModels.kt |
| 偏好持久化与主题快照底层 | UserPreferencesManager.kt |
| 路由导航与离开守卫入口 | app/src/main/java 下 OperitApp 及 AppRouterState(RegisterRouteBackGuard 声明于 ThemeSettingsContentEditor.kt 同包导航工具中) |
总结
Issue 782 的这次重构把 Operit 的主题编辑器从“共享投影直写 + 目标复制”的脆弱模型,迁移到“目标完整快照 + 单次原子替换”的可靠模型:编辑会话让角色绑定修改在保存前完全停留在内存;紧凑目标卡片让角色/主题激活即时生效而不动聊天历史;路由离开闸门让脏草稿确认在所有导航出口保持一致;WebChat 则按请求聊天解析独立主题。配合 index.md 及其六个子文档(主题作用域契约、编辑器目标草稿、WebChat 主题绑定、路由离开守卫、验证、紧凑目标切换与清理)与本仓库源码,你可以完整复现这一设计,并将“作用域原子化 + 会话化草稿 + 统一离开守卫”的通用模式迁移到其他多目标设置界面中。