import React, { useCallback, useEffect, useState, useMemo } from 'react' import IndexingModalWithMode from '../../components/IndexingModalWithMode' import { PDFPreview } from '../../components/PDFPreview' import { DragDropUpload } from '../../components/DragDropUpload' import { GlobalDragDropOverlay } from '../../components/GlobalDragDropOverlay' import { AppSettings, DEFAULT_MODELS, DEFAULT_SETTINGS, IndexingConfig, KnowledgeFile, ModelConfig, ModelType, RawFile, KnowledgeGroup, } from '../../types' import { readFile, formatBytes } from '../../utils/fileUtils' import { useLanguage } from '../../contexts/LanguageContext' import { useToast } from '../../contexts/ToastContext' import { userSettingService } from '../../services/userSettingService' import { uploadService } from '../../services/uploadService' import { knowledgeBaseService } from '../../services/knowledgeBaseService' import { knowledgeGroupService } from '../../services/knowledgeGroupService' import { useConfirm } from '../../contexts/ConfirmContext' import { ChunkInfoDrawer } from '../../components/ChunkInfoDrawer' import { Search, Plus, FileText, Image as ImageIcon, FileType, CheckCircle2, CircleDashed, RefreshCw, Eye, Trash2, Settings, Folder, Hash, Tag, Layers, ChevronRight, ChevronDown, FolderInput, Box, } from 'lucide-react' import { motion, AnimatePresence } from 'framer-motion' import { KB_ALLOWED_EXTENSIONS, IMAGE_MIME_TYPES, isExtensionAllowed, isFormatSupportedForPreview } from '../../constants/fileSupport' import { ImportFolderDrawer } from '../../components/ImportFolderDrawer' import { ImportTasksDrawer } from '../../components/drawers/ImportTasksDrawer' interface KnowledgeBaseViewProps { authToken: string; onLogout: () => void; modelConfigs?: ModelConfig[]; onNavigate: (view: any) => void; isAdmin?: boolean; } /** Flatten a tree of groups into a flat list (for file counts, filtering, etc.) */ function flattenGroups(groups: KnowledgeGroup[]): (KnowledgeGroup & { depth?: number })[] { const result: (KnowledgeGroup & { depth?: number })[] = []; function walk(items: KnowledgeGroup[], depth = 0) { for (const g of items) { result.push({ ...g, depth }); if (g.children?.length) walk(g.children, depth + 1); } } walk(groups); return result; } /** Recursively collect all descendant group IDs (including self) */ function collectGroupIds(group: KnowledgeGroup): string[] { const ids = [group.id]; if (group.children?.length) { for (const child of group.children) { ids.push(...collectGroupIds(child)); } } return ids; } // ---- Tree node component ---- interface GroupTreeNodeProps { group: KnowledgeGroup; selectedGroupId?: string; onSelect: (groupId: string) => void; isAdmin: boolean; onEdit: (group: KnowledgeGroup) => void; onDelete: (group: KnowledgeGroup) => void; depth?: number; } const GroupTreeNode: React.FC = ({ group, selectedGroupId, onSelect, isAdmin, onEdit, onDelete, depth = 0, }) => { const hasChildren = group.children && group.children.length > 0; const isSelected = selectedGroupId === group.id || (hasChildren && group.children!.some(c => collectGroupIds(c).includes(selectedGroupId || ''))); const [collapsed, setCollapsed] = useState(false); // Auto-expand if a child is selected useEffect(() => { if (selectedGroupId && hasChildren) { const allIds = collectGroupIds(group); if (allIds.includes(selectedGroupId)) setCollapsed(false); } }, [selectedGroupId]); return (
{/* Expand/collapse toggle */}
{hasChildren ? ( ) : ( )}
{isAdmin && (
)}
{/* Children */} {hasChildren && !collapsed && (
{group.children!.map(child => ( ))}
)}
); }; // ---- Pagination component ---- interface PaginationProps { currentPage: number; totalPages: number; totalItems: number; pageSize: number; onPageChange: (page: number) => void; t: (key: string, ...args: any[]) => string; } const Pagination: React.FC = ({ currentPage, totalPages, totalItems, pageSize, onPageChange, t }) => { if (totalItems === 0) return null; const start = (currentPage - 1) * pageSize + 1; const end = Math.min(currentPage * pageSize, totalItems); return (
{t('showingRange', start, end, totalItems)}
); }; export const KnowledgeBaseView: React.FC = (props) => { const { authToken, modelConfigs = DEFAULT_MODELS, isAdmin = false } = props; const { showError, showWarning, showSuccess } = useToast() const { confirm } = useConfirm() const { t } = useLanguage() // Data State const [files, setFiles] = useState([]) // groups is now a tree; flatGroups is the flattened version for lookups const [groups, setGroups] = useState([]) const flatGroups = useMemo(() => flattenGroups(groups), [groups]) const [settings, setSettings] = useState(DEFAULT_SETTINGS) const [isLoadingSettings, setIsLoadingSettings] = useState(true) const [isLoadingFiles, setIsLoadingFiles] = useState(true) // UI State const [pdfPreview, setPdfPreview] = useState<{ fileId: string; fileName: string } | null>(null) const [isIndexingModalOpen, setIsIndexingModalOpen] = useState(false) const [pendingFiles, setPendingFiles] = useState([]) const [fileInputRef] = useState>(React.createRef()) const [shouldOpenModal, setShouldOpenModal] = useState(false) // Filter & Pagination State const [filterName, setFilterName] = useState('') const [filterStatus, setFilterStatus] = useState<'all' | 'ready' | 'indexing' | 'failed'>('all') const [currentPage, setCurrentPage] = useState(1) const pageSize = 12 const [chunkDrawer, setChunkDrawer] = useState<{ isOpen: boolean; fileId: string; fileName: string } | null>(null) const [isAutoRefreshEnabled] = useState(true) const [autoRefreshInterval] = useState(5000) // Sidebar State const [selectedSidebarFilter, setSelectedSidebarFilter] = useState<{ type: 'all' | 'uncategorized' | 'group'; groupId?: string }>({ type: 'all' }) const [isGroupModalOpen, setIsGroupModalOpen] = useState(false) const [isImportDrawerOpen, setIsImportDrawerOpen] = useState(false); const [isImportTasksDrawerOpen, setIsImportTasksDrawerOpen] = useState(false); const [editingGroup, setEditingGroup] = useState(null) const [newGroupName, setNewGroupName] = useState('') const [newGroupParentId, setNewGroupParentId] = useState(null) const fetchAndSetSettings = useCallback(async () => { if (!authToken) return try { const settingsData = await userSettingService.get(authToken); setSettings({ ...DEFAULT_SETTINGS, ...settingsData }) } catch (error) { console.error('Failed to fetch settings:', error) } finally { setIsLoadingSettings(false) } }, [authToken, isAdmin]) const fetchAndSetFiles = useCallback(async () => { if (!authToken) return try { setIsLoadingFiles(true) const result = await knowledgeBaseService.getAll(authToken, { page: currentPage, limit: pageSize, name: filterName, status: filterStatus, groupId: selectedSidebarFilter.type === 'group' ? selectedSidebarFilter.groupId : undefined }) setFiles(result.items) setTotalFiles(result.total) } catch (error) { console.error('Failed to fetch files:', error) } finally { setIsLoadingFiles(false) } }, [authToken, currentPage, filterName, filterStatus, selectedSidebarFilter]) const fetchAndSetGroups = useCallback(async () => { if (!authToken) return try { const remoteGroups = await knowledgeGroupService.getGroups() setGroups(remoteGroups) } catch (error) { console.error('Failed to fetch groups:', error) } }, [authToken]) const fetchAndSetStats = useCallback(async () => { if (!authToken) return try { const stats = await knowledgeBaseService.getStats(authToken) setGlobalStats(stats) } catch (error) { console.error('Failed to fetch stats:', error) } }, [authToken]) useEffect(() => { if (authToken) { fetchAndSetSettings() fetchAndSetGroups() fetchAndSetStats() } }, [authToken, fetchAndSetSettings, fetchAndSetGroups, fetchAndSetStats]) useEffect(() => { if (shouldOpenModal && pendingFiles.length > 0) { setIsIndexingModalOpen(true); setShouldOpenModal(false); } }, [shouldOpenModal, pendingFiles.length]); const handleFileUpload = async (fileList: FileList) => { if (!authToken) { showWarning(t('loginRequired')) return } const MAX_FILE_SIZE = 104857600; const rawFiles: RawFile[] = [] const errors: string[] = [] const filesArray = Array.from(fileList); for (const file of filesArray) { const extension = file.name.split('.').pop() || '' if (!isExtensionAllowed(extension, 'kb')) { errors.push(t('unsupportedFileType', file.name, extension)) continue } if (file.size > MAX_FILE_SIZE) { errors.push(t('fileSizeLimitExceeded', file.name, formatBytes(file.size), 100)) continue; } try { const rawFile = await readFile(file) rawFiles.push(rawFile) } catch (error) { errors.push(`${file.name} - ${t('readingFailed')}`); } } if (errors.length > 0) showError(`${t('uploadErrors')}:\n${errors.join('\n')}`); if (rawFiles.length === 0) return; setPendingFiles(rawFiles); setShouldOpenModal(true); } const handleConfirmIndexing = async (config: IndexingConfig) => { if (!authToken) return let hasSuccess = false for (const rawFile of pendingFiles) { try { const indexingConfig = { ...config, groupIds: selectedSidebarFilter.type === 'group' ? [selectedSidebarFilter.groupId!] : [] }; await uploadService.uploadFileWithConfig(rawFile.file, indexingConfig, authToken) hasSuccess = true } catch (error: any) { showError(`${t('uploadFailed')}: ${rawFile.name} - ${error.message}`) } } if (hasSuccess) await fetchAndSetFiles() setPendingFiles([]) setIsIndexingModalOpen(false) } const handleRemoveFile = async (id: string) => { if (!(await confirm(t('confirmDeleteFile')))) return if (!authToken) return try { await knowledgeBaseService.deleteFile(id, authToken) setFiles(prev => prev.filter(f => f.id !== id)) showSuccess(t('fileDeleted')) } catch (error: any) { showError(`${t('deleteFailed')}: ` + error.message) } } const handleToggleFileCategory = async (file: KnowledgeFile, groupId: string) => { try { const currentGroupIds = file.groups?.map(g => g.id) || []; const isAssigned = currentGroupIds.includes(groupId); let newGroupIds: string[]; if (isAssigned) { newGroupIds = currentGroupIds.filter(id => id !== groupId); } else { newGroupIds = [...currentGroupIds, groupId]; } await knowledgeGroupService.addFileToGroups(file.id, newGroupIds); await fetchAndSetFiles(); } catch (error: any) { console.error('Failed to toggle category:', error); showError(t('actionFailed') + ': ' + error.message); } } const handleClearAll = async () => { if (!(await confirm(t('confirmClearKB')))) return if (!authToken) return try { await knowledgeBaseService.clearAll(authToken) setFiles([]) fetchAndSetStats() showSuccess(t('kbCleared')) } catch (error: any) { showError(`${t('clearFailed')}: ` + error.message) } } // Filtering: when a group is selected, include files in that group AND all descendant groups const filteredFiles = useMemo(() => { return files.filter(file => { const matchName = file.name.toLowerCase().includes(filterName.toLowerCase()); let matchGroup = true; if (selectedSidebarFilter.type === 'uncategorized') { matchGroup = !file.groups || file.groups.length === 0; } else if (selectedSidebarFilter.type === 'group' && selectedSidebarFilter.groupId) { // Find the selected group in the tree to collect all descendant IDs const selectedGroup = flatGroups.find(g => g.id === selectedSidebarFilter.groupId); const allIds = selectedGroup ? collectGroupIds(selectedGroup) : [selectedSidebarFilter.groupId]; matchGroup = file.groups?.some(g => allIds.includes(g.id)) || false; } const matchStatus = filterStatus === 'all' || (filterStatus === 'ready' && (file.status === 'ready' || file.status === 'vectorized')) || (filterStatus === 'indexing' && (file.status === 'indexing' || file.status === 'pending' || file.status === 'extracted')) || (filterStatus === 'failed' && (file.status === 'failed' || file.status === 'error')); return matchName && matchGroup && matchStatus; }); }, [files, filterName, selectedSidebarFilter, filterStatus, flatGroups]); const totalPages = Math.ceil(filteredFiles.length / pageSize); const paginatedFiles = useMemo(() => { const start = (currentPage - 1) * pageSize; return filteredFiles.slice(start, start + pageSize); }, [filteredFiles, currentPage, pageSize]); useEffect(() => { setCurrentPage(1); }, [filterName, filterStatus, selectedSidebarFilter]); useEffect(() => { let intervalId: NodeJS.Timeout | null = null; const hasIndexingFiles = files.some(file => ['pending', 'indexing', 'extracted', 'vectorized'].includes(file.status)); if (isAutoRefreshEnabled && hasIndexingFiles) { intervalId = setInterval(() => { fetchAndSetFiles(); }, autoRefreshInterval); } return () => { if (intervalId) clearInterval(intervalId); }; }, [isAutoRefreshEnabled, files, autoRefreshInterval, fetchAndSetFiles]); const getFileIcon = (file: KnowledgeFile) => { if (file.type.startsWith('image/')) return ; if (file.type === 'application/pdf') return ; return ; }; const handleCreateOrUpdateGroup = async () => { if (!newGroupName.trim()) return try { if (editingGroup) { await knowledgeGroupService.updateGroup(editingGroup.id, { name: newGroupName, parentId: newGroupParentId }) showSuccess(t('groupUpdated')) } else { await knowledgeGroupService.createGroup({ name: newGroupName, parentId: newGroupParentId }) showSuccess(t('groupCreated')) } fetchAndSetGroups() setIsGroupModalOpen(false) setEditingGroup(null) setNewGroupName('') setNewGroupParentId(null) } catch (error: any) { showError(t('actionFailed') + ': ' + error.message) } } const handleDeleteGroup = async (group: KnowledgeGroup) => { if (!(await confirm(t('confirmDeleteGroup').replace('$1', group.name)))) return try { await knowledgeGroupService.deleteGroup(group.id) showSuccess(t('groupDeleted')) if (selectedSidebarFilter.groupId === group.id) { setSelectedSidebarFilter({ type: 'all' }) } fetchAndSetGroups() } catch (error: any) { showError(t('deleteFailed') + ': ' + error.message) } } const openCreateGroup = (parentId?: string | null) => { setEditingGroup(null); setNewGroupName(''); setNewGroupParentId(parentId ?? null); setIsGroupModalOpen(true); } const openEditGroup = (group: KnowledgeGroup) => { setEditingGroup(group); setNewGroupName(group.name); setNewGroupParentId(group.parentId ?? null); setIsGroupModalOpen(true); } const selectedGroupObj = selectedSidebarFilter.type === 'group' ? flatGroups.find(g => g.id === selectedSidebarFilter.groupId) : null; if (isLoadingSettings) { return (
) } const totalPages = Math.ceil(totalFiles / pageSize); return (
{/* Sidebar */}

{t('navCatalog')}

{t('categories')}

{isAdmin && ( )}
{groups.map(group => ( setSelectedSidebarFilter({ type: 'group', groupId: gId })} isAdmin={isAdmin} onEdit={openEditGroup} onDelete={handleDeleteGroup} depth={0} /> ))}
{/* Main Content Area */}
{ if (e.target.files && e.target.files.length > 0) handleFileUpload(e.target.files) }} multiple className="hidden" accept={KB_ALLOWED_EXTENSIONS.map(ext => `.${ext}`).join(',') + ',' + IMAGE_MIME_TYPES.join(',')} /> {/* Header Section */}

{selectedSidebarFilter.type === 'all' ? t('kbManagement') : selectedSidebarFilter.type === 'uncategorized' ? t('uncategorizedFiles') : selectedGroupObj?.name || t('category')}

{selectedSidebarFilter.type === 'group' ? selectedGroupObj?.description || t('kbManagementDesc') : t('kbManagementDesc')}

{isAdmin && ( <> {selectedSidebarFilter.type === 'group' && ( )} )}
{/* Filter Bar */}
setFilterName(e.target.value)} className="w-full h-9 pl-9 pr-4 bg-white border border-slate-200 rounded-lg text-sm transition-all focus:ring-1 focus:ring-blue-500 focus:border-blue-500 outline-none" />
{/* File List */}
{isLoadingFiles ? (
) : paginatedFiles.length > 0 ? (
{paginatedFiles.map((file) => ( {/* Icon */}
{getFileIcon(file)}
{/* Name & Desc */}

setChunkDrawer({ isOpen: true, fileId: file.id, fileName: file.name })} className="font-bold text-slate-900 text-[15px] cursor-pointer hover:text-blue-600 transition-colors truncate" > {file.name}

{file.name.split('.').pop()?.toUpperCase() || 'FILE'}

{file.status === 'ready' || file.status === 'vectorized' ? t('statusReadyDesc') : t('statusIndexingDesc', file.status) }

{file.groups && file.groups.length > 0 && (
{file.groups.map(g => ( {g.name} ))}
)}
{/* Meta & Actions */}
{new Date(file.createdAt || Date.now()).toLocaleDateString()} {formatBytes(file.size)}
{file.status !== 'ready' && file.status !== 'vectorized' ? ( ) : null}
{isFormatSupportedForPreview(file.name) && ( )}
{t('selectCategory')}
{flatGroups.map(g => ( ))}
{isAdmin && ( )}
))}
) : (
)}
{/* Pagination */}
{ setPendingFiles([]); setIsIndexingModalOpen(false); }} files={pendingFiles} embeddingModels={modelConfigs.filter(m => m.type === ModelType.EMBEDDING)} defaultEmbeddingId={settings.selectedEmbeddingId} onConfirm={handleConfirmIndexing} authToken={authToken} /> {/* Group Create/Edit Modal */} {isGroupModalOpen && (

{editingGroup ? t('editCategory') : t('createCategory')}

{t('categoryDesc')}

setNewGroupName(e.target.value)} placeholder={t('exampleResearch')} className="w-full h-11 px-4 bg-slate-50 border border-slate-200 rounded-xl outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all" autoFocus onKeyDown={(e) => e.key === 'Enter' && handleCreateOrUpdateGroup()} />
{/* Parent category selector */}
)}
{pdfPreview && ( setPdfPreview(null)} /> )} {chunkDrawer && ( setChunkDrawer(null)} fileId={chunkDrawer.fileId} fileName={chunkDrawer.fileName} authToken={authToken} /> )} setIsImportDrawerOpen(false)} authToken={authToken} onImportSuccess={() => fetchAndSetFiles()} /> setIsImportTasksDrawerOpen(false)} authToken={authToken} />
); };