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
@@ -4,18 +4,26 @@ import { computed, ref, watch } from 'vue'
import { useMessage } from 'wot-design-uni/components/wd-message-box'
import {
deleteAgentSnapshot,
getAgentDetail,
getAgentSnapshot,
getAgentSnapshots,
getAgentTags,
getCorrectWordFiles,
getModelOptions,
getPluginFunctions,
getTTSVoices,
restoreAgentSnapshot,
} from '@/api/agent/agent'
import { t } from '@/i18n'
import { getCurrentLanguage, t } from '@/i18n'
import { toast } from '@/utils/toast'
import {
isSensitiveKey,
normalizeSnapshotTtsNumber as normalizeDefaultTtsNumber,
normalizeSnapshotOrderedList,
redactSnapshotDisplayValue,
SNAPSHOT_SECRET_REDACTED,
stablePrettyStringify,
toIntlLocale,
willRestorePermanentlyDeleteChatHistory,
} from './agentSnapshotUtils.mjs'
defineOptions({
name: 'AgentSnapshotPanel',
@@ -24,17 +32,21 @@ defineOptions({
const props = withDefaults(defineProps<Props>(), {
visible: false,
currentVersionNo: null,
hasUnsavedChanges: false,
mutationBusy: false,
})
const emit = defineEmits<{
(event: 'update:visible', value: boolean): void
(event: 'restored'): void
(event: 'restored', context: { agentId: string, actionSequence: number }): void
}>()
interface Props {
visible?: boolean
agentId: string
currentVersionNo?: number | null
hasUnsavedChanges?: boolean
mutationBusy?: boolean
}
interface SnapshotRow extends AgentSnapshot {
@@ -54,6 +66,7 @@ interface VersionDetail {
forceCompare?: boolean
beforeVersionNo?: number | null
afterVersionNo?: number | null
currentStateToken?: string
}
interface DetailItem {
@@ -62,18 +75,34 @@ interface DetailItem {
beforeText: string
afterText: string
single: boolean
recordedOnly?: boolean
redactedDifference?: boolean
}
interface RestoreActionContext {
actionSequence: number
agentId: string
snapshotId: string
currentStateToken: string
detailRequestSequence: number
willClearChatHistory: boolean
}
const message = useMessage()
const restoring = ref(false)
const panelVisible = computed({
get: () => props.visible,
set: value => emit('update:visible', value),
set: (value) => {
if (!value && restoring.value) {
return
}
emit('update:visible', value)
},
})
const loading = ref(false)
const detailLoading = ref(false)
const restoring = ref(false)
const deletingSnapshotId = ref('')
const snapshots = ref<SnapshotRow[]>([])
const page = ref(1)
@@ -81,6 +110,15 @@ const limit = 10
const total = ref(0)
const historyAnchorVersionNo = ref<number | null>(null)
const detailVisible = ref(false)
const detailPopupVisible = computed({
get: () => detailVisible.value,
set: (value) => {
if (!value && restoring.value) {
return
}
detailVisible.value = value
},
})
const currentDetail = ref<VersionDetail | null>(null)
const pluginNameMap = ref<Record<string, string>>({})
const modelNameMap = ref<Record<string, string>>({})
@@ -91,6 +129,9 @@ const metadataLoaded = ref(false)
const correctWordMetadataLoaded = ref(false)
let detailRequestSequence = 0
let snapshotRequestSequence = 0
let restoreActionSequence = 0
let restorePostActionSequence: number | null = null
let restorePreviewRequestSequence: number | null = null
const MODEL_TYPES = ['ASR', 'VAD', 'LLM', 'VLLM', 'TTS', 'Memory', 'Intent']
const MODEL_FIELD_TYPES: Record<string, string> = {
@@ -110,7 +151,6 @@ const CHAT_HISTORY_CONF_LABEL_KEYS: Record<string, string> = {
2: 'agentSnapshot.chatHistoryConf.textVoice',
}
const SNAPSHOT_SECRET_REDACTED = '__SNAPSHOT_SECRET_REDACTED__'
const SNAPSHOT_FIELD_ORDER = [
'agentName',
'agentCode',
@@ -159,8 +199,18 @@ const restoreWillClearChatHistory = computed(() => {
if (!currentDetail.value || currentDetail.value.mode !== 'restore') {
return false
}
return currentDetail.value.beforeData?.memModelId !== 'Memory_nomem'
&& currentDetail.value.afterData?.memModelId === 'Memory_nomem'
return willRestorePermanentlyDeleteChatHistory(
currentDetail.value.beforeData?.memModelId,
currentDetail.value.afterData?.memModelId,
)
})
const displayedCurrentVersionNo = computed(() => historyAnchorVersionNo.value || props.currentVersionNo)
const canConfirmRestore = computed(() => {
return !restoring.value
&& !props.mutationBusy
&& currentDetail.value?.mode === 'restore'
&& detailItems.value.length > 0
&& Boolean(currentDetail.value.currentStateToken?.trim())
})
watch(() => props.visible, (visible) => {
@@ -168,8 +218,9 @@ watch(() => props.visible, (visible) => {
openPanel()
}
else {
invalidateRestoreAction()
invalidateSnapshotRequest()
closeDetail()
closeDetail(true)
}
})
@@ -185,9 +236,48 @@ watch(() => props.currentVersionNo, () => {
}
})
watch(() => props.agentId, (currentAgentId, previousAgentId) => {
if (currentAgentId === previousAgentId) {
return
}
invalidateRestoreAction()
invalidateSnapshotRequest()
closeDetail(true)
if (props.visible) {
openPanel()
}
}, { flush: 'sync' })
watch(() => props.mutationBusy, (mutationBusy) => {
if (!mutationBusy) {
return
}
const restorePostInFlight = restoring.value
&& restorePostActionSequence === restoreActionSequence
if (restorePostInFlight) {
return
}
const pendingRestorePreview = restorePreviewRequestSequence !== null
|| currentDetail.value?.mode === 'restore'
const pendingRestoreConfirmation = restoring.value
&& restorePostActionSequence !== restoreActionSequence
if (!pendingRestorePreview && !pendingRestoreConfirmation) {
return
}
if (pendingRestoreConfirmation) {
invalidateRestoreAction()
}
restorePreviewRequestSequence = null
closeDetail(true)
toast.warning(t('agentSnapshot.mutationBusy'))
}, { flush: 'sync' })
async function openPanel() {
const requestId = ++snapshotRequestSequence
historyAnchorVersionNo.value = props.currentVersionNo || null
// The first page must be unanchored so that a stale parent page cannot hide
// versions created by another client. The response establishes a stable
// anchor for all subsequent pages in this panel session.
historyAnchorVersionNo.value = null
page.value = 1
snapshots.value = []
await loadSnapshots(true, requestId)
@@ -207,14 +297,18 @@ async function loadSnapshots(reset = false, requestId = snapshotRequestSequence)
page: nextPage,
limit,
}
if (historyAnchorVersionNo.value) {
if (!reset && historyAnchorVersionNo.value) {
params.maxVersionNo = historyAnchorVersionNo.value
}
const result = await getAgentSnapshots(props.agentId, params)
if (!isActiveSnapshotRequest(requestId)) {
return
}
const rows = decorateSnapshotRows(result?.list || [])
const responseRows = result?.list || []
if (reset) {
historyAnchorVersionNo.value = responseRows[0]?.versionNo || null
}
const rows = decorateSnapshotRows(responseRows)
const mergedRows = reset ? rows : [...snapshots.value, ...rows]
snapshots.value = attachPreviousRows(mergedRows)
total.value = result?.total || 0
@@ -244,11 +338,9 @@ function invalidateSnapshotRequest() {
}
function decorateSnapshotRows(rows: AgentSnapshot[]): SnapshotRow[] {
return rows.map((row, index) => ({
return rows.map(row => ({
...row,
isLatestSnapshot: props.currentVersionNo
? row.versionNo === props.currentVersionNo
: index === 0 && page.value === 1,
isLatestSnapshot: Boolean(historyAnchorVersionNo.value && row.versionNo === historyAnchorVersionNo.value),
}))
}
@@ -277,19 +369,18 @@ async function viewSnapshot(row: SnapshotRow) {
detailLoading.value = true
currentDetail.value = null
try {
await ensureMetadata()
if (!isActiveDetailRequest(requestId)) {
return
}
const metadataRequest = ensureMetadata()
const detail = await buildSavedVersionDetail(row)
if (!isActiveDetailRequest(requestId)) {
return
}
await ensureDisplayMetadata(detail.beforeData, detail.afterData)
if (!isActiveDetailRequest(requestId)) {
return
}
currentDetail.value = detail
// Names are enhancement metadata. Render the snapshot immediately with
// stable IDs, then update names reactively as metadata arrives.
void Promise.allSettled([
metadataRequest,
ensureDisplayMetadata(detail.beforeData, detail.afterData),
])
}
catch (error) {
if (!isActiveDetailRequest(requestId)) {
@@ -307,22 +398,26 @@ async function viewSnapshot(row: SnapshotRow) {
}
async function previewRestoreSnapshot(row: SnapshotRow) {
if (!canRestoreSnapshot(row)) {
return
}
if (props.mutationBusy) {
toast.warning(t('agentSnapshot.mutationBusy'))
return
}
detailVisible.value = true
const requestId = ++detailRequestSequence
restorePreviewRequestSequence = requestId
detailLoading.value = true
currentDetail.value = null
try {
await ensureMetadata()
const metadataRequest = ensureMetadata()
const targetSnapshot = await getAgentSnapshot(props.agentId, row.id)
if (!isActiveDetailRequest(requestId)) {
return
}
const [targetSnapshot, currentAgent, currentTags] = await Promise.all([
getAgentSnapshot(props.agentId, row.id),
getAgentDetail(props.agentId),
getAgentTags(props.agentId),
])
if (!isActiveDetailRequest(requestId)) {
return
if (!isPlainObject(targetSnapshot.currentSnapshotData)) {
throw new Error('Snapshot detail is missing the atomic current-state data')
}
const detail: VersionDetail = {
mode: 'restore',
@@ -331,23 +426,20 @@ async function previewRestoreSnapshot(row: SnapshotRow) {
...row,
id: targetSnapshot.id || row.id,
},
beforeData: {
...currentAgent,
tags: currentTags || [],
tagNames: (currentTags || []).map(tag => tag?.tagName).filter(Boolean),
} as AgentSnapshotData,
beforeData: targetSnapshot.currentSnapshotData,
afterData: targetSnapshot.snapshotData || {},
changedFields: [],
fieldOrder: targetSnapshot.fieldOrder || [],
forceCompare: true,
beforeVersionNo: props.currentVersionNo,
beforeVersionNo: historyAnchorVersionNo.value || props.currentVersionNo,
afterVersionNo: targetSnapshot.versionNo,
}
await ensureDisplayMetadata(detail.beforeData, detail.afterData)
if (!isActiveDetailRequest(requestId)) {
return
currentStateToken: targetSnapshot.currentStateToken,
}
currentDetail.value = detail
void Promise.allSettled([
metadataRequest,
ensureDisplayMetadata(detail.beforeData, detail.afterData),
])
}
catch (error) {
if (!isActiveDetailRequest(requestId)) {
@@ -358,6 +450,9 @@ async function previewRestoreSnapshot(row: SnapshotRow) {
detailVisible.value = false
}
finally {
if (restorePreviewRequestSequence === requestId) {
restorePreviewRequestSequence = null
}
if (isActiveDetailRequest(requestId)) {
detailLoading.value = false
}
@@ -374,7 +469,10 @@ function invalidateDetailRequest() {
currentDetail.value = null
}
function closeDetail() {
function closeDetail(force = false) {
if (restoring.value && !force) {
return
}
if (detailVisible.value) {
detailVisible.value = false
return
@@ -437,24 +535,125 @@ async function confirmRestoreSnapshot() {
if (restoring.value || !detail || detail.mode !== 'restore' || !detail.row?.id) {
return
}
const snapshotId = detail.row.id
if (props.mutationBusy) {
toast.warning(t('agentSnapshot.mutationBusy'))
return
}
if (!detailItems.value.length) {
toast.info(t('agentSnapshot.noRestoreNeeded'))
return
}
const currentStateToken = detail.currentStateToken?.trim()
if (!currentStateToken) {
console.error('Cannot restore snapshot without a current-state token')
toast.error(t('agentSnapshot.restoreFailed'))
return
}
const context: RestoreActionContext = {
actionSequence: ++restoreActionSequence,
agentId: props.agentId,
snapshotId: detail.row.id,
currentStateToken,
detailRequestSequence,
willClearChatHistory: restoreWillClearChatHistory.value,
}
// Enter the busy state before either confirmation can yield. This prevents
// rapid taps from opening duplicate dialogs or issuing duplicate restores.
restoring.value = true
try {
await restoreAgentSnapshot(props.agentId, snapshotId)
if (props.hasUnsavedChanges) {
const confirmed = await requestRestoreConfirmation({
title: t('agentSnapshot.unsavedChangesTitle'),
msg: t('agentSnapshot.unsavedChangesWarning'),
confirmButtonText: t('agentSnapshot.discardAndRestore'),
cancelButtonText: t('common.cancel'),
})
if (!confirmed || !isActiveRestoreAction(context)) {
return
}
}
// This confirmation must be the last user interaction before the POST.
// It is intentionally separate from the unsaved-form confirmation because
// clearing chat history is irreversible and outside snapshot recovery.
if (context.willClearChatHistory) {
const confirmed = await requestRestoreConfirmation({
title: t('agentSnapshot.confirmRestore'),
msg: t('agentSnapshot.restoreChatHistoryDestructiveWarning'),
confirmButtonText: t('agentSnapshot.confirmRestore'),
cancelButtonText: t('common.cancel'),
})
if (!confirmed || !isActiveRestoreAction(context)) {
return
}
}
if (!isActiveRestoreAction(context)) {
return
}
// Mark the irreversible request boundary only after the final mutation
// guard. A later parent busy transition must not pretend this POST was
// canceled; its real response still owns the terminal UI state.
restorePostActionSequence = context.actionSequence
await restoreAgentSnapshot(context.agentId, context.snapshotId, context.currentStateToken)
if (!isActiveRestoreAction(context)) {
return
}
toast.success(t('agentSnapshot.restoreSuccess'))
closeDetail()
emit('restored')
await openPanel()
closeDetail(true)
emit('restored', {
agentId: context.agentId,
actionSequence: context.actionSequence,
})
if (props.visible && props.agentId === context.agentId) {
await openPanel()
}
}
catch (error) {
console.error('Failed to restore snapshot:', error)
toast.error(t('agentSnapshot.restoreFailed'))
if (isActiveRestoreAction(context)) {
console.error('Failed to restore snapshot:', error)
toast.error(t('agentSnapshot.restoreFailed'))
}
}
finally {
restoring.value = false
if (restorePostActionSequence === context.actionSequence) {
restorePostActionSequence = null
}
if (context.actionSequence === restoreActionSequence) {
restoring.value = false
}
}
}
async function requestRestoreConfirmation(options: Parameters<typeof message.confirm>[0]) {
try {
await message.confirm(options)
return true
}
catch {
return false
}
}
function isActiveRestoreAction(context: RestoreActionContext) {
const detail = currentDetail.value
return restoring.value
&& context.actionSequence === restoreActionSequence
&& (!props.mutationBusy || restorePostActionSequence === context.actionSequence)
&& context.agentId === props.agentId
&& props.visible
&& detailVisible.value
&& context.detailRequestSequence === detailRequestSequence
&& detail?.mode === 'restore'
&& detail.row?.id === context.snapshotId
&& detail.currentStateToken?.trim() === context.currentStateToken
}
function invalidateRestoreAction() {
restoreActionSequence += 1
restoring.value = false
}
function deleteSnapshot(row: SnapshotRow) {
message.confirm({
title: t('agentSnapshot.delete'),
@@ -494,6 +693,8 @@ function buildDetailItems(detail: VersionDetail): DetailItem[] {
beforeText: '',
afterText: formatDisplayValue(field, afterValue, detail.afterData),
single: true,
recordedOnly: false,
redactedDifference: false,
}
})
}
@@ -507,12 +708,18 @@ function buildDetailItems(detail: VersionDetail): DetailItem[] {
).map((field) => {
const beforeValue = getFieldValue(detail.beforeData, field)
const afterValue = getFieldValue(detail.afterData, field)
const beforeText = formatDisplayValue(field, beforeValue, detail.beforeData)
const afterText = formatDisplayValue(field, afterValue, detail.afterData)
return {
field,
label: fieldLabel(field),
beforeText: formatDisplayValue(field, beforeValue, detail.beforeData),
afterText: formatDisplayValue(field, afterValue, detail.afterData),
beforeText,
afterText,
single: false,
recordedOnly: !detail.forceCompare && isSameFieldValue(field, beforeValue, afterValue),
redactedDifference: !isSameFieldValue(field, beforeValue, afterValue)
&& beforeText === afterText
&& beforeText.includes(t('agentSnapshot.secretRedacted')),
}
})
}
@@ -531,7 +738,13 @@ function resolveDiffFields(
.map(canonicalField)
const candidates = forceCompare ? orderedFields : directFields
return Array.from(new Set(candidates)).filter((field) => {
const uniqueCandidates = Array.from(new Set(candidates))
if (!forceCompare) {
// changedFields is an immutable fact about the saved event. Re-running
// today's comparison rules would make old events disappear from detail.
return uniqueCandidates
}
return uniqueCandidates.filter((field) => {
return !isSameFieldValue(
field,
getFieldValue(beforeData, field),
@@ -596,7 +809,7 @@ function formatDisplayValue(field: string, value: any, rowData: AgentSnapshotDat
return formatArrayValue(value)
}
if (typeof value === 'object') {
return JSON.stringify(value, null, 2)
return stablePrettyStringify(redactDisplayValue(value))
}
if (field === 'ttsLanguage' && !value && rowData.ttsVoiceId) {
return t('agentSnapshot.emptyValue')
@@ -611,9 +824,11 @@ function formatFunctions(value: any) {
return value.map((item) => {
const pluginId = item?.pluginId || item?.id || ''
const name = pluginNameMap.value[pluginId] || translatedFallback(`agentSnapshot.plugin.${pluginId}`, pluginId || t('agentSnapshot.emptyValue'))
const params = item?.paramInfo || item?.params || {}
const keys = params && typeof params === 'object' ? Object.keys(params) : []
return keys.length ? `${name} (${keys.join(', ')})` : name
const params = parseObjectValue(item?.paramInfo ?? item?.params)
const safeParams = redactDisplayValue(params)
return Object.keys(safeParams).length
? `${name}\n${stablePrettyStringify(safeParams)}`
: name
}).join('\n')
}
@@ -622,7 +837,7 @@ function formatContextProviders(value: any) {
return t('agentSnapshot.emptyValue')
}
return value.map((item, index) => {
return `${index + 1}. ${item?.url || JSON.stringify(item)}`
return `${index + 1}. ${stablePrettyStringify(redactDisplayValue(item))}`
}).join('\n')
}
@@ -630,14 +845,14 @@ function formatCorrectWordFileIds(value: any) {
if (!Array.isArray(value) || value.length === 0) {
return t('agentSnapshot.emptyValue')
}
return value.map(id => correctWordNameMap.value[id] || id).join('、')
return formatLocalizedList(value.map(id => correctWordNameMap.value[id] || id))
}
function formatStringList(value: any) {
if (!Array.isArray(value) || value.length === 0) {
return t('agentSnapshot.emptyValue')
}
return value.join('、')
return formatLocalizedList(value)
}
function formatArrayValue(value: any[]) {
@@ -645,9 +860,9 @@ function formatArrayValue(value: any[]) {
return t('agentSnapshot.emptyValue')
}
if (value.every(item => item === null || ['string', 'number', 'boolean'].includes(typeof item))) {
return value.join('、')
return formatLocalizedList(value)
}
return JSON.stringify(value, null, 2)
return stablePrettyStringify(redactDisplayValue(value))
}
async function ensureMetadata() {
@@ -760,8 +975,34 @@ function formatTime(time?: string) {
if (Number.isNaN(date.getTime())) {
return String(time)
}
const pad = (value: number) => String(value).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
try {
return new Intl.DateTimeFormat(toIntlLocale(getCurrentLanguage()), {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
}).format(date)
}
catch {
const pad = (value: number) => String(value).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
}
function formatLocalizedList(values: any[]) {
const items = values.map(value => String(value))
try {
return new Intl.ListFormat(toIntlLocale(getCurrentLanguage()), {
style: 'short',
type: 'conjunction',
}).format(items)
}
catch {
return items.join(getCurrentLanguage().startsWith('zh_') ? '、' : ', ')
}
}
function isSameFieldValue(
@@ -789,22 +1030,11 @@ function normalizeValueForField(field: string, value: any): any {
return normalizeFunctions(value)
}
if (field === 'contextProviders') {
return normalizeSortedList(value)
return normalizeSnapshotOrderedList(value)
}
return normalizeValue(value)
}
function normalizeDefaultTtsNumber(value: any) {
if (value === undefined || value === null || String(value).trim() === '') {
return 0
}
if (typeof value === 'number') {
return Math.trunc(value)
}
const text = String(value).trim()
return /^[+-]?\d+$/.test(text) ? Number.parseInt(text, 10) : text
}
function normalizeStringList(value: any) {
if (!Array.isArray(value)) {
return []
@@ -845,16 +1075,6 @@ function parseObjectValue(value: any) {
}
}
function normalizeSortedList(value: any) {
if (!Array.isArray(value)) {
return []
}
return value
.filter(item => item !== undefined && item !== null)
.map(normalizeValue)
.sort((left, right) => stableStringify(left).localeCompare(stableStringify(right)))
}
function isEquivalentValue(left: any, right: any): boolean {
if (left === SNAPSHOT_SECRET_REDACTED || right === SNAPSHOT_SECRET_REDACTED) {
return true
@@ -892,23 +1112,8 @@ function isPlainObject(value: any): value is Record<string, any> {
return value !== null && typeof value === 'object' && !Array.isArray(value)
}
function isSensitiveKey(key: string) {
const normalized = String(key).toLowerCase().replace(/[^a-z0-9]/g, '')
return normalized === 'authorization'
|| normalized === 'token'
|| normalized.endsWith('token')
|| normalized.includes('apikey')
|| normalized.includes('appkey')
|| normalized.includes('accesskey')
|| normalized.includes('privatekey')
|| normalized.includes('password')
|| normalized.includes('passwd')
|| normalized.includes('secret')
|| normalized.includes('credential')
}
function stableStringify(value: any) {
return JSON.stringify(value)
function redactDisplayValue(value: any, parentKey = ''): any {
return redactSnapshotDisplayValue(value, t('agentSnapshot.secretRedacted'), parentKey)
}
</script>
@@ -918,6 +1123,7 @@ function stableStringify(value: any) {
position="bottom"
custom-class="agent-snapshot-popup"
custom-style="height: 86vh; max-height: 900px;"
:close-on-click-modal="!restoring"
safe-area-inset-bottom
>
<view class="agent-snapshot-shell">
@@ -926,8 +1132,8 @@ function stableStringify(value: any) {
<text class="block text-[34rpx] text-[#232338] font-bold">
{{ t('agentSnapshot.title') }}
</text>
<text v-if="currentVersionNo" class="mt-[6rpx] block text-[24rpx] text-[#65686f]">
{{ t('agentSnapshot.currentVersion') }} {{ formatVersion(currentVersionNo) }}
<text v-if="displayedCurrentVersionNo" class="mt-[6rpx] block text-[24rpx] text-[#65686f]">
{{ t('agentSnapshot.currentVersion') }} {{ formatVersion(displayedCurrentVersionNo) }}
</text>
</view>
<wd-icon name="close" size="22px" custom-class="agent-snapshot-close-icon text-[#65686f]" @click="panelVisible = false" />
@@ -993,6 +1199,7 @@ function stableStringify(value: any) {
v-if="canRestoreSnapshot(snapshot)"
size="small"
type="primary"
:disabled="mutationBusy"
custom-class="agent-snapshot-action-button flex-1 !h-[64rpx] !bg-[#336cff]"
@click="previewRestoreSnapshot(snapshot)"
>
@@ -1027,10 +1234,11 @@ function stableStringify(value: any) {
</wd-popup>
<wd-popup
v-model="detailVisible"
v-model="detailPopupVisible"
position="bottom"
custom-class="agent-snapshot-popup"
custom-style="height: 88vh; max-height: 940px;"
:close-on-click-modal="!restoring"
safe-area-inset-bottom
>
<view class="agent-snapshot-shell">
@@ -1038,7 +1246,7 @@ function stableStringify(value: any) {
<text class="agent-snapshot-detail-title">
{{ detailTitle }}
</text>
<wd-icon name="close" size="22px" custom-class="agent-snapshot-close-icon text-[#65686f]" @click="detailVisible = false" />
<wd-icon name="close" size="22px" custom-class="agent-snapshot-close-icon text-[#65686f]" @click="closeDetail()" />
</view>
<scroll-view scroll-y class="agent-snapshot-body">
@@ -1060,6 +1268,12 @@ function stableStringify(value: any) {
<text class="text-[28rpx] text-[#232338] font-semibold">
{{ item.label }}
</text>
<text v-if="item.recordedOnly" class="ml-[10rpx] text-[22rpx] text-[#8b8f9a]">
{{ t('agentSnapshot.recordedChange') }}
</text>
<text v-if="item.redactedDifference" class="ml-[10rpx] text-[22rpx] text-[#a06a1f]">
{{ t('agentSnapshot.redactedValueChanged') }}
</text>
</view>
<view v-if="item.single" class="p-[22rpx]">
@@ -1097,11 +1311,17 @@ function stableStringify(value: any) {
>
{{ t('agentSnapshot.restoreConfirm', { version: currentDetail.afterVersionNo || '' }) }}
</view>
<view
v-if="currentDetail?.mode === 'restore' && hasUnsavedChanges"
class="rounded-[18rpx] bg-[rgba(245,108,108,0.1)] p-[22rpx] text-[24rpx] text-[#a34848] leading-[1.6]"
>
{{ t('agentSnapshot.unsavedChangesWarning') }}
</view>
<view
v-if="restoreWillClearChatHistory"
class="rounded-[18rpx] bg-[rgba(245,108,108,0.1)] p-[22rpx] text-[24rpx] text-[#a34848] leading-[1.6]"
>
{{ t('agentSnapshot.restoreMemoryWarning') }}
{{ t('agentSnapshot.restoreChatHistoryDestructiveWarning') }}
</view>
</view>
</scroll-view>
@@ -1110,12 +1330,13 @@ function stableStringify(value: any) {
v-if="currentDetail?.mode === 'restore'"
class="agent-snapshot-footer"
>
<wd-button type="info" custom-class="agent-snapshot-footer-button flex-1 !h-[78rpx]" @click="detailVisible = false">
<wd-button type="info" :disabled="restoring" custom-class="agent-snapshot-footer-button flex-1 !h-[78rpx]" @click="closeDetail()">
{{ t('common.cancel') }}
</wd-button>
<wd-button
type="primary"
:loading="restoring"
:disabled="restoring || !canConfirmRestore"
custom-class="agent-snapshot-footer-button flex-1 !h-[78rpx] !bg-[#336cff]"
@click="confirmRestoreSnapshot"
>
@@ -0,0 +1,103 @@
/* eslint-disable test/no-import-node-test -- zero-dependency source contract gate */
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import test from 'node:test'
const panelSource = await readFile(new URL('./AgentSnapshotPanel.vue', import.meta.url), 'utf8')
const editSource = await readFile(new URL('../edit.vue', import.meta.url), 'utf8')
function sourceBetween(source, startMarker, endMarker) {
const start = source.indexOf(startMarker)
const end = source.indexOf(endMarker, start + startMarker.length)
assert.notEqual(start, -1, `missing start marker: ${startMarker}`)
assert.notEqual(end, -1, `missing end marker: ${endMarker}`)
return source.slice(start, end)
}
test('restore enters busy state before confirmations and requires a final destructive confirmation', () => {
const restore = sourceBetween(
panelSource,
'async function confirmRestoreSnapshot()',
'async function requestRestoreConfirmation',
)
const busyIndex = restore.indexOf('restoring.value = true')
const unsavedConfirmIndex = restore.indexOf('title: t(\'agentSnapshot.unsavedChangesTitle\')')
const destructiveConfirmIndex = restore.indexOf('msg: t(\'agentSnapshot.restoreChatHistoryDestructiveWarning\')')
const postIndex = restore.indexOf('await restoreAgentSnapshot(')
assert.ok(busyIndex >= 0 && busyIndex < unsavedConfirmIndex)
assert.ok(destructiveConfirmIndex > unsavedConfirmIndex && destructiveConfirmIndex < postIndex)
assert.match(restore, /if \(context\.willClearChatHistory\)/)
assert.match(restore.slice(destructiveConfirmIndex, postIndex), /isActiveRestoreAction\(context\)/)
const finalMutationGuardIndex = restore.lastIndexOf('if (!isActiveRestoreAction(context))', postIndex)
const postBoundaryIndex = restore.indexOf('restorePostActionSequence = context.actionSequence')
assert.ok(finalMutationGuardIndex > destructiveConfirmIndex)
assert.ok(finalMutationGuardIndex < postBoundaryIndex && postBoundaryIndex < postIndex)
})
test('restore guards popup exits and stale action completion', () => {
assert.match(panelSource, /if \(!value && restoring\.value\) \{\s*return\s*\}/)
assert.equal((panelSource.match(/:close-on-click-modal="!restoring"/g) || []).length, 2)
assert.match(panelSource, /function closeDetail\(force = false\) \{\s*if \(restoring\.value && !force\)/)
assert.match(panelSource, /context\.agentId === props\.agentId/)
assert.match(panelSource, /context\.detailRequestSequence === detailRequestSequence/)
assert.match(panelSource, /if \(!isActiveRestoreAction\(context\)\) \{\s*return\s*\}\s*toast\.success/)
assert.match(panelSource, /mutationBusy\?: boolean/)
assert.match(panelSource, /&& !props\.mutationBusy[\s\S]*currentDetail\.value\?\.mode === 'restore'/)
assert.match(panelSource, /&& \(!props\.mutationBusy \|\| restorePostActionSequence === context\.actionSequence\)/)
assert.match(panelSource, /if \(restorePostInFlight\) \{\s*return\s*\}/)
})
test('post-restore detail and tag reload fail closed before save', () => {
const save = sourceBetween(editSource, 'async function saveAgent()', 'function loadPluginFunctions()')
const reload = sourceBetween(
editSource,
'async function reloadAgentAfterSnapshotRestore',
'function isActiveSnapshotReload',
)
assert.ok(save.indexOf('if (snapshotReloadBlocked.value)') < save.indexOf('await updateAgent('))
assert.match(reload, /snapshotReloadBlocked\.value = true/)
assert.match(reload, /Promise\.all\(\[\s*getAgentDetail\(targetAgentId\),\s*getAgentTags\(targetAgentId\)/)
assert.ok(reload.indexOf('const [detail, tags] = await Promise.all') < reload.indexOf('applyPersistedAgentDetail('))
assert.match(reload, /snapshotReloadFailed\.value = true/)
assert.match(editSource, /:disabled="saving \|\| ttsOptionsLoading \|\| snapshotReloadBlocked"/)
assert.match(editSource, /v-if="snapshotReloadFailed"[\s\S]*@click="retrySnapshotReload"/)
})
test('parent mutations guard history opening and propagate into every restore gate', () => {
const opener = sourceBetween(editSource, 'function openSnapshotPanel()', 'function isSameStringList')
const restore = sourceBetween(
panelSource,
'async function confirmRestoreSnapshot()',
'async function requestRestoreConfirmation',
)
assert.match(opener, /if \(saving\.value \|\| snapshotReloadBlocked\.value\) \{[\s\S]*return/)
assert.match(editSource, /:disabled="saving \|\| snapshotReloadBlocked"[\s\S]*@click="openSnapshotPanel"/)
assert.match(editSource, /:mutation-busy="saving \|\| snapshotReloadBlocked"/)
assert.match(restore, /if \(props\.mutationBusy\) \{[\s\S]*return/)
const postIndex = restore.indexOf('await restoreAgentSnapshot(')
const finalGate = restore.lastIndexOf('if (!isActiveRestoreAction(context))', postIndex)
assert.ok(finalGate >= 0 && finalGate < postIndex)
})
test('the latest snapshot hides restore and delete actions', () => {
const restoreGate = sourceBetween(
panelSource,
'function canRestoreSnapshot(row: SnapshotRow)',
'function canDeleteSnapshot(row: SnapshotRow)',
)
const deleteGate = sourceBetween(
panelSource,
'function canDeleteSnapshot(row: SnapshotRow)',
'function buildDetailItems',
)
assert.match(restoreGate, /!!row\?\.id && !row\.isLatestSnapshot/)
assert.match(deleteGate, /!!row\?\.id && !row\.isLatestSnapshot/)
assert.match(panelSource, /v-if="canRestoreSnapshot\(snapshot\)"/)
assert.match(
panelSource,
/async function previewRestoreSnapshot\(row: SnapshotRow\) \{\s*if \(!canRestoreSnapshot\(row\)\) \{\s*return/,
)
})
@@ -0,0 +1,314 @@
export const SNAPSHOT_SECRET_REDACTED = '__SNAPSHOT_SECRET_REDACTED__'
/** @param {unknown} voices */
export function hasUsableTtsVoiceMetadata(voices) {
return Array.isArray(voices) && voices.length > 0
}
/**
* @param {unknown} currentMemModelId
* @param {unknown} targetMemModelId
*/
export function willRestorePermanentlyDeleteChatHistory(currentMemModelId, targetMemModelId) {
return currentMemModelId !== 'Memory_nomem' && targetMemModelId === 'Memory_nomem'
}
/**
* @param {Array<Record<string, any>>} voices
* @param {string} language
*/
export function filterTtsVoicesByLanguage(voices, language) {
if (!language) {
return voices
}
return voices.filter((voice) => {
if (typeof voice.languages !== 'string' || !voice.languages.trim()) {
return false
}
return voice.languages
.split(/[、;;,]/)
.map(item => item.trim())
.filter(Boolean)
.includes(language)
})
}
/** @param {unknown} value */
export function normalizeSnapshotTtsNumber(value) {
if (value === undefined || value === null || String(value).trim() === '') {
return null
}
if (typeof value === 'number') {
return Math.trunc(value)
}
const text = String(value).trim()
return /^[+-]?\d+$/.test(text) ? Number.parseInt(text, 10) : text
}
/**
* Normalize object-key order without changing array order. Context providers
* are consumed sequentially by the runtime, so their list order is semantic.
* @param {unknown} value
*/
export function normalizeSnapshotOrderedList(value) {
if (!Array.isArray(value)) {
return []
}
return value
.filter(item => item !== undefined && item !== null)
.map(normalizeDisplayObject)
}
/**
* @param {unknown} value
* @param {string} redactedLabel
* @param {string} [parentKey]
* @returns {unknown}
*/
export function redactSnapshotDisplayValue(value, redactedLabel, parentKey = '') {
if (value === SNAPSHOT_SECRET_REDACTED || isSensitiveKey(parentKey)) {
return redactedLabel
}
if (Array.isArray(value)) {
return value.map(item => redactSnapshotDisplayValue(item, redactedLabel, parentKey))
}
if (isPlainObject(value)) {
const entryNameKey = Object.keys(value).find((key) => {
return ['key', 'name'].includes(key.toLowerCase())
&& typeof value[key] === 'string'
&& isSensitiveKey(value[key])
})
const entryValueKey = Object.keys(value).find(key => key.toLowerCase() === 'value')
return Object.keys(value).sort().reduce((result, key) => {
const semanticKey = resolveUrlSemanticKey(parentKey, key)
if (entryNameKey && key === entryValueKey) {
result[key] = redactedLabel
}
else {
result[key] = redactSnapshotDisplayValue(value[key], redactedLabel, semanticKey)
}
return result
}, {})
}
if (typeof value === 'string' && isSnapshotUrlValue(parentKey, value)) {
return redactUrl(value, redactedLabel, parentKey)
}
return value
}
/** @param {unknown} value */
export function stablePrettyStringify(value) {
return JSON.stringify(normalizeDisplayObject(value), null, 2)
}
/** @param {string} language */
export function toIntlLocale(language) {
return language.replace('_', '-')
}
/** @param {string} key */
export function isSensitiveKey(key) {
const normalized = String(key).toLowerCase().replace(/[^a-z0-9]/g, '')
return normalized === 'authorization'
|| normalized.includes('authorization')
|| normalized.includes('authentication')
|| normalized === 'auth'
|| normalized.endsWith('auth')
|| normalized === 'cookie'
|| normalized === 'cookie2'
|| normalized === 'setcookie'
|| normalized === 'setcookie2'
|| normalized.endsWith('cookie')
|| normalized === 'session'
|| normalized.endsWith('session')
|| normalized.includes('sessionid')
|| normalized.includes('sessionkey')
|| normalized.includes('sessiontoken')
|| normalized.includes('sessioncookie')
|| normalized.endsWith('sessid')
|| normalized === 'token'
|| normalized.endsWith('token')
|| normalized.includes('apikey')
|| normalized.includes('appkey')
|| normalized.includes('accesskey')
|| normalized.includes('subscriptionkey')
|| normalized.includes('privatekey')
|| normalized.includes('password')
|| normalized.includes('passwd')
|| normalized.includes('secret')
|| normalized.includes('credential')
}
/**
* @param {string} value
* @param {string} redactedLabel
* @param {string} parentKey
*/
function redactUrl(value, redactedLabel, parentKey) {
const withoutCredentials = value.replace(
/^((?:[a-z][a-z0-9+.-]*:)*\/\/)[^/?#\s]*@/i,
(match, prefix) => `${prefix}${redactedLabel}@`,
)
const schemeMatch = /^((?:[a-z][a-z0-9+.-]*:)*\/\/)/i.exec(withoutCredentials)
let pathRedacted = withoutCredentials
if (schemeMatch) {
const scheme = schemeMatch[1]
const remainder = withoutCredentials.slice(scheme.length)
const suffixIndex = remainder.search(/[?#]/)
const authorityAndPath = suffixIndex < 0 ? remainder : remainder.slice(0, suffixIndex)
const suffix = suffixIndex < 0 ? '' : remainder.slice(suffixIndex)
const pathIndex = authorityAndPath.indexOf('/')
const authority = pathIndex < 0 ? authorityAndPath : authorityAndPath.slice(0, pathIndex)
const path = pathIndex < 0 ? '' : authorityAndPath.slice(pathIndex)
let host = authority.slice(authority.lastIndexOf('@') + 1)
if (host.startsWith('[')) {
const closingBracket = host.indexOf(']')
host = closingBracket > 0 ? host.slice(1, closingBracket) : host
}
else {
host = host.replace(/:\d+$/, '')
}
host = host.toLowerCase()
pathRedacted = `${scheme}${authority}${redactCapabilityPath(path, host, parentKey, redactedLabel)}${suffix}`
}
else {
const suffixIndex = withoutCredentials.search(/[?#]/)
const path = suffixIndex < 0 ? withoutCredentials : withoutCredentials.slice(0, suffixIndex)
const suffix = suffixIndex < 0 ? '' : withoutCredentials.slice(suffixIndex)
pathRedacted = `${redactCapabilityPath(path, '', parentKey, redactedLabel)}${suffix}`
}
const queryIndex = pathRedacted.search(/[?#]/)
if (queryIndex < 0) {
return pathRedacted
}
return `${pathRedacted.slice(0, queryIndex)}${pathRedacted[queryIndex]}${redactedLabel}`
}
/**
* Capability URLs often carry credentials in path segments rather than in
* query parameters. Preserve public provider IDs where their formats are
* known, and fail closed after generic hook markers.
* @param {string} path
* @param {string} host
* @param {string} parentKey
* @param {string} redactedLabel
*/
function redactCapabilityPath(path, host, parentKey, redactedLabel) {
if (!path) {
return path
}
const segments = path.split('/')
const normalizedSegments = segments.map(segment => segment.toLowerCase())
if (host === 'hooks.slack.com' || host === 'hooks.slack-gov.com') {
const servicesIndex = normalizedSegments.indexOf('services')
const capabilityIndex = servicesIndex >= 0 ? servicesIndex + 3 : -1
if (capabilityIndex > 0 && capabilityIndex < segments.length) {
segments[capabilityIndex] = redactedLabel
return segments.join('/')
}
}
if (host === 'discord.com'
|| host.endsWith('.discord.com')
|| host === 'discordapp.com'
|| host.endsWith('.discordapp.com')) {
const webhooksIndex = normalizedSegments.indexOf('webhooks')
const capabilityIndex = webhooksIndex >= 0 ? webhooksIndex + 2 : -1
if (capabilityIndex > 0 && capabilityIndex < segments.length) {
segments[capabilityIndex] = redactedLabel
return segments.join('/')
}
}
if (host === 'api.telegram.org') {
const botIndex = segments.findIndex(segment => /^bot[^:]+:.+$/i.test(segment))
if (botIndex >= 0) {
segments[botIndex] = segments[botIndex].replace(/^bot([^:]+):.+$/i, `bot$1:${redactedLabel}`)
return segments.join('/')
}
}
const markerIndex = normalizedSegments.findIndex(segment => /^(?:webhooks?|hooks?)$/.test(segment))
if (markerIndex >= 0 && markerIndex + 1 < segments.length) {
return [...segments.slice(0, markerIndex + 1), redactedLabel].join('/')
}
const normalizedKey = String(parentKey).toLowerCase().replace(/[^a-z0-9]/g, '')
if (normalizedKey.includes('webhook')) {
let lastSegmentIndex = -1
for (let index = segments.length - 1; index >= 0; index -= 1) {
if (segments[index].length > 0) {
lastSegmentIndex = index
break
}
}
if (lastSegmentIndex >= 0) {
segments[lastSegmentIndex] = redactedLabel
return segments.join('/')
}
}
return path
}
/**
* Carry webhook capability semantics through wrapper objects such as
* `{ deliveryWebhook: { options: { target: "https://.../secret" } } }`.
* @param {string} parentKey
* @param {string} childKey
*/
function resolveUrlSemanticKey(parentKey, childKey) {
if (isWebhookSemanticKey(childKey)) {
return childKey
}
if (isWebhookSemanticKey(parentKey)) {
return childKey ? `${parentKey}.${childKey}` : parentKey
}
return childKey
}
/** @param {string} value */
function isWebhookSemanticKey(value) {
const normalized = String(value || '').toLowerCase().replace(/[^a-z0-9]/g, '')
return normalized.includes('webhook')
|| normalized === 'hook'
|| normalized === 'hooks'
|| normalized.endsWith('hook')
|| normalized.endsWith('hooks')
}
/**
* @param {string} parentKey
* @param {string} value
*/
function isSnapshotUrlValue(parentKey, value) {
const normalizedKey = String(parentKey).toLowerCase().replace(/[^a-z0-9]/g, '')
return normalizedKey.includes('url')
|| normalizedKey.endsWith('uri')
|| normalizedKey.includes('endpoint')
|| isWebhookSemanticKey(parentKey)
|| /^(?:[a-z][a-z0-9+.-]*:)*\/\//i.test(value)
}
/** @param {unknown} value */
function normalizeDisplayObject(value) {
if (Array.isArray(value)) {
return value.map(normalizeDisplayObject)
}
if (isPlainObject(value)) {
return Object.keys(value).sort().reduce((result, key) => {
result[key] = normalizeDisplayObject(value[key])
return result
}, {})
}
return value
}
/**
* @param {unknown} value
* @returns {value is Record<string, unknown>}
*/
function isPlainObject(value) {
return value !== null && typeof value === 'object' && !Array.isArray(value)
}
@@ -0,0 +1,183 @@
/* eslint-disable test/no-import-node-test -- this zero-dependency gate intentionally uses Node's built-in runner */
import assert from 'node:assert/strict'
import test from 'node:test'
import {
filterTtsVoicesByLanguage,
hasUsableTtsVoiceMetadata,
isSensitiveKey,
normalizeSnapshotOrderedList,
normalizeSnapshotTtsNumber,
redactSnapshotDisplayValue,
SNAPSHOT_SECRET_REDACTED,
stablePrettyStringify,
toIntlLocale,
willRestorePermanentlyDeleteChatHistory,
} from './agentSnapshotUtils.mjs'
test('rejects empty TTS metadata while keeping language-less voices selectable without a filter', () => {
const voices = [{ id: 'voice-without-language', languages: '' }]
assert.equal(hasUsableTtsVoiceMetadata([]), false)
assert.equal(hasUsableTtsVoiceMetadata(voices), true)
assert.deepEqual(filterTtsVoicesByLanguage(voices, ''), voices)
assert.deepEqual(filterTtsVoicesByLanguage(voices, 'zh-CN'), [])
})
test('keeps inherited TTS values distinct from explicit zero', () => {
assert.equal(normalizeSnapshotTtsNumber(null), null)
assert.equal(normalizeSnapshotTtsNumber(''), null)
assert.equal(normalizeSnapshotTtsNumber(0), 0)
assert.equal(normalizeSnapshotTtsNumber('0'), 0)
})
test('warns only when restoring from a memory mode to no-memory mode', () => {
assert.equal(willRestorePermanentlyDeleteChatHistory('Memory_mem_local_short', 'Memory_nomem'), true)
assert.equal(willRestorePermanentlyDeleteChatHistory(undefined, 'Memory_nomem'), true)
assert.equal(willRestorePermanentlyDeleteChatHistory('Memory_nomem', 'Memory_nomem'), false)
assert.equal(willRestorePermanentlyDeleteChatHistory('Memory_mem_local_short', 'Memory_mem0ai'), false)
})
test('keeps context-provider order while stabilizing object keys', () => {
const first = normalizeSnapshotOrderedList([
{ url: 'https://one.example', headers: { z: 'last', a: 'first' } },
{ url: 'https://two.example', headers: {} },
])
const reversed = normalizeSnapshotOrderedList([
{ url: 'https://two.example', headers: {} },
{ headers: { a: 'first', z: 'last' }, url: 'https://one.example' },
])
assert.deepEqual(first[0], {
headers: { a: 'first', z: 'last' },
url: 'https://one.example',
})
assert.notDeepEqual(first, reversed)
})
test('redacts nested credentials, header entries, URL credentials and query values', () => {
const redacted = redactSnapshotDisplayValue({
'apiKey': 'api-secret',
'Authentication': 'authentication-secret',
'X-Auth': 'auth-secret',
'PHPSESSID': 'php-session-secret',
'Session-Key': 'session-key-secret',
'headers': [
{ key: 'Authorization', value: 'Bearer secret' },
{ key: 'Proxy-Authorization', value: 'Basic secret' },
{ key: 'Cookie', value: 'sid=secret' },
{ key: 'Set-Cookie', value: 'session=secret' },
{ key: 'Content-Type', value: 'application/json' },
],
'marker': SNAPSHOT_SECRET_REDACTED,
'session_id': 'session-secret',
'url': 'https://user:pass@example.com/context?access_token=secret#fragment',
'endpoint': 'https://example.com/callback?signature=secret',
}, '[hidden]')
assert.deepEqual(redacted, {
'apiKey': '[hidden]',
'Authentication': '[hidden]',
'X-Auth': '[hidden]',
'PHPSESSID': '[hidden]',
'Session-Key': '[hidden]',
'headers': [
{ key: 'Authorization', value: '[hidden]' },
{ key: 'Proxy-Authorization', value: '[hidden]' },
{ key: 'Cookie', value: '[hidden]' },
{ key: 'Set-Cookie', value: '[hidden]' },
{ key: 'Content-Type', value: 'application/json' },
],
'marker': '[hidden]',
'session_id': '[hidden]',
'url': 'https://[hidden]@example.com/context?[hidden]',
'endpoint': 'https://example.com/callback?[hidden]',
})
})
test('redacts structured key/name entries without relying on property casing', () => {
const entries = [
{ KEY: 'Authentication', VALUE: 'key secret' },
{ Name: 'X-Auth', Value: 'name secret' },
{ key: 'public-label', NAME: 'Authorization', value: 'secret selected from either marker' },
{ NAME: 'Content-Type', VALUE: 'application/json; charset=utf-8' },
]
assert.deepEqual(redactSnapshotDisplayValue(entries, '[hidden]'), [
{ KEY: 'Authentication', VALUE: '[hidden]' },
{ Name: 'X-Auth', Value: '[hidden]' },
{ key: 'public-label', NAME: 'Authorization', value: '[hidden]' },
{ NAME: 'Content-Type', VALUE: 'application/json; charset=utf-8' },
])
})
test('redacts capability path values without hiding ordinary REST paths', () => {
const redacted = redactSnapshotDisplayValue({
slack: 'https://hooks.slack.com/services/T111/B222/capability-value',
slackGov: 'https://hooks.slack-gov.com/services/T111/B222/gov-capability-value',
discord: 'https://discord.com/api/webhooks/123456/capability-value',
telegram: 'https://api.telegram.org/bot123456:capability-value/sendMessage',
genericHook: 'https://example.com/api/hook/capability/continuation',
genericHooks: 'https://example.com/api/hooks/capability/continuation',
genericWebhook: 'https://example.com/api/webhook/capability/continuation',
genericWebhooks: 'https://example.com/api/webhooks/capability/continuation',
relativeUrl: '/api/webhooks/capability/continuation',
callbackWebhookUrl: 'https://example.com/incoming/capability-value',
nested: {
deliveryWebhook: {
options: {
target: 'https://example.com/incoming/nested-capability-value',
relativeTarget: '/incoming/nested-relative-capability-value',
},
},
},
protocolRelativeUrl: '//protocol-user:protocol-pass@example.com/context',
jdbcUrl: 'jdbc:mysql://jdbc-user:jdbc-pass@example.com/database',
ordinary: 'https://example.com/api/v1/agents/42',
}, '[hidden]')
assert.deepEqual(redacted, {
slack: 'https://hooks.slack.com/services/T111/B222/[hidden]',
slackGov: 'https://hooks.slack-gov.com/services/T111/B222/[hidden]',
discord: 'https://discord.com/api/webhooks/123456/[hidden]',
telegram: 'https://api.telegram.org/bot123456:[hidden]/sendMessage',
genericHook: 'https://example.com/api/hook/[hidden]',
genericHooks: 'https://example.com/api/hooks/[hidden]',
genericWebhook: 'https://example.com/api/webhook/[hidden]',
genericWebhooks: 'https://example.com/api/webhooks/[hidden]',
relativeUrl: '/api/webhooks/[hidden]',
callbackWebhookUrl: 'https://example.com/incoming/[hidden]',
nested: {
deliveryWebhook: {
options: {
target: 'https://example.com/incoming/[hidden]',
relativeTarget: '/incoming/[hidden]',
},
},
},
protocolRelativeUrl: '//[hidden]@example.com/context',
jdbcUrl: 'jdbc:mysql://[hidden]@example.com/database',
ordinary: 'https://example.com/api/v1/agents/42',
})
})
test('matches the backend sensitive key variants', () => {
for (const key of [
'Authentication',
'X-Auth',
'Proxy-Authorization',
'Cookie',
'Set-Cookie',
'PHPSESSID',
'Session-Key',
'session_token',
'Ocp-Apim-Subscription-Key',
]) {
assert.equal(isSensitiveKey(key), true, `${key} should be redacted`)
}
assert.equal(isSensitiveKey('Content-Type'), false)
assert.equal(isSensitiveKey('publicKey'), false)
})
test('formats objects stably and maps app locale names to Intl locales', () => {
assert.equal(stablePrettyStringify({ z: 1, a: { d: 2, c: 1 } }), '{\n "a": {\n "c": 1,\n "d": 2\n },\n "z": 1\n}')
assert.equal(toIntlLocale('pt_BR'), 'pt-BR')
})
+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>