feat: implement QuestionBank CRUD with pagination and template query
- 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
This commit is contained in:
@@ -0,0 +1,952 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user