fix: harden agent snapshot restore flow

This commit is contained in:
Tyke Chen
2026-07-10 20:21:44 +08:00
parent 687b6db96b
commit 179281e49c
47 changed files with 5701 additions and 677 deletions
+433 -69
View File
@@ -6,6 +6,7 @@ import { t } from '@/i18n'
import { usePluginStore, useProvider, useSpeedPitch } from '@/store'
import { toast } from '@/utils/toast'
import AgentSnapshotPanel from './components/AgentSnapshotPanel.vue'
import { filterTtsVoicesByLanguage, hasUsableTtsVoiceMetadata } from './components/agentSnapshotUtils.mjs'
defineOptions({
name: 'AgentEdit',
@@ -67,6 +68,8 @@ const loading = ref(false)
const saving = ref(false)
const showSnapshotPanel = ref(false)
const currentVersionNo = ref<number | null>(null)
const snapshotReloadBlocked = ref(false)
const snapshotReloadFailed = ref(false)
// 模型选项数据
const modelOptions = ref<{
@@ -120,8 +123,18 @@ const selectedTtsLanguage = ref('')
const ttsLanguageTouched = ref(false)
const ttsVoiceTouched = ref(false)
const ttsOptionsLoading = ref(false)
const ttsOptionsModelId = ref('')
const originalTagNames = ref<string[]>([])
const originalAgentDetail = ref<AgentDetail | null>(null)
let ttsOptionsRequestSequence = 0
let agentDetailRequestSequence = 0
let agentTagRequestSequence = 0
let snapshotReloadSequence = 0
interface SnapshotRestoreContext {
agentId: string
actionSequence: number
}
// 音频播放相关
const audioRef = ref<UniApp.InnerAudioContext | null>(null)
@@ -132,6 +145,91 @@ const pluginStore = usePluginStore()
const speedPitchStore = useSpeedPitch()
const providerStore = useProvider()
const EDITABLE_AGENT_FIELDS: Array<keyof AgentDetail> = [
'agentName',
'systemPrompt',
'summaryMemory',
'vadModelId',
'asrModelId',
'llmModelId',
'slmModelId',
'vllmModelId',
'intentModelId',
'memModelId',
'ttsModelId',
'chatHistoryConf',
'langCode',
'language',
'sort',
]
const hasUnsavedChanges = computed(() => {
if (loading.value || !originalAgentDetail.value) {
return false
}
return stableSerialize(buildCurrentEditableState()) !== stableSerialize(buildOriginalEditableState())
})
function buildCurrentEditableState() {
const original = originalAgentDetail.value as AgentDetail
const current = formData.value as Record<string, any>
const changedTtsFields = new Set(speedPitchStore.changedFields)
return {
...pickEditableFields(current),
ttsLanguage: ttsLanguageTouched.value ? selectedTtsLanguage.value : original.ttsLanguage,
ttsVoiceId: ttsVoiceTouched.value ? current.ttsVoiceId : original.ttsVoiceId,
ttsVolume: changedTtsFields.has('ttsVolume') ? speedPitchStore.speedPitch.ttsVolume : original.ttsVolume,
ttsRate: changedTtsFields.has('ttsRate') ? speedPitchStore.speedPitch.ttsRate : original.ttsRate,
ttsPitch: changedTtsFields.has('ttsPitch') ? speedPitchStore.speedPitch.ttsPitch : original.ttsPitch,
functions: normalizeAgentFunctions(current.functions || []),
contextProviders: providerStore.providers,
tagNames: dynamicTags.value.map((tag: any) => tag.tagName).filter(Boolean).sort(),
}
}
function buildOriginalEditableState() {
const original = originalAgentDetail.value as AgentDetail
return {
...pickEditableFields(original as unknown as Record<string, any>),
ttsLanguage: original.ttsLanguage,
ttsVoiceId: original.ttsVoiceId,
ttsVolume: original.ttsVolume,
ttsRate: original.ttsRate,
ttsPitch: original.ttsPitch,
functions: normalizeAgentFunctions(original.functions || []),
contextProviders: original.contextProviders || [],
tagNames: [...originalTagNames.value].sort(),
}
}
function pickEditableFields(data: Record<string, any>) {
return EDITABLE_AGENT_FIELDS.reduce<Record<string, any>>((result, field) => {
result[field] = data[field]
return result
}, {})
}
function stableSerialize(value: any) {
return JSON.stringify(sortObjectKeys(value))
}
function sortObjectKeys(value: any): any {
if (Array.isArray(value)) {
return value.map(sortObjectKeys)
}
if (value && typeof value === 'object') {
return Object.keys(value).sort().reduce<Record<string, any>>((result, key) => {
result[key] = sortObjectKeys(value[key])
return result
}, {})
}
return value
}
function cloneSerializable<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T
}
// tabs
const tabList = [
{
@@ -214,38 +312,63 @@ function handleRegulate() {
}
// 加载智能体详情
async function loadAgentDetail() {
if (!agentId.value)
return
async function loadAgentDetail(targetAgentId = agentId.value) {
if (!targetAgentId)
return false
const requestId = ++agentDetailRequestSequence
invalidateTtsMetadataRequest()
try {
loading.value = true
tempSummaryMemory.value = ''
ttsLanguageTouched.value = false
ttsVoiceTouched.value = false
ttsOptionsRequestSequence += 1
ttsOptionsLoading.value = false
const detail = await getAgentDetail(agentId.value)
const normalizedFunctions = normalizeAgentFunctions(detail.functions || [])
formData.value = { ...detail, functions: normalizedFunctions }
currentVersionNo.value = detail.currentVersionNo || null
const detail = await getAgentDetail(targetAgentId)
if (!isActiveAgentDetailRequest(targetAgentId, requestId)) {
return false
}
applyPersistedAgentDetail(detail, targetAgentId)
await enhanceAgentDetailMetadata(detail, targetAgentId, requestId)
return isActiveAgentDetailRequest(targetAgentId, requestId)
}
catch (error) {
if (isActiveAgentDetailRequest(targetAgentId, requestId)) {
console.error('加载智能体详情失败:', error)
toast.error(t('agent.loadFail'))
}
return false
}
finally {
if (isActiveAgentDetailRequest(targetAgentId, requestId)) {
loading.value = false
}
}
}
// 更新插件store
pluginStore.setCurrentAgentId(agentId.value)
pluginStore.setCurrentFunctions(normalizedFunctions)
function applyPersistedAgentDetail(detail: AgentDetail, targetAgentId: string) {
const normalizedFunctions = normalizeAgentFunctions(detail.functions || [])
tempSummaryMemory.value = ''
ttsLanguageTouched.value = false
ttsVoiceTouched.value = false
ttsOptionsModelId.value = ''
voiceOptions.value = []
voiceDetails.value = {}
languageOptions.value = []
formData.value = { ...detail, functions: normalizedFunctions }
originalAgentDetail.value = cloneSerializable({ ...detail, functions: normalizedFunctions })
currentVersionNo.value = detail.currentVersionNo || null
selectedTtsLanguage.value = detail.ttsLanguage || ''
// 更新语速音调
speedPitchStore.updateSpeedPitch({
ttsVolume: detail.ttsVolume ?? 0,
ttsRate: detail.ttsRate ?? 0,
ttsPitch: detail.ttsPitch ?? 0,
})
speedPitchStore.resetChangedFields()
pluginStore.setCurrentAgentId(targetAgentId)
pluginStore.setCurrentFunctions(normalizedFunctions)
speedPitchStore.updateSpeedPitch({
ttsVolume: detail.ttsVolume ?? 0,
ttsRate: detail.ttsRate ?? 0,
ttsPitch: detail.ttsPitch ?? 0,
})
speedPitchStore.resetChangedFields()
providerStore.updateProviders(detail.contextProviders || [])
}
// 加载上下文配置
providerStore.updateProviders(detail.contextProviders || [])
// 如果有TTS模型,加载对应的音色选项
async function enhanceAgentDetailMetadata(detail: AgentDetail, targetAgentId: string, requestId: number) {
try {
if (detail.ttsModelId) {
await fetchAllLanguag(detail.ttsModelId, {
preferredLanguage: detail.ttsLanguage,
@@ -258,20 +381,28 @@ async function loadAgentDetail() {
languageOptions.value = []
selectedTtsLanguage.value = ''
}
// 等待模型选项加载完成后再更新显示名称
await nextTick()
updateDisplayNames()
if (isActiveAgentDetailRequest(targetAgentId, requestId)) {
updateDisplayNames()
}
}
catch (error) {
console.error('加载智能体详情失败:', error)
toast.error(t('agent.loadFail'))
}
finally {
loading.value = false
// Persisted agent detail has already loaded successfully. Voice metadata is
// display enhancement only and must not keep the post-restore save barrier.
console.warn('Failed to enhance agent detail metadata:', error)
}
}
function isActiveAgentDetailRequest(targetAgentId: string, requestId: number) {
return targetAgentId === agentId.value && requestId === agentDetailRequestSequence
}
function invalidateTtsMetadataRequest() {
ttsOptionsRequestSequence += 1
ttsOptionsLoading.value = false
ttsOptionsModelId.value = ''
}
// 获取音色显示名称
function getVoiceDisplayName(ttsVoiceId: string) {
if (!ttsVoiceId)
@@ -374,6 +505,60 @@ interface VoiceSelectionOptions {
preferredVoiceId?: string | null
}
interface TtsSelectionState {
modelId: string
voiceId: string
language: string
selectedLanguage: string
languageTouched: boolean
voiceTouched: boolean
optionsModelId: string
voiceOptions: any[]
voiceDetails: Record<string, any>
languageOptions: any[]
displayNames: {
tts: string
voiceprint: string
language: string
}
}
function captureTtsSelectionState(): TtsSelectionState {
return {
modelId: formData.value.ttsModelId || '',
voiceId: formData.value.ttsVoiceId || '',
language: formData.value.ttsLanguage || '',
selectedLanguage: selectedTtsLanguage.value,
languageTouched: ttsLanguageTouched.value,
voiceTouched: ttsVoiceTouched.value,
optionsModelId: ttsOptionsModelId.value,
voiceOptions: voiceOptions.value,
voiceDetails: voiceDetails.value,
languageOptions: languageOptions.value,
displayNames: {
tts: displayNames.value.tts,
voiceprint: displayNames.value.voiceprint,
language: displayNames.value.language,
},
}
}
function restoreTtsSelectionState(state: TtsSelectionState) {
formData.value.ttsModelId = state.modelId
formData.value.ttsVoiceId = state.voiceId
formData.value.ttsLanguage = state.language
selectedTtsLanguage.value = state.selectedLanguage
ttsLanguageTouched.value = state.languageTouched
ttsVoiceTouched.value = state.voiceTouched
ttsOptionsModelId.value = state.optionsModelId
voiceOptions.value = state.voiceOptions
voiceDetails.value = state.voiceDetails
languageOptions.value = state.languageOptions
displayNames.value.tts = state.displayNames.tts
displayNames.value.voiceprint = state.displayNames.voiceprint
displayNames.value.language = state.displayNames.language
}
function filterVoicesByLanguage(options: VoiceSelectionOptions = {}) {
if (!voiceDetails.value || Object.keys(voiceDetails.value).length === 0) {
voiceOptions.value = []
@@ -383,13 +568,7 @@ function filterVoicesByLanguage(options: VoiceSelectionOptions = {}) {
const allVoices = Object.values(voiceDetails.value) as any[]
// 根据选中的语言筛选音色
const filteredVoices = allVoices.filter((voice) => {
if (!voice.languages) {
return false
}
const languagesArray = voice.languages.split(/[、;;,]/).map(lang => lang.trim()).filter(lang => lang)
return languagesArray.includes(selectedTtsLanguage.value)
})
const filteredVoices = filterTtsVoicesByLanguage(allVoices, selectedTtsLanguage.value)
voiceOptions.value = filteredVoices.map(voice => ({
value: voice.id,
@@ -427,13 +606,22 @@ function getVoiceDefaultLanguage(ttsVoiceId: string) {
}
// 根据语音合成模型加载语言
async function fetchAllLanguag(ttsModelId: string, options: VoiceSelectionOptions = {}) {
async function fetchAllLanguag(ttsModelId: string, options: VoiceSelectionOptions = {}): Promise<'loaded' | 'failed' | 'stale'> {
const requestId = ++ttsOptionsRequestSequence
ttsOptionsLoading.value = true
try {
const res = await getAllLanguage(ttsModelId)
if (requestId !== ttsOptionsRequestSequence) {
return
return 'stale'
}
if (!Array.isArray(res)) {
throw new TypeError('Invalid TTS voice metadata')
}
// An empty response cannot prove that the newly selected model accepts an
// empty voice. Until the API exposes an explicit "voice optional"
// capability, keep the previous tuple instead of persisting a guess.
if (!hasUsableTtsVoiceMetadata(res)) {
throw new Error('No TTS voice metadata is available')
}
// 保存完整的音色信息
voiceDetails.value = res.reduce((acc, voice) => {
@@ -457,6 +645,10 @@ async function fetchAllLanguag(ttsModelId: string, options: VoiceSelectionOption
const preferredVoiceLanguage = options.preferredVoiceId
? getVoiceDefaultLanguage(options.preferredVoiceId)
: ''
// Do not carry a language from the previous model into a provider which
// exposes no language dimension.
selectedTtsLanguage.value = ''
displayNames.value.language = ''
// 优先使用调用方指定的语言或音色默认语言,再回退到智能体当前配置
if (requestedLanguage && languageOptions.value.some(option => option.value === requestedLanguage)) {
selectedTtsLanguage.value = requestedLanguage
@@ -481,13 +673,15 @@ async function fetchAllLanguag(ttsModelId: string, options: VoiceSelectionOption
// 根据选中的语言筛选音色
filterVoicesByLanguage(options)
ttsOptionsModelId.value = ttsModelId
return 'loaded'
}
catch {
catch (error) {
if (requestId === ttsOptionsRequestSequence) {
voiceOptions.value = []
voiceDetails.value = {}
languageOptions.value = []
console.error('Failed to load TTS options:', error)
ttsOptionsModelId.value = ''
}
return requestId === ttsOptionsRequestSequence ? 'failed' : 'stale'
}
finally {
if (requestId === ttsOptionsRequestSequence) {
@@ -514,7 +708,10 @@ async function fetchAllLanguag(ttsModelId: string, options: VoiceSelectionOption
// }
// 选择角色模板
function selectRoleTemplate(templateId: string) {
async function selectRoleTemplate(templateId: string) {
if (ttsOptionsLoading.value) {
return
}
if (selectedTemplateId.value === templateId) {
selectedTemplateId.value = ''
return
@@ -523,6 +720,7 @@ function selectRoleTemplate(templateId: string) {
selectedTemplateId.value = templateId
const template = roleTemplates.value.find(t => t.id === templateId)
if (template) {
const previousTtsState = captureTtsSelectionState()
const templateTtsLanguage = template.ttsLanguage?.trim() || ''
const hasTemplateTtsLanguage = Boolean(templateTtsLanguage)
formData.value = {
@@ -548,14 +746,20 @@ function selectRoleTemplate(templateId: string) {
displayNames.value.language = templateTtsLanguage
}
if (template.ttsModelId || template.ttsVoiceId || hasTemplateTtsLanguage) {
ttsLanguageTouched.value = true
ttsVoiceTouched.value = true
const result = await fetchAllLanguag(template.ttsModelId || formData.value.ttsModelId, {
autoSelectVoice: true,
preferredLanguage: hasTemplateTtsLanguage ? templateTtsLanguage : '',
preferredVoiceId: template.ttsVoiceId,
})
if (result === 'failed') {
restoreTtsSelectionState(previousTtsState)
toast.warning(t('agent.ttsOptionsLoadFailed'))
}
else if (result === 'loaded') {
ttsLanguageTouched.value = true
ttsVoiceTouched.value = true
}
}
fetchAllLanguag(template.ttsModelId || formData.value.ttsModelId, {
autoSelectVoice: true,
preferredLanguage: hasTemplateTtsLanguage ? templateTtsLanguage : '',
preferredVoiceId: template.ttsVoiceId,
})
updateDisplayNames()
}
}
@@ -572,6 +776,7 @@ function openPicker(type: string) {
async function onPickerConfirm(type: string, value: any, name: string) {
console.log('选择器确认:', type, value, name)
const previousTtsState = type === 'tts' ? captureTtsSelectionState() : null
// 保存显示名称
displayNames.value[type] = name
@@ -613,10 +818,16 @@ async function onPickerConfirm(type: string, value: any, name: string) {
case 'tts': {
const preferredLanguage = selectedTtsLanguage.value
formData.value.ttsModelId = value
ttsLanguageTouched.value = true
ttsVoiceTouched.value = true
formData.value.ttsVoiceId = ''
await fetchAllLanguag(value, { autoSelectVoice: true, preferredLanguage })
const result = await fetchAllLanguag(value, { autoSelectVoice: true, preferredLanguage })
if (result === 'failed' && previousTtsState) {
restoreTtsSelectionState(previousTtsState)
toast.warning(t('agent.ttsOptionsLoadFailed'))
}
else if (result === 'loaded') {
ttsLanguageTouched.value = true
ttsVoiceTouched.value = true
}
break
}
case 'language':
@@ -719,9 +930,28 @@ function getModelDisplayName(modelType: string, modelId: string) {
// 保存智能体
async function saveAgent() {
if (saving.value) {
return
}
if (snapshotReloadBlocked.value) {
toast.error(t(snapshotReloadFailed.value
? 'agentSnapshot.reloadAfterRestoreFailed'
: 'agentSnapshot.reloadAfterRestorePending'))
return
}
if (ttsOptionsLoading.value) {
return
}
const ttsSelectionTouched = ttsLanguageTouched.value || ttsVoiceTouched.value
const hasLanguageOptions = languageOptions.value.length > 0
const hasVoiceOptions = Object.keys(voiceDetails.value).length > 0
if (ttsSelectionTouched
&& (ttsOptionsModelId.value !== formData.value.ttsModelId
|| (hasLanguageOptions && !selectedTtsLanguage.value)
|| (hasVoiceOptions && !formData.value.ttsVoiceId))) {
toast.warning(t('agent.ttsOptionsLoadFailed'))
return
}
if (!formData.value.agentName?.trim()) {
toast.warning(t('agent.pleaseInputAgentName'))
return
@@ -815,20 +1045,116 @@ function handleTools() {
}
// 获取智能体标签
async function loadAgentTags() {
async function loadAgentTags(targetAgentId = agentId.value) {
if (!targetAgentId) {
return false
}
const requestId = ++agentTagRequestSequence
try {
const res = await getAgentTags(agentId.value)
const res = await getAgentTags(targetAgentId)
if (!isActiveAgentTagRequest(targetAgentId, requestId)) {
return false
}
dynamicTags.value = res || []
originalTagNames.value = dynamicTags.value.map(tag => tag.tagName)
return true
}
catch (error) {
if (isActiveAgentTagRequest(targetAgentId, requestId)) {
console.error('加载智能体标签失败:', error)
}
return false
}
catch (error) {}
}
async function handleSnapshotRestored() {
await Promise.all([
loadAgentDetail(),
loadAgentTags(),
])
function isActiveAgentTagRequest(targetAgentId: string, requestId: number) {
return targetAgentId === agentId.value && requestId === agentTagRequestSequence
}
async function handleSnapshotRestored(context: SnapshotRestoreContext) {
if (!context || context.agentId !== agentId.value) {
return
}
await reloadAgentAfterSnapshotRestore(context.agentId)
}
async function reloadAgentAfterSnapshotRestore(targetAgentId = agentId.value) {
if (!targetAgentId || targetAgentId !== agentId.value) {
return false
}
const reloadId = ++snapshotReloadSequence
const detailRequestId = ++agentDetailRequestSequence
const tagRequestId = ++agentTagRequestSequence
snapshotReloadBlocked.value = true
snapshotReloadFailed.value = false
loading.value = true
invalidateTtsMetadataRequest()
try {
// Apply detail and tags only after both persisted reads succeed. If either
// fails, the old form may remain visible but cannot be saved until retry.
const [detail, tags] = await Promise.all([
getAgentDetail(targetAgentId),
getAgentTags(targetAgentId),
])
if (!isActiveSnapshotReload(targetAgentId, reloadId, detailRequestId, tagRequestId)) {
return false
}
applyPersistedAgentDetail(detail, targetAgentId)
dynamicTags.value = tags || []
originalTagNames.value = dynamicTags.value.map(tag => tag.tagName)
// Voice metadata is an optional display enhancement. Its own failure must
// not turn a successful persisted detail+tag reload into a blocked form.
await enhanceAgentDetailMetadata(detail, targetAgentId, detailRequestId)
if (!isActiveSnapshotReload(targetAgentId, reloadId, detailRequestId, tagRequestId)) {
return false
}
snapshotReloadBlocked.value = false
snapshotReloadFailed.value = false
return true
}
catch (error) {
if (isActiveSnapshotReload(targetAgentId, reloadId, detailRequestId, tagRequestId)) {
console.error('恢复后重新加载智能体失败:', error)
snapshotReloadFailed.value = true
toast.error(t('agentSnapshot.reloadAfterRestoreFailed'))
}
return false
}
finally {
if (isActiveSnapshotReload(targetAgentId, reloadId, detailRequestId, tagRequestId)) {
loading.value = false
}
}
}
function isActiveSnapshotReload(
targetAgentId: string,
reloadId: number,
detailRequestId: number,
tagRequestId: number,
) {
return targetAgentId === agentId.value
&& reloadId === snapshotReloadSequence
&& detailRequestId === agentDetailRequestSequence
&& tagRequestId === agentTagRequestSequence
}
function retrySnapshotReload() {
if (!snapshotReloadFailed.value || !agentId.value) {
return
}
void reloadAgentAfterSnapshotRestore(agentId.value)
}
function openSnapshotPanel() {
if (saving.value || snapshotReloadBlocked.value) {
toast.warning(t('agentSnapshot.mutationBusy'))
return
}
showSnapshotPanel.value = true
}
function isSameStringList(left: string[], right: string[]) {
@@ -843,6 +1169,24 @@ watch(() => pluginStore.currentFunctions, (newFunctions) => {
formData.value.functions = normalizeAgentFunctions(newFunctions || [])
}, { deep: true })
watch(agentId, (currentAgentId, previousAgentId) => {
if (currentAgentId === previousAgentId) {
return
}
agentDetailRequestSequence += 1
agentTagRequestSequence += 1
snapshotReloadSequence += 1
invalidateTtsMetadataRequest()
loading.value = false
snapshotReloadFailed.value = false
snapshotReloadBlocked.value = Boolean(currentAgentId)
originalAgentDetail.value = null
originalTagNames.value = []
if (currentAgentId) {
void reloadAgentAfterSnapshotRestore(currentAgentId)
}
}, { flush: 'sync' })
onMounted(async () => {
loadAgentTags()
@@ -854,7 +1198,7 @@ onMounted(async () => {
])
// 然后加载智能体详情,这样可以正确映射显示名称
if (agentId.value) {
if (agentId.value && !snapshotReloadBlocked.value) {
await loadAgentDetail()
}
})
@@ -874,8 +1218,9 @@ onMounted(async () => {
<wd-button
size="small"
type="primary"
:disabled="saving || snapshotReloadBlocked"
custom-class="!bg-[#336cff] !h-[64rpx] !rounded-[32rpx]"
@click="showSnapshotPanel = true"
@click="openSnapshotPanel"
>
{{ t('agentSnapshot.title') }}
</wd-button>
@@ -1152,10 +1497,27 @@ onMounted(async () => {
<!-- 保存按钮 -->
<view class="mt-[40rpx] p-0">
<view
v-if="snapshotReloadBlocked"
class="mb-[18rpx] rounded-[16rpx] bg-[rgba(245,108,108,0.1)] p-[20rpx] text-[24rpx] text-[#a34848] leading-[1.6]"
>
<text class="block">
{{ t(snapshotReloadFailed ? 'agentSnapshot.reloadAfterRestoreFailed' : 'agentSnapshot.reloadAfterRestorePending') }}
</text>
<wd-button
v-if="snapshotReloadFailed"
size="small"
type="info"
custom-class="mt-[14rpx] !h-[60rpx]"
@click="retrySnapshotReload"
>
{{ t('agentSnapshot.retryReload') }}
</wd-button>
</view>
<wd-button
type="primary"
:loading="saving"
:disabled="saving || ttsOptionsLoading"
:disabled="saving || ttsOptionsLoading || snapshotReloadBlocked"
custom-class="w-full h-[80rpx] rounded-[16rpx] text-[30rpx] font-semibold bg-[#336cff] active:bg-[#2d5bd1]"
@click="saveAgent"
>
@@ -1260,6 +1622,8 @@ onMounted(async () => {
v-model:visible="showSnapshotPanel"
:agent-id="agentId"
:current-version-no="currentVersionNo"
:has-unsaved-changes="hasUnsavedChanges"
:mutation-busy="saving || snapshotReloadBlocked"
@restored="handleSnapshotRestored"
/>
</view>