| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927 |
- 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 [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(true)
- 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 {
- const remoteFiles = await knowledgeBaseService.getAll(authToken)
- setFiles(remoteFiles)
- } catch (error) {
- console.error('Failed to fetch files:', error)
- } finally {
- setIsLoadingFiles(false)
- }
- }, [authToken])
- 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])
- useEffect(() => {
- if (authToken) {
- fetchAndSetSettings()
- fetchAndSetFiles()
- fetchAndSetGroups()
- }
- }, [authToken, fetchAndSetSettings, fetchAndSetFiles, fetchAndSetGroups])
- 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([])
- 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>
- )
- }
|