Operit 主题编辑器紧凑目标切换与草稿清理重构指南
Operit 主题编辑器紧凑目标切换与草稿清理重构指南
导读
本文基于 Operit(一款 Android 上能力强大、发展时间较长的 AI Agent 与 AI 聊天软件)仓库中 docs/TODO/issue_782_theme_editor_drafts/6_CompactTargetSwitchAndCleanup.md 的技术方案,深入讲解主题编辑器(Theme Editor)的一次关键重构:用单个紧凑目标卡片取代旧的目标选择器与 Basic 标签页身份卡片,让“活动提示词(Active Prompt)”成为权威编辑目标,并将散落的持久化管理器式草稿门面统一收敛为单一编辑器会话(Editor Session)。读完本文,你将掌握紧凑目标切换的 UI 设计、ActivePromptManager.setActivePrompt 的激活语义、草稿保存/丢弃/取消的确定性顺序,以及如何在不破坏已发布兼容边界的前提下清理被取代的旧 API。
该方案与 issue_782 主题编辑器草稿系列的其他文档紧密衔接,属于整个主题持久化重构的最后一块拼图。仓库中的实现证据主要位于 ThemeSettingsContentEditor.kt、ThemeEditorSession.kt 与 ActivePromptManager.kt。
一、重构前的行为与痛点
1.1 双重身份渲染
在重构之前,主题编辑器存在两处同时渲染“当前选中角色身份”的入口:
- 目标选择器(Target Selector):位于界面顶部,用于切换编辑目标;
- Basic 标签页内的身份卡片:在 Basic 页中再次展示同一角色的身份信息。
这两者冗余展示同一份信息,但选择器只改变编辑器本地状态——这意味着即使用户在选择器中选中了某个角色,想要应用该角色已保存的主题,仍然必须回到聊天对话框中再选择一次相同的角色,操作链路被割裂。
1.2 持久化管理器形状的草稿门面
旧实现的草稿机制镜像(mirror)了持久化偏好管理器(persistent preference manager)的结构:为每个设置项(per-setting)单独设计数据流,并暴露一个带有大量可选参数的保存方法(large optional-parameter save method)。这种“管理器形状”的草稿门面带来两个直接问题:
- 控件每次变化都走独立的保存流程,容易在用户主动点击保存之前就把部分状态写入持久层,破坏“整份草稿一次提交”的语义;
- 编辑非活动目标(inactive target)几乎不可能——因为界面绑定的是活动提示词的主题投影,想编辑其他角色必须先把活动提示词切过去,连带切换聊天历史。
这两个痛点正是 issue_782 系列文档(1_ThemeScopeContract.md、2_EditorTargetDrafts.md)反复强调的根因,本方案的“紧凑目标切换 + 编辑器会话”正是针对它们的收尾修复。
二、核心变更:一张紧凑目标卡片接管目标切换
2.1 界面层:卡片替换双重身份渲染
变更的第一步是删除目标选择器与 Basic 标签页中重复的身份卡片,在标签页上方放置一个紧凑目标卡片(compact target card)。仓库源码中的 ThemeSettingsTargetSelector 正是该卡片的实现(见 ThemeSettingsContentEditor.kt):
- 卡片主体为一整行可点击的
Card,内部依次排列:44dp 圆形角色头像(无头像时显示Person/Groups图标占位)、目标名称与目标类型小字(character / group)、右侧下拉箭头; - 点击卡片展开
DropdownMenu,菜单项依次为:默认角色卡片(CharacterCardManager.DEFAULT_CHARACTER_CARD_ID)、非默认角色卡片(filter { it.id != DEFAULT_CHARACTER_CARD_ID }),以及分组卡片(以HorizontalDivider分隔); - 每个菜单项都带有一个小头像(
ThemeSettingsTargetMenuAvatar),当前选中的目标以Check图标标记。
卡片在布局中的位置是 Column 的顶部,位于标签切换(tab)之前:
Column {
ThemeSettingsTargetSelector(...) // 紧凑目标卡片
ThemeSettingsTabbedContent(...) // Basic / Background / Chat / Input / Interface 标签
}
enabled 状态由 editorState != null && pendingAction == null && !isSaving && targetSwitchesInFlight == 0 共同决定,保证目标切换只能在草稿会话就绪、无待处理动作、无保存进行中时可用。
2.2 语义层:活动提示词成为权威编辑目标
界面紧凑化的同时,数据语义也发生根本变化:活动提示词(active prompt)成为权威的编辑器目标。用户在紧凑卡片上接受(accept)一个目标选择时,编辑器的处理逻辑不再是只改本地状态,而是调用:
activePromptManager.setActivePrompt(target)
setActivePrompt 是 ActivePromptManager.kt 中的核心激活入口,其实现为:
suspend fun setActivePrompt(prompt: ActivePrompt) {
themeOperations.runTransition {
when (prompt) {
is ActivePrompt.CharacterGroup -> {
characterGroupCardManager.setActiveCharacterGroupCard(prompt.id)
characterCardManager.clearActiveCharacterCard()
}
is ActivePrompt.CharacterCard -> {
characterCardManager.setActiveCharacterCard(prompt.id)
characterGroupCardManager.setActiveCharacterGroupCard(null)
}
}
}
}
要点在于:
- 目标激活通过
ThemeTargetOperationCoordinator的runTransition串行化执行,避免并发切换竞态; - 激活角色/分组后,
activePromptFlow(由observeActiveCharacterGroupId()与observeActiveCharacterCardId()组合而来)会立即发出新值,主题偏好快照流activeThemePreferenceSnapshotFlow随之 flatMapLatest 投影出该目标的已保存主题——这就是“接受选择即激活角色并投影其已保存主题”的底层原理; - 关键差异:此处的激活“不调用聊天历史自动切换行为(chat-history auto-switch)”。从源码结构看,
setActivePrompt只操作CharacterCardManager/CharacterGroupCardManager的活动 ID,聊天历史切换是另一套由activateForChatBinding等入口触发的流程,二者在职责上被明确切开。
因此用户可以做到:在主题编辑器中选中默认角色、任意角色卡片或任意分组,立刻看到该目标保存的主题被应用,但当前聊天历史保持不变——这正是预期结果中的“role/theme activation is immediate, chat history is unchanged”。
2.3 激活失败与进行中保护
仓库源码对激活过程做了额外保护(activateTarget 函数):
- 用
targetSwitchesInFlight计数标记“激活进行中”,期间选择器不可交互; - 激活抛异常时记录
AppLogger.e("ThemeSettings", "Failed to activate theme target", e)并弹出theme_target_switch_failed的长 Toast,随后editorReloadToken += 1触发会话重建; - 若选中目标恰好就是当前活动目标且无进行中切换,则直接重建编辑器会话(
editorState = null; editorReloadToken += 1),相当于“重新加载当前目标快照”。
三、编辑器会话:取代管理器形状的草稿门面
3.1 会话的组成
变更的第三条要求是:用“一个编辑器会话”取代“manager 形状的草稿门面”。该会话包含四类状态:
| 状态 | 说明 | 源码依据 |
|---|---|---|
| values | 草稿值,MutableStateFlow<ThemePreferenceValues> 承载,控件直接读写 |
ThemeEditorSession.kt |
| baseline | 基线值(会话建立时从持久层解析的快照),用于判定脏状态 | 同文件 baselineValues 字段 |
| dirty/reset state | 通过 _hasUnsavedChanges StateFlow 与 resetRequested 标志表达 |
同文件 L17、L19 |
| staged assets | 暂存的外部资源 URI(如头像、背景图),随会话生命周期管理 | 同文件 stagedAssetUris 字段 |
会话的关键能力一览:
- 同步更新:
update(transform)直接对currentValues做不可变变换并写回_values,值未变化则直接返回;setString/setOptionalString/setBoolean/setInt/setFloat都是同步的内存操作; - 互斥玻璃效果:
setBoolean内部处理了互斥键——例如开启chat_input_liquid_glass会自动关闭chat_input_water_glass,开启bubble_user_bubble_liquid_glass会同时关闭bubble_user_bubble_water_glass与bubble_user_use_image,从草稿层就杜绝了冲突配置; - 脏状态计算:
hasUnsavedChanges = resetRequested || currentValues != baselineValues,即“用户点了重置”或“当前值偏离基线”任一为真即为脏; - 重置语义:
reset()将值替换为ThemePreferenceValues.defaultVisual(),但保留custom_ai_avatar_uri与custom_chat_title两个目标元数据字段,并置resetRequested = true——重置只清视觉主题,不清角色 AI 头像与自定义聊天标题; - 丢弃:
discard()删除全部暂存资源并回退到baselineValues; - 暂存资源清理:
update时deleteUnreferencedStagedAssets(updated)删除不再被引用的资源;dispose()时若草稿未保存或保存失败则删除暂存文件(对应原文档“Staged files stay available while their draft is saving, then are deleted when an unsaved or failed draft is disposed”)。
3.2 控件直接绑定草稿值
旧实现中“控件写共享 Android 主题投影,再复制到活动目标前缀”的双写路径被彻底移除。现在标签页中的所有控件通过 ThemeSettingsShared 拿到同一个 ThemeEditorSession:
val shared = ThemeSettingsShared(
context = context,
editorSession = draft,
displayPreferencesManager = displayPreferencesManager,
scope = scope,
)
key(draft) {
ThemeSettingsTabbedContent(
basicContent = { ThemeSettingsBasicTab(shared = shared, ...) },
backgroundContent = { ThemeSettingsBackgroundTab(shared = shared, ...) },
...
)
}
key(draft) 保证外部选择器回调(如相册选图)即使在用户切换目标之后,仍持有并回写原来的那个草稿会话——这正是“Picker results retain their source draft before launching external activities”的落地方式。控件读取 draft.values(一个 StateFlow),调用 draft.setXxx 同步更新内存草稿,直到用户显式保存,任何角色绑定的主题键都不会触碰持久层。
四、脏草稿的确定性顺序:保存 / 丢弃 / 取消
4.1 三类待处理动作
重构后,目标激活必须排在“草稿是否被处理”之后。源码用密封接口 ThemeEditorPendingAction 表达三类待处理动作:
private sealed interface ThemeEditorPendingAction {
data class SelectTarget(val target: ActivePrompt) : ThemeEditorPendingAction
data object ActiveTargetChanged : ThemeEditorPendingAction
data object LeaveScreen : ThemeEditorPendingAction
}
SelectTarget:用户在紧凑卡片上选了新目标,而当前草稿为脏——弹窗询问,确认后才activateTarget(action.target);ActiveTargetChanged:活动提示词在外部(如其他入口)被改掉,而当前草稿为脏——弹窗询问后,选择“保存”则editorState = null并重建会话加载新目标,选择“放弃”则把会话重新激活回原目标;LeaveScreen:离开本页面前有脏草稿——由路由离开守卫触发。
4.2 保存、丢弃、取消的先后保证
弹窗(AlertDialog)提供三个按钮,对应三种确定性结局:
| 按钮 | 行为 | 触发代码 |
|---|---|---|
| 保存并继续 | 先 saveCurrentDraft() 提交当前目标的完整草稿,成功后再执行待处理动作 |
TextButton(onClick = ::saveCurrentDraft) |
| 放弃并继续 | 先 editorState?.session?.discard() 丢弃草稿,再放行导航/切换 |
draft.discard(); finishPendingAction(allowNavigation = true) |
| 取消 | finishPendingAction(allowNavigation = false),保持原目标与草稿不变 |
TextButton(onClick = { finishPendingAction(false) }) |
finishPendingAction 内部按动作类型统一收口,任何路径下动作完成之后才允许 activateTarget,即“save, discard, and cancel ordering before target activation”被严格执行。
4.3 保存前的目标捕获
saveCurrentDraft() 还有一个容易被忽略但至关重要的细节:异步工作开始前先捕获源目标:
val target = state.target // 先捕获
val savedValues = draft.currentValues
val resetRequested = draft.isResetRequested
draft.beginSave(savedValues) // 标记保存中
scope.launch { // 异步提交
if (resetRequested) {
activePromptManager.resetThemeDraft(target, savedValues)
} else {
activePromptManager.commitThemeDraft(target, savedValues)
}
...
}
因为在协程真正执行 commitThemeDraft / resetThemeDraft 之前,用户可能又切换了目标或外部激活变化;提前捕获 target 与 savedValues 可以保证写入的始终是保存那一刻的目标与其完整值,陈旧的后台失败(stale background failure)不可能回头去改写当前目标。commitThemeDraft / resetThemeDraft 内部同样包裹在 themeOperations.runTransition 中并校验 getActivePrompt() != target 时跳过(见 mutateActiveThemeForPrompt 的守卫逻辑),形成双层保护。
4.4 路由离开守卫
本方案还要求所有离开路径——返回导航、抽屉项、快捷方式、外部路由请求——都经过同一个挂起的路由转换门(route-transition gate)。系列文档 4_RouteLeaveGuard.md 对此有专门说明:OperitApp 中所有 AppRouterState.navigate / resetTo / pop 入口统一经过该门,先捕获当前路由实例、等待其注册的守卫处理器,仅当同一实例仍存活时才应用转换。主题编辑器侧通过 RegisterRouteBackGuard 注册自己的守卫:有脏草稿且无待处理动作时,挂起一个 suspendCancellableCoroutine<Boolean>,把 LeaveScreen 动作与 continuation 记录下来,由弹窗的保存/丢弃/取消按钮决定是否放行导航。
五、清理被取代的 API
变更的最后一条是删除三组被取代的旧实现:
- 即时保存(immediate-save):控件一变化就写持久层的旧流程,被“会话 + 显式保存”取代;
- 当前投影复制(current-projection copy):先写共享 Android 主题投影、再复制进目标前缀的双事务路径,被 1_ThemeScopeContract.md 引入的“完整快照原子替换”取代;
- 仅声明包装 API(declaration-only wrapper APIs):只声明而无实际职责、纯粹镜像持久化管理器形状的旧编辑器 API。
源码层面,旧的管理器形状保存方法(如带大量可选参数的 saveThemeSettingsWithCharacterCard 一类)在主题编辑器路径中已无引用;验证清单中明确包含“确认被移除的 manager 形状编辑器 API、重复身份卡片资源、即时保存包装器在源码中已无引用”,并成功通过 git diff --check。
六、兼容性边界:改了内部,不碰已发布面
虽然这是一次内部结构性重构,但对外已发布的行为必须保持稳定。方案明确列出的兼容边界包括:
- 已发布的卡片与分组主题前缀(card and group theme prefixes) 键名不变;
- 持久化主题键名(persisted theme key names) 不变;
- 旧默认主题迁移(legacy default-theme migration) 逻辑保留;
- 旧垂直重复值(legacy vertical repeat values) 继续被接受;
- WebChat 响应字段保持既有 JSON 字段可用,新目标元数据仅在 Web 客户端需要时以追加方式提供;
- 现有聊天绑定解析继续按“分组 ID 优先、否则按存储的角色卡片名解析”工作(详见 3_WebChatThemeBinding.md)。
与此同时,文档明确声明:该改进尚未发布(has not shipped),因此其内部的草稿 API 不需要任何兼容层——这是“内部大改、外部零破坏”得以成立的先决条件。一个已知数据约束也需要读者注意:现有聊天记录以角色名标识卡片绑定,若存在重名角色,在聊天 schema 引入稳定的卡片 ID 之前无法唯一定位主题。
七、预期结果与验证
7.1 预期结果
重构完成后,主题编辑器的行为收敛为:
- 屏幕只使用一个紧凑角色选择器,不再有 Basic 页的重复身份卡片;
- 角色/主题激活立即生效(选择即投影已保存主题);
- 聊天历史保持不变(激活与聊天历史自动切换解耦);
- 草稿确认保持确定性(脏草稿下,保存/丢弃/取消严格先于目标激活执行);
- 不再存在旧的持久化管理器形状编辑器路径。
7.2 验证要点
5_Verification.md 提供的静态检查与手动场景覆盖了本次变更的核心风险点,其中与本文主题直接相关的包括:
- 检查每个主题编辑器写入路径都作用于编辑器会话而非持久化管理器;
- 检查保存/重置路径在异步工作前捕获单一目标;
- 手动验证:选择另一张角色卡后确认其角色与已保存主题激活而当前聊天 ID 不变;
- 手动验证:编辑分组后切换目标再丢弃,确认该分组存储的主题未被改动;
- 手动验证:编辑目标后切换目标,确认保存、丢弃、取消各自在请求的激活发生前完成;
- 静态验证:目标/会话配对在外部激活变化时仍正确,并发激活请求保持串行化,陈旧后台失败不会改写先前目标。
注意:按文档记录,本次重构虽已通过远端 Nightly 构建,但本地 Gradle、lint、单元测试与构建默认不运行,仅在显式请求时才执行——读者在复现验证时应自行按需触发对应检查。
八、小结
6_CompactTargetSwitchAndCleanup.md 描述的重构,本质上是把主题编辑器从“围绕活动提示词投影、分散持久化”的旧模型,迁移到“一个紧凑选择器 + 一个编辑器会话 + 一次显式保存”的新模型。它同时解决了三个长期痛点:非活动目标无法编辑、控件过早写盘、离开路径不询问脏草稿。通过 ThemeSettingsContentEditor.kt 中的目标卡片与待处理动作编排、ThemeEditorSession.kt 中的会话状态机,以及 ActivePromptManager.kt 中的 setActivePrompt 激活语义,读者可以在源码层面完整复现这条“紧凑切换 + 确定性草稿提交 + 内部清理”的改造链路,并为后续的聊天绑定稳定化(引入稳定卡片 ID)预留出干净的扩展空间。