mirror of
https://github.com/xinnan-tech/xiaozhi-esp32-server.git
synced 2026-07-28 10:03:54 +08:00
fix: harden agent snapshot restore flow
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user