import type { QueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query' import { useEventListener } from 'ahooks' import isDeepEqual from 'fast-deep-equal' import * as React from 'react' import { useCallback, useEffect, useMemo, useRef } from 'react' import { useTranslation } from 'react-i18next' import { toast } from '@/app/components/base/ui/toast' import { useWorkflowStore } from '@/app/components/workflow/store' import { extractToolConfigIds } from '@/app/components/workflow/utils' import { useGlobalPublicStore } from '@/context/global-public-context' import { consoleQuery } from '@/service/client' import { useUpdateAppAssetFileContent } from '@/service/use-app-asset' import { skillCollaborationManager } from '../../collaboration/skills/skill-collaboration-manager' import { START_TAB_ID } from '../constants' import { SkillSaveContext } from './skill-save-context' type SaveSnapshot = { content: string metadata?: Record hasDraftContent: boolean hasMetadataDirty: boolean } type CachedFileContent = { content?: string metadata?: Record [key: string]: unknown } export type SaveFileOptions = { fallbackContent?: string fallbackMetadata?: Record } export type SaveResult = { saved: boolean error?: unknown } export type FallbackEntry = { content: string metadata?: Record } type SkillSaveProviderProps = { appId: string children: React.ReactNode } type CollaborativeSaveWaiter = { promise: Promise cancel: () => void } const COLLABORATION_SYNC_TIMEOUT_MS = 1500 const normalizeMetadata = ( rawMetadata: Record | undefined, content: string, ): Record | undefined => { if (!rawMetadata || typeof rawMetadata !== 'object' || !('tools' in rawMetadata)) return rawMetadata const toolIds = extractToolConfigIds(content) const rawTools = (rawMetadata as Record).tools if (!rawTools || typeof rawTools !== 'object') return rawMetadata const entries = Object.entries(rawTools as Record) const nextTools = entries.reduce>((acc, [id, value]) => { if (toolIds.has(id)) acc[id] = value return acc }, {}) const nextMetadata = { ...(rawMetadata as Record) } if (Object.keys(nextTools).length > 0) nextMetadata.tools = nextTools else delete nextMetadata.tools return nextMetadata } const patchFileContentCache = ( qc: QueryClient, queryKey: readonly unknown[], serialized: string, ) => { qc.setQueryData(queryKey, (existing) => { if (!existing || typeof existing !== 'object') return { content: serialized } return { ...existing, content: serialized } }) } export const SkillSaveProvider = ({ appId, children, }: SkillSaveProviderProps) => { const { t } = useTranslation() const storeApi = useWorkflowStore() const queryClient = useQueryClient() const { mutateAsync: updateFileContent } = useUpdateAppAssetFileContent() const isCollaborationEnabled = useGlobalPublicStore(s => s.systemFeatures.enable_collaboration_mode) const queueRef = useRef>>(new Map()) const fallbackRegistryRef = useRef>(new Map()) const getCachedContent = useCallback((fileId: string): string | undefined => { if (!appId) return undefined const cached = queryClient.getQueryData( consoleQuery.appAsset.getFileContent.queryKey({ input: { params: { appId, nodeId: fileId } }, }), ) const rawContent = cached?.content if (!rawContent) return undefined try { const parsed = JSON.parse(rawContent) as { content?: unknown } if (parsed && typeof parsed === 'object' && typeof parsed.content === 'string') return parsed.content } catch { // Fall back to raw content when it's not a JSON wrapper. } return rawContent }, [appId, queryClient]) const buildSnapshot = useCallback(( fileId: string, fallbackContent?: string, fallbackMetadata?: Record, ): SaveSnapshot | null => { const state = storeApi.getState() const draftContent = state.dirtyContents.get(fileId) const isMetadataDirty = state.dirtyMetadataIds.has(fileId) if (draftContent === undefined && !isMetadataDirty) return null const registryEntry = fallbackRegistryRef.current.get(fileId) const rawMetadata = state.fileMetadata.get(fileId) ?? fallbackMetadata ?? registryEntry?.metadata const content = draftContent ?? getCachedContent(fileId) ?? fallbackContent ?? registryEntry?.content if (content === undefined) return null const metadata = normalizeMetadata(rawMetadata, content) return { content, metadata, hasDraftContent: draftContent !== undefined, hasMetadataDirty: isMetadataDirty, } }, [getCachedContent, storeApi]) const updateCachedContent = useCallback((fileId: string, snapshot: SaveSnapshot) => { if (!appId) return const queryKey = consoleQuery.appAsset.getFileContent.queryKey({ input: { params: { appId, nodeId: fileId } }, }) const serialized = JSON.stringify({ content: snapshot.content, ...(snapshot.metadata ? { metadata: snapshot.metadata } : {}), }) patchFileContentCache(queryClient, queryKey, serialized) }, [appId, queryClient]) const createCollaborativeSaveWaiter = useCallback((fileId: string): CollaborativeSaveWaiter => { let settled = false let unsubscribe: (() => void) | null = null let timeoutId: ReturnType | null = null const promise = new Promise((resolve) => { const finish = (saved: boolean) => { if (settled) return settled = true if (timeoutId !== null) clearTimeout(timeoutId) unsubscribe?.() resolve(saved) } unsubscribe = skillCollaborationManager.onAnyFileSaved((payload) => { if (!payload || payload.file_id !== fileId) return finish(true) }) timeoutId = setTimeout(() => finish(false), COLLABORATION_SYNC_TIMEOUT_MS) }) return { promise, cancel: () => { if (settled) return settled = true if (timeoutId !== null) clearTimeout(timeoutId) unsubscribe?.() }, } }, []) const performSave = useCallback(async ( fileId: string, options?: SaveFileOptions, ): Promise => { if (!appId || !fileId || fileId === START_TAB_ID) return { saved: false } const isCollaborativeFollower = isCollaborationEnabled && skillCollaborationManager.isFileCollaborative(fileId) && !skillCollaborationManager.isLeader(fileId) if (isCollaborativeFollower) { const delegatedSaveWaiter = createCollaborativeSaveWaiter(fileId) const didRequestSync = skillCollaborationManager.requestSync(fileId) if (didRequestSync) { const wasSavedByLeader = await delegatedSaveWaiter.promise if (wasSavedByLeader) return { saved: true } } else { delegatedSaveWaiter.cancel() } } const snapshot = buildSnapshot(fileId, options?.fallbackContent, options?.fallbackMetadata) if (!snapshot) { return { saved: false } } try { await updateFileContent({ appId, nodeId: fileId, payload: { content: snapshot.content, ...(snapshot.metadata ? { metadata: snapshot.metadata } : {}), }, }) updateCachedContent(fileId, snapshot) const latestState = storeApi.getState() if (snapshot.hasDraftContent) { const latestDraft = latestState.dirtyContents.get(fileId) if (latestDraft === snapshot.content) latestState.clearDraftContent(fileId) } if (snapshot.hasMetadataDirty) { const latestMetadata = latestState.fileMetadata.get(fileId) const normalizedLatest = normalizeMetadata(latestMetadata, snapshot.content) if (isDeepEqual(normalizedLatest, snapshot.metadata)) latestState.clearDraftMetadata(fileId) } if (isCollaborationEnabled && skillCollaborationManager.isFileCollaborative(fileId)) { skillCollaborationManager.emitFileSaved(fileId, snapshot.content, snapshot.metadata) } return { saved: true } } catch (error) { return { saved: false, error } } }, [appId, buildSnapshot, createCollaborativeSaveWaiter, isCollaborationEnabled, storeApi, updateCachedContent, updateFileContent]) const saveFile = useCallback(async ( fileId: string, options?: SaveFileOptions, ): Promise => { if (!fileId || fileId === START_TAB_ID) return { saved: false } const previous = queueRef.current.get(fileId) || Promise.resolve({ saved: false }) const next = previous.then(() => performSave(fileId, options)) queueRef.current.set(fileId, next) return next.finally(() => { if (queueRef.current.get(fileId) === next) queueRef.current.delete(fileId) }) }, [performSave]) const saveAllDirty = useCallback(() => { if (!appId) return const { dirtyContents, dirtyMetadataIds } = storeApi.getState() if (dirtyContents.size === 0 && dirtyMetadataIds.size === 0) return const dirtyIds = new Set() dirtyContents.forEach((_value, fileId) => { dirtyIds.add(fileId) }) dirtyMetadataIds.forEach((fileId) => { dirtyIds.add(fileId) }) const tasks = Array.from(dirtyIds) .filter(fileId => fileId !== START_TAB_ID) .map(fileId => saveFile(fileId)) if (tasks.length === 0) return void Promise.allSettled(tasks) }, [appId, saveFile, storeApi]) const registerFallback = useCallback((fileId: string, entry: FallbackEntry) => { fallbackRegistryRef.current.set(fileId, entry) }, []) const unregisterFallback = useCallback((fileId: string) => { fallbackRegistryRef.current.delete(fileId) }, []) useEventListener('keydown', (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault() const { activeTabId } = storeApi.getState() if (!activeTabId || activeTabId === START_TAB_ID) return const fallback = fallbackRegistryRef.current.get(activeTabId) void saveFile(activeTabId, { fallbackContent: fallback?.content, fallbackMetadata: fallback?.metadata, }).then((result) => { if (result.error) { const errorMessage = result.error instanceof Error ? result.error.message : String(result.error) toast.error(errorMessage) } else if (result.saved) { toast.success(t('api.saved', { ns: 'common' })) } }) } }, { target: typeof window !== 'undefined' ? window : undefined }) const value = useMemo(() => ({ saveFile, saveAllDirty, registerFallback, unregisterFallback, }), [saveAllDirty, saveFile, registerFallback, unregisterFallback]) useEffect(() => { if (!appId || !isCollaborationEnabled) return return skillCollaborationManager.onAnyFileSaved((payload) => { if (!payload?.file_id || typeof payload.content !== 'string') return const fileId = payload.file_id const queryKey = consoleQuery.appAsset.getFileContent.queryKey({ input: { params: { appId, nodeId: fileId } }, }) const serialized = JSON.stringify({ content: payload.content, ...(payload.metadata ? { metadata: payload.metadata } : {}), }) patchFileContentCache(queryClient, queryKey, serialized) const state = storeApi.getState() state.clearDraftContent(fileId) const latestMetadata = state.fileMetadata.get(fileId) const normalizedLatest = normalizeMetadata(latestMetadata, payload.content) if (payload.metadata === undefined || isDeepEqual(normalizedLatest, payload.metadata)) state.clearDraftMetadata(fileId) }) }, [appId, isCollaborationEnabled, queryClient, storeApi]) return ( {children} ) }