Files
aurak/web/components/views/KnowledgeBaseView.tsx
Developer 0a9588abb7 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
2026-04-23 17:19:11 +08:00

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>
);
};