mirror of
https://github.com/langgenius/dify.git
synced 2026-03-09 17:36:44 +08:00
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.
340 lines
11 KiB
TypeScript
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>
|
|
)
|
|
}
|