0a9588abb7
- Add pagination support to findAll (page, limit query params) - Add findByTemplateId method to service - Add GET /by-template/:templateId endpoint to controller - Service already includes CRUD for QuestionBank and QuestionBankItem
953 lines
47 KiB
TypeScript
953 lines
47 KiB
TypeScript
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<GroupTreeNodeProps> = ({
|
|
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 (
|
|
<div>
|
|
<div
|
|
className={`group flex items-center justify-between rounded-lg transition-colors ${selectedGroupId === group.id ? 'bg-blue-50 text-blue-700' : 'text-slate-600 hover:bg-slate-50 hover:text-slate-900'}`}
|
|
style={{ paddingLeft: `${depth * 12 + 12}px` }}
|
|
>
|
|
{/* Expand/collapse toggle */}
|
|
<div className="flex items-center flex-1">
|
|
{hasChildren ? (
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); setCollapsed(c => !c); }}
|
|
className="p-0.5 mr-1 shrink-0 text-slate-400 hover:text-slate-700"
|
|
>
|
|
{collapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
|
|
</button>
|
|
) : (
|
|
<span className="w-5 shrink-0" />
|
|
)}
|
|
<button
|
|
onClick={() => onSelect(group.id)}
|
|
className="flex-1 flex items-center gap-1.5 py-1.5 text-sm font-medium text-left whitespace-nowrap"
|
|
>
|
|
<Folder size={14} className={selectedGroupId === group.id ? 'text-blue-500 shrink-0' : 'text-slate-400 shrink-0'} />
|
|
<span>{group.name}</span>
|
|
</button>
|
|
</div>
|
|
|
|
{isAdmin && (
|
|
<div className="opacity-0 group-hover:opacity-100 flex items-center gap-0.5 pr-2 shrink-0">
|
|
<button
|
|
onClick={() => onEdit(group)}
|
|
className="p-1 text-slate-400 hover:text-blue-600 rounded"
|
|
>
|
|
<Settings size={11} />
|
|
</button>
|
|
<button
|
|
onClick={() => onDelete(group)}
|
|
className="p-1 text-slate-400 hover:text-red-500 rounded"
|
|
>
|
|
<Trash2 size={11} />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Children */}
|
|
{hasChildren && !collapsed && (
|
|
<div>
|
|
{group.children!.map(child => (
|
|
<GroupTreeNode
|
|
key={child.id}
|
|
group={child}
|
|
selectedGroupId={selectedGroupId}
|
|
onSelect={onSelect}
|
|
isAdmin={isAdmin}
|
|
onEdit={onEdit}
|
|
onDelete={onDelete}
|
|
depth={depth + 1}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ---- 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<PaginationProps> = ({ 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 (
|
|
<div className="px-8 py-4 border-t border-slate-200/60 bg-white/50 backdrop-blur-md flex items-center justify-center gap-2 shrink-0">
|
|
<button
|
|
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
|
|
disabled={currentPage === 1}
|
|
className="p-2 border border-slate-200 rounded-lg hover:bg-slate-50 disabled:opacity-30 transition-all font-medium text-slate-600 text-sm"
|
|
>
|
|
{t('previous')}
|
|
</button>
|
|
<div className="px-3 py-2 text-sm font-semibold text-slate-700">
|
|
{t('showingRange', start, end, totalItems)}
|
|
</div>
|
|
<button
|
|
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
|
|
disabled={currentPage === totalPages}
|
|
className="p-2 border border-slate-200 rounded-lg hover:bg-slate-50 disabled:opacity-30 transition-all font-medium text-slate-600 text-sm"
|
|
>
|
|
{t('next')}
|
|
</button>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export const KnowledgeBaseView: React.FC<KnowledgeBaseViewProps> = (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<KnowledgeFile[]>([])
|
|
// groups is now a tree; flatGroups is the flattened version for lookups
|
|
const [groups, setGroups] = useState<KnowledgeGroup[]>([])
|
|
const flatGroups = useMemo(() => flattenGroups(groups), [groups])
|
|
const [settings, setSettings] = useState<AppSettings>(DEFAULT_SETTINGS)
|
|
const [globalStats, setGlobalStats] = useState<{ total: number, uncategorized: number }>({ total: 0, uncategorized: 0 })
|
|
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<RawFile[]>([])
|
|
const [fileInputRef] = useState<React.RefObject<HTMLInputElement>>(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(false)
|
|
const [autoRefreshInterval] = useState<number>(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<KnowledgeGroup | null>(null)
|
|
const [newGroupName, setNewGroupName] = useState('')
|
|
const [newGroupParentId, setNewGroupParentId] = useState<string | null>(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)
|
|
} 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 (authToken) {
|
|
fetchAndSetFiles()
|
|
}
|
|
}, [fetchAndSetFiles])
|
|
|
|
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 <ImageIcon size={20} className="text-slate-500" />;
|
|
if (file.type === 'application/pdf') return <FileType size={20} className="text-blue-500" />;
|
|
return <FileText size={20} className="text-blue-500" />;
|
|
};
|
|
|
|
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 (
|
|
<div className='flex items-center justify-center min-h-[400px] w-full'>
|
|
<div className='text-blue-600 animate-spin rounded-full h-8 w-8 border-2 border-t-transparent border-blue-600'></div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className='flex flex-row h-full w-full bg-slate-50 overflow-hidden'>
|
|
{/* Sidebar */}
|
|
<div className="w-64 bg-white border-r border-slate-200 flex flex-col shrink-0">
|
|
<div className="p-6 flex flex-col min-h-0">
|
|
<h2 className="text-sm font-semibold text-slate-400 uppercase tracking-wider mb-4">{t('navCatalog')}</h2>
|
|
<nav className="space-y-1">
|
|
<button
|
|
onClick={() => setSelectedSidebarFilter({ type: 'all' })}
|
|
className={`w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm font-medium transition-colors ${selectedSidebarFilter.type === 'all' ? 'bg-blue-50 text-blue-700' : 'text-slate-600 hover:bg-slate-50 hover:text-slate-900'}`}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<Layers size={16} />
|
|
<span>{t('allDocuments')}</span>
|
|
</div>
|
|
<span className="text-xs bg-slate-100 text-slate-500 px-1.5 py-0.5 rounded-full">{files.length}</span>
|
|
</button>
|
|
<button
|
|
onClick={() => setSelectedSidebarFilter({ type: 'uncategorized' })}
|
|
className={`w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm font-medium transition-colors ${selectedSidebarFilter.type === 'uncategorized' ? 'bg-blue-50 text-blue-700' : 'text-slate-600 hover:bg-slate-50 hover:text-slate-900'}`}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<FileText size={16} />
|
|
<span>{t('uncategorized')}</span>
|
|
</div>
|
|
<span className="text-xs bg-slate-100 text-slate-500 px-1.5 py-0.5 rounded-full">
|
|
{files.filter(f => !f.groups || f.groups.length === 0).length}
|
|
</span>
|
|
</button>
|
|
</nav>
|
|
|
|
<div className="mt-6 flex items-center justify-between mb-3">
|
|
<h2 className="text-sm font-semibold text-slate-400 uppercase tracking-wider">{t('categories')}</h2>
|
|
{isAdmin && (
|
|
<button
|
|
onClick={() => openCreateGroup(null)}
|
|
className="p-1 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded transition-all"
|
|
title={t('createCategory') as string}
|
|
>
|
|
<Plus size={14} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-0.5 overflow-y-auto overflow-x-auto flex-1 pr-1 pb-4">
|
|
{groups.map(group => (
|
|
<GroupTreeNode
|
|
key={group.id}
|
|
group={group}
|
|
selectedGroupId={selectedSidebarFilter.type === 'group' ? selectedSidebarFilter.groupId : undefined}
|
|
onSelect={(gId) => setSelectedSidebarFilter({ type: 'group', groupId: gId })}
|
|
isAdmin={isAdmin}
|
|
onEdit={openEditGroup}
|
|
onDelete={handleDeleteGroup}
|
|
depth={0}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Main Content Area */}
|
|
<div className='flex flex-col flex-1 h-full w-full bg-transparent overflow-hidden'>
|
|
<input
|
|
type="file"
|
|
ref={fileInputRef}
|
|
onChange={(e) => {
|
|
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(',')}
|
|
/>
|
|
|
|
<GlobalDragDropOverlay onFilesSelected={handleFileUpload} isAdmin={isAdmin} />
|
|
|
|
{/* Header Section */}
|
|
<div className="px-8 pt-8 pb-6 flex items-start justify-between shrink-0">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-slate-900 leading-tight">
|
|
{selectedSidebarFilter.type === 'all' ? t('kbManagement') :
|
|
selectedSidebarFilter.type === 'uncategorized' ? t('uncategorizedFiles') :
|
|
selectedGroupObj?.name || t('category')}
|
|
</h1>
|
|
<p className="text-[15px] text-slate-500 mt-1">
|
|
{selectedSidebarFilter.type === 'group'
|
|
? selectedGroupObj?.description || t('kbManagementDesc')
|
|
: t('kbManagementDesc')}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
{isAdmin && (
|
|
<>
|
|
{selectedSidebarFilter.type === 'group' && (
|
|
<button
|
|
onClick={() => openCreateGroup(selectedSidebarFilter.groupId)}
|
|
className="flex items-center gap-2 px-4 py-2.5 bg-white border border-slate-200 text-slate-700 rounded-lg shadow-sm transition-all font-semibold text-sm active:scale-95 hover:bg-slate-50"
|
|
>
|
|
<Plus size={16} />
|
|
{t('addSubcategory')}
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={() => setIsImportTasksDrawerOpen(true)}
|
|
className="flex items-center gap-2 px-4 py-2.5 bg-white border border-slate-200 text-slate-700 rounded-lg shadow-sm transition-all font-semibold text-sm active:scale-95 hover:bg-slate-50"
|
|
>
|
|
<Box size={18} className="text-indigo-600" />
|
|
{t('importTasksTitle')}
|
|
</button>
|
|
<button
|
|
onClick={() => setIsImportDrawerOpen(true)}
|
|
className="flex items-center gap-2 px-4 py-2.5 bg-white border border-slate-200 text-slate-700 rounded-lg shadow-sm transition-all font-semibold text-sm active:scale-95 hover:bg-slate-50"
|
|
>
|
|
<FolderInput size={18} className="text-blue-600" />
|
|
{t('importFolder')}
|
|
</button>
|
|
<button
|
|
onClick={() => fileInputRef.current?.click()}
|
|
className="flex items-center gap-2 px-5 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg shadow-sm shadow-blue-100 transition-all font-semibold text-sm active:scale-95"
|
|
>
|
|
<Plus size={18} />
|
|
{t('addFile')}
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Filter Bar */}
|
|
<div className="px-8 pb-6 flex items-center justify-between gap-4 shrink-0">
|
|
<div className="flex items-center gap-3 flex-1">
|
|
<div className="relative max-w-xs w-full">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={16} />
|
|
<input
|
|
type="text"
|
|
placeholder={t('searchPlaceholder')}
|
|
value={filterName}
|
|
onChange={(e) => 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"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
onClick={() => fetchAndSetFiles()}
|
|
className="p-2 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-all"
|
|
title="Refresh"
|
|
>
|
|
<RefreshCw size={18} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* File List */}
|
|
<div className="flex-1 overflow-y-auto px-8 pb-4">
|
|
{isLoadingFiles ? (
|
|
<div className="flex flex-col items-center justify-center py-20 gap-4">
|
|
<div className="w-10 h-10 border-2 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
|
|
</div>
|
|
) : paginatedFiles.length > 0 ? (
|
|
<div className="flex flex-col gap-3">
|
|
<AnimatePresence mode="popLayout">
|
|
{paginatedFiles.map((file) => (
|
|
<motion.div
|
|
key={file.id}
|
|
layout
|
|
initial={{ opacity: 0, y: 10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, scale: 0.95 }}
|
|
className="bg-white rounded-xl border border-slate-200/80 p-4 shadow-sm hover:shadow-md transition-all group relative flex items-center gap-4"
|
|
>
|
|
{/* Icon */}
|
|
<div className="p-2.5 bg-blue-50 rounded-lg text-blue-600 shadow-sm border border-blue-100/30 shrink-0">
|
|
{getFileIcon(file)}
|
|
</div>
|
|
|
|
{/* Name & Desc */}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-3 mb-1">
|
|
<h3
|
|
onClick={() => 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}
|
|
</h3>
|
|
<span className="px-2 py-0.5 bg-slate-100 text-slate-500 rounded text-[10px] font-bold tracking-wider uppercase shrink-0">
|
|
{file.name.split('.').pop()?.toUpperCase() || 'FILE'}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<p className="text-[13px] text-slate-500 truncate">
|
|
{file.status === 'ready' || file.status === 'vectorized'
|
|
? t('statusReadyDesc')
|
|
: t('statusIndexingDesc', file.status)
|
|
}
|
|
</p>
|
|
{file.groups && file.groups.length > 0 && (
|
|
<div className="flex gap-1 ml-2">
|
|
{file.groups.map(g => (
|
|
<span key={g.id} className="text-[10px] px-1.5 py-0.5 bg-blue-50 text-blue-600 rounded-full border border-blue-100">
|
|
{g.name}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Meta & Actions */}
|
|
<div className="flex items-center gap-6 shrink-0">
|
|
<div className="text-[12px] font-medium text-slate-400 flex flex-col items-end">
|
|
<span>{new Date(file.createdAt || Date.now()).toLocaleDateString()}</span>
|
|
<span>{formatBytes(file.size)}</span>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
{file.status !== 'ready' && file.status !== 'vectorized' ? (
|
|
<CircleDashed size={16} className="text-blue-400 animate-spin mr-2" />
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1 opacity-100 md:opacity-0 group-hover:opacity-100 transition-opacity">
|
|
{isFormatSupportedForPreview(file.name) && (
|
|
<button onClick={() => setPdfPreview({ fileId: file.id, fileName: file.name })} className="p-1.5 text-slate-400 hover:text-blue-600 rounded-md bg-slate-50" title={t('preview') as string || 'Preview'}>
|
|
<Eye size={16} />
|
|
</button>
|
|
)}
|
|
<div className="relative group/tag">
|
|
<button className="p-1.5 text-slate-400 hover:text-blue-600 rounded-md bg-slate-50" title={t('groups') as string || 'Groups'}>
|
|
<Tag size={16} />
|
|
</button>
|
|
<div className="absolute right-0 top-full mt-1 w-52 bg-white border border-slate-200 rounded-lg shadow-lg opacity-0 invisible group-hover/tag:opacity-100 group-hover/tag:visible transition-all z-20 overflow-hidden">
|
|
<div className="p-2 border-b border-slate-100 bg-slate-50 text-[10px] font-bold text-slate-400 uppercase tracking-wider">
|
|
{t('selectCategory')}
|
|
</div>
|
|
<div className="max-h-48 overflow-y-auto">
|
|
<button
|
|
onClick={() => knowledgeGroupService.addFileToGroups(file.id, []).then(fetchAndSetFiles)}
|
|
className="w-full text-left px-3 py-2 text-xs text-slate-600 hover:bg-slate-50 flex items-center gap-2"
|
|
>
|
|
<Layers size={12} />
|
|
{t('noneUncategorized')}
|
|
</button>
|
|
{flatGroups.map(g => (
|
|
<button
|
|
key={g.id}
|
|
onClick={() => handleToggleFileCategory(file, g.id)}
|
|
style={{ paddingLeft: `${(g.depth || 0) * 12 + 12}px` }}
|
|
className="w-full text-left pr-3 py-2 text-xs text-slate-600 hover:bg-slate-50 border-t border-slate-50 flex items-center justify-between"
|
|
>
|
|
<div className="flex items-center gap-1.5 truncate">
|
|
<Hash size={12} className={file.groups?.some(fg => fg.id === g.id) ? 'text-blue-500 shrink-0' : 'text-slate-400 shrink-0'} />
|
|
<span className={file.groups?.some(fg => fg.id === g.id) ? 'text-blue-600 font-medium truncate' : 'truncate'}>{g.name}</span>
|
|
</div>
|
|
{file.groups?.some(fg => fg.id === g.id) && <CheckCircle2 size={12} className="text-blue-600 shrink-0 ml-2" />}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{isAdmin && (
|
|
<button onClick={() => handleRemoveFile(file.id)} className="p-1.5 text-slate-400 hover:text-red-500 rounded-md bg-slate-50" title={t('delete') as string || 'Delete'}>
|
|
<Trash2 size={16} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
))}
|
|
</AnimatePresence>
|
|
</div>
|
|
) : (
|
|
<div className="max-w-4xl mx-auto w-full pt-12">
|
|
<DragDropUpload onFilesSelected={handleFileUpload} isAdmin={isAdmin} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Pagination */}
|
|
<Pagination
|
|
currentPage={currentPage}
|
|
totalPages={totalPages}
|
|
totalItems={filteredFiles.length}
|
|
pageSize={pageSize}
|
|
onPageChange={setCurrentPage}
|
|
t={t}
|
|
/>
|
|
</div>
|
|
|
|
<IndexingModalWithMode
|
|
isOpen={isIndexingModalOpen}
|
|
onClose={() => { setPendingFiles([]); setIsIndexingModalOpen(false); }}
|
|
files={pendingFiles}
|
|
embeddingModels={modelConfigs.filter(m => m.type === ModelType.EMBEDDING)}
|
|
defaultEmbeddingId={settings.selectedEmbeddingId}
|
|
onConfirm={handleConfirmIndexing}
|
|
authToken={authToken}
|
|
/>
|
|
|
|
{/* Group Create/Edit Modal */}
|
|
<AnimatePresence>
|
|
{isGroupModalOpen && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/40 backdrop-blur-sm">
|
|
<motion.div
|
|
initial={{ opacity: 0, scale: 0.95 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
exit={{ opacity: 0, scale: 0.95 }}
|
|
className="bg-white rounded-2xl shadow-xl w-full max-w-md overflow-hidden"
|
|
>
|
|
<div className="p-6">
|
|
<h2 className="text-xl font-bold text-slate-900 mb-2">
|
|
{editingGroup ? t('editCategory') : t('createCategory')}
|
|
</h2>
|
|
<p className="text-slate-500 text-sm mb-6">
|
|
{t('categoryDesc')}
|
|
</p>
|
|
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">{t('categoryName')}</label>
|
|
<input
|
|
type="text"
|
|
value={newGroupName}
|
|
onChange={(e) => 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()}
|
|
/>
|
|
</div>
|
|
|
|
{/* Parent category selector */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">{t('parentCategory')}</label>
|
|
<select
|
|
value={newGroupParentId ?? ''}
|
|
onChange={(e) => setNewGroupParentId(e.target.value || null)}
|
|
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 text-sm"
|
|
>
|
|
<option value="">{t('noParentTopLevel')}</option>
|
|
{flatGroups
|
|
.filter(g => g.id !== editingGroup?.id) // don't allow self as parent
|
|
.map(g => (
|
|
<option key={g.id} value={g.id}>
|
|
{'\u00A0'.repeat((g.depth || 0) * 4)}{g.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="px-6 py-4 bg-slate-50 border-t border-slate-100 flex justify-end gap-3">
|
|
<button
|
|
onClick={() => { setIsGroupModalOpen(false); setEditingGroup(null); setNewGroupParentId(null); }}
|
|
className="px-4 py-2 text-slate-600 font-medium hover:bg-slate-200/50 rounded-lg transition-all"
|
|
>
|
|
{t('cancel')}
|
|
</button>
|
|
<button
|
|
onClick={handleCreateOrUpdateGroup}
|
|
className="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white font-bold rounded-lg shadow-sm transition-all active:scale-95"
|
|
>
|
|
{editingGroup ? t('saveChanges') : t('createCategoryBtn')}
|
|
</button>
|
|
</div>
|
|
</motion.div>
|
|
</div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{pdfPreview && (
|
|
<PDFPreview
|
|
fileId={pdfPreview.fileId}
|
|
fileName={pdfPreview.fileName}
|
|
authToken={authToken}
|
|
onClose={() => setPdfPreview(null)}
|
|
/>
|
|
)}
|
|
|
|
{chunkDrawer && (
|
|
<ChunkInfoDrawer
|
|
isOpen={chunkDrawer.isOpen}
|
|
onClose={() => setChunkDrawer(null)}
|
|
fileId={chunkDrawer.fileId}
|
|
fileName={chunkDrawer.fileName}
|
|
authToken={authToken}
|
|
/>
|
|
)}
|
|
|
|
<ImportFolderDrawer
|
|
isOpen={isImportDrawerOpen}
|
|
onClose={() => setIsImportDrawerOpen(false)}
|
|
authToken={authToken}
|
|
onImportSuccess={() => fetchAndSetFiles()}
|
|
/>
|
|
|
|
<ImportTasksDrawer
|
|
isOpen={isImportTasksDrawerOpen}
|
|
onClose={() => setIsImportTasksDrawerOpen(false)}
|
|
authToken={authToken}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|