|
import { useState, useEffect, useCallback, useMemo, useRef } from 'react' |
|
import { useTranslation } from 'react-i18next' |
|
import { useSettingsStore } from '@/stores/settings' |
|
import Button from '@/components/ui/Button' |
|
import { cn } from '@/lib/utils' |
|
import { |
|
Table, |
|
TableBody, |
|
TableCell, |
|
TableHead, |
|
TableHeader, |
|
TableRow |
|
} from '@/components/ui/Table' |
|
import { Card, CardHeader, CardTitle, CardContent, CardDescription } from '@/components/ui/Card' |
|
import EmptyCard from '@/components/ui/EmptyCard' |
|
import Checkbox from '@/components/ui/Checkbox' |
|
import UploadDocumentsDialog from '@/components/documents/UploadDocumentsDialog' |
|
import ClearDocumentsDialog from '@/components/documents/ClearDocumentsDialog' |
|
import DeleteDocumentsDialog from '@/components/documents/DeleteDocumentsDialog' |
|
import DeselectDocumentsDialog from '@/components/documents/DeselectDocumentsDialog' |
|
|
|
import { getDocuments, scanNewDocuments, DocsStatusesResponse, DocStatus, DocStatusResponse } from '@/api/lightrag' |
|
import { errorMessage } from '@/lib/utils' |
|
import { toast } from 'sonner' |
|
import { useBackendState } from '@/stores/state' |
|
|
|
import { RefreshCwIcon, ActivityIcon, ArrowUpIcon, ArrowDownIcon, FilterIcon } from 'lucide-react' |
|
import PipelineStatusDialog from '@/components/documents/PipelineStatusDialog' |
|
|
|
type StatusFilter = DocStatus | 'all'; |
|
|
|
|
|
const getDisplayFileName = (doc: DocStatusResponse, maxLength: number = 20): string => { |
|
|
|
if (!doc.file_path || typeof doc.file_path !== 'string' || doc.file_path.trim() === '') { |
|
return doc.id; |
|
} |
|
|
|
|
|
const parts = doc.file_path.split('/'); |
|
const fileName = parts[parts.length - 1]; |
|
|
|
|
|
if (!fileName || fileName.trim() === '') { |
|
return doc.id; |
|
} |
|
|
|
|
|
return fileName.length > maxLength |
|
? fileName.slice(0, maxLength) + '...' |
|
: fileName; |
|
}; |
|
|
|
const pulseStyle = ` |
|
/* Tooltip styles */ |
|
.tooltip-container { |
|
position: relative; |
|
overflow: visible !important; |
|
} |
|
|
|
.tooltip { |
|
position: fixed; /* Use fixed positioning to escape overflow constraints */ |
|
z-index: 9999; /* Ensure tooltip appears above all other elements */ |
|
max-width: 600px; |
|
white-space: normal; |
|
border-radius: 0.375rem; |
|
padding: 0.5rem 0.75rem; |
|
background-color: rgba(0, 0, 0, 0.95); |
|
color: white; |
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); |
|
pointer-events: none; /* Prevent tooltip from interfering with mouse events */ |
|
opacity: 0; |
|
visibility: hidden; |
|
transition: opacity 0.15s, visibility 0.15s; |
|
} |
|
|
|
.tooltip.visible { |
|
opacity: 1; |
|
visibility: visible; |
|
} |
|
|
|
.dark .tooltip { |
|
background-color: rgba(255, 255, 255, 0.95); |
|
color: black; |
|
} |
|
|
|
/* Position tooltip helper class */ |
|
.tooltip-helper { |
|
position: absolute; |
|
visibility: hidden; |
|
pointer-events: none; |
|
top: 0; |
|
left: 0; |
|
width: 100%; |
|
height: 0; |
|
} |
|
|
|
@keyframes pulse { |
|
0% { |
|
background-color: rgb(255 0 0 / 0.1); |
|
border-color: rgb(255 0 0 / 0.2); |
|
} |
|
50% { |
|
background-color: rgb(255 0 0 / 0.2); |
|
border-color: rgb(255 0 0 / 0.4); |
|
} |
|
100% { |
|
background-color: rgb(255 0 0 / 0.1); |
|
border-color: rgb(255 0 0 / 0.2); |
|
} |
|
} |
|
|
|
.dark .pipeline-busy { |
|
animation: dark-pulse 2s infinite; |
|
} |
|
|
|
@keyframes dark-pulse { |
|
0% { |
|
background-color: rgb(255 0 0 / 0.2); |
|
border-color: rgb(255 0 0 / 0.4); |
|
} |
|
50% { |
|
background-color: rgb(255 0 0 / 0.3); |
|
border-color: rgb(255 0 0 / 0.6); |
|
} |
|
100% { |
|
background-color: rgb(255 0 0 / 0.2); |
|
border-color: rgb(255 0 0 / 0.4); |
|
} |
|
} |
|
|
|
.pipeline-busy { |
|
animation: pulse 2s infinite; |
|
border: 1px solid; |
|
} |
|
`; |
|
|
|
|
|
type SortField = 'created_at' | 'updated_at' | 'id'; |
|
type SortDirection = 'asc' | 'desc'; |
|
|
|
export default function DocumentManager() { |
|
|
|
const isMountedRef = useRef(true); |
|
|
|
|
|
useEffect(() => { |
|
isMountedRef.current = true; |
|
|
|
|
|
const handleBeforeUnload = () => { |
|
isMountedRef.current = false; |
|
}; |
|
|
|
window.addEventListener('beforeunload', handleBeforeUnload); |
|
|
|
return () => { |
|
isMountedRef.current = false; |
|
window.removeEventListener('beforeunload', handleBeforeUnload); |
|
}; |
|
}, []); |
|
|
|
const [showPipelineStatus, setShowPipelineStatus] = useState(false) |
|
const { t, i18n } = useTranslation() |
|
const health = useBackendState.use.health() |
|
const pipelineBusy = useBackendState.use.pipelineBusy() |
|
const [docs, setDocs] = useState<DocsStatusesResponse | null>(null) |
|
const currentTab = useSettingsStore.use.currentTab() |
|
const showFileName = useSettingsStore.use.showFileName() |
|
const setShowFileName = useSettingsStore.use.setShowFileName() |
|
|
|
|
|
const [sortField, setSortField] = useState<SortField>('updated_at') |
|
const [sortDirection, setSortDirection] = useState<SortDirection>('desc') |
|
|
|
|
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all'); |
|
|
|
|
|
const [selectedDocIds, setSelectedDocIds] = useState<string[]>([]) |
|
const isSelectionMode = selectedDocIds.length > 0 |
|
|
|
|
|
const handleDocumentSelect = useCallback((docId: string, checked: boolean) => { |
|
setSelectedDocIds(prev => { |
|
if (checked) { |
|
return [...prev, docId] |
|
} else { |
|
return prev.filter(id => id !== docId) |
|
} |
|
}) |
|
}, []) |
|
|
|
|
|
const handleDeselectAll = useCallback(() => { |
|
setSelectedDocIds([]) |
|
}, []) |
|
|
|
|
|
const handleSort = (field: SortField) => { |
|
if (sortField === field) { |
|
|
|
setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc') |
|
} else { |
|
|
|
setSortField(field) |
|
setSortDirection('desc') |
|
} |
|
} |
|
|
|
|
|
const sortDocuments = useCallback((documents: DocStatusResponse[]) => { |
|
return [...documents].sort((a, b) => { |
|
let valueA, valueB; |
|
|
|
|
|
if (sortField === 'id' && showFileName) { |
|
valueA = getDisplayFileName(a); |
|
valueB = getDisplayFileName(b); |
|
} else if (sortField === 'id') { |
|
valueA = a.id; |
|
valueB = b.id; |
|
} else { |
|
|
|
valueA = new Date(a[sortField]).getTime(); |
|
valueB = new Date(b[sortField]).getTime(); |
|
} |
|
|
|
|
|
const sortMultiplier = sortDirection === 'asc' ? 1 : -1; |
|
|
|
|
|
if (typeof valueA === 'string' && typeof valueB === 'string') { |
|
return sortMultiplier * valueA.localeCompare(valueB); |
|
} else { |
|
return sortMultiplier * (valueA > valueB ? 1 : valueA < valueB ? -1 : 0); |
|
} |
|
}); |
|
}, [sortField, sortDirection, showFileName]); |
|
|
|
|
|
type DocStatusWithStatus = DocStatusResponse & { status: DocStatus }; |
|
|
|
const filteredAndSortedDocs = useMemo(() => { |
|
if (!docs) return null; |
|
|
|
|
|
const allDocuments: DocStatusWithStatus[] = []; |
|
|
|
if (statusFilter === 'all') { |
|
|
|
Object.entries(docs.statuses).forEach(([status, documents]) => { |
|
documents.forEach(doc => { |
|
allDocuments.push({ |
|
...doc, |
|
status: status as DocStatus |
|
}); |
|
}); |
|
}); |
|
} else { |
|
|
|
const documents = docs.statuses[statusFilter] || []; |
|
documents.forEach(doc => { |
|
allDocuments.push({ |
|
...doc, |
|
status: statusFilter |
|
}); |
|
}); |
|
} |
|
|
|
|
|
if (sortField && sortDirection) { |
|
return sortDocuments(allDocuments); |
|
} |
|
|
|
return allDocuments; |
|
}, [docs, sortField, sortDirection, statusFilter, sortDocuments]); |
|
|
|
|
|
const documentCounts = useMemo(() => { |
|
if (!docs) return { all: 0 } as Record<string, number>; |
|
|
|
const counts: Record<string, number> = { all: 0 }; |
|
|
|
Object.entries(docs.statuses).forEach(([status, documents]) => { |
|
counts[status as DocStatus] = documents.length; |
|
counts.all += documents.length; |
|
}); |
|
|
|
return counts; |
|
}, [docs]); |
|
|
|
|
|
const prevStatusCounts = useRef({ |
|
processed: 0, |
|
processing: 0, |
|
pending: 0, |
|
failed: 0 |
|
}) |
|
|
|
|
|
useEffect(() => { |
|
const style = document.createElement('style') |
|
style.textContent = pulseStyle |
|
document.head.appendChild(style) |
|
return () => { |
|
document.head.removeChild(style) |
|
} |
|
}, []) |
|
|
|
|
|
const cardContentRef = useRef<HTMLDivElement>(null); |
|
|
|
|
|
useEffect(() => { |
|
if (!docs) return; |
|
|
|
|
|
const positionTooltips = () => { |
|
|
|
const containers = document.querySelectorAll<HTMLElement>('.tooltip-container'); |
|
|
|
containers.forEach(container => { |
|
const tooltip = container.querySelector<HTMLElement>('.tooltip'); |
|
if (!tooltip) return; |
|
|
|
|
|
if (!tooltip.classList.contains('visible')) return; |
|
|
|
|
|
const rect = container.getBoundingClientRect(); |
|
|
|
|
|
tooltip.style.left = `${rect.left}px`; |
|
tooltip.style.top = `${rect.top - 5}px`; |
|
tooltip.style.transform = 'translateY(-100%)'; |
|
}); |
|
}; |
|
|
|
|
|
const handleMouseOver = (e: MouseEvent) => { |
|
|
|
const target = e.target as HTMLElement; |
|
const container = target.closest('.tooltip-container'); |
|
if (!container) return; |
|
|
|
|
|
const tooltip = container.querySelector<HTMLElement>('.tooltip'); |
|
if (tooltip) { |
|
tooltip.classList.add('visible'); |
|
|
|
positionTooltips(); |
|
} |
|
}; |
|
|
|
const handleMouseOut = (e: MouseEvent) => { |
|
const target = e.target as HTMLElement; |
|
const container = target.closest('.tooltip-container'); |
|
if (!container) return; |
|
|
|
const tooltip = container.querySelector<HTMLElement>('.tooltip'); |
|
if (tooltip) { |
|
tooltip.classList.remove('visible'); |
|
} |
|
}; |
|
|
|
document.addEventListener('mouseover', handleMouseOver); |
|
document.addEventListener('mouseout', handleMouseOut); |
|
|
|
return () => { |
|
document.removeEventListener('mouseover', handleMouseOver); |
|
document.removeEventListener('mouseout', handleMouseOut); |
|
}; |
|
}, [docs]); |
|
|
|
const fetchDocuments = useCallback(async () => { |
|
try { |
|
|
|
if (!isMountedRef.current) return; |
|
|
|
const docs = await getDocuments(); |
|
|
|
|
|
if (!isMountedRef.current) return; |
|
|
|
|
|
if (isMountedRef.current) { |
|
|
|
if (docs && docs.statuses) { |
|
const numDocuments = Object.values(docs.statuses).reduce( |
|
(acc, status) => acc + status.length, |
|
0 |
|
) |
|
if (numDocuments > 0) { |
|
setDocs(docs) |
|
} else { |
|
setDocs(null) |
|
} |
|
} else { |
|
setDocs(null) |
|
} |
|
} |
|
} catch (err) { |
|
|
|
if (isMountedRef.current) { |
|
toast.error(t('documentPanel.documentManager.errors.loadFailed', { error: errorMessage(err) })) |
|
} |
|
} |
|
}, [setDocs, t]) |
|
|
|
|
|
useEffect(() => { |
|
if (currentTab === 'documents') { |
|
fetchDocuments() |
|
} |
|
}, [currentTab, fetchDocuments]) |
|
|
|
const scanDocuments = useCallback(async () => { |
|
try { |
|
|
|
if (!isMountedRef.current) return; |
|
|
|
const { status } = await scanNewDocuments(); |
|
|
|
|
|
if (!isMountedRef.current) return; |
|
|
|
toast.message(status); |
|
} catch (err) { |
|
|
|
if (isMountedRef.current) { |
|
toast.error(t('documentPanel.documentManager.errors.scanFailed', { error: errorMessage(err) })); |
|
} |
|
} |
|
}, [t]) |
|
|
|
|
|
useEffect(() => { |
|
if (currentTab !== 'documents' || !health) { |
|
return |
|
} |
|
|
|
const interval = setInterval(async () => { |
|
try { |
|
|
|
if (isMountedRef.current) { |
|
await fetchDocuments() |
|
} |
|
} catch (err) { |
|
|
|
if (isMountedRef.current) { |
|
toast.error(t('documentPanel.documentManager.errors.scanProgressFailed', { error: errorMessage(err) })) |
|
} |
|
} |
|
}, 5000) |
|
|
|
return () => { |
|
clearInterval(interval) |
|
} |
|
}, [health, fetchDocuments, t, currentTab]) |
|
|
|
|
|
useEffect(() => { |
|
if (!docs) return; |
|
|
|
|
|
const newStatusCounts = { |
|
processed: docs?.statuses?.processed?.length || 0, |
|
processing: docs?.statuses?.processing?.length || 0, |
|
pending: docs?.statuses?.pending?.length || 0, |
|
failed: docs?.statuses?.failed?.length || 0 |
|
} |
|
|
|
|
|
const hasStatusCountChange = (Object.keys(newStatusCounts) as Array<keyof typeof newStatusCounts>).some( |
|
status => newStatusCounts[status] !== prevStatusCounts.current[status] |
|
) |
|
|
|
|
|
if (hasStatusCountChange && isMountedRef.current) { |
|
useBackendState.getState().check() |
|
} |
|
|
|
|
|
prevStatusCounts.current = newStatusCounts |
|
}, [docs]); |
|
|
|
|
|
const handleDocumentsDeleted = useCallback(async () => { |
|
setSelectedDocIds([]) |
|
await fetchDocuments() |
|
}, [fetchDocuments]) |
|
|
|
|
|
useEffect(() => { |
|
|
|
}, [sortField, sortDirection]); |
|
|
|
return ( |
|
<Card className="!rounded-none !overflow-hidden flex flex-col h-full min-h-0"> |
|
<CardHeader className="py-2 px-6"> |
|
<CardTitle className="text-lg">{t('documentPanel.documentManager.title')}</CardTitle> |
|
</CardHeader> |
|
<CardContent className="flex-1 flex flex-col min-h-0 overflow-auto"> |
|
<div className="flex gap-2 mb-2"> |
|
<div className="flex gap-2"> |
|
<Button |
|
variant="outline" |
|
onClick={scanDocuments} |
|
side="bottom" |
|
tooltip={t('documentPanel.documentManager.scanTooltip')} |
|
size="sm" |
|
> |
|
<RefreshCwIcon /> {t('documentPanel.documentManager.scanButton')} |
|
</Button> |
|
<Button |
|
variant="outline" |
|
onClick={() => setShowPipelineStatus(true)} |
|
side="bottom" |
|
tooltip={t('documentPanel.documentManager.pipelineStatusTooltip')} |
|
size="sm" |
|
className={cn( |
|
pipelineBusy && 'pipeline-busy' |
|
)} |
|
> |
|
<ActivityIcon /> {t('documentPanel.documentManager.pipelineStatusButton')} |
|
</Button> |
|
</div> |
|
<div className="flex-1" /> |
|
{isSelectionMode && ( |
|
<DeleteDocumentsDialog |
|
selectedDocIds={selectedDocIds} |
|
totalCompletedCount={documentCounts.processed || 0} |
|
onDocumentsDeleted={handleDocumentsDeleted} |
|
/> |
|
)} |
|
{isSelectionMode ? ( |
|
<DeselectDocumentsDialog |
|
selectedCount={selectedDocIds.length} |
|
onDeselect={handleDeselectAll} |
|
/> |
|
) : ( |
|
<ClearDocumentsDialog onDocumentsCleared={fetchDocuments} /> |
|
)} |
|
<UploadDocumentsDialog onDocumentsUploaded={fetchDocuments} /> |
|
<PipelineStatusDialog |
|
open={showPipelineStatus} |
|
onOpenChange={setShowPipelineStatus} |
|
/> |
|
</div> |
|
|
|
<Card className="flex-1 flex flex-col border rounded-md min-h-0 mb-2"> |
|
<CardHeader className="flex-none py-2 px-4"> |
|
<div className="flex justify-between items-center"> |
|
<CardTitle>{t('documentPanel.documentManager.uploadedTitle')}</CardTitle> |
|
<div className="flex items-center gap-2"> |
|
<FilterIcon className="h-4 w-4" /> |
|
<div className="flex gap-1" dir={i18n.dir()}> |
|
<Button |
|
size="sm" |
|
variant={statusFilter === 'all' ? 'secondary' : 'outline'} |
|
onClick={() => setStatusFilter('all')} |
|
className={cn( |
|
statusFilter === 'all' && 'bg-gray-100 dark:bg-gray-900 font-medium border border-gray-400 dark:border-gray-500 shadow-sm' |
|
)} |
|
> |
|
{t('documentPanel.documentManager.status.all')} ({documentCounts.all}) |
|
</Button> |
|
<Button |
|
size="sm" |
|
variant={statusFilter === 'processed' ? 'secondary' : 'outline'} |
|
onClick={() => setStatusFilter('processed')} |
|
className={cn( |
|
documentCounts.processed > 0 ? 'text-green-600' : 'text-gray-500', |
|
statusFilter === 'processed' && 'bg-green-100 dark:bg-green-900/30 font-medium border border-green-400 dark:border-green-600 shadow-sm' |
|
)} |
|
> |
|
{t('documentPanel.documentManager.status.completed')} ({documentCounts.processed || 0}) |
|
</Button> |
|
<Button |
|
size="sm" |
|
variant={statusFilter === 'processing' ? 'secondary' : 'outline'} |
|
onClick={() => setStatusFilter('processing')} |
|
className={cn( |
|
documentCounts.processing > 0 ? 'text-blue-600' : 'text-gray-500', |
|
statusFilter === 'processing' && 'bg-blue-100 dark:bg-blue-900/30 font-medium border border-blue-400 dark:border-blue-600 shadow-sm' |
|
)} |
|
> |
|
{t('documentPanel.documentManager.status.processing')} ({documentCounts.processing || 0}) |
|
</Button> |
|
<Button |
|
size="sm" |
|
variant={statusFilter === 'pending' ? 'secondary' : 'outline'} |
|
onClick={() => setStatusFilter('pending')} |
|
className={cn( |
|
documentCounts.pending > 0 ? 'text-yellow-600' : 'text-gray-500', |
|
statusFilter === 'pending' && 'bg-yellow-100 dark:bg-yellow-900/30 font-medium border border-yellow-400 dark:border-yellow-600 shadow-sm' |
|
)} |
|
> |
|
{t('documentPanel.documentManager.status.pending')} ({documentCounts.pending || 0}) |
|
</Button> |
|
<Button |
|
size="sm" |
|
variant={statusFilter === 'failed' ? 'secondary' : 'outline'} |
|
onClick={() => setStatusFilter('failed')} |
|
className={cn( |
|
documentCounts.failed > 0 ? 'text-red-600' : 'text-gray-500', |
|
statusFilter === 'failed' && 'bg-red-100 dark:bg-red-900/30 font-medium border border-red-400 dark:border-red-600 shadow-sm' |
|
)} |
|
> |
|
{t('documentPanel.documentManager.status.failed')} ({documentCounts.failed || 0}) |
|
</Button> |
|
</div> |
|
</div> |
|
<div className="flex items-center gap-2"> |
|
<label |
|
htmlFor="toggle-filename-btn" |
|
className="text-sm text-gray-500" |
|
> |
|
{t('documentPanel.documentManager.fileNameLabel')} |
|
</label> |
|
<Button |
|
id="toggle-filename-btn" |
|
variant="outline" |
|
size="sm" |
|
onClick={() => setShowFileName(!showFileName)} |
|
className="border-gray-200 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800" |
|
> |
|
{showFileName |
|
? t('documentPanel.documentManager.hideButton') |
|
: t('documentPanel.documentManager.showButton') |
|
} |
|
</Button> |
|
</div> |
|
</div> |
|
<CardDescription aria-hidden="true" className="hidden">{t('documentPanel.documentManager.uploadedDescription')}</CardDescription> |
|
</CardHeader> |
|
|
|
<CardContent className="flex-1 relative p-0" ref={cardContentRef}> |
|
{!docs && ( |
|
<div className="absolute inset-0 p-0"> |
|
<EmptyCard |
|
title={t('documentPanel.documentManager.emptyTitle')} |
|
description={t('documentPanel.documentManager.emptyDescription')} |
|
/> |
|
</div> |
|
)} |
|
{docs && ( |
|
<div className="absolute inset-0 flex flex-col p-0"> |
|
<div className="absolute inset-[-1px] flex flex-col p-0 border rounded-md border-gray-200 dark:border-gray-700 overflow-hidden"> |
|
<Table className="w-full"> |
|
<TableHeader className="sticky top-0 bg-background z-10 shadow-sm"> |
|
<TableRow className="border-b bg-card/95 backdrop-blur supports-[backdrop-filter]:bg-card/75 shadow-[inset_0_-1px_0_rgba(0,0,0,0.1)]"> |
|
<TableHead |
|
onClick={() => handleSort('id')} |
|
className="cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-800 select-none" |
|
> |
|
<div className="flex items-center"> |
|
{t('documentPanel.documentManager.columns.id')} |
|
{sortField === 'id' && ( |
|
<span className="ml-1"> |
|
{sortDirection === 'asc' ? <ArrowUpIcon size={14} /> : <ArrowDownIcon size={14} />} |
|
</span> |
|
)} |
|
</div> |
|
</TableHead> |
|
<TableHead>{t('documentPanel.documentManager.columns.summary')}</TableHead> |
|
<TableHead>{t('documentPanel.documentManager.columns.status')}</TableHead> |
|
<TableHead>{t('documentPanel.documentManager.columns.length')}</TableHead> |
|
<TableHead>{t('documentPanel.documentManager.columns.chunks')}</TableHead> |
|
<TableHead |
|
onClick={() => handleSort('created_at')} |
|
className="cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-800 select-none" |
|
> |
|
<div className="flex items-center"> |
|
{t('documentPanel.documentManager.columns.created')} |
|
{sortField === 'created_at' && ( |
|
<span className="ml-1"> |
|
{sortDirection === 'asc' ? <ArrowUpIcon size={14} /> : <ArrowDownIcon size={14} />} |
|
</span> |
|
)} |
|
</div> |
|
</TableHead> |
|
<TableHead |
|
onClick={() => handleSort('updated_at')} |
|
className="cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-800 select-none" |
|
> |
|
<div className="flex items-center"> |
|
{t('documentPanel.documentManager.columns.updated')} |
|
{sortField === 'updated_at' && ( |
|
<span className="ml-1"> |
|
{sortDirection === 'asc' ? <ArrowUpIcon size={14} /> : <ArrowDownIcon size={14} />} |
|
</span> |
|
)} |
|
</div> |
|
</TableHead> |
|
<TableHead className="w-16 text-center"> |
|
{t('documentPanel.documentManager.columns.select')} |
|
</TableHead> |
|
</TableRow> |
|
</TableHeader> |
|
<TableBody className="text-sm overflow-auto"> |
|
{filteredAndSortedDocs && filteredAndSortedDocs.map((doc) => ( |
|
<TableRow key={doc.id}> |
|
<TableCell className="truncate font-mono overflow-visible max-w-[250px]"> |
|
{showFileName ? ( |
|
<> |
|
<div className="group relative overflow-visible tooltip-container"> |
|
<div className="truncate"> |
|
{getDisplayFileName(doc, 30)} |
|
</div> |
|
<div className="invisible group-hover:visible tooltip"> |
|
{doc.file_path} |
|
</div> |
|
</div> |
|
<div className="text-xs text-gray-500">{doc.id}</div> |
|
</> |
|
) : ( |
|
<div className="group relative overflow-visible tooltip-container"> |
|
<div className="truncate"> |
|
{doc.id} |
|
</div> |
|
<div className="invisible group-hover:visible tooltip"> |
|
{doc.file_path} |
|
</div> |
|
</div> |
|
)} |
|
</TableCell> |
|
<TableCell className="max-w-xs min-w-45 truncate overflow-visible"> |
|
<div className="group relative overflow-visible tooltip-container"> |
|
<div className="truncate"> |
|
{doc.content_summary} |
|
</div> |
|
<div className="invisible group-hover:visible tooltip"> |
|
{doc.content_summary} |
|
</div> |
|
</div> |
|
</TableCell> |
|
<TableCell> |
|
{doc.status === 'processed' && ( |
|
<span className="text-green-600">{t('documentPanel.documentManager.status.completed')}</span> |
|
)} |
|
{doc.status === 'processing' && ( |
|
<span className="text-blue-600">{t('documentPanel.documentManager.status.processing')}</span> |
|
)} |
|
{doc.status === 'pending' && ( |
|
<span className="text-yellow-600">{t('documentPanel.documentManager.status.pending')}</span> |
|
)} |
|
{doc.status === 'failed' && ( |
|
<span className="text-red-600">{t('documentPanel.documentManager.status.failed')}</span> |
|
)} |
|
{doc.error && ( |
|
<span className="ml-2 text-red-500" title={doc.error}> |
|
⚠️ |
|
</span> |
|
)} |
|
</TableCell> |
|
<TableCell>{doc.content_length ?? '-'}</TableCell> |
|
<TableCell>{doc.chunks_count ?? '-'}</TableCell> |
|
<TableCell className="truncate"> |
|
{new Date(doc.created_at).toLocaleString()} |
|
</TableCell> |
|
<TableCell className="truncate"> |
|
{new Date(doc.updated_at).toLocaleString()} |
|
</TableCell> |
|
<TableCell className="text-center"> |
|
<Checkbox |
|
checked={selectedDocIds.includes(doc.id)} |
|
onCheckedChange={(checked) => handleDocumentSelect(doc.id, checked === true)} |
|
// disabled={doc.status !== 'processed'} |
|
className="mx-auto" |
|
/> |
|
</TableCell> |
|
</TableRow> |
|
))} |
|
</TableBody> |
|
</Table> |
|
</div> |
|
</div> |
|
)} |
|
</CardContent> |
|
</Card> |
|
</CardContent> |
|
</Card> |
|
) |
|
} |
|
|