From 34c92e1045da3c2d5cf6f46ec307bdff813ab22a Mon Sep 17 00:00:00 2001 From: yangdx Date: Fri, 28 Mar 2025 14:16:53 +0800 Subject: [PATCH 1/7] Feat: improve file upload error handling for unspported files --- .../documents/UploadDocumentsDialog.tsx | 147 +++++++++++++----- .../src/components/ui/FileUploader.tsx | 141 +++++++++++++---- 2 files changed, 222 insertions(+), 66 deletions(-) diff --git a/lightrag_webui/src/components/documents/UploadDocumentsDialog.tsx b/lightrag_webui/src/components/documents/UploadDocumentsDialog.tsx index 0a4d1026..bf1d00b7 100644 --- a/lightrag_webui/src/components/documents/UploadDocumentsDialog.tsx +++ b/lightrag_webui/src/components/documents/UploadDocumentsDialog.tsx @@ -1,4 +1,5 @@ import { useState, useCallback } from 'react' +import { FileRejection } from 'react-dropzone' import Button from '@/components/ui/Button' import { Dialog, @@ -23,57 +24,126 @@ export default function UploadDocumentsDialog() { const [progresses, setProgresses] = useState>({}) const [fileErrors, setFileErrors] = useState>({}) + const handleRejectedFiles = useCallback( + (rejectedFiles: FileRejection[]) => { + // Process rejected files and add them to fileErrors + rejectedFiles.forEach(({ file, errors }) => { + // Get the first error message + let errorMsg = errors[0]?.message || t('documentPanel.uploadDocuments.fileUploader.fileRejected', { name: file.name }) + + // Simplify error message for unsupported file types + if (errorMsg.includes('file-invalid-type')) { + errorMsg = t('documentPanel.uploadDocuments.fileUploader.unsupportedType') + } + + // Set progress to 100% to display error message + setProgresses((pre) => ({ + ...pre, + [file.name]: 100 + })) + + // Add error message to fileErrors + setFileErrors(prev => ({ + ...prev, + [file.name]: errorMsg + })) + }) + }, + [setProgresses, setFileErrors, t] + ) + const handleDocumentsUpload = useCallback( async (filesToUpload: File[]) => { setIsUploading(true) - setFileErrors({}) - + + // Only clear errors for files that are being uploaded, keep errors for rejected files + setFileErrors(prev => { + const newErrors = { ...prev }; + filesToUpload.forEach(file => { + delete newErrors[file.name]; + }); + return newErrors; + }); + + // Show uploading toast + const toastId = toast.loading(t('documentPanel.uploadDocuments.batch.uploading')) + try { - toast.promise( - (async () => { + // Track errors locally to ensure we have the final state + const uploadErrors: Record = {} + + await Promise.all( + filesToUpload.map(async (file) => { try { - await Promise.all( - filesToUpload.map(async (file) => { - try { - const result = await uploadDocument(file, (percentCompleted: number) => { - console.debug(t('documentPanel.uploadDocuments.single.uploading', { name: file.name, percent: percentCompleted })) - setProgresses((pre) => ({ - ...pre, - [file.name]: percentCompleted - })) - }) + // Initialize upload progress + setProgresses((pre) => ({ + ...pre, + [file.name]: 0 + })) + + const result = await uploadDocument(file, (percentCompleted: number) => { + console.debug(t('documentPanel.uploadDocuments.single.uploading', { name: file.name, percent: percentCompleted })) + setProgresses((pre) => ({ + ...pre, + [file.name]: percentCompleted + })) + }) - if (result.status !== 'success') { - setFileErrors(prev => ({ - ...prev, - [file.name]: result.message - })) - } - } catch (err) { - setFileErrors(prev => ({ - ...prev, - [file.name]: errorMessage(err) - })) - } - }) - ) - } catch (error) { - console.error('Upload failed:', error) + if (result.status !== 'success') { + uploadErrors[file.name] = result.message + setFileErrors(prev => ({ + ...prev, + [file.name]: result.message + })) + } + } catch (err) { + console.error(`Upload failed for ${file.name}:`, err) + + // Handle HTTP errors, including 400 errors + let errorMsg = errorMessage(err) + + // If it's an axios error with response data, try to extract more detailed error info + if (err && typeof err === 'object' && 'response' in err) { + const axiosError = err as { response?: { status: number, data?: { detail?: string } } } + if (axiosError.response?.status === 400) { + // Extract specific error message from backend response + errorMsg = axiosError.response.data?.detail || errorMsg + } + + // Set progress to 100% to display error message + setProgresses((pre) => ({ + ...pre, + [file.name]: 100 + })) + } + + // Record error message in both local tracking and state + uploadErrors[file.name] = errorMsg + setFileErrors(prev => ({ + ...prev, + [file.name]: errorMsg + })) } - })(), - { - loading: t('documentPanel.uploadDocuments.batch.uploading'), - success: t('documentPanel.uploadDocuments.batch.success'), - error: t('documentPanel.uploadDocuments.batch.error') - } + }) ) + + // Check if any files failed to upload using our local tracking + const hasErrors = Object.keys(uploadErrors).length > 0 + + // Update toast status + if (hasErrors) { + toast.error(t('documentPanel.uploadDocuments.batch.error'), { id: toastId }) + } else { + toast.success(t('documentPanel.uploadDocuments.batch.success'), { id: toastId }) + } } catch (err) { - toast.error(t('documentPanel.uploadDocuments.generalError', { error: errorMessage(err) })) + console.error('Unexpected error during upload:', err) + toast.error(t('documentPanel.uploadDocuments.generalError', { error: errorMessage(err) }), { id: toastId }) } finally { setIsUploading(false) } }, - [setIsUploading, setProgresses, t] + [setIsUploading, setProgresses, setFileErrors, t] ) return ( @@ -107,6 +177,7 @@ export default function UploadDocumentsDialog() { maxSize={200 * 1024 * 1024} description={t('documentPanel.uploadDocuments.fileTypes')} onUpload={handleDocumentsUpload} + onReject={handleRejectedFiles} progresses={progresses} fileErrors={fileErrors} disabled={isUploading} diff --git a/lightrag_webui/src/components/ui/FileUploader.tsx b/lightrag_webui/src/components/ui/FileUploader.tsx index c94cb293..7ecfc817 100644 --- a/lightrag_webui/src/components/ui/FileUploader.tsx +++ b/lightrag_webui/src/components/ui/FileUploader.tsx @@ -39,6 +39,14 @@ interface FileUploaderProps extends React.HTMLAttributes { */ onUpload?: (files: File[]) => Promise + /** + * Function to be called when files are rejected. + * @type (rejections: FileRejection[]) => void + * @default undefined + * @example onReject={(rejections) => handleRejectedFiles(rejections)} + */ + onReject?: (rejections: FileRejection[]) => void + /** * Progress of the uploaded files. * @type Record | undefined @@ -125,6 +133,7 @@ function FileUploader(props: FileUploaderProps) { value: valueProp, onValueChange, onUpload, + onReject, progresses, fileErrors, accept = supportedFileTypes, @@ -144,38 +153,77 @@ function FileUploader(props: FileUploaderProps) { const onDrop = React.useCallback( (acceptedFiles: File[], rejectedFiles: FileRejection[]) => { - if (!multiple && maxFileCount === 1 && acceptedFiles.length > 1) { + // Calculate total file count including both accepted and rejected files + const totalFileCount = (files?.length ?? 0) + acceptedFiles.length + rejectedFiles.length + + // Check file count limits + if (!multiple && maxFileCount === 1 && (acceptedFiles.length + rejectedFiles.length) > 1) { toast.error(t('documentPanel.uploadDocuments.fileUploader.singleFileLimit')) return } - if ((files?.length ?? 0) + acceptedFiles.length > maxFileCount) { + if (totalFileCount > maxFileCount) { toast.error(t('documentPanel.uploadDocuments.fileUploader.maxFilesLimit', { count: maxFileCount })) return } - const newFiles = acceptedFiles.map((file) => + // Handle rejected files first - this will set error states + if (rejectedFiles.length > 0) { + if (onReject) { + // Use the onReject callback if provided + onReject(rejectedFiles) + } else { + // Fall back to toast notifications if no callback is provided + rejectedFiles.forEach(({ file }) => { + toast.error(t('documentPanel.uploadDocuments.fileUploader.fileRejected', { name: file.name })) + }) + } + } + + // Process accepted files + const newAcceptedFiles = acceptedFiles.map((file) => Object.assign(file, { preview: URL.createObjectURL(file) }) ) - - const updatedFiles = files ? [...files, ...newFiles] : newFiles - + + // Process rejected files for UI display + const newRejectedFiles = rejectedFiles.map(({ file }) => + Object.assign(file, { + preview: URL.createObjectURL(file), + rejected: true + }) + ) + + // Combine all files for display + const allNewFiles = [...newAcceptedFiles, ...newRejectedFiles] + const updatedFiles = files ? [...files, ...allNewFiles] : allNewFiles + + // Update the files state with all files setFiles(updatedFiles) - if (rejectedFiles.length > 0) { - rejectedFiles.forEach(({ file }) => { - toast.error(t('documentPanel.uploadDocuments.fileUploader.fileRejected', { name: file.name })) - }) - } - - if (onUpload && updatedFiles.length > 0 && updatedFiles.length <= maxFileCount) { - onUpload(updatedFiles) + // Only upload accepted files - make sure we're not uploading rejected files + if (onUpload && acceptedFiles.length > 0) { + // Filter out any files that might have been rejected by our custom validator + const validFiles = acceptedFiles.filter(file => { + // Check if file type is accepted + const fileExt = `.${file.name.split('.').pop()?.toLowerCase() || ''}`; + const isAccepted = Object.entries(accept || {}).some(([mimeType, extensions]) => { + return file.type === mimeType || extensions.includes(fileExt); + }); + + // Check file size + const isSizeValid = file.size <= maxSize; + + return isAccepted && isSizeValid; + }); + + if (validFiles.length > 0) { + onUpload(validFiles); + } } }, - - [files, maxFileCount, multiple, onUpload, setFiles, t] + [files, maxFileCount, multiple, onUpload, onReject, setFiles, t, accept, maxSize] ) function onRemove(index: number) { @@ -204,11 +252,39 @@ function FileUploader(props: FileUploaderProps) {
1 || multiple} disabled={isDisabled} + validator={(file) => { + // Check if file type is accepted + const fileExt = `.${file.name.split('.').pop()?.toLowerCase() || ''}`; + const isAccepted = Object.entries(accept || {}).some(([mimeType, extensions]) => { + return file.type === mimeType || extensions.includes(fileExt); + }); + + if (!isAccepted) { + return { + code: 'file-invalid-type', + message: t('documentPanel.uploadDocuments.fileUploader.unsupportedType') + }; + } + + // Check file size + if (file.size > maxSize) { + return { + code: 'file-too-large', + message: t('documentPanel.uploadDocuments.fileUploader.fileTooLarge', { + maxSize: formatBytes(maxSize) + }) + }; + } + + return null; + }} > {({ getRootProps, getInputProps, isDragActive }) => (
-
+
+
+
+
) } @@ -307,7 +386,11 @@ function FileCard({ file, progress, error, onRemove }: FileCardProps) { return (
- {isFileWithPreview(file) ? : null} + {error ? ( +