| 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 PaginationControls from '@/components/ui/PaginationControls' |
|
|
| import { |
| scanNewDocuments, |
| getDocumentsPaginatedWithTimeout, |
| DocsStatusesResponse, |
| DocStatus, |
| DocStatusResponse, |
| DocumentsRequest, |
| PaginationInfo |
| } from '@/api/lightrag' |
| import { errorMessage } from '@/lib/utils' |
| import { toast } from 'sonner' |
| import { useBackendState } from '@/stores/state' |
|
|
| import { RefreshCwIcon, ActivityIcon, ArrowUpIcon, ArrowDownIcon, RotateCcwIcon, CheckSquareIcon, XIcon, AlertTriangle, Info } from 'lucide-react' |
| import PipelineStatusDialog from '@/components/documents/PipelineStatusDialog' |
|
|
| type StatusFilter = DocStatus | 'all'; |
|
|
| |
| const getCountValue = (counts: Record<string, number>, ...keys: string[]): number => { |
| for (const key of keys) { |
| const value = counts[key] |
| if (typeof value === 'number') { |
| return value |
| } |
| } |
| return 0 |
| } |
|
|
| const hasActiveDocumentsStatus = (counts: Record<string, number>): boolean => |
| getCountValue(counts, 'PROCESSING', 'processing') > 0 || |
| getCountValue(counts, 'PENDING', 'pending') > 0 || |
| getCountValue(counts, 'PREPROCESSED', 'preprocessed') > 0 |
|
|
| 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 formatMetadata = (metadata: Record<string, any>): string => { |
| const formattedMetadata = { ...metadata }; |
|
|
| if (formattedMetadata.processing_start_time && typeof formattedMetadata.processing_start_time === 'number') { |
| const date = new Date(formattedMetadata.processing_start_time * 1000); |
| if (!isNaN(date.getTime())) { |
| formattedMetadata.processing_start_time = date.toLocaleString(); |
| } |
| } |
|
|
| if (formattedMetadata.processing_end_time && typeof formattedMetadata.processing_end_time === 'number') { |
| const date = new Date(formattedMetadata.processing_end_time * 1000); |
| if (!isNaN(date.getTime())) { |
| formattedMetadata.processing_end_time = date.toLocaleString(); |
| } |
| } |
|
|
| |
| const jsonStr = JSON.stringify(formattedMetadata, null, 2); |
| const lines = jsonStr.split('\n'); |
| |
| return lines.slice(1, -1) |
| .map(line => line.replace(/^ {2}/, '')) |
| .join('\n'); |
| }; |
|
|
| 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; |
| word-break: break-word; |
| overflow-wrap: break-word; |
| border-radius: 0.375rem; |
| padding: 0.5rem 0.75rem; |
| font-size: 0.75rem; /* 12px */ |
| 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; |
| } |
| |
| .tooltip pre { |
| white-space: pre-wrap; |
| word-break: break-word; |
| overflow-wrap: break-word; |
| } |
| |
| /* 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' | 'file_path'; |
| type SortDirection = 'asc' | 'desc'; |
| type QuerySnapshot = { |
| statusFilter: StatusFilter |
| page: number |
| pageSize: number |
| sortField: SortField |
| sortDirection: SortDirection |
| } |
| type RefreshRequest = |
| | { |
| type: 'intelligent'; |
| query: QuerySnapshot; |
| customTimeout?: number; |
| requestVersion: number; |
| } |
| | { |
| type: 'manual'; |
| query: QuerySnapshot; |
| requestVersion: number; |
| }; |
|
|
| 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 documentsPageSize = useSettingsStore.use.documentsPageSize() |
| const setDocumentsPageSize = useSettingsStore.use.setDocumentsPageSize() |
|
|
| |
| const [currentPageDocs, setCurrentPageDocs] = useState<DocStatusResponse[]>([]) |
| const [pagination, setPagination] = useState<PaginationInfo>({ |
| page: 1, |
| page_size: documentsPageSize, |
| total_count: 0, |
| total_pages: 0, |
| has_next: false, |
| has_prev: false |
| }) |
| const [statusCounts, setStatusCounts] = useState<Record<string, number>>({ all: 0 }) |
| const [isRefreshing, setIsRefreshing] = useState(false) |
|
|
| |
| const [sortField, setSortField] = useState<SortField>('updated_at') |
| const [sortDirection, setSortDirection] = useState<SortDirection>('desc') |
|
|
| |
| const [statusFilter, setStatusFilter] = useState<StatusFilter>('all'); |
|
|
| |
| const [pageByStatus, setPageByStatus] = useState<Record<StatusFilter, number>>({ |
| all: 1, |
| processed: 1, |
| preprocessed: 1, |
| processing: 1, |
| pending: 1, |
| failed: 1, |
| }); |
|
|
| |
| const [selectedDocIds, setSelectedDocIds] = useState<string[]>([]) |
| const isSelectionMode = selectedDocIds.length > 0 |
|
|
| |
| const prevPipelineBusyRef = useRef<boolean | undefined>(undefined); |
| const pollingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null); |
| const activeRefreshPromiseRef = useRef<Promise<void> | null>(null); |
| const pendingRefreshRequestRef = useRef<RefreshRequest | null>(null); |
| const latestRefreshRequestVersionRef = useRef(0); |
|
|
| |
| const [retryState, setRetryState] = useState({ |
| count: 0, |
| lastError: null as Error | null, |
| isBackingOff: false |
| }); |
|
|
| |
| const [circuitBreakerState, setCircuitBreakerState] = useState({ |
| isOpen: false, |
| failureCount: 0, |
| lastFailureTime: null as number | null, |
| nextRetryTime: null as number | null |
| }); |
|
|
|
|
| |
| 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) => { |
| let actualField = field; |
|
|
| |
| if (field === 'id') { |
| actualField = showFileName ? 'file_path' : 'id'; |
| } |
|
|
| const newDirection = (sortField === actualField && sortDirection === 'desc') ? 'asc' : 'desc'; |
|
|
| setSortField(actualField); |
| setSortDirection(newDirection); |
|
|
| |
| setPagination(prev => ({ ...prev, page: 1 })); |
|
|
| |
| setPageByStatus({ |
| all: 1, |
| processed: 1, |
| preprocessed: 1, |
| processing: 1, |
| pending: 1, |
| failed: 1, |
| }); |
| }; |
|
|
| |
| 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 (currentPageDocs && currentPageDocs.length > 0) { |
| return currentPageDocs.map(doc => ({ |
| ...doc, |
| status: doc.status as DocStatus |
| })) as DocStatusWithStatus[]; |
| } |
|
|
| |
| 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; |
| }, [currentPageDocs, docs, sortField, sortDirection, statusFilter, sortDocuments]); |
|
|
| |
| const currentPageDocIds = useMemo(() => { |
| return filteredAndSortedDocs?.map(doc => doc.id) || [] |
| }, [filteredAndSortedDocs]) |
|
|
| const selectedCurrentPageCount = useMemo(() => { |
| return currentPageDocIds.filter(id => selectedDocIds.includes(id)).length |
| }, [currentPageDocIds, selectedDocIds]) |
|
|
| const isCurrentPageFullySelected = useMemo(() => { |
| return currentPageDocIds.length > 0 && selectedCurrentPageCount === currentPageDocIds.length |
| }, [currentPageDocIds, selectedCurrentPageCount]) |
|
|
| const hasCurrentPageSelection = useMemo(() => { |
| return selectedCurrentPageCount > 0 |
| }, [selectedCurrentPageCount]) |
|
|
| |
| const handleSelectCurrentPage = useCallback(() => { |
| setSelectedDocIds(currentPageDocIds) |
| }, [currentPageDocIds]) |
|
|
|
|
| |
| const getSelectionButtonProps = useCallback(() => { |
| if (!hasCurrentPageSelection) { |
| return { |
| text: t('documentPanel.selectDocuments.selectCurrentPage', { count: currentPageDocIds.length }), |
| action: handleSelectCurrentPage, |
| icon: CheckSquareIcon |
| } |
| } else if (isCurrentPageFullySelected) { |
| return { |
| text: t('documentPanel.selectDocuments.deselectAll', { count: currentPageDocIds.length }), |
| action: handleDeselectAll, |
| icon: XIcon |
| } |
| } else { |
| return { |
| text: t('documentPanel.selectDocuments.selectCurrentPage', { count: currentPageDocIds.length }), |
| action: handleSelectCurrentPage, |
| icon: CheckSquareIcon |
| } |
| } |
| }, [hasCurrentPageSelection, isCurrentPageFullySelected, currentPageDocIds.length, handleSelectCurrentPage, handleDeselectAll, t]) |
|
|
| |
| 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 processedCount = getCountValue(statusCounts, 'PROCESSED', 'processed') || documentCounts.processed || 0; |
| const preprocessedCount = |
| getCountValue(statusCounts, 'PREPROCESSED', 'preprocessed') || |
| documentCounts.preprocessed || |
| 0; |
| const processingCount = getCountValue(statusCounts, 'PROCESSING', 'processing') || documentCounts.processing || 0; |
| const pendingCount = getCountValue(statusCounts, 'PENDING', 'pending') || documentCounts.pending || 0; |
| const failedCount = getCountValue(statusCounts, 'FAILED', 'failed') || documentCounts.failed || 0; |
|
|
| |
| const prevStatusCounts = useRef({ |
| processed: 0, |
| preprocessed: 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 buildQuerySnapshot = useCallback(( |
| overrides: Partial<QuerySnapshot> = {} |
| ): QuerySnapshot => ({ |
| statusFilter: overrides.statusFilter ?? statusFilter, |
| page: overrides.page ?? pagination.page, |
| pageSize: overrides.pageSize ?? pagination.page_size, |
| sortField: overrides.sortField ?? sortField, |
| sortDirection: overrides.sortDirection ?? sortDirection |
| }), [pagination.page, pagination.page_size, sortField, sortDirection, statusFilter]) |
|
|
| const buildDocumentsRequest = useCallback(( |
| query: QuerySnapshot, |
| page: number = query.page |
| ): DocumentsRequest => ({ |
| status_filter: query.statusFilter === 'all' ? null : query.statusFilter, |
| page, |
| page_size: query.pageSize, |
| sort_field: query.sortField, |
| sort_direction: query.sortDirection |
| }), []) |
|
|
| |
| const updateComponentState = useCallback((response: any) => { |
| setPagination(response.pagination); |
| setCurrentPageDocs(response.documents); |
| setStatusCounts(response.status_counts); |
|
|
| |
| const legacyDocs: DocsStatusesResponse = { |
| statuses: { |
| processed: response.documents.filter((doc: DocStatusResponse) => doc.status === 'processed'), |
| preprocessed: response.documents.filter((doc: DocStatusResponse) => doc.status === 'preprocessed'), |
| processing: response.documents.filter((doc: DocStatusResponse) => doc.status === 'processing'), |
| pending: response.documents.filter((doc: DocStatusResponse) => doc.status === 'pending'), |
| failed: response.documents.filter((doc: DocStatusResponse) => doc.status === 'failed') |
| } |
| }; |
|
|
| setDocs(response.pagination.total_count > 0 ? legacyDocs : null); |
| }, []); |
|
|
|
|
| |
| const classifyError = useCallback((error: any) => { |
| if (error.name === 'AbortError') { |
| return { type: 'cancelled', shouldRetry: false, shouldShowToast: false }; |
| } |
|
|
| if (error.message === 'Request timeout') { |
| return { type: 'timeout', shouldRetry: true, shouldShowToast: true }; |
| } |
|
|
| if (error.message?.includes('Network Error') || error.code === 'NETWORK_ERROR') { |
| return { type: 'network', shouldRetry: true, shouldShowToast: true }; |
| } |
|
|
| if (error.status >= 500) { |
| return { type: 'server', shouldRetry: true, shouldShowToast: true }; |
| } |
|
|
| if (error.status >= 400 && error.status < 500) { |
| return { type: 'client', shouldRetry: false, shouldShowToast: true }; |
| } |
|
|
| return { type: 'unknown', shouldRetry: true, shouldShowToast: true }; |
| }, []); |
|
|
| |
| const isCircuitBreakerOpen = useCallback(() => { |
| if (!circuitBreakerState.isOpen) return false; |
|
|
| const now = Date.now(); |
| if (circuitBreakerState.nextRetryTime && now >= circuitBreakerState.nextRetryTime) { |
| |
| setCircuitBreakerState(prev => ({ |
| ...prev, |
| isOpen: false, |
| failureCount: Math.max(0, prev.failureCount - 1) |
| })); |
| return false; |
| } |
|
|
| return true; |
| }, [circuitBreakerState]); |
|
|
| const recordFailure = useCallback((error: Error) => { |
| const now = Date.now(); |
| setCircuitBreakerState(prev => { |
| const newFailureCount = prev.failureCount + 1; |
| const shouldOpen = newFailureCount >= 3; |
|
|
| return { |
| isOpen: shouldOpen, |
| failureCount: newFailureCount, |
| lastFailureTime: now, |
| nextRetryTime: shouldOpen ? now + (Math.pow(2, newFailureCount) * 1000) : null |
| }; |
| }); |
|
|
| setRetryState(prev => ({ |
| count: prev.count + 1, |
| lastError: error, |
| isBackingOff: true |
| })); |
| }, []); |
|
|
| const recordSuccess = useCallback(() => { |
| setCircuitBreakerState({ |
| isOpen: false, |
| failureCount: 0, |
| lastFailureTime: null, |
| nextRetryTime: null |
| }); |
|
|
| setRetryState({ |
| count: 0, |
| lastError: null, |
| isBackingOff: false |
| }); |
| }, []); |
|
|
| |
| const handlePageSizeChange = useCallback((newPageSize: number) => { |
| if (newPageSize === pagination.page_size) return; |
|
|
| |
| setDocumentsPageSize(newPageSize); |
|
|
| |
| setPageByStatus({ |
| all: 1, |
| processed: 1, |
| preprocessed: 1, |
| processing: 1, |
| pending: 1, |
| failed: 1, |
| }); |
|
|
| setPagination(prev => ({ ...prev, page: 1, page_size: newPageSize })); |
| }, [pagination.page_size, setDocumentsPageSize]); |
|
|
| const runRefreshRequest = useCallback(async (refreshRequest: RefreshRequest) => { |
| try { |
| if (!isMountedRef.current) return; |
|
|
| setIsRefreshing(true); |
|
|
| const { query, requestVersion } = refreshRequest |
| const isStaleRequest = () => requestVersion !== latestRefreshRequestVersionRef.current |
|
|
| if (refreshRequest.type === 'manual') { |
| const request = buildDocumentsRequest(query, 1) |
| const response = await getDocumentsPaginatedWithTimeout(request) |
|
|
| if (!isMountedRef.current || isStaleRequest()) return; |
|
|
| if (response.pagination.total_count < query.pageSize && query.pageSize !== 10) { |
| handlePageSizeChange(10); |
| } else { |
| setPagination(response.pagination); |
| setCurrentPageDocs(response.documents); |
| setStatusCounts(response.status_counts); |
|
|
| const legacyDocs: DocsStatusesResponse = { |
| statuses: { |
| processed: response.documents.filter(doc => doc.status === 'processed'), |
| preprocessed: response.documents.filter(doc => doc.status === 'preprocessed'), |
| processing: response.documents.filter(doc => doc.status === 'processing'), |
| pending: response.documents.filter(doc => doc.status === 'pending'), |
| failed: response.documents.filter(doc => doc.status === 'failed') |
| } |
| }; |
|
|
| if (response.pagination.total_count > 0) { |
| setDocs(legacyDocs); |
| } else { |
| setDocs(null); |
| } |
| } |
| } else { |
| const { customTimeout } = refreshRequest; |
| const pageToFetch = query.page; |
| const request = buildDocumentsRequest(query, pageToFetch) |
| const response = await getDocumentsPaginatedWithTimeout(request, customTimeout) |
|
|
| if (!isMountedRef.current || isStaleRequest()) return; |
|
|
| |
| if (response.documents.length === 0 && response.pagination.total_count > 0) { |
| const lastPage = Math.max(1, response.pagination.total_pages); |
|
|
| if (pageToFetch !== lastPage) { |
| const lastPageRequest = buildDocumentsRequest(query, lastPage) |
| const lastPageResponse = await getDocumentsPaginatedWithTimeout( |
| lastPageRequest, |
| customTimeout |
| ) |
|
|
| if (!isMountedRef.current || isStaleRequest()) return; |
|
|
| setPageByStatus(prev => ({ ...prev, [query.statusFilter]: lastPage })); |
| updateComponentState(lastPageResponse); |
| return; |
| } |
| } |
|
|
| setPageByStatus(prev => ( |
| prev[query.statusFilter] === pageToFetch |
| ? prev |
| : { ...prev, [query.statusFilter]: pageToFetch } |
| )); |
| updateComponentState(response); |
| } |
|
|
| } catch (err) { |
| if (isMountedRef.current) { |
| const errorClassification = classifyError(err); |
|
|
| if (errorClassification.shouldShowToast) { |
| toast.error(t('documentPanel.documentManager.errors.loadFailed', { error: errorMessage(err) })); |
| } |
|
|
| if (errorClassification.shouldRetry) { |
| recordFailure(err as Error); |
| } |
| } |
| } finally { |
| if (isMountedRef.current) { |
| setIsRefreshing(false); |
| } |
| } |
| }, [ |
| t, |
| updateComponentState, |
| classifyError, |
| recordFailure, |
| handlePageSizeChange, |
| buildDocumentsRequest |
| ]); |
|
|
| const enqueueRefresh = useCallback(async (refreshRequest: RefreshRequest) => { |
| if (activeRefreshPromiseRef.current) { |
| pendingRefreshRequestRef.current = refreshRequest; |
| await activeRefreshPromiseRef.current; |
| return; |
| } |
|
|
| const refreshLoopPromise = (async () => { |
| let nextRequest: RefreshRequest | null = refreshRequest; |
|
|
| while (nextRequest) { |
| pendingRefreshRequestRef.current = null; |
| await runRefreshRequest(nextRequest); |
| nextRequest = pendingRefreshRequestRef.current; |
| } |
| })(); |
|
|
| activeRefreshPromiseRef.current = refreshLoopPromise; |
|
|
| try { |
| await refreshLoopPromise; |
| } finally { |
| if (activeRefreshPromiseRef.current === refreshLoopPromise) { |
| activeRefreshPromiseRef.current = null; |
| } |
| pendingRefreshRequestRef.current = null; |
| } |
| }, [runRefreshRequest]); |
|
|
| |
| const handleIntelligentRefresh = useCallback(async ( |
| targetPage?: number, |
| resetToFirst?: boolean, |
| customTimeout?: number |
| ) => { |
| const page = resetToFirst ? 1 : (targetPage || pagination.page) |
| const query = buildQuerySnapshot({ page }) |
| const requestVersion = latestRefreshRequestVersionRef.current |
|
|
| await enqueueRefresh({ |
| type: 'intelligent', |
| query, |
| customTimeout, |
| requestVersion |
| }); |
| }, [buildQuerySnapshot, enqueueRefresh, pagination.page]); |
|
|
| |
| const fetchPaginatedDocuments = useCallback(async ( |
| page: number, |
| pageSize: number, |
| currentStatusFilter: StatusFilter |
| ) => { |
| |
| setPagination(prev => ({ ...prev, page, page_size: pageSize })); |
|
|
| |
| await enqueueRefresh({ |
| type: 'intelligent', |
| query: buildQuerySnapshot({ |
| page, |
| pageSize, |
| statusFilter: currentStatusFilter |
| }), |
| requestVersion: latestRefreshRequestVersionRef.current |
| }); |
| }, [buildQuerySnapshot, enqueueRefresh]); |
|
|
| |
| const fetchDocuments = useCallback(async () => { |
| await fetchPaginatedDocuments(pagination.page, pagination.page_size, statusFilter); |
| }, [fetchPaginatedDocuments, pagination.page, pagination.page_size, statusFilter]); |
|
|
| |
| const clearPollingInterval = useCallback(() => { |
| if (pollingIntervalRef.current) { |
| clearInterval(pollingIntervalRef.current); |
| pollingIntervalRef.current = null; |
| } |
| }, []); |
|
|
| |
| const startPollingInterval = useCallback((intervalMs: number) => { |
| clearPollingInterval(); |
|
|
| pollingIntervalRef.current = setInterval(async () => { |
| try { |
| |
| if (isCircuitBreakerOpen()) { |
| return; |
| } |
|
|
| |
| if (isMountedRef.current) { |
| await fetchDocuments(); |
| recordSuccess(); |
| } |
| } catch (err) { |
| |
| if (isMountedRef.current) { |
| const errorClassification = classifyError(err); |
|
|
| |
| setIsRefreshing(false); |
|
|
| if (errorClassification.shouldShowToast) { |
| toast.error(t('documentPanel.documentManager.errors.scanProgressFailed', { error: errorMessage(err) })); |
| } |
|
|
| if (errorClassification.shouldRetry) { |
| recordFailure(err as Error); |
|
|
| |
| const backoffDelay = Math.min(Math.pow(2, retryState.count) * 1000, 30000); |
|
|
| if (retryState.count < 3) { |
| setTimeout(() => { |
| if (isMountedRef.current) { |
| setRetryState(prev => ({ ...prev, isBackingOff: false })); |
| } |
| }, backoffDelay); |
| } |
| } else { |
| |
| clearPollingInterval(); |
| } |
| } |
| } |
| }, intervalMs); |
| }, [fetchDocuments, t, clearPollingInterval, isCircuitBreakerOpen, recordSuccess, recordFailure, classifyError, retryState.count]); |
|
|
| const scanDocuments = useCallback(async () => { |
| try { |
| |
| if (!isMountedRef.current) return; |
|
|
| const { status, message, track_id: _track_id } = await scanNewDocuments(); |
|
|
| |
| if (!isMountedRef.current) return; |
|
|
| |
| toast.message(message || status); |
|
|
| |
| useBackendState.getState().resetHealthCheckTimerDelayed(1000); |
|
|
| |
| await handleIntelligentRefresh(undefined, false, 90000); |
|
|
| |
| startPollingInterval(2000); |
|
|
| |
| setTimeout(() => { |
| if (isMountedRef.current && currentTab === 'documents' && health) { |
| |
| const hasActiveDocuments = hasActiveDocumentsStatus(statusCounts); |
| const normalInterval = hasActiveDocuments ? 5000 : 30000; |
| startPollingInterval(normalInterval); |
| } |
| }, 15000); |
| } catch (err) { |
| |
| if (isMountedRef.current) { |
| toast.error(t('documentPanel.documentManager.errors.scanFailed', { error: errorMessage(err) })); |
| } |
| } |
| }, [t, startPollingInterval, currentTab, health, statusCounts, handleIntelligentRefresh]) |
|
|
| |
| const handleManualRefresh = useCallback(async () => { |
| await enqueueRefresh({ |
| type: 'manual', |
| query: buildQuerySnapshot(), |
| requestVersion: latestRefreshRequestVersionRef.current |
| }); |
| }, [buildQuerySnapshot, enqueueRefresh]); |
|
|
| useEffect(() => { |
| latestRefreshRequestVersionRef.current += 1 |
| }, [pagination.page, pagination.page_size, statusFilter, sortField, sortDirection]) |
|
|
| |
| useEffect(() => { |
| |
| if (prevPipelineBusyRef.current !== undefined && prevPipelineBusyRef.current !== pipelineBusy) { |
| |
| if (currentTab === 'documents' && health && isMountedRef.current) { |
| |
| handleIntelligentRefresh(); |
|
|
| |
| const hasActiveDocuments = hasActiveDocumentsStatus(statusCounts); |
| const pollingInterval = hasActiveDocuments ? 5000 : 30000; |
| startPollingInterval(pollingInterval); |
| } |
| } |
| |
| prevPipelineBusyRef.current = pipelineBusy; |
| }, [ |
| pipelineBusy, |
| currentTab, |
| health, |
| handleIntelligentRefresh, |
| statusCounts, |
| startPollingInterval |
| ]); |
|
|
| |
| useEffect(() => { |
| if (currentTab !== 'documents' || !health) { |
| clearPollingInterval(); |
| return |
| } |
|
|
| |
| const hasActiveDocuments = hasActiveDocumentsStatus(statusCounts); |
| const pollingInterval = hasActiveDocuments ? 5000 : 30000; |
|
|
| startPollingInterval(pollingInterval); |
|
|
| return () => { |
| clearPollingInterval(); |
| } |
| }, [health, t, currentTab, statusCounts, startPollingInterval, clearPollingInterval]) |
|
|
| |
| useEffect(() => { |
| if (!docs) return; |
|
|
| |
| const newStatusCounts = { |
| processed: docs?.statuses?.processed?.length || 0, |
| preprocessed: docs?.statuses?.preprocessed?.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 handlePageChange = useCallback((newPage: number) => { |
| if (newPage === pagination.page) return; |
|
|
| |
| setPageByStatus(prev => ({ ...prev, [statusFilter]: newPage })); |
| setPagination(prev => ({ ...prev, page: newPage })); |
| }, [pagination.page, statusFilter]); |
|
|
| |
| const handleStatusFilterChange = useCallback((newStatusFilter: StatusFilter) => { |
| if (newStatusFilter === statusFilter) return; |
|
|
| |
| setPageByStatus(prev => ({ ...prev, [statusFilter]: pagination.page })); |
|
|
| |
| const newPage = pageByStatus[newStatusFilter]; |
|
|
| |
| setStatusFilter(newStatusFilter); |
| setPagination(prev => ({ ...prev, page: newPage })); |
| }, [statusFilter, pagination.page, pageByStatus]); |
|
|
| |
| const handleDocumentsDeleted = useCallback(async () => { |
| setSelectedDocIds([]) |
|
|
| |
| useBackendState.getState().resetHealthCheckTimerDelayed(1000) |
|
|
| |
| startPollingInterval(2000) |
| }, [startPollingInterval]) |
|
|
| |
| const handleDocumentsCleared = useCallback(async () => { |
| |
| clearPollingInterval(); |
|
|
| |
| setStatusCounts({ |
| all: 0, |
| processed: 0, |
| processing: 0, |
| pending: 0, |
| failed: 0 |
| }); |
|
|
| |
| if (isMountedRef.current) { |
| try { |
| await fetchDocuments(); |
| } catch (err) { |
| console.error('Error fetching documents after clear:', err); |
| } |
| } |
|
|
| |
| |
| if (currentTab === 'documents' && health && isMountedRef.current) { |
| startPollingInterval(30000); |
| } |
| }, [clearPollingInterval, setStatusCounts, fetchDocuments, currentTab, health, startPollingInterval]) |
|
|
|
|
| |
| useEffect(() => { |
| |
| if (sortField === 'id' || sortField === 'file_path') { |
| const newSortField = showFileName ? 'file_path' : 'id'; |
| if (sortField !== newSortField) { |
| setSortField(newSortField); |
| } |
| } |
| }, [showFileName, sortField]); |
|
|
| |
| useEffect(() => { |
| setSelectedDocIds([]) |
| }, [pagination.page, statusFilter, sortField, sortDirection]); |
|
|
| |
| useEffect(() => { |
| if (currentTab === 'documents') { |
| fetchPaginatedDocuments(pagination.page, pagination.page_size, statusFilter); |
| } |
| }, [ |
| currentTab, |
| pagination.page, |
| pagination.page_size, |
| statusFilter, |
| sortField, |
| sortDirection, |
| fetchPaginatedDocuments |
| ]); |
|
|
| 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 justify-between items-center 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> |
| |
| {/* Pagination Controls in the middle */} |
| {pagination.total_pages > 1 && ( |
| <PaginationControls |
| currentPage={pagination.page} |
| totalPages={pagination.total_pages} |
| pageSize={pagination.page_size} |
| totalCount={pagination.total_count} |
| onPageChange={handlePageChange} |
| onPageSizeChange={handlePageSizeChange} |
| isLoading={isRefreshing} |
| compact={true} |
| /> |
| )} |
| |
| <div className="flex gap-2"> |
| {isSelectionMode && ( |
| <DeleteDocumentsDialog |
| selectedDocIds={selectedDocIds} |
| onDocumentsDeleted={handleDocumentsDeleted} |
| /> |
| )} |
| {isSelectionMode && hasCurrentPageSelection ? ( |
| (() => { |
| const buttonProps = getSelectionButtonProps(); |
| const IconComponent = buttonProps.icon; |
| return ( |
| <Button |
| variant="outline" |
| size="sm" |
| onClick={buttonProps.action} |
| side="bottom" |
| tooltip={buttonProps.text} |
| > |
| <IconComponent className="h-4 w-4" /> |
| {buttonProps.text} |
| </Button> |
| ); |
| })() |
| ) : !isSelectionMode ? ( |
| <ClearDocumentsDialog onDocumentsCleared={handleDocumentsCleared} /> |
| ) : null} |
| <UploadDocumentsDialog onDocumentsUploaded={() => handleIntelligentRefresh(undefined, false, 120000)} /> |
| <PipelineStatusDialog |
| open={showPipelineStatus} |
| onOpenChange={setShowPipelineStatus} |
| /> |
| </div> |
| </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"> |
| <div className="flex gap-1" dir={i18n.dir()}> |
| <Button |
| size="sm" |
| variant={statusFilter === 'all' ? 'secondary' : 'outline'} |
| onClick={() => handleStatusFilterChange('all')} |
| disabled={isRefreshing} |
| 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')} ({statusCounts.all || documentCounts.all}) |
| </Button> |
| <Button |
| size="sm" |
| variant={statusFilter === 'processed' ? 'secondary' : 'outline'} |
| onClick={() => handleStatusFilterChange('processed')} |
| disabled={isRefreshing} |
| className={cn( |
| processedCount > 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')} ({processedCount}) |
| </Button> |
| <Button |
| size="sm" |
| variant={statusFilter === 'preprocessed' ? 'secondary' : 'outline'} |
| onClick={() => handleStatusFilterChange('preprocessed')} |
| disabled={isRefreshing} |
| className={cn( |
| preprocessedCount > 0 ? 'text-purple-600' : 'text-gray-500', |
| statusFilter === 'preprocessed' && 'bg-purple-100 dark:bg-purple-900/30 font-medium border border-purple-400 dark:border-purple-600 shadow-sm' |
| )} |
| > |
| {t('documentPanel.documentManager.status.preprocessed')} ({preprocessedCount}) |
| </Button> |
| <Button |
| size="sm" |
| variant={statusFilter === 'processing' ? 'secondary' : 'outline'} |
| onClick={() => handleStatusFilterChange('processing')} |
| disabled={isRefreshing} |
| className={cn( |
| processingCount > 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')} ({processingCount}) |
| </Button> |
| <Button |
| size="sm" |
| variant={statusFilter === 'pending' ? 'secondary' : 'outline'} |
| onClick={() => handleStatusFilterChange('pending')} |
| disabled={isRefreshing} |
| className={cn( |
| pendingCount > 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')} ({pendingCount}) |
| </Button> |
| <Button |
| size="sm" |
| variant={statusFilter === 'failed' ? 'secondary' : 'outline'} |
| onClick={() => handleStatusFilterChange('failed')} |
| disabled={isRefreshing} |
| className={cn( |
| failedCount > 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')} ({failedCount}) |
| </Button> |
| </div> |
| <Button |
| variant="ghost" |
| size="sm" |
| onClick={handleManualRefresh} |
| disabled={isRefreshing} |
| side="bottom" |
| tooltip={t('documentPanel.documentManager.refreshTooltip')} |
| > |
| <RotateCcwIcon className="h-4 w-4" /> |
| </Button> |
| </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"> |
| {showFileName |
| ? t('documentPanel.documentManager.columns.fileName') |
| : t('documentPanel.documentManager.columns.id') |
| } |
| {((sortField === 'id' && !showFileName) || (sortField === 'file_path' && showFileName)) && ( |
| <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> |
| <div className="group relative flex items-center overflow-visible tooltip-container"> |
| {doc.status === 'processed' && ( |
| <span className="text-green-600">{t('documentPanel.documentManager.status.completed')}</span> |
| )} |
| {doc.status === 'preprocessed' && ( |
| <span className="text-purple-600">{t('documentPanel.documentManager.status.preprocessed')}</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> |
| )} |
| |
| {/* Icon rendering logic */} |
| {doc.error_msg ? ( |
| <AlertTriangle className="ml-2 h-4 w-4 text-yellow-500" /> |
| ) : (doc.metadata && Object.keys(doc.metadata).length > 0) && ( |
| <Info className="ml-2 h-4 w-4 text-blue-500" /> |
| )} |
| |
| {/* Tooltip rendering logic */} |
| {(doc.error_msg || (doc.metadata && Object.keys(doc.metadata).length > 0) || doc.track_id) && ( |
| <div className="invisible group-hover:visible tooltip"> |
| {doc.track_id && ( |
| <div className="mt-1">Track ID: {doc.track_id}</div> |
| )} |
| {doc.metadata && Object.keys(doc.metadata).length > 0 && ( |
| <pre>{formatMetadata(doc.metadata)}</pre> |
| )} |
| {doc.error_msg && ( |
| <pre>{doc.error_msg}</pre> |
| )} |
| </div> |
| )} |
| </div> |
| </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> |
| ) |
| } |
|
|