|
import React, { useState, useCallback } from 'react'; |
|
import { Upload, FileText, Image, FileCode, Loader2, CheckCircle, XCircle, AlertCircle } from 'lucide-react'; |
|
import { Button } from '@/components/ui/button'; |
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; |
|
import { Progress } from '@/components/ui/progress'; |
|
import { Badge } from '@/components/ui/badge'; |
|
import { Alert, AlertDescription } from '@/components/ui/alert'; |
|
import { Input } from '@/components/ui/input'; |
|
import { Label } from '@/components/ui/label'; |
|
|
|
interface UploadedFile { |
|
id: string; |
|
file: File; |
|
status: 'pending' | 'uploading' | 'processing' | 'completed' | 'failed'; |
|
progress: number; |
|
documentId?: number; |
|
error?: string; |
|
} |
|
|
|
interface Document { |
|
id: number; |
|
title: string; |
|
fileName: string; |
|
fileSize: number; |
|
mimeType: string; |
|
processingStatus: string; |
|
createdAt: string; |
|
} |
|
|
|
export default function DocumentUpload() { |
|
const [files, setFiles] = useState<UploadedFile[]>([]); |
|
const [isDragging, setIsDragging] = useState(false); |
|
const [uploadTitle, setUploadTitle] = useState(''); |
|
const [uploadSource, setUploadSource] = useState(''); |
|
const [documents, setDocuments] = useState<Document[]>([]); |
|
const [isProcessing, setIsProcessing] = useState(false); |
|
|
|
const acceptedTypes = [ |
|
'application/pdf', |
|
'image/jpeg', |
|
'image/png', |
|
'image/gif', |
|
'image/webp', |
|
'text/plain', |
|
'text/markdown', |
|
'application/json', |
|
'application/msword', |
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' |
|
]; |
|
|
|
const getFileIcon = (mimeType: string) => { |
|
if (mimeType.startsWith('image/')) return <Image className="w-5 h-5" />; |
|
if (mimeType === 'application/pdf') return <FileText className="w-5 h-5" />; |
|
if (mimeType.includes('text') || mimeType.includes('json')) return <FileCode className="w-5 h-5" />; |
|
return <FileText className="w-5 h-5" />; |
|
}; |
|
|
|
const getStatusIcon = (status: string) => { |
|
switch (status) { |
|
case 'completed': |
|
return <CheckCircle className="w-4 h-4 text-green-500" />; |
|
case 'failed': |
|
return <XCircle className="w-4 h-4 text-red-500" />; |
|
case 'processing': |
|
case 'uploading': |
|
return <Loader2 className="w-4 h-4 text-blue-500 animate-spin" />; |
|
default: |
|
return <AlertCircle className="w-4 h-4 text-yellow-500" />; |
|
} |
|
}; |
|
|
|
const formatFileSize = (bytes: number): string => { |
|
if (bytes === 0) return '0 Bytes'; |
|
const k = 1024; |
|
const sizes = ['Bytes', 'KB', 'MB', 'GB']; |
|
const i = Math.floor(Math.log(bytes) / Math.log(k)); |
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; |
|
}; |
|
|
|
const handleDragOver = useCallback((e: React.DragEvent) => { |
|
e.preventDefault(); |
|
setIsDragging(true); |
|
}, []); |
|
|
|
const handleDragLeave = useCallback((e: React.DragEvent) => { |
|
e.preventDefault(); |
|
setIsDragging(false); |
|
}, []); |
|
|
|
const handleDrop = useCallback((e: React.DragEvent) => { |
|
e.preventDefault(); |
|
setIsDragging(false); |
|
|
|
const droppedFiles = Array.from(e.dataTransfer.files); |
|
handleFiles(droppedFiles); |
|
}, []); |
|
|
|
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { |
|
if (e.target.files) { |
|
const selectedFiles = Array.from(e.target.files); |
|
handleFiles(selectedFiles); |
|
} |
|
}; |
|
|
|
const handleFiles = (newFiles: File[]) => { |
|
const validFiles = newFiles.filter(file => { |
|
if (!acceptedTypes.includes(file.type)) { |
|
alert(`File type ${file.type} is not supported`); |
|
return false; |
|
} |
|
if (file.size > 50 * 1024 * 1024) { |
|
alert(`File ${file.name} is too large. Maximum size is 50MB`); |
|
return false; |
|
} |
|
return true; |
|
}); |
|
|
|
const uploadFiles: UploadedFile[] = validFiles.map(file => ({ |
|
id: `${Date.now()}-${Math.random()}`, |
|
file, |
|
status: 'pending', |
|
progress: 0 |
|
})); |
|
|
|
setFiles(prev => [...prev, ...uploadFiles]); |
|
}; |
|
|
|
const uploadFiles = async () => { |
|
const pendingFiles = files.filter(f => f.status === 'pending'); |
|
if (pendingFiles.length === 0) return; |
|
|
|
for (const uploadFile of pendingFiles) { |
|
try { |
|
|
|
setFiles(prev => prev.map(f => |
|
f.id === uploadFile.id |
|
? { ...f, status: 'uploading', progress: 0 } |
|
: f |
|
)); |
|
|
|
const formData = new FormData(); |
|
formData.append('files', uploadFile.file); |
|
if (uploadTitle) formData.append('title', uploadTitle); |
|
if (uploadSource) formData.append('source', uploadSource); |
|
|
|
const response = await fetch('/api/documents/upload', { |
|
method: 'POST', |
|
body: formData, |
|
}); |
|
|
|
const result = await response.json(); |
|
|
|
if (!response.ok) { |
|
|
|
if (response.status === 503) { |
|
throw new Error(result.message || 'File uploads are disabled in this environment'); |
|
} |
|
throw new Error(result.message || `Upload failed: ${response.statusText}`); |
|
} |
|
|
|
if (result.success && result.documents?.length > 0) { |
|
const document = result.documents[0]; |
|
|
|
|
|
setFiles(prev => prev.map(f => |
|
f.id === uploadFile.id |
|
? { |
|
...f, |
|
status: 'processing', |
|
progress: 100, |
|
documentId: document.id |
|
} |
|
: f |
|
)); |
|
|
|
|
|
setDocuments(prev => [document, ...prev]); |
|
|
|
|
|
if (document.processingStatus === 'pending') { |
|
await processDocument(document.id, uploadFile.id); |
|
} else { |
|
|
|
setFiles(prev => prev.map(f => |
|
f.id === uploadFile.id |
|
? { ...f, status: 'completed' } |
|
: f |
|
)); |
|
} |
|
} else { |
|
throw new Error(result.message || 'Upload failed'); |
|
} |
|
|
|
} catch (error) { |
|
console.error('Upload error:', error); |
|
setFiles(prev => prev.map(f => |
|
f.id === uploadFile.id |
|
? { |
|
...f, |
|
status: 'failed', |
|
error: error instanceof Error ? error.message : 'Upload failed' |
|
} |
|
: f |
|
)); |
|
} |
|
} |
|
}; |
|
|
|
const processDocument = async (documentId: number, fileId: string) => { |
|
try { |
|
const response = await fetch(`/api/documents/process/${documentId}`, { |
|
method: 'POST', |
|
headers: { |
|
'Content-Type': 'application/json' |
|
}, |
|
body: JSON.stringify({ |
|
operations: ['extract_text', 'generate_embedding'] |
|
}) |
|
}); |
|
|
|
const result = await response.json(); |
|
|
|
if (result.success) { |
|
|
|
setFiles(prev => prev.map(f => |
|
f.id === fileId |
|
? { ...f, status: 'completed' } |
|
: f |
|
)); |
|
|
|
|
|
setDocuments(prev => prev.map(doc => |
|
doc.id === documentId |
|
? { ...doc, processingStatus: 'completed' } |
|
: doc |
|
)); |
|
} else { |
|
throw new Error(result.message || 'Processing failed'); |
|
} |
|
|
|
} catch (error) { |
|
console.error('Processing error:', error); |
|
setFiles(prev => prev.map(f => |
|
f.id === fileId |
|
? { |
|
...f, |
|
status: 'failed', |
|
error: error instanceof Error ? error.message : 'Processing failed' |
|
} |
|
: f |
|
)); |
|
} |
|
}; |
|
|
|
const batchProcessDocuments = async () => { |
|
const completedDocuments = documents.filter(doc => doc.processingStatus === 'completed'); |
|
if (completedDocuments.length === 0) { |
|
alert('No processed documents available for batch operations'); |
|
return; |
|
} |
|
|
|
setIsProcessing(true); |
|
|
|
try { |
|
|
|
const response = await fetch('/api/documents/index/build', { |
|
method: 'POST', |
|
headers: { |
|
'Content-Type': 'application/json' |
|
}, |
|
body: JSON.stringify({ |
|
documentIds: completedDocuments.map(doc => doc.id), |
|
indexName: 'main_index' |
|
}) |
|
}); |
|
|
|
const result = await response.json(); |
|
|
|
if (result.success) { |
|
alert(`Vector index built successfully with ${result.documentCount} documents`); |
|
} else { |
|
throw new Error(result.message || 'Index building failed'); |
|
} |
|
|
|
} catch (error) { |
|
console.error('Batch processing error:', error); |
|
alert(`Batch processing failed: ${error instanceof Error ? error.message : 'Unknown error'}`); |
|
} finally { |
|
setIsProcessing(false); |
|
} |
|
}; |
|
|
|
const removeFile = (fileId: string) => { |
|
setFiles(prev => prev.filter(f => f.id !== fileId)); |
|
}; |
|
|
|
const clearCompleted = () => { |
|
setFiles(prev => prev.filter(f => f.status !== 'completed' && f.status !== 'failed')); |
|
}; |
|
|
|
return ( |
|
<div className="space-y-6"> |
|
{/* Upload Area */} |
|
<Card> |
|
<CardHeader> |
|
<CardTitle className="flex items-center gap-2"> |
|
<Upload className="w-5 h-5" /> |
|
Document Upload |
|
</CardTitle> |
|
</CardHeader> |
|
<CardContent className="space-y-4"> |
|
{/* Optional metadata */} |
|
<div className="grid grid-cols-2 gap-4"> |
|
<div> |
|
<Label htmlFor="title">Title (optional)</Label> |
|
<Input |
|
id="title" |
|
placeholder="Document title" |
|
value={uploadTitle} |
|
onChange={(e) => setUploadTitle(e.target.value)} |
|
/> |
|
</div> |
|
<div> |
|
<Label htmlFor="source">Source (optional)</Label> |
|
<Input |
|
id="source" |
|
placeholder="Document source" |
|
value={uploadSource} |
|
onChange={(e) => setUploadSource(e.target.value)} |
|
/> |
|
</div> |
|
</div> |
|
|
|
{/* Drag and drop area */} |
|
<div |
|
className={` |
|
border-2 border-dashed rounded-lg p-8 text-center transition-colors |
|
${isDragging |
|
? 'border-blue-500 bg-blue-50 dark:bg-blue-950' |
|
: 'border-gray-300 hover:border-gray-400 dark:border-gray-600' |
|
} |
|
`} |
|
onDragOver={handleDragOver} |
|
onDragLeave={handleDragLeave} |
|
onDrop={handleDrop} |
|
> |
|
<Upload className="w-12 h-12 mx-auto text-gray-400 mb-4" /> |
|
<p className="text-lg font-medium mb-2"> |
|
Drop files here or click to browse |
|
</p> |
|
<p className="text-sm text-gray-500 mb-4"> |
|
Supports PDF, images, text files, Word documents (max 50MB each) |
|
</p> |
|
<input |
|
type="file" |
|
multiple |
|
accept={acceptedTypes.join(',')} |
|
onChange={handleFileSelect} |
|
className="hidden" |
|
id="file-upload" |
|
/> |
|
<Button asChild variant="outline"> |
|
<label htmlFor="file-upload" className="cursor-pointer"> |
|
Browse Files |
|
</label> |
|
</Button> |
|
</div> |
|
|
|
{/* File list */} |
|
{files.length > 0 && ( |
|
<div className="space-y-2"> |
|
<div className="flex justify-between items-center"> |
|
<h3 className="font-medium">Files ({files.length})</h3> |
|
<div className="space-x-2"> |
|
<Button onClick={uploadFiles} size="sm" disabled={!files.some(f => f.status === 'pending')}> |
|
Upload All |
|
</Button> |
|
<Button onClick={clearCompleted} variant="outline" size="sm"> |
|
Clear Completed |
|
</Button> |
|
</div> |
|
</div> |
|
|
|
{files.map((file) => ( |
|
<div key={file.id} className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-800 rounded-lg"> |
|
<div className="flex items-center gap-3 flex-1"> |
|
{getFileIcon(file.file.type)} |
|
<div className="flex-1 min-w-0"> |
|
<p className="font-medium truncate">{file.file.name}</p> |
|
<p className="text-sm text-gray-500"> |
|
{formatFileSize(file.file.size)} |
|
</p> |
|
</div> |
|
</div> |
|
|
|
<div className="flex items-center gap-3"> |
|
<Badge variant={ |
|
file.status === 'completed' ? 'default' : |
|
file.status === 'failed' ? 'destructive' : |
|
'secondary' |
|
}> |
|
{file.status} |
|
</Badge> |
|
|
|
{getStatusIcon(file.status)} |
|
|
|
{(file.status === 'uploading' || file.status === 'processing') && ( |
|
<div className="w-24"> |
|
<Progress value={file.progress} className="h-2" /> |
|
</div> |
|
)} |
|
|
|
{(file.status === 'pending' || file.status === 'failed') && ( |
|
<Button |
|
variant="ghost" |
|
size="sm" |
|
onClick={() => removeFile(file.id)} |
|
> |
|
<XCircle className="w-4 h-4" /> |
|
</Button> |
|
)} |
|
</div> |
|
</div> |
|
))} |
|
</div> |
|
)} |
|
|
|
{/* Error alerts */} |
|
{files.some(f => f.error) && ( |
|
<Alert variant="destructive"> |
|
<AlertCircle className="w-4 h-4" /> |
|
<AlertDescription> |
|
Some files failed to upload. Check individual file status above. |
|
</AlertDescription> |
|
</Alert> |
|
)} |
|
</CardContent> |
|
</Card> |
|
|
|
{/* Document Management */} |
|
{documents.length > 0 && ( |
|
<Card> |
|
<CardHeader> |
|
<CardTitle className="flex items-center justify-between"> |
|
<span>Uploaded Documents ({documents.length})</span> |
|
<Button |
|
onClick={batchProcessDocuments} |
|
disabled={isProcessing || documents.filter(d => d.processingStatus === 'completed').length === 0} |
|
> |
|
{isProcessing ? ( |
|
<> |
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" /> |
|
Building Index... |
|
</> |
|
) : ( |
|
'Build Vector Index' |
|
)} |
|
</Button> |
|
</CardTitle> |
|
</CardHeader> |
|
<CardContent> |
|
<div className="space-y-2"> |
|
{documents.map((doc) => ( |
|
<div key={doc.id} className="flex items-center justify-between p-3 border rounded-lg"> |
|
<div className="flex items-center gap-3 flex-1"> |
|
{getFileIcon(doc.mimeType)} |
|
<div className="flex-1 min-w-0"> |
|
<p className="font-medium truncate">{doc.title}</p> |
|
<p className="text-sm text-gray-500"> |
|
{doc.fileName} • {formatFileSize(doc.fileSize)} |
|
</p> |
|
</div> |
|
</div> |
|
|
|
<div className="flex items-center gap-3"> |
|
<Badge variant={ |
|
doc.processingStatus === 'completed' ? 'default' : |
|
doc.processingStatus === 'failed' ? 'destructive' : |
|
'secondary' |
|
}> |
|
{doc.processingStatus} |
|
</Badge> |
|
{getStatusIcon(doc.processingStatus)} |
|
</div> |
|
</div> |
|
))} |
|
</div> |
|
</CardContent> |
|
</Card> |
|
)} |
|
</div> |
|
); |
|
} |