import { useQueryClient } from '@tanstack/react-query' import { useEventListener } from 'ahooks' import isDeepEqual from 'fast-deep-equal' import * as React from 'react' import { useCallback, useMemo, useRef } from 'react' import { useTranslation } from 'react-i18next' import Toast from '@/app/components/base/toast' import { useWorkflowStore } from '@/app/components/workflow/store' import { extractToolConfigIds } from '@/app/components/workflow/utils' import { consoleQuery } from '@/service/client' import { useUpdateAppAssetFileContent } from '@/service/use-app-asset' import { START_TAB_ID } from '../constants' 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 SkillSaveContextValue = { saveFile: (fileId: string, options?: SaveFileOptions) => Promise saveAllDirty: () => void registerFallback: (fileId: string, entry: FallbackEntry) => void unregisterFallback: (fileId: string) => void } type SkillSaveProviderProps = { appId: string children: React.ReactNode } const SkillSaveContext = React.createContext(null) export const SkillSaveProvider = ({ appId, children, }: SkillSaveProviderProps) => { const { t } = useTranslation() const storeApi = useWorkflowStore() const queryClient = useQueryClient() const updateContent = useUpdateAppAssetFileContent() 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 let metadata = rawMetadata if (rawMetadata && typeof rawMetadata === 'object' && 'tools' in rawMetadata) { const toolIds = extractToolConfigIds(content) const rawTools = (rawMetadata as Record).tools if (rawTools && typeof rawTools === 'object') { 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 metadata = nextMetadata } } 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 existing = queryClient.getQueryData(queryKey) const serialized = JSON.stringify({ content: snapshot.content, ...(snapshot.metadata ? { metadata: snapshot.metadata } : {}), }) const nextData: CachedFileContent = { ...(existing && typeof existing === 'object' ? existing : {}), content: serialized, } queryClient.setQueryData(queryKey, nextData) }, [appId, queryClient]) const performSave = useCallback(async ( fileId: string, options?: SaveFileOptions, ): Promise => { if (!appId || !fileId || fileId === START_TAB_ID) return { saved: false } const snapshot = buildSnapshot(fileId, options?.fallbackContent, options?.fallbackMetadata) if (!snapshot) return { saved: false } try { await updateContent.mutateAsync({ 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) if (isDeepEqual(latestMetadata, snapshot.metadata)) latestState.clearDraftMetadata(fileId) } return { saved: true } } catch (error) { return { saved: false, error } } }, [appId, buildSnapshot, storeApi, updateCachedContent, updateContent]) 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.notify({ type: 'error', message: errorMessage }) } else if (result.saved) { Toast.notify({ type: 'success', message: t('api.saved', { ns: 'common' }) }) } }) } }, { target: typeof window !== 'undefined' ? window : undefined }) const value = useMemo(() => ({ saveFile, saveAllDirty, registerFallback, unregisterFallback, }), [saveAllDirty, saveFile, registerFallback, unregisterFallback]) return ( {children} ) } export const useSkillSaveManager = () => { const context = React.useContext(SkillSaveContext) if (!context) throw new Error('Missing SkillSaveProvider in the tree') return context }