mirror of
https://github.com/aljazceru/enclava.git
synced 2025-12-17 15:34:36 +01:00
clean commit
This commit is contained in:
499
frontend/src/components/rag/document-browser.tsx
Normal file
499
frontend/src/components/rag/document-browser.tsx
Normal file
@@ -0,0 +1,499 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||
import { Search, FileText, Trash2, Eye, Download, Calendar, Hash, FileIcon, Filter } from "lucide-react"
|
||||
import { useToast } from "@/hooks/use-toast"
|
||||
|
||||
interface Collection {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface Document {
|
||||
id: string
|
||||
filename: string
|
||||
original_filename: string
|
||||
file_type: string
|
||||
size: number
|
||||
created_at: string
|
||||
processed_at: string
|
||||
word_count: number
|
||||
status: 'processed' | 'processing' | 'error'
|
||||
collection_id: string
|
||||
collection_name: string
|
||||
converted_content?: string
|
||||
metadata?: {
|
||||
language?: string
|
||||
entities?: Array<{ text: string; label: string }>
|
||||
keywords?: string[]
|
||||
}
|
||||
}
|
||||
|
||||
interface DocumentBrowserProps {
|
||||
collections: Collection[]
|
||||
selectedCollection: string | null
|
||||
onCollectionSelected: (collectionId: string | null) => void
|
||||
}
|
||||
|
||||
export function DocumentBrowser({ collections, selectedCollection, onCollectionSelected }: DocumentBrowserProps) {
|
||||
const [documents, setDocuments] = useState<Document[]>([])
|
||||
const [filteredDocuments, setFilteredDocuments] = useState<Document[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [searchTerm, setSearchTerm] = useState("")
|
||||
const [filterCollection, setFilterCollection] = useState(selectedCollection || "all")
|
||||
const [filterType, setFilterType] = useState("all")
|
||||
const [filterStatus, setFilterStatus] = useState("all")
|
||||
const [selectedDocument, setSelectedDocument] = useState<Document | null>(null)
|
||||
const [deleting, setDeleting] = useState<string | null>(null)
|
||||
const { toast } = useToast()
|
||||
|
||||
useEffect(() => {
|
||||
loadDocuments()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
filterDocuments()
|
||||
}, [documents, searchTerm, filterCollection, filterType, filterStatus])
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedCollection !== filterCollection) {
|
||||
setFilterCollection(selectedCollection || "all")
|
||||
}
|
||||
}, [selectedCollection])
|
||||
|
||||
const loadDocuments = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await fetch('/api/rag/documents', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setDocuments(data.documents || [])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load documents:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const filterDocuments = () => {
|
||||
let filtered = [...documents]
|
||||
|
||||
// Search filter
|
||||
if (searchTerm) {
|
||||
filtered = filtered.filter(doc =>
|
||||
doc.original_filename.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
doc.metadata?.keywords?.some(keyword =>
|
||||
keyword.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Collection filter
|
||||
if (filterCollection !== "all") {
|
||||
filtered = filtered.filter(doc => doc.collection_id === filterCollection)
|
||||
}
|
||||
|
||||
// Type filter
|
||||
if (filterType !== "all") {
|
||||
filtered = filtered.filter(doc => doc.file_type === filterType)
|
||||
}
|
||||
|
||||
// Status filter
|
||||
if (filterStatus !== "all") {
|
||||
filtered = filtered.filter(doc => doc.status === filterStatus)
|
||||
}
|
||||
|
||||
setFilteredDocuments(filtered)
|
||||
}
|
||||
|
||||
const handleDeleteDocument = async (documentId: string) => {
|
||||
setDeleting(documentId)
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/rag/documents/${documentId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
setDocuments(prev => prev.filter(doc => doc.id !== documentId))
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Document deleted successfully",
|
||||
})
|
||||
} else {
|
||||
const error = await response.json()
|
||||
throw new Error(error.message || 'Failed to delete document')
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: error instanceof Error ? error.message : "Failed to delete document",
|
||||
variant: "destructive",
|
||||
})
|
||||
} finally {
|
||||
setDeleting(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownloadDocument = async (document: Document) => {
|
||||
try {
|
||||
const response = await fetch(`/api/rag/documents/${document.id}/download`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const blob = await response.blob()
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const linkElement = window.document.createElement('a')
|
||||
linkElement.style.display = 'none'
|
||||
linkElement.href = url
|
||||
linkElement.download = document.original_filename
|
||||
window.document.body.appendChild(linkElement)
|
||||
linkElement.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
window.document.body.removeChild(linkElement)
|
||||
} else {
|
||||
throw new Error('Download failed')
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to download document",
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
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 formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const getFileTypeIcon = (fileType: string) => {
|
||||
const iconClass = "h-4 w-4"
|
||||
switch (fileType.toLowerCase()) {
|
||||
case 'pdf':
|
||||
return <FileText className={`${iconClass} text-red-500`} />
|
||||
case 'docx':
|
||||
case 'doc':
|
||||
return <FileText className={`${iconClass} text-blue-500`} />
|
||||
case 'xlsx':
|
||||
case 'xls':
|
||||
return <FileText className={`${iconClass} text-green-500`} />
|
||||
default:
|
||||
return <FileIcon className={`${iconClass} text-gray-500`} />
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const variants = {
|
||||
processed: 'bg-green-100 text-green-800',
|
||||
processing: 'bg-yellow-100 text-yellow-800',
|
||||
error: 'bg-red-100 text-red-800'
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge variant="secondary" className={variants[status as keyof typeof variants] || ''}>
|
||||
{status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
// Get unique file types for filter
|
||||
const uniqueFileTypes = Array.from(new Set(documents.map(doc => doc.file_type)))
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Filters */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Filter className="h-5 w-5" />
|
||||
Search & Filter Documents
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Search</label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search documents..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Collection</label>
|
||||
<Select value={filterCollection} onValueChange={setFilterCollection}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Collections</SelectItem>
|
||||
{collections.map((collection) => (
|
||||
<SelectItem key={collection.id} value={collection.id}>
|
||||
{collection.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">File Type</label>
|
||||
<Select value={filterType} onValueChange={setFilterType}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Types</SelectItem>
|
||||
{uniqueFileTypes.map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{type.toUpperCase()}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Status</label>
|
||||
<Select value={filterStatus} onValueChange={setFilterStatus}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Status</SelectItem>
|
||||
<SelectItem value="processed">Processed</SelectItem>
|
||||
<SelectItem value="processing">Processing</SelectItem>
|
||||
<SelectItem value="error">Error</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Results Summary */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing {filteredDocuments.length} of {documents.length} documents
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={loadDocuments} disabled={loading}>
|
||||
{loading ? "Loading..." : "Refresh"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Documents List */}
|
||||
<div className="grid gap-4">
|
||||
{filteredDocuments.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-8">
|
||||
<FileText className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">No Documents Found</h3>
|
||||
<p className="text-muted-foreground text-center">
|
||||
{searchTerm || filterCollection !== "all" || filterType !== "all" || filterStatus !== "all"
|
||||
? "Try adjusting your search criteria or filters."
|
||||
: "Upload some documents to get started."}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
filteredDocuments.map((document) => (
|
||||
<Card key={document.id} className="hover:shadow-md transition-shadow">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start space-x-3 flex-1">
|
||||
{getFileTypeIcon(document.file_type)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center space-x-2 mb-1">
|
||||
<h3 className="text-sm font-medium truncate">{document.original_filename}</h3>
|
||||
{getStatusBadge(document.status)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-4 text-xs text-muted-foreground mb-2">
|
||||
<span className="flex items-center gap-1">
|
||||
<Hash className="h-3 w-3" />
|
||||
{document.collection_name}
|
||||
</span>
|
||||
<span>{formatFileSize(document.size)}</span>
|
||||
<span>{document.word_count} words</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
{formatDate(document.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
{document.metadata?.keywords && document.metadata.keywords.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mb-2">
|
||||
{document.metadata.keywords.slice(0, 5).map((keyword, index) => (
|
||||
<Badge key={index} variant="outline" className="text-xs">
|
||||
{keyword}
|
||||
</Badge>
|
||||
))}
|
||||
{document.metadata.keywords.length > 5 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
+{document.metadata.keywords.length - 5} more
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-1 ml-4">
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => setSelectedDocument(document)}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{document.original_filename}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Document details and converted content
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{selectedDocument && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">File Information</h4>
|
||||
<div className="text-sm space-y-1">
|
||||
<p><span className="font-medium">Type:</span> {selectedDocument.file_type.toUpperCase()}</p>
|
||||
<p><span className="font-medium">Size:</span> {formatFileSize(selectedDocument.size)}</p>
|
||||
<p><span className="font-medium">Words:</span> {selectedDocument.word_count}</p>
|
||||
<p><span className="font-medium">Collection:</span> {selectedDocument.collection_name}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Processing Info</h4>
|
||||
<div className="text-sm space-y-1">
|
||||
<div><span className="font-medium">Status:</span> {getStatusBadge(selectedDocument.status)}</div>
|
||||
<p><span className="font-medium">Uploaded:</span> {formatDate(selectedDocument.created_at)}</p>
|
||||
<p><span className="font-medium">Processed:</span> {formatDate(selectedDocument.processed_at)}</p>
|
||||
{selectedDocument.metadata?.language && (
|
||||
<p><span className="font-medium">Language:</span> {selectedDocument.metadata.language}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedDocument.metadata?.entities && selectedDocument.metadata.entities.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Entities</h4>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{selectedDocument.metadata.entities.slice(0, 10).map((entity, index) => (
|
||||
<Badge key={index} variant="outline" className="text-xs">
|
||||
{entity.text} ({entity.label})
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedDocument.converted_content && (
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Converted Content</h4>
|
||||
<div className="bg-gray-50 p-4 rounded-lg max-h-96 overflow-y-auto">
|
||||
<pre className="text-sm whitespace-pre-wrap">
|
||||
{selectedDocument.converted_content.substring(0, 2000)}
|
||||
{selectedDocument.converted_content.length > 2000 && "..."}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => handleDownloadDocument(document)}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 hover:bg-red-100"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Document</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete "{document.original_filename}"? This action cannot be undone and will remove the document from the collection and search index.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => handleDeleteDocument(document.id)}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
disabled={deleting === document.id}
|
||||
>
|
||||
{deleting === document.id ? "Deleting..." : "Delete"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user