mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-04 12:13:05 +00:00
957 lines
34 KiB
TypeScript
957 lines
34 KiB
TypeScript
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<string>('');
|
|
const debouncedSearchTerm = useDebouncedValue(searchTerm, 500);
|
|
const [modalState, setModalState] = useState<ActiveState>('INACTIVE');
|
|
const [isOnboarding, setIsOnboarding] = useState<boolean>(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<number>(1);
|
|
const [rowsPerPage, setRowsPerPage] = useState<number>(10);
|
|
const [totalPages, setTotalPages] = useState<number>(1);
|
|
|
|
const [actionMenuDocId, setActionMenuDocId] = useState<string | null>(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<Doc>();
|
|
const [documentToShare, setDocumentToShare] = useState<Doc | null>(null);
|
|
const [documentToConfigure, setDocumentToConfigure] = useState<Doc | null>(
|
|
null,
|
|
);
|
|
const [configModalState, setConfigModalState] =
|
|
useState<ActiveState>('INACTIVE');
|
|
const [documentToConvert, setDocumentToConvert] = useState<Doc | null>(null);
|
|
const [convertModalState, setConvertModalState] =
|
|
useState<ActiveState>('INACTIVE');
|
|
const [documentToGraphRAG, setDocumentToGraphRAG] = useState<Doc | null>(
|
|
null,
|
|
);
|
|
const [graphRAGModalState, setGraphRAGModalState] =
|
|
useState<ActiveState>('INACTIVE');
|
|
const [graphRAGAvailable, setGraphRAGAvailable] = useState<boolean>(false);
|
|
const [hybridAvailable, setHybridAvailable] = useState<boolean>(false);
|
|
const [availableModels, setAvailableModels] = useState<Model[]>([]);
|
|
// 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<string | null> => {
|
|
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<ActiveState>('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 ? (
|
|
<div className="mt-8 flex flex-col">
|
|
{documentToView.config?.kind === 'wiki' ||
|
|
documentToView.type === 'wiki' ? (
|
|
<WikiViewer
|
|
docId={documentToView.id || ''}
|
|
sourceName={documentToView.name}
|
|
canEdit={
|
|
documentToView.ownership !== 'team' ||
|
|
documentToView.team_access === 'editor'
|
|
}
|
|
onBackToDocuments={() => setDocumentToView(undefined)}
|
|
/>
|
|
) : documentToView.config?.kind === 'graphrag' ? (
|
|
<GraphView
|
|
docId={documentToView.id || ''}
|
|
sourceName={documentToView.name}
|
|
onBackToDocuments={() => setDocumentToView(undefined)}
|
|
/>
|
|
) : documentToView.isNested ? (
|
|
documentToView.type === 'connector:file' ? (
|
|
<ConnectorTree
|
|
docId={documentToView.id || ''}
|
|
sourceName={documentToView.name}
|
|
onBackToDocuments={() => setDocumentToView(undefined)}
|
|
/>
|
|
) : (
|
|
<FileTree
|
|
docId={documentToView.id || ''}
|
|
sourceName={documentToView.name}
|
|
onBackToDocuments={() => setDocumentToView(undefined)}
|
|
/>
|
|
)
|
|
) : (
|
|
<Chunks
|
|
documentId={documentToView.id || ''}
|
|
documentName={documentToView.name}
|
|
handleGoBack={() => setDocumentToView(undefined)}
|
|
/>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="mt-8 flex w-full max-w-full flex-col">
|
|
<div className="relative flex grow flex-col">
|
|
<p className="text-muted-foreground mb-5 text-sm leading-6">
|
|
{t('settings.sources.subtitle')}
|
|
</p>
|
|
<div className="mb-6 flex flex-col items-start justify-between gap-3 sm:flex-row sm:items-center">
|
|
<div className="w-full max-w-md sm:w-auto">
|
|
<Input
|
|
maxLength={256}
|
|
label={t('settings.sources.searchPlaceholder')}
|
|
name="Document-search-input"
|
|
type="text"
|
|
id="document-search-input"
|
|
value={searchTerm}
|
|
onChange={(e) => {
|
|
setSearchTerm(e.target.value);
|
|
setCurrentPage(1);
|
|
}}
|
|
labelBgClassName="bg-background"
|
|
className="rounded-full"
|
|
leftIcon={
|
|
<SearchIcon
|
|
className="text-muted-foreground size-4"
|
|
strokeWidth={1.75}
|
|
/>
|
|
}
|
|
/>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
className="h-11 min-w-[108px] rounded-full whitespace-normal text-white"
|
|
title={t('settings.sources.addSource')}
|
|
onClick={() => {
|
|
setIsOnboarding(false);
|
|
setModalState('ACTIVE');
|
|
}}
|
|
>
|
|
{t('settings.sources.addSource')}
|
|
</Button>
|
|
</div>
|
|
<div className="relative w-full">
|
|
{loading ? (
|
|
<div className="grid w-full grid-cols-1 gap-6 px-2 py-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
|
<SkeletonLoader component="sourceCards" count={rowsPerPage} />
|
|
</div>
|
|
) : !currentDocuments?.length ? (
|
|
<div className="flex flex-col items-center justify-center py-12">
|
|
<img
|
|
src={isDarkTheme ? NoFilesDarkIcon : NoFilesIcon}
|
|
alt={t('settings.sources.noData')}
|
|
className="mx-auto mb-6 h-32 w-32"
|
|
/>
|
|
<p className="text-center text-lg text-gray-500 dark:text-gray-400">
|
|
{t('settings.sources.noData')}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="grid w-full grid-cols-1 gap-6 px-2 py-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
|
{currentDocuments.map((document, index) => {
|
|
const docId = document.id ? document.id.toString() : '';
|
|
|
|
return (
|
|
<div key={docId} className="relative">
|
|
<div
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-label={document.name}
|
|
onClick={() => 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]'
|
|
}`}
|
|
>
|
|
<div className="w-full flex-1">
|
|
<div className="flex w-full items-center justify-between gap-2">
|
|
<h3
|
|
className="dark:text-foreground text-foreground line-clamp-2 min-w-0 flex-1 text-sm leading-[18px] font-semibold wrap-anywhere"
|
|
title={document.name}
|
|
>
|
|
{document.name}
|
|
</h3>
|
|
<div className="relative flex shrink-0 items-center justify-end">
|
|
{document.syncFrequency && (
|
|
<DropdownMenu
|
|
open={
|
|
syncMenuState.docId === docId &&
|
|
syncMenuState.isOpen
|
|
}
|
|
onOpenChange={(isOpen) => {
|
|
setSyncMenuState((prev) => ({
|
|
...prev,
|
|
isOpen,
|
|
docId: isOpen ? docId : null,
|
|
document: isOpen ? document : null,
|
|
}));
|
|
}}
|
|
>
|
|
<DropdownMenuTrigger asChild>
|
|
<span
|
|
aria-hidden
|
|
className="pointer-events-none absolute inset-0 opacity-0"
|
|
/>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent
|
|
align="end"
|
|
className="min-w-[120px]"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{syncOptions.map((opt) => (
|
|
<DropdownMenuItem
|
|
key={opt.value}
|
|
onSelect={() =>
|
|
handleManageSync(document, opt.value)
|
|
}
|
|
>
|
|
{opt.label}
|
|
</DropdownMenuItem>
|
|
))}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)}
|
|
<DropdownMenu
|
|
open={actionMenuDocId === docId}
|
|
onOpenChange={(open) =>
|
|
setActionMenuDocId(open ? docId : null)
|
|
}
|
|
>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="h-[35px] w-6"
|
|
aria-label={t('settings.sources.menuAlt')}
|
|
data-testid={`menu-button-${docId}`}
|
|
>
|
|
<img
|
|
src={ThreeDots}
|
|
alt={t('settings.sources.menuAlt')}
|
|
className="opacity-60 hover:opacity-100"
|
|
/>
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent
|
|
align="end"
|
|
className="min-w-[144px]"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{getActionOptions(index, document).map(
|
|
(option, idx) => (
|
|
<DropdownMenuItem
|
|
key={idx}
|
|
variant={option.variant}
|
|
onSelect={() => option.onClick()}
|
|
>
|
|
{typeof option.icon === 'string' ? (
|
|
<img
|
|
src={option.icon}
|
|
alt=""
|
|
width={option.iconWidth ?? 16}
|
|
height={option.iconHeight ?? 16}
|
|
/>
|
|
) : (
|
|
<option.icon
|
|
size={Math.max(
|
|
option.iconWidth ?? 16,
|
|
option.iconHeight ?? 16,
|
|
)}
|
|
strokeWidth={1.75}
|
|
aria-hidden="true"
|
|
/>
|
|
)}
|
|
<span>{option.label}</span>
|
|
</DropdownMenuItem>
|
|
),
|
|
)}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-col items-start justify-start gap-1">
|
|
{document.ownership === 'team' && (
|
|
<span className="bg-muted-foreground/10 text-muted-foreground flex items-center gap-1 rounded-full px-2 py-0.5 text-xs leading-[16px] font-medium">
|
|
<Users
|
|
size={11}
|
|
strokeWidth={2}
|
|
aria-hidden="true"
|
|
/>
|
|
{document.team_access === 'editor'
|
|
? t('teamAccess.editor')
|
|
: t('teamAccess.viewer')}
|
|
</span>
|
|
)}
|
|
{document.ingestStatus === 'failed' && (
|
|
<span className="rounded-full bg-red-100 px-2 py-0.5 text-xs leading-[16px] font-medium text-red-700 dark:bg-red-900/30 dark:text-red-400">
|
|
{t('settings.sources.ingestFailed')}
|
|
</span>
|
|
)}
|
|
{document.ingestStatus === 'processing' && (
|
|
<span className="bg-muted-foreground/10 text-muted-foreground rounded-full px-2 py-0.5 text-xs leading-[16px] font-medium">
|
|
{t('settings.sources.ingestProcessing')}
|
|
</span>
|
|
)}
|
|
{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 (
|
|
<span className="bg-muted-foreground/10 text-muted-foreground flex items-center gap-1 rounded-full px-2 py-0.5 text-xs leading-[16px] font-medium">
|
|
<Network
|
|
size={11}
|
|
strokeWidth={2}
|
|
aria-hidden="true"
|
|
/>
|
|
{isBuilding
|
|
? pct !== null
|
|
? t(
|
|
'settings.sources.graphrag.buildingPct',
|
|
{ pct },
|
|
)
|
|
: t('settings.sources.graphrag.building')
|
|
: t('settings.sources.graphrag.badge')}
|
|
</span>
|
|
);
|
|
})()}
|
|
<div className="flex items-center gap-2">
|
|
<img
|
|
src={CalendarIcon}
|
|
alt=""
|
|
className="h-3.5 w-3.5"
|
|
/>
|
|
<span className="text-muted-foreground text-xs leading-[18px] font-medium">
|
|
{document.date ? formatDate(document.date) : ''}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<img src={DiscIcon} alt="" className="h-3.5 w-3.5" />
|
|
<span className="text-muted-foreground text-xs leading-[18px] font-medium">
|
|
{document.tokens
|
|
? formatTokens(+document.tokens)
|
|
: ''}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{currentDocuments.length > 0 && totalPages > 1 && (
|
|
<div className="mt-auto pt-4">
|
|
<Pagination
|
|
currentPage={currentPage}
|
|
totalPages={totalPages}
|
|
rowsPerPage={rowsPerPage}
|
|
onPageChange={(page) => {
|
|
setCurrentPage(page);
|
|
refreshDocs(undefined, page, rowsPerPage);
|
|
}}
|
|
onRowsPerPageChange={(rows) => {
|
|
setRowsPerPage(rows);
|
|
setCurrentPage(1);
|
|
refreshDocs(undefined, 1, rows);
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{modalState === 'ACTIVE' && (
|
|
<Upload
|
|
receivedFile={[]}
|
|
setModalState={setModalState}
|
|
isOnboarding={isOnboarding}
|
|
renderTab={null}
|
|
close={() => setModalState('INACTIVE')}
|
|
onSuccessfulUpload={() =>
|
|
refreshDocs(undefined, currentPage, rowsPerPage)
|
|
}
|
|
/>
|
|
)}
|
|
|
|
{deleteModalState === 'ACTIVE' && documentToDelete && (
|
|
<ConfirmationModal
|
|
message={t('settings.sources.deleteWarning', {
|
|
name: documentToDelete.document.name,
|
|
})}
|
|
modalState={deleteModalState}
|
|
setModalState={setDeleteModalState}
|
|
handleSubmit={handleConfirmedDelete}
|
|
handleCancel={() => {
|
|
setDeleteModalState('INACTIVE');
|
|
setDocumentToDelete(null);
|
|
}}
|
|
submitLabel={t('convTile.delete')}
|
|
variant="danger"
|
|
/>
|
|
)}
|
|
|
|
{documentToShare && documentToShare.id && (
|
|
<ShareToTeamModal
|
|
resourceType="source"
|
|
resourceId={documentToShare.id}
|
|
resourceName={documentToShare.name}
|
|
onClose={() => setDocumentToShare(null)}
|
|
/>
|
|
)}
|
|
|
|
<SourceConfigModal
|
|
modalState={configModalState}
|
|
setModalState={(state) => {
|
|
setConfigModalState(state);
|
|
if (state === 'INACTIVE') {
|
|
setDocumentToConfigure(null);
|
|
}
|
|
}}
|
|
document={documentToConfigure}
|
|
onReingest={handleReingest}
|
|
hybridAvailable={hybridAvailable}
|
|
graphRAGAvailable={graphRAGAvailable}
|
|
availableModels={availableModels}
|
|
onEnableGraphRAG={(doc) => {
|
|
setDocumentToGraphRAG(doc);
|
|
setGraphRAGModalState('ACTIVE');
|
|
}}
|
|
/>
|
|
|
|
<ConvertToWikiModal
|
|
modalState={convertModalState}
|
|
setModalState={(state) => {
|
|
setConvertModalState(state);
|
|
if (state === 'INACTIVE') {
|
|
setDocumentToConvert(null);
|
|
}
|
|
}}
|
|
document={documentToConvert}
|
|
onConverted={() => refreshDocs(undefined, currentPage, rowsPerPage)}
|
|
/>
|
|
|
|
<EnableGraphRAGModal
|
|
modalState={graphRAGModalState}
|
|
setModalState={(state) => {
|
|
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);
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|