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 UploadDocumentsDialog from '@/components/documents/UploadDocumentsDialog' import ClearDocumentsDialog from '@/components/documents/ClearDocumentsDialog' 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 => { // Check if file_path exists and is a non-empty string if (!doc.file_path || typeof doc.file_path !== 'string' || doc.file_path.trim() === '') { return doc.id; } // Try to extract filename from path const parts = doc.file_path.split('/'); const fileName = parts[parts.length - 1]; // Ensure extracted filename is valid if (!fileName || fileName.trim() === '') { return doc.id; } // If filename is longer than maxLength, truncate it and add ellipsis 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 definitions for sort field and direction type SortField = 'created_at' | 'updated_at' | 'id'; type SortDirection = 'asc' | 'desc'; export default function DocumentManager() { // Track component mount status const isMountedRef = useRef(true); // Set up mount/unmount status tracking useEffect(() => { isMountedRef.current = true; // Handle page reload/unload 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(null) const currentTab = useSettingsStore.use.currentTab() const showFileName = useSettingsStore.use.showFileName() const setShowFileName = useSettingsStore.use.setShowFileName() // Sort state const [sortField, setSortField] = useState('updated_at') const [sortDirection, setSortDirection] = useState('desc') // State for document status filter const [statusFilter, setStatusFilter] = useState('all'); // Handle sort column click const handleSort = (field: SortField) => { if (sortField === field) { // Toggle sort direction if clicking the same field setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc') } else { // Set new sort field with default desc direction setSortField(field) setSortDirection('desc') } } // Sort documents based on current sort field and direction const sortDocuments = useCallback((documents: DocStatusResponse[]) => { return [...documents].sort((a, b) => { let valueA, valueB; // Special handling for ID field based on showFileName setting if (sortField === 'id' && showFileName) { valueA = getDisplayFileName(a); valueB = getDisplayFileName(b); } else if (sortField === 'id') { valueA = a.id; valueB = b.id; } else { // Date fields valueA = new Date(a[sortField]).getTime(); valueB = new Date(b[sortField]).getTime(); } // Apply sort direction const sortMultiplier = sortDirection === 'asc' ? 1 : -1; // Compare values 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]); // Define a new type that includes status information type DocStatusWithStatus = DocStatusResponse & { status: DocStatus }; const filteredAndSortedDocs = useMemo(() => { if (!docs) return null; // Create a flat array of documents with status information const allDocuments: DocStatusWithStatus[] = []; if (statusFilter === 'all') { // When filter is 'all', include documents from all statuses Object.entries(docs.statuses).forEach(([status, documents]) => { documents.forEach(doc => { allDocuments.push({ ...doc, status: status as DocStatus }); }); }); } else { // When filter is specific status, only include documents from that status const documents = docs.statuses[statusFilter] || []; documents.forEach(doc => { allDocuments.push({ ...doc, status: statusFilter }); }); } // Sort all documents together if sort field and direction are specified if (sortField && sortDirection) { return sortDocuments(allDocuments); } return allDocuments; }, [docs, sortField, sortDirection, statusFilter, sortDocuments]); // Calculate document counts for each status const documentCounts = useMemo(() => { if (!docs) return { all: 0 } as Record; const counts: Record = { all: 0 }; Object.entries(docs.statuses).forEach(([status, documents]) => { counts[status as DocStatus] = documents.length; counts.all += documents.length; }); return counts; }, [docs]); // Store previous status counts const prevStatusCounts = useRef({ processed: 0, processing: 0, pending: 0, failed: 0 }) // Add pulse style to document useEffect(() => { const style = document.createElement('style') style.textContent = pulseStyle document.head.appendChild(style) return () => { document.head.removeChild(style) } }, []) // Reference to the card content element const cardContentRef = useRef(null); // Add tooltip position adjustment for fixed positioning useEffect(() => { if (!docs) return; // Function to position tooltips const positionTooltips = () => { // Get all tooltip containers const containers = document.querySelectorAll('.tooltip-container'); containers.forEach(container => { const tooltip = container.querySelector('.tooltip'); if (!tooltip) return; // Skip tooltips that aren't visible if (!tooltip.classList.contains('visible')) return; // Get container position const rect = container.getBoundingClientRect(); // Position tooltip above the container tooltip.style.left = `${rect.left}px`; tooltip.style.top = `${rect.top - 5}px`; tooltip.style.transform = 'translateY(-100%)'; }); }; // Set up event listeners const handleMouseOver = (e: MouseEvent) => { // Check if target or its parent is a tooltip container const target = e.target as HTMLElement; const container = target.closest('.tooltip-container'); if (!container) return; // Find tooltip and make it visible const tooltip = container.querySelector('.tooltip'); if (tooltip) { tooltip.classList.add('visible'); // Position immediately without delay positionTooltips(); } }; const handleMouseOut = (e: MouseEvent) => { const target = e.target as HTMLElement; const container = target.closest('.tooltip-container'); if (!container) return; const tooltip = container.querySelector('.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 { // Check if component is still mounted before starting the request if (!isMountedRef.current) return; const docs = await getDocuments(); // Check again if component is still mounted after the request completes if (!isMountedRef.current) return; // Only update state if component is still mounted if (isMountedRef.current) { // Update docs state 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) { // Only show error if component is still mounted if (isMountedRef.current) { toast.error(t('documentPanel.documentManager.errors.loadFailed', { error: errorMessage(err) })) } } }, [setDocs, t]) // Fetch documents when the tab becomes visible useEffect(() => { if (currentTab === 'documents') { fetchDocuments() } }, [currentTab, fetchDocuments]) const scanDocuments = useCallback(async () => { try { // Check if component is still mounted before starting the request if (!isMountedRef.current) return; const { status } = await scanNewDocuments(); // Check again if component is still mounted after the request completes if (!isMountedRef.current) return; toast.message(status); } catch (err) { // Only show error if component is still mounted if (isMountedRef.current) { toast.error(t('documentPanel.documentManager.errors.scanFailed', { error: errorMessage(err) })); } } }, [t]) // Set up polling when the documents tab is active and health is good useEffect(() => { if (currentTab !== 'documents' || !health) { return } const interval = setInterval(async () => { try { // Only perform fetch if component is still mounted if (isMountedRef.current) { await fetchDocuments() } } catch (err) { // Only show error if component is still mounted if (isMountedRef.current) { toast.error(t('documentPanel.documentManager.errors.scanProgressFailed', { error: errorMessage(err) })) } } }, 5000) return () => { clearInterval(interval) } }, [health, fetchDocuments, t, currentTab]) // Monitor docs changes to check status counts and trigger health check if needed useEffect(() => { if (!docs) return; // Get new status counts 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 } // Check if any status count has changed const hasStatusCountChange = (Object.keys(newStatusCounts) as Array).some( status => newStatusCounts[status] !== prevStatusCounts.current[status] ) // Trigger health check if changes detected and component is still mounted if (hasStatusCountChange && isMountedRef.current) { useBackendState.getState().check() } // Update previous status counts prevStatusCounts.current = newStatusCounts }, [docs]); // Add dependency on sort state to re-render when sort changes useEffect(() => { // This effect ensures the component re-renders when sort state changes }, [sortField, sortDirection]); return ( {t('documentPanel.documentManager.title')}
{t('documentPanel.documentManager.uploadedTitle')}
{!docs && (
)} {docs && (
handleSort('id')} className="cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-800 select-none" >
{t('documentPanel.documentManager.columns.id')} {sortField === 'id' && ( {sortDirection === 'asc' ? : } )}
{t('documentPanel.documentManager.columns.summary')} {t('documentPanel.documentManager.columns.status')} {t('documentPanel.documentManager.columns.length')} {t('documentPanel.documentManager.columns.chunks')} handleSort('created_at')} className="cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-800 select-none" >
{t('documentPanel.documentManager.columns.created')} {sortField === 'created_at' && ( {sortDirection === 'asc' ? : } )}
handleSort('updated_at')} className="cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-800 select-none" >
{t('documentPanel.documentManager.columns.updated')} {sortField === 'updated_at' && ( {sortDirection === 'asc' ? : } )}
{filteredAndSortedDocs && filteredAndSortedDocs.map((doc) => ( {showFileName ? ( <>
{getDisplayFileName(doc, 30)}
{doc.file_path}
{doc.id}
) : (
{doc.id}
{doc.file_path}
)}
{doc.content_summary}
{doc.content_summary}
{doc.status === 'processed' && ( {t('documentPanel.documentManager.status.completed')} )} {doc.status === 'processing' && ( {t('documentPanel.documentManager.status.processing')} )} {doc.status === 'pending' && ( {t('documentPanel.documentManager.status.pending')} )} {doc.status === 'failed' && ( {t('documentPanel.documentManager.status.failed')} )} {doc.error && ( ⚠️ )} {doc.content_length ?? '-'} {doc.chunks_count ?? '-'} {new Date(doc.created_at).toLocaleString()} {new Date(doc.updated_at).toLocaleString()}
))}
)}
) }