Files
dify/web/app/components/workflow/skill/hooks/use-create-operations.ts
yyh acec271e88 fix(skill): resolve race condition in upload progress tracking
Use shared object reference instead of separate variables to track
upload progress across concurrent Promise.all operations, preventing
progress bar from showing incorrect or regressing values.
2026-01-28 20:25:12 +08:00

175 lines
5.2 KiB
TypeScript

'use client'
import type { StoreApi } from 'zustand'
import type { SkillEditorSliceShape } from '@/app/components/workflow/store/workflow/skill-editor/types'
import type { BatchUploadNodeInput } from '@/types/app-asset'
import { useCallback, useRef } from 'react'
import {
useBatchUpload,
useCreateAppAssetFolder,
useUploadFileWithPresignedUrl,
} from '@/service/use-app-asset'
import { prepareSkillUploadFile } from '../utils/skill-upload-utils'
type UseCreateOperationsOptions = {
parentId: string | null
appId: string
storeApi: StoreApi<SkillEditorSliceShape>
onClose: () => void
}
const getRelativePath = (file: File) => {
return (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name
}
export function useCreateOperations({
parentId,
appId,
storeApi,
onClose,
}: UseCreateOperationsOptions) {
const fileInputRef = useRef<HTMLInputElement>(null)
const folderInputRef = useRef<HTMLInputElement>(null)
const createFolder = useCreateAppAssetFolder()
const uploadFile = useUploadFileWithPresignedUrl()
const batchUpload = useBatchUpload()
const handleNewFile = useCallback(() => {
storeApi.getState().startCreateNode('file', parentId)
onClose()
}, [onClose, parentId, storeApi])
const handleNewFolder = useCallback(() => {
storeApi.getState().startCreateNode('folder', parentId)
onClose()
}, [onClose, parentId, storeApi])
const handleFileChange = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || [])
if (files.length === 0) {
onClose()
return
}
const total = files.length
const progress = { uploaded: 0, failed: 0 }
storeApi.getState().setUploadStatus('uploading')
storeApi.getState().setUploadProgress({ uploaded: 0, total, failed: 0 })
try {
const uploadFiles = await Promise.all(files.map(file => prepareSkillUploadFile(file)))
await Promise.all(
uploadFiles.map(async (file) => {
try {
await uploadFile.mutateAsync({ appId, file, parentId })
progress.uploaded++
}
catch {
progress.failed++
}
storeApi.getState().setUploadProgress({ uploaded: progress.uploaded, total, failed: progress.failed })
}),
)
storeApi.getState().setUploadStatus(progress.failed > 0 ? 'partial_error' : 'success')
storeApi.getState().setUploadProgress({ uploaded: progress.uploaded, total, failed: progress.failed })
}
catch {
storeApi.getState().setUploadStatus('partial_error')
}
finally {
e.target.value = ''
onClose()
}
}, [appId, uploadFile, onClose, parentId, storeApi])
const handleFolderChange = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || [])
if (files.length === 0) {
onClose()
return
}
storeApi.getState().setUploadStatus('uploading')
storeApi.getState().setUploadProgress({ uploaded: 0, total: files.length, failed: 0 })
try {
const fileMap = new Map<string, File>()
const tree: BatchUploadNodeInput[] = []
const folderMap = new Map<string, BatchUploadNodeInput>()
const uploadFiles = await Promise.all(files.map(async (file) => {
const relativePath = getRelativePath(file)
const uploadFile = await prepareSkillUploadFile(file)
return { relativePath, uploadFile }
}))
for (const { relativePath, uploadFile } of uploadFiles) {
fileMap.set(relativePath, uploadFile)
const parts = relativePath.split('/')
let currentLevel = tree
let currentPath = ''
for (let i = 0; i < parts.length; i++) {
const part = parts[i]
const isLastPart = i === parts.length - 1
currentPath = currentPath ? `${currentPath}/${part}` : part
if (isLastPart) {
currentLevel.push({
name: part,
node_type: 'file',
size: uploadFile.size,
})
}
else {
let folder = folderMap.get(currentPath)
if (!folder) {
folder = {
name: part,
node_type: 'folder',
children: [],
}
folderMap.set(currentPath, folder)
currentLevel.push(folder)
}
currentLevel = folder.children!
}
}
}
await batchUpload.mutateAsync({
appId,
tree,
files: fileMap,
parentId,
onProgress: (uploaded, total) => {
storeApi.getState().setUploadProgress({ uploaded, total, failed: 0 })
},
})
storeApi.getState().setUploadStatus('success')
storeApi.getState().setUploadProgress({ uploaded: files.length, total: files.length, failed: 0 })
}
catch {
storeApi.getState().setUploadStatus('partial_error')
}
finally {
e.target.value = ''
onClose()
}
}, [appId, batchUpload, onClose, parentId, storeApi])
return {
fileInputRef,
folderInputRef,
isCreating: uploadFile.isPending || createFolder.isPending || batchUpload.isPending,
handleNewFile,
handleNewFolder,
handleFileChange,
handleFolderChange,
}
}