Files
dify/web/app/components/workflow/skill/hooks/use-skill-save-manager.tsx
yyh f1d099d50d refactor: extract skill save context, stabilize mutation dependency, and deduplicate cache updates
Split SkillSaveContext and useSkillSaveManager into a separate file to
fix react-refresh/only-export-components lint error. Destructure
mutateAsync from useUpdateAppAssetFileContent for a stable callback
reference, preventing unnecessary useCallback cascade rebuilds. Extract
shared patchFileContentCache helper to unify setQueryData logic between
updateCachedContent and the collaboration event handler.
2026-02-03 21:09:35 +08:00

340 lines
11 KiB
TypeScript

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/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<string, unknown>
hasDraftContent: boolean
hasMetadataDirty: boolean
}
type CachedFileContent = {
content?: string
metadata?: Record<string, unknown>
[key: string]: unknown
}
export type SaveFileOptions = {
fallbackContent?: string
fallbackMetadata?: Record<string, unknown>
}
export type SaveResult = {
saved: boolean
error?: unknown
}
export type FallbackEntry = {
content: string
metadata?: Record<string, unknown>
}
type SkillSaveProviderProps = {
appId: string
children: React.ReactNode
}
const normalizeMetadata = (
rawMetadata: Record<string, unknown> | undefined,
content: string,
): Record<string, unknown> | undefined => {
if (!rawMetadata || typeof rawMetadata !== 'object' || !('tools' in rawMetadata))
return rawMetadata
const toolIds = extractToolConfigIds(content)
const rawTools = (rawMetadata as Record<string, unknown>).tools
if (!rawTools || typeof rawTools !== 'object')
return rawMetadata
const entries = Object.entries(rawTools as Record<string, unknown>)
const nextTools = entries.reduce<Record<string, unknown>>((acc, [id, value]) => {
if (toolIds.has(id))
acc[id] = value
return acc
}, {})
const nextMetadata = { ...(rawMetadata as Record<string, unknown>) }
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<CachedFileContent>(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<Map<string, Promise<SaveResult>>>(new Map())
const fallbackRegistryRef = useRef<Map<string, FallbackEntry>>(new Map())
const getCachedContent = useCallback((fileId: string): string | undefined => {
if (!appId)
return undefined
const cached = queryClient.getQueryData<CachedFileContent>(
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<string, unknown>,
): 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 performSave = useCallback(async (
fileId: string,
options?: SaveFileOptions,
): Promise<SaveResult> => {
if (!appId || !fileId || fileId === START_TAB_ID)
return { saved: false }
if (isCollaborationEnabled && skillCollaborationManager.isFileCollaborative(fileId) && !skillCollaborationManager.isLeader(fileId)) {
skillCollaborationManager.requestSync(fileId)
return { saved: false }
}
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, isCollaborationEnabled, storeApi, updateCachedContent, updateFileContent])
const saveFile = useCallback(async (
fileId: string,
options?: SaveFileOptions,
): Promise<SaveResult> => {
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<string>()
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])
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 (
<SkillSaveContext.Provider value={value}>
{children}
</SkillSaveContext.Provider>
)
}