feat(upload): support multiple file upload on RAG Database page

This commit is contained in:
Woody 2026-04-28 13:22:25 +08:00
parent fdd5a09c28
commit aa5f716578
2 changed files with 84 additions and 43 deletions

View File

@ -1,24 +1,21 @@
import React from 'react' import React from 'react'
import { Upload, Loader2, CheckCircle, AlertCircle } from 'lucide-react' import { Upload, Loader2 } from 'lucide-react'
interface DocumentUploadProps { interface DocumentUploadProps {
onUpload: (file: File) => void onUpload: (files: File[]) => void
isLoading: boolean isLoading: boolean
success: string | null
error: string | null
} }
export const DocumentUpload: React.FC<DocumentUploadProps> = ({ export const DocumentUpload: React.FC<DocumentUploadProps> = ({
onUpload, onUpload,
isLoading, isLoading,
success,
error,
}) => { }) => {
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => { const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0] const files = event.target.files
if (file) { if (files && files.length > 0) {
onUpload(file) onUpload(Array.from(files))
} }
event.target.value = ''
} }
return ( return (
@ -26,6 +23,7 @@ export const DocumentUpload: React.FC<DocumentUploadProps> = ({
<input <input
type="file" type="file"
accept=".pdf,.docx,.txt" accept=".pdf,.docx,.txt"
multiple
onChange={handleFileChange} onChange={handleFileChange}
disabled={isLoading} disabled={isLoading}
className="hidden" className="hidden"
@ -52,20 +50,6 @@ export const DocumentUpload: React.FC<DocumentUploadProps> = ({
</> </>
)} )}
</label> </label>
{success && (
<div className="flex items-center space-x-2 text-green-700">
<CheckCircle className="w-4 h-4" />
<span className="text-sm">{success}</span>
</div>
)}
{error && (
<div className="flex items-center space-x-2 text-red-700">
<AlertCircle className="w-4 h-4" />
<span className="text-sm">{error}</span>
</div>
)}
</div> </div>
) )
} }

View File

@ -1,15 +1,20 @@
import React, { useState } from 'react' import React, { useState, useCallback } from 'react'
import { Database, AlertCircle } from 'lucide-react' import { Database, AlertCircle, CheckCircle, XCircle, Loader2 } from 'lucide-react'
import { useQueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query'
import { useDocuments, useDocumentChunks, useDeleteDocument, useDeleteChunk, useIngestDocument } from '../lib/queries' import { useDocuments, useDocumentChunks, useDeleteDocument, useDeleteChunk, useIngestDocument } from '../lib/queries'
import { DocumentList } from '../components/DocumentList' import { DocumentList } from '../components/DocumentList'
import { ChunkList } from '../components/ChunkList' import { ChunkList } from '../components/ChunkList'
import { DocumentUpload } from '../components/DocumentUpload' import { DocumentUpload } from '../components/DocumentUpload'
interface FileUploadEntry {
name: string
status: 'uploading' | 'success' | 'error'
error?: string
}
export const RAGDatabasePage: React.FC = () => { export const RAGDatabasePage: React.FC = () => {
const [expandedId, setExpandedId] = useState<string | null>(null) const [expandedId, setExpandedId] = useState<string | null>(null)
const [uploadSuccess, setUploadSuccess] = useState<string | null>(null) const [uploadEntries, setUploadEntries] = useState<FileUploadEntry[]>([])
const [uploadError, setUploadError] = useState<string | null>(null)
const { data: documentsData, isLoading: isLoadingDocuments, error: documentsError } = useDocuments() const { data: documentsData, isLoading: isLoadingDocuments, error: documentsError } = useDocuments()
const { data: chunks, isLoading: isLoadingChunks } = useDocumentChunks(expandedId) const { data: chunks, isLoading: isLoadingChunks } = useDocumentChunks(expandedId)
@ -38,20 +43,42 @@ export const RAGDatabasePage: React.FC = () => {
} }
} }
const handleUpload = (file: File) => { const handleUpload = useCallback(async (files: File[]) => {
setUploadSuccess(null) const entries: FileUploadEntry[] = files.map((f) => ({
setUploadError(null) name: f.name,
ingestDocumentMutation.mutate(file, { status: 'uploading' as const,
onSuccess: (data) => { }))
setUploadSuccess(`Uploaded ${data.filename}`) setUploadEntries(entries)
queryClient.invalidateQueries({ queryKey: ['documents'] })
setTimeout(() => setUploadSuccess(null), 3000) const results = await Promise.allSettled(
}, files.map(async (file) => {
onError: (error) => { try {
setUploadError(error.message) await ingestDocumentMutation.mutateAsync(file)
}, setUploadEntries((prev) =>
}) prev.map((e) =>
} e.name === file.name ? { ...e, status: 'success' as const } : e
)
)
} catch (err: any) {
setUploadEntries((prev) =>
prev.map((e) =>
e.name === file.name
? { ...e, status: 'error' as const, error: err?.message ?? 'Upload failed' }
: e
)
)
}
})
)
queryClient.invalidateQueries({ queryKey: ['documents'] })
setTimeout(() => setUploadEntries([]), 5000)
}, [ingestDocumentMutation, queryClient])
const uploadingCount = uploadEntries.filter((e) => e.status === 'uploading').length
const successCount = uploadEntries.filter((e) => e.status === 'success').length
const errorCount = uploadEntries.filter((e) => e.status === 'error').length
const hasEntries = uploadEntries.length > 0
if (isLoadingDocuments) { if (isLoadingDocuments) {
return ( return (
@ -90,10 +117,40 @@ export const RAGDatabasePage: React.FC = () => {
<DocumentUpload <DocumentUpload
onUpload={handleUpload} onUpload={handleUpload}
isLoading={ingestDocumentMutation.isPending} isLoading={ingestDocumentMutation.isPending}
success={uploadSuccess}
error={uploadError}
/> />
</div> </div>
{hasEntries && (
<div className="mt-4 space-y-2">
<div className="text-sm font-medium text-gray-600">
{uploadingCount > 0
? `Uploading ${successCount + errorCount + 1} of ${uploadEntries.length}`
: `${successCount} succeeded${errorCount > 0 ? `, ${errorCount} failed` : ''}`}
</div>
<div className="space-y-1 max-h-40 overflow-y-auto">
{uploadEntries.map((entry) => (
<div
key={entry.name}
className="flex items-center space-x-2 text-sm py-1"
>
{entry.status === 'uploading' && (
<Loader2 className="w-4 h-4 text-blue-500 animate-spin shrink-0" />
)}
{entry.status === 'success' && (
<CheckCircle className="w-4 h-4 text-green-600 shrink-0" />
)}
{entry.status === 'error' && (
<XCircle className="w-4 h-4 text-red-600 shrink-0" />
)}
<span className="truncate">{entry.name}</span>
{entry.status === 'error' && entry.error && (
<span className="text-red-600 truncate"> {entry.error}</span>
)}
</div>
))}
</div>
</div>
)}
</div> </div>
{hasDocuments && ( {hasDocuments && (