Operit 主题编辑器草稿验证指南:目标作用域、会话持久化与路由守卫的静态检查与手动回归
Operit 主题编辑器草稿验证指南:目标作用域、会话持久化与路由守卫的静态检查与手动回归
本文是 Operit(Android AI Agent / AI 聊天应用)中 Issue 782 主题编辑器草稿 系列的收官章节《Verification》的展开版本。文章围绕主题编辑器在"目标草稿(target draft)"改造完成后如何进行验证展开,覆盖静态检查清单、六组手动回归场景、已完成检查的结论,以及当前已知的数据约束(重复角色卡名无法唯一识别主题),并补充仓库源码级的实现证据与对应路径,帮助读者在阅读或复现该改造时建立"先静态、后手动、再确认边界"的完整验证思路。
验证对象:一段需要被证明"无副作用"的编辑器改造
在进入检查清单之前,需要先明确这段改造到底改了什么。Issue 782 的目标是让主题编辑器只修改编辑器会话(editor session)中的内存草稿,而不是直接改写持久化的角色/分组主题管理器,并让一次显式保存动作原子化地落盘。与之配套的还有三条行为约束:
- 切换编辑目标(默认角色、任意角色卡、任意分组)时,激活该角色及其已保存主题,但不切换、不创建聊天历史;
- WebChat 的主题请求必须从被请求的那个聊天解析目标,而不是从"当前激活 prompt"解析;
- 所有路由变更(返回键、抽屉、快捷方式、外部路由请求)都必须经过统一的"路由离开守卫",保证脏草稿得到一致的处理。
这些改造的源码证据集中体现在:
- ActivePromptManager.kt ——
setActivePrompt只切换激活角色,不触碰聊天历史; - ThemeEditorSession.kt —— 屏幕持有的编辑器会话(values / baseline / dirty / staged assets);
- ThemeSettingsContentEditor.kt —— 目标切换、保存、丢弃、取消与离开守卫的编排;
- UserPreferencesManager.kt ——
replaceThemeForPrompt/resetVisualThemeForPrompt单事务原子替换; - WebChatHttpBridge.kt —— 从聊天元数据解析主题快照;
- OperitApp.kt —— 统一的路由过渡门。
静态检查清单:从写路径到路由的六项审查
验证文档给出了六项静态检查,它们对应六个不同的"边界",每一条都需要在源码中逐一对号入座:
1. 检查每一个主题编辑器写路径,确保它只改变编辑器会话,而不是持久化管理器
对应证据:ThemeEditorSession.update / setString / setBoolean / setInt / setFloat 系列方法只更新内存中的 MutableStateFlow<ThemePreferenceValues>,并通过 deleteUnreferencedStagedAssets 同步清理不再被引用的暂存资源(ThemeEditorSession.kt)。会话直到 beginSave 被调用前,都不会触达 UserPreferencesManager;真正落盘只发生在 ThemeSettingsContentEditor.kt 的 saveCurrentDraft 中调用 commitThemeDraft / resetThemeDraft 之后。
2. 检查保存与重置路径,确保在异步工作开始前已捕获唯一目标
对应证据:saveCurrentDraft 的第一步就是 val target = state.target,随后才 draft.beginSave(savedValues) 并启动协程(ThemeSettingsContentEditor.kt)。而 ThemeEditorSession.markSaved 会更新 baselineValues、清空 inFlightSavedValues,保证保存成功后脏状态归零(ThemeEditorSession.kt)。
3. 检查实体清理,区分"目标删除"与"视觉主题重置"
对应证据:resetVisualThemeForPrompt 只调用 clearVisualThemeValues 清除目标前缀下的视觉键,随后 writeThemeTargetMetadata 仍会写入 AI 头像与自定义聊天标题(UserPreferencesManager.kt);而删除角色卡/分组走的是独立的 deleteThemeByPrefix,它会按类型移除全部 string / boolean / int / float 主题键(UserPreferencesManager.kt)。这正是文档所述"重置只改视觉配置、删除则移除该目标全部数据"的边界。
4. 检查 WebChat 主题与结构化渲染,确保从被请求的聊天解析
对应证据:resolveThemePreferenceSnapshot(chat: ChatHistory?) 优先使用聊天的 characterGroupId,否则用 characterCardName 查找角色卡,最终回退到默认角色卡(WebChatHttpBridge.kt);resolveStructuredRenderPreferences 同样通过该快照解析 showThinkingProcess 等渲染开关(WebChatHttpBridge.kt)。
5. 检查每个路由变更都经过路由离开守卫
对应证据:requestRouteTransition 在切换前先调用 routeBackGuardRegistry.canLeaveRoute(routeInstanceId),并校验 routerState.currentEntry.instanceId 仍是同一个实例后才执行 onAllowed()(OperitApp.kt)。抽屉、快捷方式、外部路由请求都统一走 requestRouteTransition,不再只是返回键。
6. 仅在显式请求时才运行格式化与构建验证
这是流程约束而非代码约束:Gradle、lint、单测、构建以及 xmllint 独立解析均不在默认验证范围内,需要明确请求才执行。
手动回归场景:六组可直接复现的验证步骤
验证文档定义了六组手动场景,它们是验证清单的"运行时版本",覆盖了目标激活、草稿隔离、重置边界、WebChat 隔离与路由守卫一致性。下面逐条展开并给出预期结果与代码依据。
场景 1:切换另一张角色卡,确认其角色与已保存主题被激活,而当前聊天 ID 不变
操作:在主题编辑器顶部的目标选择器中选中另一张角色卡。预期:setActivePrompt(ActivePrompt.CharacterCard(...)) 被调用,角色卡管理器写入新激活卡并清空激活分组(ActivePromptManager.kt),但聊天历史不切换。activateTarget 中对失败的兜底(Toast 提示与 editorReloadToken 刷新)也在 ThemeSettingsContentEditor.kt 中体现。
场景 2:编辑一个分组,切换到其他目标再丢弃,确认其已存主题不变
操作:进入某分组的主题编辑,改动若干项使其脏化,随后选择另一目标,在确认弹窗中选择"丢弃"。预期:ThemeEditorSession.discard() 删除暂存资源并把 _values 恢复为 baselineValues(ThemeEditorSession.kt),持久化层从未被写入,原分组主题保持不变。
场景 3:重置一张角色卡的主题,确认其 AI 头像与自定义聊天标题保留
操作:对某角色卡执行"重置"。预期:resetThemeDraft → resetVisualThemeForPrompt 只清除视觉键集合,writeThemeTargetMetadata 仍把 custom_ai_avatar_uri 与 custom_chat_title 写入同一 DataStore 事务(UserPreferencesManager.kt)。会话侧的 reset() 同样在 defaultVisual() 基础上保留这两个元数据键(ThemeEditorSession.kt)。
场景 4:请求两个不同 WebChat 主题,确认每次响应都携带各自匹配的目标来源与玻璃设置
操作:在 WebChat 中打开两个分别绑定到不同角色/分组的聊天,分别请求 GET /chats/{id}/theme。预期:每个响应都来自 resolveThemePreferenceSnapshot(当前聊天的 ChatHistory) 解析出的快照,气泡玻璃(liquid/water glass)与字体开关等字段都映射自同一个快照,互不串扰。会话内 setBoolean 中 liquid/water glass 互斥的规则(ThemeEditorSession.kt)保证单目标内部也不会出现两种玻璃同时开启。
场景 5:编辑一个目标后分别通过抽屉、快捷方式、返回键离开,确认每种路由变更都等待同一个对话框
操作:使草稿脏化后,分别尝试(a)抽屉导航、(b)快捷方式导航、(c)返回键离开。预期:三条路径最终都汇入 requestRouteTransition → canLeaveRoute → 编辑器注册的 RegisterRouteBackGuard,弹出"保存 / 丢弃 / 取消"对话框;用户选择结果通过 exitContinuation.resume(allowNavigation) 回传给路由门(ThemeSettingsContentEditor.kt)。队列机制保证路由请求串行化,最新一次外部目标请求在守卫等待期间被保留(OperitApp.kt)。
场景 6:编辑一个目标后选择另一个目标,验证保存、丢弃、取消各自在目标激活之前完成
操作:脏草稿下切换目标。预期:pendingAction = ThemeEditorPendingAction.SelectTarget(target) 被挂起,finishPendingAction(allowNavigation) 只有用户确认后才调用 activateTarget;若取消,则保持当前编辑器目标并重新加载(ThemeSettingsContentEditor.kt)。同时 targetSwitchesInFlight 计数保证并发激活请求被串行化,避免旧目标在切换中写入新目标。
已完成静态检查的结论汇总
验证文档记录了改造后已完成的静态检查结果,均与上文源码证据一一对应:
- 主题控件只读取一个编辑器会话的 values 流,并直接同步更新内存草稿;
- 目标捕获、暂存资源清理、目标元数据提交与视觉重置的边界均已核实;
- 当外部 prompt 在待确认期间变化时,目标/会话配对仍保持正确(对应
ActiveTargetChanged分支); - 并发激活请求保持串行化,过期后台失败不会改写前一个目标(
mutateActiveThemeForPrompt只在getActivePrompt() == target时才写); - 重置提交保留 AI 头像与自定义聊天标题元数据;
- WebChat 在构建主题与结构化渲染响应前解析被请求的聊天;
OperitApp中的路由变更都进入统一离开守卫;- 已移除的管理器形态编辑器 API、重复的 identity 卡片资源与即时保存包装器在源码中已无任何引用;
git diff --check通过,无空白/补丁格式问题。
未执行的验证项与适用前提
- Gradle、lint、单元测试与构建未运行:依据验证约定,未显式请求验证命令时一概不执行。需要复现构建验证时,应显式运行对应 Gradle 任务(如
./gradlew lint与测试任务),并确认环境已按仓库 README.md 的要求配置好 Android SDK 与本地属性(参考 local.properties.example)。 - 独立 XML 解析未运行:环境缺少
xmllint,因此不执行独立的 XML 校验,相关资源合法性由 lint 阶段(显式请求时)兜底。
已知数据约束:重复角色卡名的歧义边界
验证文档明确记录了一条当前版本必须接受的约束:
现有聊天绑定存储的是角色卡名称(character-card names)。重复的名称无法唯一识别一张卡的主题,直到专门的聊天 schema 迁移引入稳定的卡 ID(card ID)为止。
在源码中的对应表现是:resolveThemePreferenceSnapshot 通过 characterCardManager.findCharacterCardByName(cardName) 按名称反查角色卡(WebChatHttpBridge.kt),因此在存在重名卡的聊天上,主题解析可能命中第一个匹配项。这一约束同样适用于"角色/分组前缀键"的读取方:UserPreferencesManager 的主题快照均以目标前缀为键(themePrefixForPrompt),前缀本身由稳定的卡 ID / 分组 ID 派生,名称歧义只影响"从聊天反查卡"这一环节。验证与测试中应把"重名卡"场景标记为已知限制,而不是缺陷。
验证建议:把静态检查固化为回归测试
从仓库的测试布局看,Android 端测试分布在 app/src/test/java 与 app/src/androidTest/java(均为 Kotlin),工具包测试另有 tools/test 目录。针对本改造,建议在后续显式构建验证时覆盖以下断言:
replaceThemeForPrompt与resetVisualThemeForPrompt单事务语义(先清除视觉键、再写完整草稿/元数据,见 UserPreferencesManager.kt);ThemeEditorSession的 dirty 判定:resetRequested || currentValues != baselineValues(ThemeEditorSession.kt);- 路由门
canLeaveRoute在同一路由实例上等待守卫结果的语义(OperitApp.kt)。
小结
Issue 782 主题编辑器草稿的验证工作可以归纳为一条主线:所有写操作先进内存草稿,保存/重置/丢弃三个出口分别走单事务落盘或纯内存回滚,所有离开路径统一经过路由守卫。静态检查证明代码结构符合这一契约,六组手动场景则从用户视角确认了目标激活不换聊天、草稿隔离、重置保元数据、WebChat 按聊天解析与守卫一致性五个核心行为。唯一需要长期跟进的是重名角色卡的主题解析歧义,它依赖未来的聊天 schema 迁移引入稳定卡 ID 才能彻底解决——在此之前,验证与测试都必须把该场景当作已知约束处理。