Operit 角色主题持久化改造:目标作用域快照、串行化写入与编辑器会话设计

原创2026-09-26 18:29:22114 阅读
文章标签:AI Agent人工智能大模型AI 应用工具调用本地部署MCP ClientsAgent 记忆GUI 自动化

Operit 角色主题持久化改造:目标作用域快照、串行化写入与编辑器会话设计

导读

本文基于 Operit 仓库中 issue_782_theme_persistence 系列设计文档,完整讲解"角色卡 / 群组主题持久化"这一从两阶段保存到编辑器会话的演进过程。你将掌握:主题投影与目标作用域的关系、旧版全局主题的一次性迁移策略、用互斥协调器把"目标激活 + 主题写入"串行化以避免跨目标误写,以及 WebChat 按聊天元数据解析主题快照的实现方案。文章同时给出 UserPreferencesManager.kt、ActivePromptManager.kt 等源码级证据,供读者按图索骥。

一、问题背景:两阶段保存与目标切换竞态

Issue 782 要解决的核心问题,在于旧版主题编辑路径是一条两阶段流水线:

  1. 编辑器先把改动写入共享投影键(全局 Android 主题使用的键名);
  2. 随后在另一个独立协程中,把共享键的值复制到当前目标(角色卡或群组)的专属前缀下。

这条路径存在两个致命缺陷:

  • 跨目标误写:在"写共享键"与"复制到目标前缀"两次 DataStore 事务之间,用户可以切换角色卡。若切换发生在两次操作的间隙,前一个目标的投影就可能被存进后一个目标的快照里。原文档明确记录了该竞态:A target switch between those operations can save one target's projection into another target's snapshot.
  • 编辑非活动目标不可能:编辑器只绑定当前活动的 chat prompt,控件改动立即写入共享投影并再复制一份到活动目标,因此无法编辑非活动目标,也做不到"先改、后存、可放弃"的草稿体验。

后续的 issue_782_theme_editor_drafts/index.md 是这一轮持久化工作的正式实现(status: implementation_complete),其中 issue_782_theme_persistence 被标记为 superseded,其"两阶段保存路径与验证契约"由编辑器会话方案取代。本文以持久化设计文档为主线,结合最终源码说明完整落地形态。

二、目标作用域模型:角色卡与群组的主题前缀

2.1 前缀命名规则

主题数据并不只存在一套全局键里,而是为每个目标(角色卡、群组、默认卡)分配独立前缀。从 UserPreferencesManager.kt 可以看到:

private fun getCharacterCardThemePrefix(characterCardId: String): String =
    "character_card_theme_${characterCardId}_"

private fun getCharacterGroupThemePrefix(characterGroupId: String): String =
    "character_group_theme_${characterGroupId}_"

即一个角色卡的主题键形如 character_card_theme_<cardId>_THEME_MODE,一个群组的主题键形如 character_group_theme_<groupId>_BUBBLE_AI_BUBBLE_COLOR。默认角色卡的 ID 在 CharacterCardManager.kt 中定义为常量 DEFAULT_CHARACTER_CARD_ID = "default_character"。

2.2 主题键按原始类型分组维护

这些键被严格按 DataStore 原始类型划分为四组,分别由四个函数维护(UserPreferencesManager.kt):

分组 代表性键 数据类型
String 键 THEME_MODE、BACKGROUND_IMAGE_URI、CHAT_STYLE、INPUT_STYLE、FONT_TYPE、KEY_CUSTOM_USER_AVATAR_URI、KEY_CUSTOM_AI_AVATAR_URI、KEY_CUSTOM_CHAT_TITLE、气泡图片/字体相关键 Preferences.Key<String>
Boolean 键 USE_SYSTEM_THEME、USE_CUSTOM_COLORS、TOOLBAR_TRANSPARENT、NAVIGATION_DRAWER_WATER_GLASS、CHAT_INPUT_LIQUID_GLASS、BUBBLE_AI_BUBBLE_WATER_GLASS、KEY_SHOW_THINKING_PROCESS、KEY_SHOW_TIMESTAMP 等显示开关 Preferences.Key<Boolean>
Int 键 CUSTOM_PRIMARY_COLOR、CUSTOM_SECONDARY_COLOR、CUSTOM_APP_BAR_COLOR、CUSTOM_STATUS_BAR_COLOR、气泡文字/背景颜色 Preferences.Key<Int>
Float 键 BACKGROUND_IMAGE_OPACITY、BACKGROUND_BLUR_RADIUS、KEY_AVATAR_CORNER_RADIUS、FONT_SCALE、气泡图片裁剪/平铺/缩放参数 Preferences.Key<Float>

注意 KEY_CUSTOM_AI_AVATAR_URI(AI 头像)与 KEY_CUSTOM_CHAT_TITLE(自定义聊天标题)虽属于 String 键,但在设计中是目标展示元数据而非视觉主题数据——这一区分直接决定后面"重置"与"删除"的边界。

2.3 快照读取:前缀为空则回落到应用默认值

readThemePreferenceValues(preferences, prefix) 的做法是:先用 ThemePreferenceValues.defaultVisual() 建立默认值,再逐一用 ${prefix}${key.name} 覆盖(UserPreferencesManager.kt)。因此:

  • 目标前缀下没有任何键时,读出的就是应用默认主题;
  • 目标前缀下只覆盖了少数键时,其余字段自动取默认值。

这也是"无已存主题的卡必须显示应用默认值、且不得保留前一个目标的投影"这一验收标准的实现基础。另外该方法还兼容旧版"仅存水平重复值、无垂直重复值"的数据(copyLegacyRepeatYValue),把水平值回落为垂直值,属于兼容性边界的一部分。

三、投影与迁移:默认卡与旧版全局主题

3.1 旧行为

旧版激活角色卡时,如果该卡的 scoped 前缀为空,会跳过投影,于是共享键继续保留上一个目标的主题——"默认切换到 A 再切回,A 看到的是别人的主题"正是由此产生。此外旧安装可能只有一套全局主题、却没有默认卡的快照。

3.2 新行为:空前缀也执行投影

新设计下,激活每一个选中的卡都必须执行前缀投影(1_ProjectionAndMigration.md):

  • 空前缀同样被应用:激活默认卡(无已存主题)时,用空前缀"清空"活动投影,Compose 因此解析到应用默认值;
  • 激活时清除投影中目标缺失的键:copyThemeValues(preferences, sourcePrefix, targetPrefix, clearMissingTargetValues = false) 中 clearMissingTargetValues 参数控制是否删除目标中不存在的键(UserPreferencesManager.kt),激活路径会清理掉"共享投影中存在、但所选作用域中没有"的键;
  • 常规保存保留独立元数据:普通保存不清理 KEY_CUSTOM_AI_AVATAR_URI、KEY_CUSTOM_CHAT_TITLE 这类目标级元数据,避免误删用户单独维护的信息。

3.3 一次性迁移:仅当归属明确

旧版全局主题只在"确实属于默认卡"时才迁移为默认卡的 scoped 快照,且只执行一次。迁移决策由 ThemeScopeMigrationPolicy.kt 独立成类:

internal object ThemeScopeMigrationPolicy {
    fun shouldCopyLegacyThemeToDefaultCharacter(
        migrationCompleted: Boolean,
        activeCharacterCardId: String?,
        defaultCharacterId: String,
        hasDefaultCharacterTheme: Boolean,
        hasAnyScopedTheme: Boolean,
        defaultCharacterWasCreated: Boolean,
    ): Boolean {
        if (migrationCompleted || hasDefaultCharacterTheme || hasAnyScopedTheme) {
            return false
        }
        return defaultCharacterWasCreated || activeCharacterCardId == defaultCharacterId
    }
}

判定规则可概括为:已迁移过、默认卡已有主题、或已存在任何 scoped 主题时绝不迁移;只有当前激活卡是默认卡、或默认卡刚被创建(此时旧全局主题只可能属于它)才允许把无前缀的全局主题复制到 default_character 前缀下。迁移入口在 UserPreferencesManager.kt 的 migrateLegacyDefaultCharacterThemeIfEligible,迁移完成后写入 CHARACTER_THEME_DEFAULT_MIGRATION_COMPLETED = true 标记防止重复执行。

该策略有完整的单元测试覆盖(ThemeScopeMigrationPolicyTest.kt),包括五个用例:活动默认卡迁移、存在 scoped 数据阻止迁移、新创建默认卡迁移、未知活动卡不迁移、迁移完成后不再执行。

四、目标序列化:共享互斥的写入协调器

4.1 从"两协程各干各的"到"一个协调器"

旧实现中,设置编辑器为全局偏好更新启动一个协程、为 scoped 快照再调度另一个协程,目标激活可以插在两者之间执行。修复方案是引入唯一协调器(ThemeTargetOperationCoordinator),让 prompt 切换、目标绑定保存、目标绑定重置全部走同一把互斥锁;在持有与激活相同的 mutex 时校验捕获的目标,再同步完成共享更新与 scoped 快照写入。其实现非常精简(ThemeTargetOperationCoordinator.kt):

internal class ThemeTargetOperationCoordinator {
    private val mutex = Mutex()

    suspend fun <T> runTransition(action: suspend () -> T): T {
        return mutex.withLock {
            action()
        }
    }
}

4.2 ActivePromptManager:对外统一入口

ActivePromptManager.kt 是角色/群组激活与主题写入的门面,内部持有 themeOperations = ThemeTargetOperationCoordinator()(L17),并派生两条核心数据流:

  • activePromptFlow(L19-L29):由 observeActiveCharacterGroupId() 与 observeActiveCharacterCardId() combine 而来,群组优先、其次角色卡、否则回落到默认卡;
  • activeThemePreferenceSnapshotFlow(L32-L47):对活动 prompt 做 flatMapLatest,为该目标实时观测主题快照,目标切换后自动改读新目标的 scoped 数据。

所有变更类操作都包在 runTransition 里,与激活共用一个 mutex:

suspend fun setActivePrompt(prompt: ActivePrompt) {            // L51-64
    themeOperations.runTransition {
        when (prompt) {
            is ActivePrompt.CharacterGroup -> {
                characterGroupCardManager.setActiveCharacterGroupCard(prompt.id)
                characterCardManager.clearActiveCharacterCard()
            }
            is ActivePrompt.CharacterCard -> {
                characterCardManager.setActiveCharacterCard(prompt.id)
                characterGroupCardManager.setActiveCharacterGroupCard(null)
            }
        }
    }
}

suspend fun mutateActiveThemeForPrompt(target: ActivePrompt, transform: ...) {  // L70-81
    themeOperations.runTransition {
        if (getActivePrompt() != target) return@runTransition   // 目标校验
        userPreferencesManager.mutateThemeForPrompt(target = target, transform = transform)
    }
}

suspend fun commitThemeDraft(target: ActivePrompt, values: ThemePreferenceValues) {  // L83-93
    themeOperations.runTransition {
        userPreferencesManager.replaceThemeForPrompt(target = target, values = values)
    }
}

suspend fun resetThemeDraft(target: ActivePrompt, values: ThemePreferenceValues) {  // L95-105
    themeOperations.runTransition {
        userPreferencesManager.resetVisualThemeForPrompt(target = target, values = values)
    }
}

关键点在于 mutateActiveThemeForPrompt 中持有锁的同时校验当前活动目标(getActivePrompt() != target 则直接放弃),从而保证"一次编辑只属于发起它的目标,已离开页面的事件不会写进新目标"——这正是原文档期望结果 An edit belongs only to the target that initiated it 的落点。此外 saveAiAvatarForPrompt、saveCustomChatTitleForPrompt 也走同一锁路径写入目标元数据。

4.3 数据层:一次 DataStore 事务完成三件事

底层写操作位于 UserPreferencesManager.kt,每个方法都在单个 userPreferencesDataStore.edit { ... } 事务内完成:

  • replaceThemeForPrompt:整段替换——先 writeVisualThemeValues 写入全部视觉键,再 writeThemeTargetMetadata 写入 AI 头像与自定义聊天标题;
  • mutateThemeForPrompt:读取目标前缀现值 → 变换 → 同事务写回视觉键与元数据;
  • resetVisualThemeForPrompt:clearVisualThemeValues 只删除视觉键(String 组中排除 KEY_CUSTOM_AI_AVATAR_URI 与 KEY_CUSTOM_CHAT_TITLE,见 isVisualThemeStringKey,L914-L937),随后在同一事务中写回目标元数据。

也就是说:保存不会让共享投影与目标作用域不同步;重置只改视觉配置;而删除角色/群组时才走 deleteCharacterCardTheme / deleteCharacterGroupTheme(deleteThemeByPrefix,L1096-L1111)把该目标全部键清掉。

五、编辑器会话:从"边改边存"到"一张草稿一次保存"

5.1 会话取代"立即保存"

持久化文档最终被 编辑器会话设计 取代。其核心变化是:主题控件不再直接调 saveThemeSettingsWithCharacterCard 立即落库,而是绑定一个屏幕持有的编辑器会话(editor session):

  • 会话由"选中目标的主题快照"初始化,持有 values(值流)、baseline(基线)、dirty/reset 状态与 staged assets(暂存文件);
  • 各主题分区读取同一个 values StateFlow 并同步更新内存草稿;
  • 最近颜色等全局项仍委托给全局存储,但角色绑定主题键不写入,直到用户显式提交草稿;
  • 保存前先捕获源目标(val target = state.target),再做异步工作;reset 在保存前只是"草稿重置",真正清键发生在提交时。

见 ThemeSettingsContentEditor.kt 的 saveCurrentDraft:

fun saveCurrentDraft() {
    val state = editorState ?: return
    val draft = state.session
    if (isSaving) return
    val target = state.target                       // 先捕获目标
    val savedValues = draft.currentValues
    val resetRequested = draft.isResetRequested
    isSaving = true
    draft.beginSave(savedValues)
    scope.launch {
        try {
            if (resetRequested) {
                activePromptManager.resetThemeDraft(target, savedValues)
            } else {
                activePromptManager.commitThemeDraft(target, savedValues)
            }
            draft.markSaved(savedValues)
            ...
        } catch (e: CancellationException) {
            draft.cancelSave(); throw e
        } catch (e: Exception) {
            draft.cancelSave()                       // 失败回滚到草稿态
            ...
        } finally { isSaving = false }
    }
}

失败或取消时 cancelSave() 让草稿回到可继续编辑的状态;保存成功或草稿被放弃时,暂存的 staged 文件才被清理。

5.2 目标切换与离开守卫

  • 目标选择器(ThemeSettingsTargetSelector)在 tab 上方以紧凑卡片展示默认角色、非默认卡与群组。接受选择时调用 ActivePromptManager.setActivePrompt:激活角色并投影其已存主题,但不切换/创建聊天历史(without invoking chat-history auto-switch behavior,见 6_CompactTargetSwitchAndCleanup.md)。
  • 脏草稿的三选一:切换目标时若当前草稿有未保存修改,弹出保存 / 放弃 / 取消,ThemeEditorPendingAction(SelectTarget / ActiveTargetChanged / LeaveScreen)串行处理,任何激活都发生在确认完成之后(ThemeSettingsContentEditor.kt)。
  • 路由离开守卫:RegisterRouteBackGuard 在 pendingAction 非空或有脏草稿时挂起 suspendCancellableCoroutine,等待用户确认再放行(ThemeSettingsContentEditor.kt)。配套设计 4_RouteLeaveGuard.md 要求 OperitApp 中所有 AppRouterState.navigate、resetTo、pop 入口统一经过同一个挂起守卫,使返回键、抽屉项、快捷方式与外部路由请求都触发同一个保存/放弃/取消对话框。

六、WebChat 主题绑定:按请求的聊天解析目标

6.1 旧行为与问题

GET /chats/{id}/theme 只确认聊天存在,却从活动 prompt 解析主题;WebChat 的多个字段又直接读共享 Android flow,导致响应字段可能来自与返回快照不同的目标。

6.2 新行为:从聊天元数据解析

新实现中,主题解析不再依赖活动 prompt,而是按请求的聊天元数据决定目标(WebChatHttpBridge.kt):

private suspend fun resolveThemePreferenceSnapshot(chat: ChatHistory?): ThemePreferenceSnapshot {
    val groupId = chat?.characterGroupId?.trim()?.takeIf { it.isNotBlank() }
    if (groupId != null) {
        return userPreferencesManager.resolveThemePreferenceSnapshot(characterGroupId = groupId)
    }
    val cardName = chat?.characterCardName?.trim()?.takeIf { it.isNotBlank() }
    if (cardName != null) {
        val matchingCard = characterCardManager.findCharacterCardByName(cardName)
        if (matchingCard != null) {
            return userPreferencesManager.resolveThemePreferenceSnapshot(characterCardId = matchingCard.id)
        }
    }
    return userPreferencesManager.resolveThemePreferenceSnapshot(
        characterCardId = CharacterCardManager.DEFAULT_CHARACTER_CARD_ID,
    )
}

解析优先级:群组 ID > 角色卡名绑定 > 默认卡。resolveThemePreferenceSnapshot(UserPreferencesManager.kt)与 observeThemePreferenceSnapshot(L1205-L1236)都要求显式给出卡或群组目标,从目标前缀一次性读出完整 ThemePreferenceSnapshot;WebChat 响应中的所有字段(含气泡玻璃效果 BUBBLE_AI_BUBBLE_WATER_GLASS 等、字体开关)都从同一份快照映射,结构化渲染也使用同一解析结果。

6.3 已知数据约束

现有聊天记录只保存角色卡名称(characterCardName),不保存稳定卡 ID;因此重名角色卡在 chat schema 引入稳定卡 ID 之前无法唯一解析主题。这是文档明确记录、尚未消除的约束,也是后续迁移的候选方向。

七、验证与兼容性边界

7.1 自动化与手动验收

设计文档给出三层验证:

  • 自动化覆盖(3_Verification.md):prompt 切换与目标写入共用串行协调器;编辑器提交捕获并写入显式目标快照;旧版迁移仅在默认卡归属无歧义时复制全局数据。策略逻辑由 ThemeScopeMigrationPolicyTest.kt 的 5 个用例落地。
  • 手动验收:配置默认卡并创建 A → 激活 A 修改若干主题值 → 切回默认卡再切回 A → 确认 A 与默认卡各自保留自己的值;反复在切换同时改主题值,确认任一目标都不会继承另一目标的值。
  • 编辑器会话版静态检查(5_Verification.md):确认所有写入路径只改编辑器会话而非持久化 manager;目标捕获发生在异步工作之前;实体清理区分"删除目标"与"视觉主题重置";WebChat 主题与结构化渲染都从请求的聊天解析;OperitApp 的所有路由变更都经过离开守卫;git diff --check 通过。文档同时注明 Gradle、lint、单元测试与构建在未显式请求时未运行,xmllint 不可用时跳过独立 XML 解析。

7.2 兼容性边界

为不破坏已发布数据,以下内容保持原样(见 6_CompactTargetSwitchAndCleanup.md 的 Compatibility Boundary):已发布的角色卡/群组主题前缀、已持久化的主题键名、旧版默认主题迁移逻辑、旧版垂直 repeat 值兼容、WebChat 响应字段,以及既有聊天绑定解析。因此升级后:

  • 已有的 scoped 角色卡/群组主题保持原样;
  • 明确归属于默认卡、且此前从未有 scoped 主题的旧全局主题,在升级后被一次性迁移进默认卡前缀;
  • 任何目标都不会再显示"别人的过期投影"。

八、从源码结构看整体调用链

将各部件串联起来,主题持久化在最终实现中的调用链如下:

ThemeSettingsContentEditor (Compose 屏幕)
  └─ ThemeEditorSession(草稿:values / baseline / dirty / staged assets)
      └─ saveCurrentDraft / reset(先捕获 target)
          └─ ActivePromptManager
              ├─ commitThemeDraft / resetThemeDraft / mutateActiveThemeForPrompt
              └─ ThemeTargetOperationCoordinator(Mutex 串行化,含 setActivePrompt)
                  └─ UserPreferencesManager
                      ├─ replaceThemeForPrompt / mutateThemeForPrompt / resetVisualThemeForPrompt
                      │    └─ 单次 DataStore edit:视觉键 + 目标元数据
                      ├─ observeThemePreferenceSnapshot / resolveThemePreferenceSnapshot
                      │    └─ 按 character_card_theme_{id}_ / character_group_theme_{id}_ 前缀读取
                      └─ migrateLegacyDefaultCharacterThemeIfEligible
                           └─ ThemeScopeMigrationPolicy(一次性、归属明确才迁移)

WebChatHttpBridge
  └─ resolveThemePreferenceSnapshot(chat):group id > card name > default_character

从代码结构可以推断,ThemeTargetOperationCoordinator 是全局唯一的串行化闸门:无论是用户点击目标选择器触发的 setActivePrompt,还是编辑器提交触发的 commitThemeDraft,都进入同一把 Mutex,从根上排除了"激活发生在保存两阶段之间"的竞态窗口。

结语

Issue 782 的持久化改造本质上是把"共享投影 + 延迟复制"的松散模型,收敛为"目标作用域快照 + 串行事务 + 显式提交"的严谨模型:空前缀投影保证无主题目标回落默认值;一次性迁移守住旧全局数据的所有权边界;互斥协调器让激活与写入成为原子序列;编辑器会话把多次隐式写入收敛为一次显式保存;WebChat 则按聊天元数据而非活动 prompt 解析主题。感兴趣的读者可以继续深入 issue_782_theme_editor_drafts 的完整设计,以及 ActivePromptManager.kt 与 UserPreferencesManager.kt 中的实际实现。

登录后查看全文
Operit