Merge pull request #1214 from danielaskdd/upload-error

Feat: Improve file upload error handling
This commit is contained in:
Daniel.y
2025-03-28 19:19:20 +08:00
committed by GitHub
13 changed files with 358 additions and 176 deletions

View File

@@ -1 +1 @@
__api_version__ = "1.2.7"
__api_version__ = "1.2.8"

View File

@@ -542,6 +542,7 @@ def create_document_routes(
Returns:
InsertResponse: A response object containing the upload status and a message.
status can be "success", "duplicated", or error is thrown.
Raises:
HTTPException: If the file type is not supported (400) or other errors occur (500).
@@ -554,6 +555,13 @@ def create_document_routes(
)
file_path = doc_manager.input_dir / file.filename
# Check if file already exists
if file_path.exists():
return InsertResponse(
status="duplicated",
message=f"File '{file.filename}' already exists in the input directory.",
)
with open(file_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -8,8 +8,8 @@
<link rel="icon" type="image/svg+xml" href="logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Lightrag</title>
<script type="module" crossorigin src="/webui/assets/index-B4QL89Xd.js"></script>
<link rel="stylesheet" crossorigin href="/webui/assets/index-Bwboeqcm.css">
<script type="module" crossorigin src="/webui/assets/index-Bz28HSH8.js"></script>
<link rel="stylesheet" crossorigin href="/webui/assets/index-Cl6-O9yL.css">
</head>
<body>
<div id="root"></div>

View File

@@ -109,7 +109,7 @@ export type QueryResponse = {
}
export type DocActionResponse = {
status: 'success' | 'partial_success' | 'failure'
status: 'success' | 'partial_success' | 'failure' | 'duplicated'
message: string
}

View File

@@ -1,4 +1,5 @@
import { useState, useCallback } from 'react'
import { FileRejection } from 'react-dropzone'
import Button from '@/components/ui/Button'
import {
Dialog,
@@ -23,18 +24,63 @@ export default function UploadDocumentsDialog() {
const [progresses, setProgresses] = useState<Record<string, number>>({})
const [fileErrors, setFileErrors] = useState<Record<string, string>>({})
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 () => {
try {
// Track errors locally to ensure we have the final state
const uploadErrors: Record<string, string> = {}
await Promise.all(
filesToUpload.map(async (file) => {
try {
// 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) => ({
@@ -43,37 +89,67 @@ export default function UploadDocumentsDialog() {
}))
})
if (result.status !== 'success') {
if (result.status === 'duplicated') {
uploadErrors[file.name] = t('documentPanel.uploadDocuments.fileUploader.duplicateFile')
setFileErrors(prev => ({
...prev,
[file.name]: t('documentPanel.uploadDocuments.fileUploader.duplicateFile')
}))
} else 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]: errorMessage(err)
[file.name]: errorMsg
}))
}
})
)
} catch (error) {
console.error('Upload failed:', 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 })
}
})(),
{
loading: t('documentPanel.uploadDocuments.batch.uploading'),
success: t('documentPanel.uploadDocuments.batch.success'),
error: t('documentPanel.uploadDocuments.batch.error')
}
)
} 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 +183,7 @@ export default function UploadDocumentsDialog() {
maxSize={200 * 1024 * 1024}
description={t('documentPanel.uploadDocuments.fileTypes')}
onUpload={handleDocumentsUpload}
onReject={handleRejectedFiles}
progresses={progresses}
fileErrors={fileErrors}
disabled={isUploading}

View File

@@ -39,6 +39,14 @@ interface FileUploaderProps extends React.HTMLAttributes<HTMLDivElement> {
*/
onUpload?: (files: File[]) => Promise<void>
/**
* 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<string, number> | 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 }))
})
}
// 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);
});
if (onUpload && updatedFiles.length > 0 && updatedFiles.length <= maxFileCount) {
onUpload(updatedFiles)
// 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) {
<div className="relative flex flex-col gap-6 overflow-hidden">
<Dropzone
onDrop={onDrop}
accept={accept}
// remove acceptuse customizd validator
noClick={false}
noKeyboard={false}
maxSize={maxSize}
maxFiles={maxFileCount}
multiple={maxFileCount > 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 }) => (
<div
@@ -279,19 +355,22 @@ function FileUploader(props: FileUploaderProps) {
interface ProgressProps {
value: number
error?: boolean
showIcon?: boolean // New property to control icon display
}
function Progress({ value, error }: ProgressProps) {
return (
<div className="h-2 w-full overflow-hidden rounded-full bg-secondary">
<div className="relative h-2 w-full">
<div className="h-full w-full overflow-hidden rounded-full bg-secondary">
<div
className={cn(
'h-full transition-all',
error ? 'bg-destructive' : 'bg-primary'
error ? 'bg-red-400' : 'bg-primary'
)}
style={{ width: `${value}%` }}
/>
</div>
</div>
)
}
@@ -307,16 +386,22 @@ function FileCard({ file, progress, error, onRemove }: FileCardProps) {
return (
<div className="relative flex items-center gap-2.5">
<div className="flex flex-1 gap-2.5">
{isFileWithPreview(file) ? <FilePreview file={file} /> : null}
{error ? (
<FileText className="text-red-400 size-10" aria-hidden="true" />
) : (
isFileWithPreview(file) ? <FilePreview file={file} /> : null
)}
<div className="flex w-full flex-col gap-2">
<div className="flex flex-col gap-px">
<p className="text-foreground/80 line-clamp-1 text-sm font-medium">{file.name}</p>
<p className="text-muted-foreground text-xs">{formatBytes(file.size)}</p>
</div>
{error ? (
<div className="text-destructive text-sm">
<div className="text-red-400 text-sm">
<div className="relative mb-2">
<Progress value={100} error={true} />
<p className="mt-1">{error}</p>
</div>
<p>{error}</p>
</div>
) : (
progress ? <Progress value={progress} /> : null

View File

@@ -65,10 +65,13 @@
"singleFileLimit": "لا يمكن رفع أكثر من ملف واحد في المرة الواحدة",
"maxFilesLimit": "لا يمكن رفع أكثر من {{count}} ملفات",
"fileRejected": "تم رفض الملف {{name}}",
"unsupportedType": "نوع الملف غير مدعوم",
"fileTooLarge": "حجم الملف كبير جدًا، الحد الأقصى {{maxSize}}",
"dropHere": "أفلت الملفات هنا",
"dragAndDrop": "اسحب وأفلت الملفات هنا، أو انقر للاختيار",
"removeFile": "إزالة الملف",
"uploadDescription": "يمكنك رفع {{isMultiple ? 'عدة' : count}} ملفات (حتى {{maxSize}} لكل منها)"
"uploadDescription": "يمكنك رفع {{isMultiple ? 'عدة' : count}} ملفات (حتى {{maxSize}} لكل منها)",
"duplicateFile": "اسم الملف موجود بالفعل في ذاكرة التخزين المؤقت للخادم"
}
},
"documentManager": {

View File

@@ -65,10 +65,13 @@
"singleFileLimit": "Cannot upload more than 1 file at a time",
"maxFilesLimit": "Cannot upload more than {{count}} files",
"fileRejected": "File {{name}} was rejected",
"unsupportedType": "Unsupported file type",
"fileTooLarge": "File too large, maximum size is {{maxSize}}",
"dropHere": "Drop the files here",
"dragAndDrop": "Drag and drop files here, or click to select files",
"removeFile": "Remove file",
"uploadDescription": "You can upload {{isMultiple ? 'multiple' : count}} files (up to {{maxSize}} each)"
"uploadDescription": "You can upload {{isMultiple ? 'multiple' : count}} files (up to {{maxSize}} each)",
"duplicateFile": "File name already exists in server cache"
}
},
"documentManager": {

View File

@@ -65,10 +65,13 @@
"singleFileLimit": "Impossible de télécharger plus d'un fichier à la fois",
"maxFilesLimit": "Impossible de télécharger plus de {{count}} fichiers",
"fileRejected": "Le fichier {{name}} a été rejeté",
"unsupportedType": "Type de fichier non pris en charge",
"fileTooLarge": "Fichier trop volumineux, taille maximale {{maxSize}}",
"dropHere": "Déposez les fichiers ici",
"dragAndDrop": "Glissez et déposez les fichiers ici, ou cliquez pour sélectionner",
"removeFile": "Supprimer le fichier",
"uploadDescription": "Vous pouvez télécharger {{isMultiple ? 'plusieurs' : count}} fichiers (jusqu'à {{maxSize}} chacun)"
"uploadDescription": "Vous pouvez télécharger {{isMultiple ? 'plusieurs' : count}} fichiers (jusqu'à {{maxSize}} chacun)",
"duplicateFile": "Le nom du fichier existe déjà dans le cache du serveur"
}
},
"documentManager": {

View File

@@ -65,10 +65,13 @@
"singleFileLimit": "一次只能上传一个文件",
"maxFilesLimit": "最多只能上传 {{count}} 个文件",
"fileRejected": "文件 {{name}} 被拒绝",
"unsupportedType": "不支持的文件类型",
"fileTooLarge": "文件过大,最大允许 {{maxSize}}",
"dropHere": "将文件拖放到此处",
"dragAndDrop": "拖放文件到此处,或点击选择文件",
"removeFile": "移除文件",
"uploadDescription": "您可以上传{{isMultiple ? '多个' : count}}个文件(每个文件最大{{maxSize}}"
"uploadDescription": "您可以上传{{isMultiple ? '多个' : count}}个文件(每个文件最大{{maxSize}}",
"duplicateFile": "文件名与服务器上的缓存重复"
}
},
"documentManager": {