import { BookOpen, Network, Search as SearchIcon, SlidersHorizontal, Users, } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; import { useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useDispatch, useSelector } from 'react-redux'; import userService from '../api/services/userService'; import modelService from '../api/services/modelService'; import EyeView from '../assets/eye-view.svg'; import NoFilesIcon from '../assets/no-files.svg'; import NoFilesDarkIcon from '../assets/no-files-dark.svg'; import Trash from '../assets/red-trash.svg'; import SyncIcon from '../assets/sync.svg'; import ThreeDots from '../assets/three-dots.svg'; import CalendarIcon from '../assets/calendar.svg'; import DiscIcon from '../assets/disc.svg'; import Pagination from '../components/DocumentPagination'; import SkeletonLoader from '../components/SkeletonLoader'; import { Button } from '../components/ui/button'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '../components/ui/dropdown-menu'; import { Input } from '../components/ui/input'; import { useDarkTheme, useDebouncedValue, useLoaderState } from '../hooks'; import ConfirmationModal from '../modals/ConfirmationModal'; import { ActiveState, Doc, DocumentsProps } from '../models/misc'; import type { Model } from '../models/types'; import ShareToTeamModal from '../teams/ShareToTeamModal'; import { getDocs, getDocsWithPagination } from '../preferences/preferenceApi'; import { selectToken, setPaginatedDocuments, setSourceDocs, } from '../preferences/preferenceSlice'; import Upload from '../upload/Upload'; import { addUploadTask, removeUploadTask, selectUploadTasks, updateUploadTask, } from '../upload/uploadSlice'; import { formatDate } from '../utils/dateTimeUtils'; import FileTree from '../components/FileTree'; import ConnectorTree from '../components/ConnectorTree'; import Chunks from '../components/Chunks'; import WikiViewer from '../components/WikiViewer'; import GraphView from '../components/GraphView'; import ConvertToWikiModal from './ConvertToWikiModal'; import EnableGraphRAGModal from './EnableGraphRAGModal'; import { clearGraphBuild, selectGraphBuilds } from './graphBuildSlice'; import SourceConfigModal from './SourceConfigModal'; type SourceMenuOption = { icon: string | LucideIcon; label: string; onClick: () => void; variant: 'default' | 'destructive'; iconWidth?: number; iconHeight?: number; }; const formatTokens = (tokens: number): string => { const roundToTwoDecimals = (num: number): string => { return (Math.round((num + Number.EPSILON) * 100) / 100).toString(); }; if (tokens >= 1_000_000_000) { return roundToTwoDecimals(tokens / 1_000_000_000) + 'b'; } else if (tokens >= 1_000_000) { return roundToTwoDecimals(tokens / 1_000_000) + 'm'; } else if (tokens >= 1_000) { return roundToTwoDecimals(tokens / 1_000) + 'k'; } else { return tokens.toString(); } }; export default function Sources({ paginatedDocuments, handleDeleteDocument, }: DocumentsProps) { const { t } = useTranslation(); const [isDarkTheme] = useDarkTheme(); const dispatch = useDispatch(); const token = useSelector(selectToken); const uploadTasks = useSelector(selectUploadTasks); const [searchTerm, setSearchTerm] = useState(''); const debouncedSearchTerm = useDebouncedValue(searchTerm, 500); const [modalState, setModalState] = useState('INACTIVE'); const [isOnboarding, setIsOnboarding] = useState(false); const [loading, setLoading] = useLoaderState(false); const [sortField, setSortField] = useState<'date' | 'tokens'>('date'); const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc'); // Pagination const [currentPage, setCurrentPage] = useState(1); const [rowsPerPage, setRowsPerPage] = useState(10); const [totalPages, setTotalPages] = useState(1); const [actionMenuDocId, setActionMenuDocId] = useState(null); const currentDocuments = paginatedDocuments ?? []; const syncOptions = [ { label: t('settings.sources.syncFrequency.never'), value: 'never' }, { label: t('settings.sources.syncFrequency.daily'), value: 'daily' }, { label: t('settings.sources.syncFrequency.weekly'), value: 'weekly' }, { label: t('settings.sources.syncFrequency.monthly'), value: 'monthly' }, ]; const [documentToView, setDocumentToView] = useState(); const [documentToShare, setDocumentToShare] = useState(null); const [documentToConfigure, setDocumentToConfigure] = useState( null, ); const [configModalState, setConfigModalState] = useState('INACTIVE'); const [documentToConvert, setDocumentToConvert] = useState(null); const [convertModalState, setConvertModalState] = useState('INACTIVE'); const [documentToGraphRAG, setDocumentToGraphRAG] = useState( null, ); const [graphRAGModalState, setGraphRAGModalState] = useState('INACTIVE'); const [graphRAGAvailable, setGraphRAGAvailable] = useState(false); const [hybridAvailable, setHybridAvailable] = useState(false); const [availableModels, setAvailableModels] = useState([]); // Graph-build progress is SSE-driven (graphBuildSlice), so the "building" // badge survives closing the modal and reflects the real backend state. const graphBuilds = useSelector(selectGraphBuilds); const [syncMenuState, setSyncMenuState] = useState<{ isOpen: boolean; docId: string | null; document: Doc | null; }>({ isOpen: false, docId: null, document: null, }); const refreshDocs = useCallback( ( field: 'date' | 'tokens' | undefined, pageNumber?: number, rows?: number, ) => { const page = pageNumber ?? currentPage; const rowsPerPg = rows ?? rowsPerPage; // If field is undefined, (Pagination or Search) use the current sortField const newSortField = field ?? sortField; // If field is undefined, (Pagination or Search) use the current sortOrder const newSortOrder = field === sortField ? sortOrder === 'asc' ? 'desc' : 'asc' : sortOrder; // If field is defined, update the sortField and sortOrder if (field) { setSortField(newSortField); setSortOrder(newSortOrder); } setLoading(true); getDocsWithPagination( newSortField, newSortOrder, page, rowsPerPg, debouncedSearchTerm, token, ) .then((data) => { dispatch(setPaginatedDocuments(data ? data.docs : [])); setTotalPages(data ? data.totalPages : 0); }) .catch((error) => console.error(error)) .finally(() => { setLoading(false); }); }, [currentPage, rowsPerPage, sortField, sortOrder, debouncedSearchTerm], ); const handleManageSync = (doc: Doc, sync_frequency: string) => { setLoading(true); userService .manageSync({ source_id: doc.id, sync_frequency }, token) .then(() => { return getDocs(token); }) .then((data) => { dispatch(setSourceDocs(data)); return getDocsWithPagination( sortField, sortOrder, currentPage, rowsPerPage, searchTerm, token, ); }) .then((paginatedData) => { dispatch( setPaginatedDocuments(paginatedData ? paginatedData.docs : []), ); setTotalPages(paginatedData ? paginatedData.totalPages : 0); }) .catch((error) => console.error('Error in handleManageSync:', error)) .finally(() => { setLoading(false); }); }; const getConnectorProvider = async (doc: Doc): Promise => { if (doc.provider) { return doc.provider; } if (!doc.id) { return null; } try { const directoryResponse = await userService.getDirectoryStructure( doc.id, token, ); const directoryData = await directoryResponse.json(); return directoryData?.provider ?? null; } catch (error) { console.error('Error fetching connector provider:', error); return null; } }; const handleSyncNow = async (doc: Doc) => { if (!doc.id) { return; } try { if (doc.type?.startsWith('connector')) { const provider = await getConnectorProvider(doc); if (!provider) { console.error('Sync now failed: provider not found'); return; } const response = await userService.syncConnector( doc.id, provider, token, ); const data = await response.json(); if (!data.success) { console.error('Sync now failed:', data.error || data.message); } return; } const response = await userService.syncSource( { source_id: doc.id }, token, ); const data = await response.json(); if (!data.success) { console.error('Sync now failed:', data.error || data.message); } } catch (error) { console.error('Error syncing source:', error); } }; const handleReingest = async (doc: Doc) => { if (!doc.id) { return; } const sourceId = doc.id; // Drop stale toast rows for this source (a finished/dismissed task // would swallow the reingest's SSE events), then open a fresh one. uploadTasks .filter((task) => task.sourceId === sourceId) .forEach((task) => dispatch(removeUploadTask(task.id))); const reingestTaskId = `reingest-${sourceId}-${Date.now()}`; dispatch( addUploadTask({ id: reingestTaskId, fileName: doc.name || sourceId, progress: 0, status: 'training', sourceId, }), ); try { const response = await userService.reingestSource( { source_id: sourceId }, token, ); const data = await response.json(); if (!data.success) { console.error('Reingest failed:', data.error || data.message); dispatch( updateUploadTask({ id: reingestTaskId, updates: { status: 'failed', errorMessage: data.error || data.message, }, }), ); return; } refreshDocs(undefined, currentPage, rowsPerPage); } catch (error) { console.error('Error reingesting source:', error); dispatch( updateUploadTask({ id: reingestTaskId, updates: { status: 'failed' }, }), ); } }; const [documentToDelete, setDocumentToDelete] = useState<{ index: number; document: Doc; } | null>(null); const [deleteModalState, setDeleteModalState] = useState('INACTIVE'); const handleDeleteConfirmation = (index: number, document: Doc) => { setDocumentToDelete({ index, document }); setDeleteModalState('ACTIVE'); }; const handleConfirmedDelete = () => { if (documentToDelete) { handleDeleteDocument(documentToDelete.index, documentToDelete.document); setDeleteModalState('INACTIVE'); setDocumentToDelete(null); } }; const getActionOptions = ( index: number, document: Doc, ): SourceMenuOption[] => { const isWiki = document.config?.kind === 'wiki' || document.type === 'wiki'; const isGraphRAG = document.config?.kind === 'graphrag'; // 'team' viewers cannot write; convert is owner/editor only. const canEdit = document.ownership !== 'team' || document.team_access === 'editor'; const actions: SourceMenuOption[] = [ { icon: isGraphRAG ? Network : EyeView, label: isWiki ? t('settings.sources.wiki.view') : isGraphRAG ? t('settings.sources.graphrag.view.action') : t('settings.sources.view'), onClick: () => { setDocumentToView(document); }, iconWidth: 18, iconHeight: 18, variant: 'default', }, ]; if (document.ingestStatus === 'failed') { actions.push({ icon: SyncIcon, label: t('settings.sources.reingest'), onClick: () => { handleReingest(document); }, iconWidth: 14, iconHeight: 14, variant: 'default', }); } if (document.syncFrequency) { actions.push({ icon: SyncIcon, label: t('settings.sources.sync'), onClick: () => { setSyncMenuState({ isOpen: true, docId: document.id ?? null, document: document, }); }, iconWidth: 14, iconHeight: 14, variant: 'default', }); actions.push({ icon: SyncIcon, label: t('settings.sources.syncNow'), onClick: () => { handleSyncNow(document); }, iconWidth: 14, iconHeight: 14, variant: 'default', }); } if (document.id && !isWiki) { actions.push({ icon: SlidersHorizontal, label: t('settings.sources.editConfig'), onClick: () => { setDocumentToConfigure(document); setConfigModalState('ACTIVE'); }, iconWidth: 16, iconHeight: 16, variant: 'default', }); } if ( document.id && !isWiki && canEdit && document.ingestStatus !== 'processing' && document.ingestStatus !== 'failed' ) { actions.push({ icon: BookOpen, label: t('settings.sources.wiki.convert.action'), onClick: () => { setDocumentToConvert(document); setConvertModalState('ACTIVE'); }, iconWidth: 16, iconHeight: 16, variant: 'default', }); } // Sharing is an owner-only action: hide it for sources shared into the // user's workspace by a team. if (document.ownership !== 'team' && document.id) { actions.push({ icon: Users, label: t('settings.sources.shareWithTeam'), onClick: () => { setDocumentToShare(document); }, iconWidth: 16, iconHeight: 16, variant: 'default', }); } actions.push({ icon: Trash, label: t('convTile.delete'), onClick: () => { handleDeleteConfirmation(index, document); }, iconWidth: 18, iconHeight: 18, variant: 'destructive', }); return actions; }; useEffect(() => { refreshDocs(undefined, 1, rowsPerPage); }, [debouncedSearchTerm]); // When a graph build reaches a terminal state via SSE, refresh the list so // the badge reflects the final state, then drop the entry. The modal (a // child) captures its summary in its own effect first, so clearing here // doesn't race its summary view. useEffect(() => { const terminal = Object.entries(graphBuilds).filter( ([, b]) => b.status === 'completed' || b.status === 'failed', ); if (terminal.length === 0) return; terminal.forEach(([sourceId]) => dispatch(clearGraphBuild(sourceId))); refreshDocs(undefined, currentPage, rowsPerPage); }, [graphBuilds, dispatch, refreshDocs, currentPage, rowsPerPage]); useEffect(() => { let cancelled = false; userService .getConfig() .then((response) => response.json()) .then((config) => { if (!cancelled) { setGraphRAGAvailable(!!config?.graphrag_available); setHybridAvailable(!!config?.hybrid_available); } }) .catch(() => undefined); return () => { cancelled = true; }; }, []); // Models back the graphrag extraction-model picker in the source settings // modal; only fetched when the instance supports graphrag. useEffect(() => { if (!graphRAGAvailable) return; let cancelled = false; modelService .getModels(token) .then((response) => (response.ok ? response.json() : null)) .then((data) => { if (!cancelled && data) setAvailableModels(modelService.transformModels(data.models || [])); }) .catch(() => undefined); return () => { cancelled = true; }; }, [graphRAGAvailable, token]); return documentToView ? (
{documentToView.config?.kind === 'wiki' || documentToView.type === 'wiki' ? ( setDocumentToView(undefined)} /> ) : documentToView.config?.kind === 'graphrag' ? ( setDocumentToView(undefined)} /> ) : documentToView.isNested ? ( documentToView.type === 'connector:file' ? ( setDocumentToView(undefined)} /> ) : ( setDocumentToView(undefined)} /> ) ) : ( setDocumentToView(undefined)} /> )}
) : (

{t('settings.sources.subtitle')}

{ setSearchTerm(e.target.value); setCurrentPage(1); }} labelBgClassName="bg-background" className="rounded-full" leftIcon={ } />
{loading ? (
) : !currentDocuments?.length ? (
{t('settings.sources.noData')}

{t('settings.sources.noData')}

) : (
{currentDocuments.map((document, index) => { const docId = document.id ? document.id.toString() : ''; return (
setDocumentToView(document)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setDocumentToView(document); } }} className={`bg-muted dark:bg-accent focus-visible:ring-ring/50 flex min-h-[130px] w-full cursor-pointer flex-col rounded-2xl p-5 transition-all duration-200 outline-none focus-visible:ring-[3px] ${ actionMenuDocId === docId || syncMenuState.docId === docId ? 'scale-[1.05]' : 'hover:scale-[1.05]' }`} >

{document.name}

{document.syncFrequency && ( { setSyncMenuState((prev) => ({ ...prev, isOpen, docId: isOpen ? docId : null, document: isOpen ? document : null, })); }} > e.stopPropagation()} > {syncOptions.map((opt) => ( handleManageSync(document, opt.value) } > {opt.label} ))} )} setActionMenuDocId(open ? docId : null) } > e.stopPropagation()} > {getActionOptions(index, document).map( (option, idx) => ( option.onClick()} > {typeof option.icon === 'string' ? ( ) : ( ), )}
{document.ownership === 'team' && ( )} {document.ingestStatus === 'failed' && ( {t('settings.sources.ingestFailed')} )} {document.ingestStatus === 'processing' && ( {t('settings.sources.ingestProcessing')} )} {document.config?.kind === 'graphrag' && (() => { const build = document.id ? graphBuilds[document.id] : undefined; const isBuilding = build?.status === 'building'; const pct = isBuilding && build.total > 0 ? Math.min( 100, Math.round( (build.current / build.total) * 100, ), ) : null; return ( ); })()}
{document.date ? formatDate(document.date) : ''}
{document.tokens ? formatTokens(+document.tokens) : ''}
); })}
)}
{currentDocuments.length > 0 && totalPages > 1 && (
{ setCurrentPage(page); refreshDocs(undefined, page, rowsPerPage); }} onRowsPerPageChange={(rows) => { setRowsPerPage(rows); setCurrentPage(1); refreshDocs(undefined, 1, rows); }} />
)} {modalState === 'ACTIVE' && ( setModalState('INACTIVE')} onSuccessfulUpload={() => refreshDocs(undefined, currentPage, rowsPerPage) } /> )} {deleteModalState === 'ACTIVE' && documentToDelete && ( { setDeleteModalState('INACTIVE'); setDocumentToDelete(null); }} submitLabel={t('convTile.delete')} variant="danger" /> )} {documentToShare && documentToShare.id && ( setDocumentToShare(null)} /> )} { setConfigModalState(state); if (state === 'INACTIVE') { setDocumentToConfigure(null); } }} document={documentToConfigure} onReingest={handleReingest} hybridAvailable={hybridAvailable} graphRAGAvailable={graphRAGAvailable} availableModels={availableModels} onEnableGraphRAG={(doc) => { setDocumentToGraphRAG(doc); setGraphRAGModalState('ACTIVE'); }} /> { setConvertModalState(state); if (state === 'INACTIVE') { setDocumentToConvert(null); } }} document={documentToConvert} onConverted={() => refreshDocs(undefined, currentPage, rowsPerPage)} /> { setGraphRAGModalState(state); if (state === 'INACTIVE') { setDocumentToGraphRAG(null); } }} document={documentToGraphRAG} onEnabled={() => { // The "building" badge is now driven by SSE progress events; just // refresh so the source flips to graphrag kind in the list. refreshDocs(undefined, currentPage, rowsPerPage); }} />
); }