| import { useState, useCallback } from 'react' |
| import { FileRejection } from 'react-dropzone' |
| import Button from '@/components/ui/Button' |
| import { |
| Dialog, |
| DialogContent, |
| DialogDescription, |
| DialogHeader, |
| DialogTitle, |
| DialogTrigger |
| } from '@/components/ui/Dialog' |
| import FileUploader from '@/components/ui/FileUploader' |
| import { toast } from 'sonner' |
| import { errorMessage } from '@/lib/utils' |
| import { uploadDocument } from '@/api/lightrag' |
|
|
| import { UploadIcon } from 'lucide-react' |
| import { useTranslation } from 'react-i18next' |
|
|
| interface UploadDocumentsDialogProps { |
| onDocumentsUploaded?: () => Promise<void> |
| } |
|
|
| export default function UploadDocumentsDialog({ onDocumentsUploaded }: UploadDocumentsDialogProps) { |
| const { t } = useTranslation() |
| const [open, setOpen] = useState(false) |
| const [isUploading, setIsUploading] = useState(false) |
| const [progresses, setProgresses] = useState<Record<string, number>>({}) |
| const [fileErrors, setFileErrors] = useState<Record<string, string>>({}) |
|
|
| const handleRejectedFiles = useCallback( |
| (rejectedFiles: FileRejection[]) => { |
| |
| rejectedFiles.forEach(({ file, errors }) => { |
| |
| let errorMsg = errors[0]?.message || t('documentPanel.uploadDocuments.fileUploader.fileRejected', { name: file.name }) |
|
|
| |
| if (errorMsg.includes('file-invalid-type')) { |
| errorMsg = t('documentPanel.uploadDocuments.fileUploader.unsupportedType') |
| } |
|
|
| |
| setProgresses((pre) => ({ |
| ...pre, |
| [file.name]: 100 |
| })) |
|
|
| |
| setFileErrors(prev => ({ |
| ...prev, |
| [file.name]: errorMsg |
| })) |
| }) |
| }, |
| [setProgresses, setFileErrors, t] |
| ) |
|
|
| const handleDocumentsUpload = useCallback( |
| async (filesToUpload: File[]) => { |
| setIsUploading(true) |
| let hasSuccessfulUpload = false |
|
|
| |
| setFileErrors(prev => { |
| const newErrors = { ...prev }; |
| filesToUpload.forEach(file => { |
| delete newErrors[file.name]; |
| }); |
| return newErrors; |
| }); |
|
|
| |
| const toastId = toast.loading(t('documentPanel.uploadDocuments.batch.uploading')) |
|
|
| try { |
| |
| const uploadErrors: Record<string, string> = {} |
|
|
| |
| const collator = new Intl.Collator(['zh-CN', 'en'], { |
| sensitivity: 'accent', |
| numeric: true |
| }); |
| const sortedFiles = [...filesToUpload].sort((a, b) => |
| collator.compare(a.name, b.name) |
| ); |
|
|
| |
| for (const file of sortedFiles) { |
| try { |
| |
| 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 === '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 |
| })) |
| } else { |
| |
| hasSuccessfulUpload = true |
| } |
| } catch (err) { |
| console.error(`Upload failed for ${file.name}:`, err) |
|
|
| |
| let errorMsg = errorMessage(err) |
|
|
| |
| if (err && typeof err === 'object' && 'response' in err) { |
| const axiosError = err as { response?: { status: number, data?: { detail?: string } } } |
| if (axiosError.response?.status === 400) { |
| |
| errorMsg = axiosError.response.data?.detail || errorMsg |
| } |
|
|
| |
| setProgresses((pre) => ({ |
| ...pre, |
| [file.name]: 100 |
| })) |
| } |
|
|
| |
| uploadErrors[file.name] = errorMsg |
| setFileErrors(prev => ({ |
| ...prev, |
| [file.name]: errorMsg |
| })) |
| } |
| } |
|
|
| |
| const hasErrors = Object.keys(uploadErrors).length > 0 |
|
|
| |
| if (hasErrors) { |
| toast.error(t('documentPanel.uploadDocuments.batch.error'), { id: toastId }) |
| } else { |
| toast.success(t('documentPanel.uploadDocuments.batch.success'), { id: toastId }) |
| } |
|
|
| |
| if (hasSuccessfulUpload) { |
| |
| if (onDocumentsUploaded) { |
| onDocumentsUploaded().catch(err => { |
| console.error('Error refreshing documents:', err) |
| }) |
| } |
| } |
| } catch (err) { |
| console.error('Unexpected error during upload:', err) |
| toast.error(t('documentPanel.uploadDocuments.generalError', { error: errorMessage(err) }), { id: toastId }) |
| } finally { |
| setIsUploading(false) |
| } |
| }, |
| [setIsUploading, setProgresses, setFileErrors, t, onDocumentsUploaded] |
| ) |
|
|
| return ( |
| <Dialog |
| open={open} |
| onOpenChange={(open) => { |
| if (isUploading) { |
| return |
| } |
| if (!open) { |
| setProgresses({}) |
| setFileErrors({}) |
| } |
| setOpen(open) |
| }} |
| > |
| <DialogTrigger asChild> |
| <Button variant="default" side="bottom" tooltip={t('documentPanel.uploadDocuments.tooltip')} size="sm"> |
| <UploadIcon /> {t('documentPanel.uploadDocuments.button')} |
| </Button> |
| </DialogTrigger> |
| <DialogContent className="sm:max-w-xl" onCloseAutoFocus={(e) => e.preventDefault()}> |
| <DialogHeader> |
| <DialogTitle>{t('documentPanel.uploadDocuments.title')}</DialogTitle> |
| <DialogDescription> |
| {t('documentPanel.uploadDocuments.description')} |
| </DialogDescription> |
| </DialogHeader> |
| <FileUploader |
| maxFileCount={Infinity} |
| maxSize={200 * 1024 * 1024} |
| description={t('documentPanel.uploadDocuments.fileTypes')} |
| onUpload={handleDocumentsUpload} |
| onReject={handleRejectedFiles} |
| progresses={progresses} |
| fileErrors={fileErrors} |
| disabled={isUploading} |
| /> |
| </DialogContent> |
| </Dialog> |
| ) |
| } |
|
|