KnowledgeBaseView.tsx 47 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946
  1. import React, { useCallback, useEffect, useState, useMemo } from 'react'
  2. import IndexingModalWithMode from '../../components/IndexingModalWithMode'
  3. import { PDFPreview } from '../../components/PDFPreview'
  4. import { DragDropUpload } from '../../components/DragDropUpload'
  5. import { GlobalDragDropOverlay } from '../../components/GlobalDragDropOverlay'
  6. import {
  7. AppSettings,
  8. DEFAULT_MODELS,
  9. DEFAULT_SETTINGS,
  10. IndexingConfig,
  11. KnowledgeFile,
  12. ModelConfig,
  13. ModelType,
  14. RawFile,
  15. KnowledgeGroup,
  16. } from '../../types'
  17. import { readFile, formatBytes } from '../../utils/fileUtils'
  18. import { useLanguage } from '../../contexts/LanguageContext'
  19. import { useToast } from '../../contexts/ToastContext'
  20. import { userSettingService } from '../../services/userSettingService'
  21. import { uploadService } from '../../services/uploadService'
  22. import { knowledgeBaseService } from '../../services/knowledgeBaseService'
  23. import { knowledgeGroupService } from '../../services/knowledgeGroupService'
  24. import { useConfirm } from '../../contexts/ConfirmContext'
  25. import { ChunkInfoDrawer } from '../../components/ChunkInfoDrawer'
  26. import {
  27. Search,
  28. Plus,
  29. FileText,
  30. Image as ImageIcon,
  31. FileType,
  32. CheckCircle2,
  33. CircleDashed,
  34. RefreshCw,
  35. Eye,
  36. Trash2,
  37. Settings,
  38. Folder,
  39. Hash,
  40. Tag,
  41. Layers,
  42. ChevronRight,
  43. ChevronDown,
  44. FolderInput,
  45. Box,
  46. } from 'lucide-react'
  47. import { motion, AnimatePresence } from 'framer-motion'
  48. import { KB_ALLOWED_EXTENSIONS, IMAGE_MIME_TYPES, isExtensionAllowed, isFormatSupportedForPreview } from '../../constants/fileSupport'
  49. import { ImportFolderDrawer } from '../../components/ImportFolderDrawer'
  50. import { ImportTasksDrawer } from '../../components/drawers/ImportTasksDrawer'
  51. interface KnowledgeBaseViewProps {
  52. authToken: string;
  53. onLogout: () => void;
  54. modelConfigs?: ModelConfig[];
  55. onNavigate: (view: any) => void;
  56. isAdmin?: boolean;
  57. }
  58. /** Flatten a tree of groups into a flat list (for file counts, filtering, etc.) */
  59. function flattenGroups(groups: KnowledgeGroup[]): (KnowledgeGroup & { depth?: number })[] {
  60. const result: (KnowledgeGroup & { depth?: number })[] = [];
  61. function walk(items: KnowledgeGroup[], depth = 0) {
  62. for (const g of items) {
  63. result.push({ ...g, depth });
  64. if (g.children?.length) walk(g.children, depth + 1);
  65. }
  66. }
  67. walk(groups);
  68. return result;
  69. }
  70. /** Recursively collect all descendant group IDs (including self) */
  71. function collectGroupIds(group: KnowledgeGroup): string[] {
  72. const ids = [group.id];
  73. if (group.children?.length) {
  74. for (const child of group.children) {
  75. ids.push(...collectGroupIds(child));
  76. }
  77. }
  78. return ids;
  79. }
  80. // ---- Tree node component ----
  81. interface GroupTreeNodeProps {
  82. group: KnowledgeGroup;
  83. selectedGroupId?: string;
  84. onSelect: (groupId: string) => void;
  85. isAdmin: boolean;
  86. onEdit: (group: KnowledgeGroup) => void;
  87. onDelete: (group: KnowledgeGroup) => void;
  88. depth?: number;
  89. }
  90. const GroupTreeNode: React.FC<GroupTreeNodeProps> = ({
  91. group,
  92. selectedGroupId,
  93. onSelect,
  94. isAdmin,
  95. onEdit,
  96. onDelete,
  97. depth = 0,
  98. }) => {
  99. const hasChildren = group.children && group.children.length > 0;
  100. const isSelected = selectedGroupId === group.id ||
  101. (hasChildren && group.children!.some(c => collectGroupIds(c).includes(selectedGroupId || '')));
  102. const [collapsed, setCollapsed] = useState(false);
  103. // Auto-expand if a child is selected
  104. useEffect(() => {
  105. if (selectedGroupId && hasChildren) {
  106. const allIds = collectGroupIds(group);
  107. if (allIds.includes(selectedGroupId)) setCollapsed(false);
  108. }
  109. }, [selectedGroupId]);
  110. return (
  111. <div>
  112. <div
  113. 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'}`}
  114. style={{ paddingLeft: `${depth * 12 + 12}px` }}
  115. >
  116. {/* Expand/collapse toggle */}
  117. <div className="flex items-center flex-1">
  118. {hasChildren ? (
  119. <button
  120. onClick={(e) => { e.stopPropagation(); setCollapsed(c => !c); }}
  121. className="p-0.5 mr-1 shrink-0 text-slate-400 hover:text-slate-700"
  122. >
  123. {collapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
  124. </button>
  125. ) : (
  126. <span className="w-5 shrink-0" />
  127. )}
  128. <button
  129. onClick={() => onSelect(group.id)}
  130. className="flex-1 flex items-center gap-1.5 py-1.5 text-sm font-medium text-left whitespace-nowrap"
  131. >
  132. <Folder size={14} className={selectedGroupId === group.id ? 'text-blue-500 shrink-0' : 'text-slate-400 shrink-0'} />
  133. <span>{group.name}</span>
  134. </button>
  135. </div>
  136. {isAdmin && (
  137. <div className="opacity-0 group-hover:opacity-100 flex items-center gap-0.5 pr-2 shrink-0">
  138. <button
  139. onClick={() => onEdit(group)}
  140. className="p-1 text-slate-400 hover:text-blue-600 rounded"
  141. >
  142. <Settings size={11} />
  143. </button>
  144. <button
  145. onClick={() => onDelete(group)}
  146. className="p-1 text-slate-400 hover:text-red-500 rounded"
  147. >
  148. <Trash2 size={11} />
  149. </button>
  150. </div>
  151. )}
  152. </div>
  153. {/* Children */}
  154. {hasChildren && !collapsed && (
  155. <div>
  156. {group.children!.map(child => (
  157. <GroupTreeNode
  158. key={child.id}
  159. group={child}
  160. selectedGroupId={selectedGroupId}
  161. onSelect={onSelect}
  162. isAdmin={isAdmin}
  163. onEdit={onEdit}
  164. onDelete={onDelete}
  165. depth={depth + 1}
  166. />
  167. ))}
  168. </div>
  169. )}
  170. </div>
  171. );
  172. };
  173. // ---- Pagination component ----
  174. interface PaginationProps {
  175. currentPage: number;
  176. totalPages: number;
  177. totalItems: number;
  178. pageSize: number;
  179. onPageChange: (page: number) => void;
  180. t: (key: string, ...args: any[]) => string;
  181. }
  182. const Pagination: React.FC<PaginationProps> = ({ currentPage, totalPages, totalItems, pageSize, onPageChange, t }) => {
  183. if (totalItems === 0) return null;
  184. const start = (currentPage - 1) * pageSize + 1;
  185. const end = Math.min(currentPage * pageSize, totalItems);
  186. return (
  187. <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">
  188. <button
  189. onClick={() => onPageChange(Math.max(1, currentPage - 1))}
  190. disabled={currentPage === 1}
  191. 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"
  192. >
  193. {t('previous')}
  194. </button>
  195. <div className="px-3 py-2 text-sm font-semibold text-slate-700">
  196. {t('showingRange', start, end, totalItems)}
  197. </div>
  198. <button
  199. onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
  200. disabled={currentPage === totalPages}
  201. 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"
  202. >
  203. {t('next')}
  204. </button>
  205. </div>
  206. );
  207. };
  208. export const KnowledgeBaseView: React.FC<KnowledgeBaseViewProps> = (props) => {
  209. const { authToken, modelConfigs = DEFAULT_MODELS, isAdmin = false } = props;
  210. const { showError, showWarning, showSuccess } = useToast()
  211. const { confirm } = useConfirm()
  212. const { t } = useLanguage()
  213. // Data State
  214. const [files, setFiles] = useState<KnowledgeFile[]>([])
  215. // groups is now a tree; flatGroups is the flattened version for lookups
  216. const [groups, setGroups] = useState<KnowledgeGroup[]>([])
  217. const flatGroups = useMemo(() => flattenGroups(groups), [groups])
  218. const [settings, setSettings] = useState<AppSettings>(DEFAULT_SETTINGS)
  219. const [isLoadingSettings, setIsLoadingSettings] = useState(true)
  220. const [isLoadingFiles, setIsLoadingFiles] = useState(true)
  221. // UI State
  222. const [pdfPreview, setPdfPreview] = useState<{ fileId: string; fileName: string } | null>(null)
  223. const [isIndexingModalOpen, setIsIndexingModalOpen] = useState(false)
  224. const [pendingFiles, setPendingFiles] = useState<RawFile[]>([])
  225. const [fileInputRef] = useState<React.RefObject<HTMLInputElement>>(React.createRef())
  226. const [shouldOpenModal, setShouldOpenModal] = useState(false)
  227. // Filter & Pagination State
  228. const [filterName, setFilterName] = useState('')
  229. const [filterStatus, setFilterStatus] = useState<'all' | 'ready' | 'indexing' | 'failed'>('all')
  230. const [currentPage, setCurrentPage] = useState(1)
  231. const pageSize = 12
  232. const [chunkDrawer, setChunkDrawer] = useState<{ isOpen: boolean; fileId: string; fileName: string } | null>(null)
  233. const [isAutoRefreshEnabled] = useState(true)
  234. const [autoRefreshInterval] = useState<number>(5000)
  235. // Sidebar State
  236. const [selectedSidebarFilter, setSelectedSidebarFilter] = useState<{ type: 'all' | 'uncategorized' | 'group'; groupId?: string }>({ type: 'all' })
  237. const [isGroupModalOpen, setIsGroupModalOpen] = useState(false)
  238. const [isImportDrawerOpen, setIsImportDrawerOpen] = useState(false);
  239. const [isImportTasksDrawerOpen, setIsImportTasksDrawerOpen] = useState(false);
  240. const [editingGroup, setEditingGroup] = useState<KnowledgeGroup | null>(null)
  241. const [newGroupName, setNewGroupName] = useState('')
  242. const [newGroupParentId, setNewGroupParentId] = useState<string | null>(null)
  243. const fetchAndSetSettings = useCallback(async () => {
  244. if (!authToken) return
  245. try {
  246. const settingsData = await userSettingService.get(authToken);
  247. setSettings({ ...DEFAULT_SETTINGS, ...settingsData })
  248. } catch (error) {
  249. console.error('Failed to fetch settings:', error)
  250. } finally {
  251. setIsLoadingSettings(false)
  252. }
  253. }, [authToken, isAdmin])
  254. const fetchAndSetFiles = useCallback(async () => {
  255. if (!authToken) return
  256. try {
  257. setIsLoadingFiles(true)
  258. const result = await knowledgeBaseService.getAll(authToken, {
  259. page: currentPage,
  260. limit: pageSize,
  261. name: filterName,
  262. status: filterStatus,
  263. groupId: selectedSidebarFilter.type === 'group' ? selectedSidebarFilter.groupId : undefined
  264. })
  265. setFiles(result.items)
  266. setTotalFiles(result.total)
  267. } catch (error) {
  268. console.error('Failed to fetch files:', error)
  269. } finally {
  270. setIsLoadingFiles(false)
  271. }
  272. }, [authToken, currentPage, filterName, filterStatus, selectedSidebarFilter])
  273. const fetchAndSetGroups = useCallback(async () => {
  274. if (!authToken) return
  275. try {
  276. const remoteGroups = await knowledgeGroupService.getGroups()
  277. setGroups(remoteGroups)
  278. } catch (error) {
  279. console.error('Failed to fetch groups:', error)
  280. }
  281. }, [authToken])
  282. const fetchAndSetStats = useCallback(async () => {
  283. if (!authToken) return
  284. try {
  285. const stats = await knowledgeBaseService.getStats(authToken)
  286. setGlobalStats(stats)
  287. } catch (error) {
  288. console.error('Failed to fetch stats:', error)
  289. }
  290. }, [authToken])
  291. useEffect(() => {
  292. if (authToken) {
  293. fetchAndSetSettings()
  294. fetchAndSetGroups()
  295. fetchAndSetStats()
  296. }
  297. }, [authToken, fetchAndSetSettings, fetchAndSetGroups, fetchAndSetStats])
  298. useEffect(() => {
  299. if (shouldOpenModal && pendingFiles.length > 0) {
  300. setIsIndexingModalOpen(true);
  301. setShouldOpenModal(false);
  302. }
  303. }, [shouldOpenModal, pendingFiles.length]);
  304. const handleFileUpload = async (fileList: FileList) => {
  305. if (!authToken) {
  306. showWarning(t('loginRequired'))
  307. return
  308. }
  309. const MAX_FILE_SIZE = 104857600;
  310. const rawFiles: RawFile[] = []
  311. const errors: string[] = []
  312. const filesArray = Array.from(fileList);
  313. for (const file of filesArray) {
  314. const extension = file.name.split('.').pop() || ''
  315. if (!isExtensionAllowed(extension, 'kb')) {
  316. errors.push(t('unsupportedFileType', file.name, extension))
  317. continue
  318. }
  319. if (file.size > MAX_FILE_SIZE) {
  320. errors.push(t('fileSizeLimitExceeded', file.name, formatBytes(file.size), 100))
  321. continue;
  322. }
  323. try {
  324. const rawFile = await readFile(file)
  325. rawFiles.push(rawFile)
  326. } catch (error) {
  327. errors.push(`${file.name} - ${t('readingFailed')}`);
  328. }
  329. }
  330. if (errors.length > 0) showError(`${t('uploadErrors')}:\n${errors.join('\n')}`);
  331. if (rawFiles.length === 0) return;
  332. setPendingFiles(rawFiles);
  333. setShouldOpenModal(true);
  334. }
  335. const handleConfirmIndexing = async (config: IndexingConfig) => {
  336. if (!authToken) return
  337. let hasSuccess = false
  338. for (const rawFile of pendingFiles) {
  339. try {
  340. const indexingConfig = {
  341. ...config,
  342. groupIds: selectedSidebarFilter.type === 'group' ? [selectedSidebarFilter.groupId!] : []
  343. };
  344. await uploadService.uploadFileWithConfig(rawFile.file, indexingConfig, authToken)
  345. hasSuccess = true
  346. } catch (error: any) {
  347. showError(`${t('uploadFailed')}: ${rawFile.name} - ${error.message}`)
  348. }
  349. }
  350. if (hasSuccess) await fetchAndSetFiles()
  351. setPendingFiles([])
  352. setIsIndexingModalOpen(false)
  353. }
  354. const handleRemoveFile = async (id: string) => {
  355. if (!(await confirm(t('confirmDeleteFile')))) return
  356. if (!authToken) return
  357. try {
  358. await knowledgeBaseService.deleteFile(id, authToken)
  359. setFiles(prev => prev.filter(f => f.id !== id))
  360. showSuccess(t('fileDeleted'))
  361. } catch (error: any) {
  362. showError(`${t('deleteFailed')}: ` + error.message)
  363. }
  364. }
  365. const handleToggleFileCategory = async (file: KnowledgeFile, groupId: string) => {
  366. try {
  367. const currentGroupIds = file.groups?.map(g => g.id) || [];
  368. const isAssigned = currentGroupIds.includes(groupId);
  369. let newGroupIds: string[];
  370. if (isAssigned) {
  371. newGroupIds = currentGroupIds.filter(id => id !== groupId);
  372. } else {
  373. newGroupIds = [...currentGroupIds, groupId];
  374. }
  375. await knowledgeGroupService.addFileToGroups(file.id, newGroupIds);
  376. await fetchAndSetFiles();
  377. } catch (error: any) {
  378. console.error('Failed to toggle category:', error);
  379. showError(t('actionFailed') + ': ' + error.message);
  380. }
  381. }
  382. const handleClearAll = async () => {
  383. if (!(await confirm(t('confirmClearKB')))) return
  384. if (!authToken) return
  385. try {
  386. await knowledgeBaseService.clearAll(authToken)
  387. setFiles([])
  388. fetchAndSetStats()
  389. showSuccess(t('kbCleared'))
  390. } catch (error: any) {
  391. showError(`${t('clearFailed')}: ` + error.message)
  392. }
  393. }
  394. // Filtering: when a group is selected, include files in that group AND all descendant groups
  395. const filteredFiles = useMemo(() => {
  396. return files.filter(file => {
  397. const matchName = file.name.toLowerCase().includes(filterName.toLowerCase());
  398. let matchGroup = true;
  399. if (selectedSidebarFilter.type === 'uncategorized') {
  400. matchGroup = !file.groups || file.groups.length === 0;
  401. } else if (selectedSidebarFilter.type === 'group' && selectedSidebarFilter.groupId) {
  402. // Find the selected group in the tree to collect all descendant IDs
  403. const selectedGroup = flatGroups.find(g => g.id === selectedSidebarFilter.groupId);
  404. const allIds = selectedGroup ? collectGroupIds(selectedGroup) : [selectedSidebarFilter.groupId];
  405. matchGroup = file.groups?.some(g => allIds.includes(g.id)) || false;
  406. }
  407. const matchStatus = filterStatus === 'all' ||
  408. (filterStatus === 'ready' && (file.status === 'ready' || file.status === 'vectorized')) ||
  409. (filterStatus === 'indexing' && (file.status === 'indexing' || file.status === 'pending' || file.status === 'extracted')) ||
  410. (filterStatus === 'failed' && (file.status === 'failed' || file.status === 'error'));
  411. return matchName && matchGroup && matchStatus;
  412. });
  413. }, [files, filterName, selectedSidebarFilter, filterStatus, flatGroups]);
  414. const totalPages = Math.ceil(filteredFiles.length / pageSize);
  415. const paginatedFiles = useMemo(() => {
  416. const start = (currentPage - 1) * pageSize;
  417. return filteredFiles.slice(start, start + pageSize);
  418. }, [filteredFiles, currentPage, pageSize]);
  419. useEffect(() => {
  420. setCurrentPage(1);
  421. }, [filterName, filterStatus, selectedSidebarFilter]);
  422. useEffect(() => {
  423. let intervalId: NodeJS.Timeout | null = null;
  424. const hasIndexingFiles = files.some(file => ['pending', 'indexing', 'extracted', 'vectorized'].includes(file.status));
  425. if (isAutoRefreshEnabled && hasIndexingFiles) {
  426. intervalId = setInterval(() => {
  427. fetchAndSetFiles();
  428. }, autoRefreshInterval);
  429. }
  430. return () => { if (intervalId) clearInterval(intervalId); };
  431. }, [isAutoRefreshEnabled, files, autoRefreshInterval, fetchAndSetFiles]);
  432. const getFileIcon = (file: KnowledgeFile) => {
  433. if (file.type.startsWith('image/')) return <ImageIcon size={20} className="text-slate-500" />;
  434. if (file.type === 'application/pdf') return <FileType size={20} className="text-blue-500" />;
  435. return <FileText size={20} className="text-blue-500" />;
  436. };
  437. const handleCreateOrUpdateGroup = async () => {
  438. if (!newGroupName.trim()) return
  439. try {
  440. if (editingGroup) {
  441. await knowledgeGroupService.updateGroup(editingGroup.id, { name: newGroupName, parentId: newGroupParentId })
  442. showSuccess(t('groupUpdated'))
  443. } else {
  444. await knowledgeGroupService.createGroup({ name: newGroupName, parentId: newGroupParentId })
  445. showSuccess(t('groupCreated'))
  446. }
  447. fetchAndSetGroups()
  448. setIsGroupModalOpen(false)
  449. setEditingGroup(null)
  450. setNewGroupName('')
  451. setNewGroupParentId(null)
  452. } catch (error: any) {
  453. showError(t('actionFailed') + ': ' + error.message)
  454. }
  455. }
  456. const handleDeleteGroup = async (group: KnowledgeGroup) => {
  457. if (!(await confirm(t('confirmDeleteGroup').replace('$1', group.name)))) return
  458. try {
  459. await knowledgeGroupService.deleteGroup(group.id)
  460. showSuccess(t('groupDeleted'))
  461. if (selectedSidebarFilter.groupId === group.id) {
  462. setSelectedSidebarFilter({ type: 'all' })
  463. }
  464. fetchAndSetGroups()
  465. } catch (error: any) {
  466. showError(t('deleteFailed') + ': ' + error.message)
  467. }
  468. }
  469. const openCreateGroup = (parentId?: string | null) => {
  470. setEditingGroup(null);
  471. setNewGroupName('');
  472. setNewGroupParentId(parentId ?? null);
  473. setIsGroupModalOpen(true);
  474. }
  475. const openEditGroup = (group: KnowledgeGroup) => {
  476. setEditingGroup(group);
  477. setNewGroupName(group.name);
  478. setNewGroupParentId(group.parentId ?? null);
  479. setIsGroupModalOpen(true);
  480. }
  481. const selectedGroupObj = selectedSidebarFilter.type === 'group'
  482. ? flatGroups.find(g => g.id === selectedSidebarFilter.groupId)
  483. : null;
  484. if (isLoadingSettings) {
  485. return (
  486. <div className='flex items-center justify-center min-h-[400px] w-full'>
  487. <div className='text-blue-600 animate-spin rounded-full h-8 w-8 border-2 border-t-transparent border-blue-600'></div>
  488. </div>
  489. )
  490. }
  491. return (
  492. <div className='flex flex-row h-full w-full bg-slate-50 overflow-hidden'>
  493. {/* Sidebar */}
  494. <div className="w-64 bg-white border-r border-slate-200 flex flex-col shrink-0">
  495. <div className="p-6 flex flex-col min-h-0">
  496. <h2 className="text-sm font-semibold text-slate-400 uppercase tracking-wider mb-4">{t('navCatalog')}</h2>
  497. <nav className="space-y-1">
  498. <button
  499. onClick={() => setSelectedSidebarFilter({ type: 'all' })}
  500. 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'}`}
  501. >
  502. <div className="flex items-center gap-2">
  503. <Layers size={16} />
  504. <span>{t('allDocuments')}</span>
  505. </div>
  506. <span className="text-xs bg-slate-100 text-slate-500 px-1.5 py-0.5 rounded-full">{files.length}</span>
  507. </button>
  508. <button
  509. onClick={() => setSelectedSidebarFilter({ type: 'uncategorized' })}
  510. 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'}`}
  511. >
  512. <div className="flex items-center gap-2">
  513. <FileText size={16} />
  514. <span>{t('uncategorized')}</span>
  515. </div>
  516. <span className="text-xs bg-slate-100 text-slate-500 px-1.5 py-0.5 rounded-full">
  517. {files.filter(f => !f.groups || f.groups.length === 0).length}
  518. </span>
  519. </button>
  520. </nav>
  521. <div className="mt-6 flex items-center justify-between mb-3">
  522. <h2 className="text-sm font-semibold text-slate-400 uppercase tracking-wider">{t('categories')}</h2>
  523. {isAdmin && (
  524. <button
  525. onClick={() => openCreateGroup(null)}
  526. className="p-1 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded transition-all"
  527. title={t('createCategory') as string}
  528. >
  529. <Plus size={14} />
  530. </button>
  531. )}
  532. </div>
  533. <div className="space-y-0.5 overflow-y-auto overflow-x-auto flex-1 pr-1 pb-4">
  534. {groups.map(group => (
  535. <GroupTreeNode
  536. key={group.id}
  537. group={group}
  538. selectedGroupId={selectedSidebarFilter.type === 'group' ? selectedSidebarFilter.groupId : undefined}
  539. onSelect={(gId) => setSelectedSidebarFilter({ type: 'group', groupId: gId })}
  540. isAdmin={isAdmin}
  541. onEdit={openEditGroup}
  542. onDelete={handleDeleteGroup}
  543. depth={0}
  544. />
  545. ))}
  546. </div>
  547. </div>
  548. </div>
  549. {/* Main Content Area */}
  550. <div className='flex flex-col flex-1 h-full w-full bg-transparent overflow-hidden'>
  551. <input
  552. type="file"
  553. ref={fileInputRef}
  554. onChange={(e) => {
  555. if (e.target.files && e.target.files.length > 0) handleFileUpload(e.target.files)
  556. }}
  557. multiple
  558. className="hidden"
  559. accept={KB_ALLOWED_EXTENSIONS.map(ext => `.${ext}`).join(',') + ',' + IMAGE_MIME_TYPES.join(',')}
  560. />
  561. <GlobalDragDropOverlay onFilesSelected={handleFileUpload} isAdmin={isAdmin} />
  562. {/* Header Section */}
  563. <div className="px-8 pt-8 pb-6 flex items-start justify-between shrink-0">
  564. <div>
  565. <h1 className="text-2xl font-bold text-slate-900 leading-tight">
  566. {selectedSidebarFilter.type === 'all' ? t('kbManagement') :
  567. selectedSidebarFilter.type === 'uncategorized' ? t('uncategorizedFiles') :
  568. selectedGroupObj?.name || t('category')}
  569. </h1>
  570. <p className="text-[15px] text-slate-500 mt-1">
  571. {selectedSidebarFilter.type === 'group'
  572. ? selectedGroupObj?.description || t('kbManagementDesc')
  573. : t('kbManagementDesc')}
  574. </p>
  575. </div>
  576. <div className="flex items-center gap-3">
  577. {isAdmin && (
  578. <>
  579. {selectedSidebarFilter.type === 'group' && (
  580. <button
  581. onClick={() => openCreateGroup(selectedSidebarFilter.groupId)}
  582. 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"
  583. >
  584. <Plus size={16} />
  585. {t('addSubcategory')}
  586. </button>
  587. )}
  588. <button
  589. onClick={() => setIsImportTasksDrawerOpen(true)}
  590. 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"
  591. >
  592. <Box size={18} className="text-indigo-600" />
  593. {t('importTasksTitle')}
  594. </button>
  595. <button
  596. onClick={() => setIsImportDrawerOpen(true)}
  597. 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"
  598. >
  599. <FolderInput size={18} className="text-blue-600" />
  600. {t('importFolder')}
  601. </button>
  602. <button
  603. onClick={() => fileInputRef.current?.click()}
  604. 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"
  605. >
  606. <Plus size={18} />
  607. {t('addFile')}
  608. </button>
  609. </>
  610. )}
  611. </div>
  612. </div>
  613. {/* Filter Bar */}
  614. <div className="px-8 pb-6 flex items-center justify-between gap-4 shrink-0">
  615. <div className="flex items-center gap-3 flex-1">
  616. <div className="relative max-w-xs w-full">
  617. <Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={16} />
  618. <input
  619. type="text"
  620. placeholder={t('searchPlaceholder')}
  621. value={filterName}
  622. onChange={(e) => setFilterName(e.target.value)}
  623. 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"
  624. />
  625. </div>
  626. </div>
  627. <div className="flex items-center gap-3">
  628. <button
  629. onClick={() => fetchAndSetFiles()}
  630. className="p-2 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-all"
  631. title="Refresh"
  632. >
  633. <RefreshCw size={18} />
  634. </button>
  635. </div>
  636. </div>
  637. {/* File List */}
  638. <div className="flex-1 overflow-y-auto px-8 pb-4">
  639. {isLoadingFiles ? (
  640. <div className="flex flex-col items-center justify-center py-20 gap-4">
  641. <div className="w-10 h-10 border-2 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
  642. </div>
  643. ) : paginatedFiles.length > 0 ? (
  644. <div className="flex flex-col gap-3">
  645. <AnimatePresence mode="popLayout">
  646. {paginatedFiles.map((file) => (
  647. <motion.div
  648. key={file.id}
  649. layout
  650. initial={{ opacity: 0, y: 10 }}
  651. animate={{ opacity: 1, y: 0 }}
  652. exit={{ opacity: 0, scale: 0.95 }}
  653. 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"
  654. >
  655. {/* Icon */}
  656. <div className="p-2.5 bg-blue-50 rounded-lg text-blue-600 shadow-sm border border-blue-100/30 shrink-0">
  657. {getFileIcon(file)}
  658. </div>
  659. {/* Name & Desc */}
  660. <div className="flex-1 min-w-0">
  661. <div className="flex items-center gap-3 mb-1">
  662. <h3
  663. onClick={() => setChunkDrawer({ isOpen: true, fileId: file.id, fileName: file.name })}
  664. className="font-bold text-slate-900 text-[15px] cursor-pointer hover:text-blue-600 transition-colors truncate"
  665. >
  666. {file.name}
  667. </h3>
  668. <span className="px-2 py-0.5 bg-slate-100 text-slate-500 rounded text-[10px] font-bold tracking-wider uppercase shrink-0">
  669. {file.name.split('.').pop()?.toUpperCase() || 'FILE'}
  670. </span>
  671. </div>
  672. <div className="flex items-center gap-2">
  673. <p className="text-[13px] text-slate-500 truncate">
  674. {file.status === 'ready' || file.status === 'vectorized'
  675. ? t('statusReadyDesc')
  676. : t('statusIndexingDesc', file.status)
  677. }
  678. </p>
  679. {file.groups && file.groups.length > 0 && (
  680. <div className="flex gap-1 ml-2">
  681. {file.groups.map(g => (
  682. <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">
  683. {g.name}
  684. </span>
  685. ))}
  686. </div>
  687. )}
  688. </div>
  689. </div>
  690. {/* Meta & Actions */}
  691. <div className="flex items-center gap-6 shrink-0">
  692. <div className="text-[12px] font-medium text-slate-400 flex flex-col items-end">
  693. <span>{new Date(file.createdAt || Date.now()).toLocaleDateString()}</span>
  694. <span>{formatBytes(file.size)}</span>
  695. </div>
  696. <div className="flex items-center gap-2">
  697. {file.status !== 'ready' && file.status !== 'vectorized' ? (
  698. <CircleDashed size={16} className="text-blue-400 animate-spin mr-2" />
  699. ) : null}
  700. </div>
  701. <div className="flex items-center gap-1 opacity-100 md:opacity-0 group-hover:opacity-100 transition-opacity">
  702. {isFormatSupportedForPreview(file.name) && (
  703. <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'}>
  704. <Eye size={16} />
  705. </button>
  706. )}
  707. <div className="relative group/tag">
  708. <button className="p-1.5 text-slate-400 hover:text-blue-600 rounded-md bg-slate-50" title={t('groups') as string || 'Groups'}>
  709. <Tag size={16} />
  710. </button>
  711. <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">
  712. <div className="p-2 border-b border-slate-100 bg-slate-50 text-[10px] font-bold text-slate-400 uppercase tracking-wider">
  713. {t('selectCategory')}
  714. </div>
  715. <div className="max-h-48 overflow-y-auto">
  716. <button
  717. onClick={() => knowledgeGroupService.addFileToGroups(file.id, []).then(fetchAndSetFiles)}
  718. className="w-full text-left px-3 py-2 text-xs text-slate-600 hover:bg-slate-50 flex items-center gap-2"
  719. >
  720. <Layers size={12} />
  721. {t('noneUncategorized')}
  722. </button>
  723. {flatGroups.map(g => (
  724. <button
  725. key={g.id}
  726. onClick={() => handleToggleFileCategory(file, g.id)}
  727. style={{ paddingLeft: `${(g.depth || 0) * 12 + 12}px` }}
  728. 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"
  729. >
  730. <div className="flex items-center gap-1.5 truncate">
  731. <Hash size={12} className={file.groups?.some(fg => fg.id === g.id) ? 'text-blue-500 shrink-0' : 'text-slate-400 shrink-0'} />
  732. <span className={file.groups?.some(fg => fg.id === g.id) ? 'text-blue-600 font-medium truncate' : 'truncate'}>{g.name}</span>
  733. </div>
  734. {file.groups?.some(fg => fg.id === g.id) && <CheckCircle2 size={12} className="text-blue-600 shrink-0 ml-2" />}
  735. </button>
  736. ))}
  737. </div>
  738. </div>
  739. </div>
  740. {isAdmin && (
  741. <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'}>
  742. <Trash2 size={16} />
  743. </button>
  744. )}
  745. </div>
  746. </div>
  747. </motion.div>
  748. ))}
  749. </AnimatePresence>
  750. </div>
  751. ) : (
  752. <div className="max-w-4xl mx-auto w-full pt-12">
  753. <DragDropUpload onFilesSelected={handleFileUpload} isAdmin={isAdmin} />
  754. </div>
  755. )}
  756. </div>
  757. {/* Pagination */}
  758. <Pagination
  759. currentPage={currentPage}
  760. totalPages={totalPages}
  761. totalItems={filteredFiles.length}
  762. pageSize={pageSize}
  763. onPageChange={setCurrentPage}
  764. t={t}
  765. />
  766. </div>
  767. <IndexingModalWithMode
  768. isOpen={isIndexingModalOpen}
  769. onClose={() => { setPendingFiles([]); setIsIndexingModalOpen(false); }}
  770. files={pendingFiles}
  771. embeddingModels={modelConfigs.filter(m => m.type === ModelType.EMBEDDING)}
  772. defaultEmbeddingId={settings.selectedEmbeddingId}
  773. onConfirm={handleConfirmIndexing}
  774. authToken={authToken}
  775. />
  776. {/* Group Create/Edit Modal */}
  777. <AnimatePresence>
  778. {isGroupModalOpen && (
  779. <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/40 backdrop-blur-sm">
  780. <motion.div
  781. initial={{ opacity: 0, scale: 0.95 }}
  782. animate={{ opacity: 1, scale: 1 }}
  783. exit={{ opacity: 0, scale: 0.95 }}
  784. className="bg-white rounded-2xl shadow-xl w-full max-w-md overflow-hidden"
  785. >
  786. <div className="p-6">
  787. <h2 className="text-xl font-bold text-slate-900 mb-2">
  788. {editingGroup ? t('editCategory') : t('createCategory')}
  789. </h2>
  790. <p className="text-slate-500 text-sm mb-6">
  791. {t('categoryDesc')}
  792. </p>
  793. <div className="space-y-4">
  794. <div>
  795. <label className="block text-sm font-medium text-slate-700 mb-1">{t('categoryName')}</label>
  796. <input
  797. type="text"
  798. value={newGroupName}
  799. onChange={(e) => setNewGroupName(e.target.value)}
  800. placeholder={t('exampleResearch')}
  801. 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"
  802. autoFocus
  803. onKeyDown={(e) => e.key === 'Enter' && handleCreateOrUpdateGroup()}
  804. />
  805. </div>
  806. {/* Parent category selector */}
  807. <div>
  808. <label className="block text-sm font-medium text-slate-700 mb-1">{t('parentCategory')}</label>
  809. <select
  810. value={newGroupParentId ?? ''}
  811. onChange={(e) => setNewGroupParentId(e.target.value || null)}
  812. 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"
  813. >
  814. <option value="">{t('noParentTopLevel')}</option>
  815. {flatGroups
  816. .filter(g => g.id !== editingGroup?.id) // don't allow self as parent
  817. .map(g => (
  818. <option key={g.id} value={g.id}>
  819. {'\u00A0'.repeat((g.depth || 0) * 4)}{g.name}
  820. </option>
  821. ))}
  822. </select>
  823. </div>
  824. </div>
  825. </div>
  826. <div className="px-6 py-4 bg-slate-50 border-t border-slate-100 flex justify-end gap-3">
  827. <button
  828. onClick={() => { setIsGroupModalOpen(false); setEditingGroup(null); setNewGroupParentId(null); }}
  829. className="px-4 py-2 text-slate-600 font-medium hover:bg-slate-200/50 rounded-lg transition-all"
  830. >
  831. {t('cancel')}
  832. </button>
  833. <button
  834. onClick={handleCreateOrUpdateGroup}
  835. 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"
  836. >
  837. {editingGroup ? t('saveChanges') : t('createCategoryBtn')}
  838. </button>
  839. </div>
  840. </motion.div>
  841. </div>
  842. )}
  843. </AnimatePresence>
  844. {pdfPreview && (
  845. <PDFPreview
  846. fileId={pdfPreview.fileId}
  847. fileName={pdfPreview.fileName}
  848. authToken={authToken}
  849. onClose={() => setPdfPreview(null)}
  850. />
  851. )}
  852. {chunkDrawer && (
  853. <ChunkInfoDrawer
  854. isOpen={chunkDrawer.isOpen}
  855. onClose={() => setChunkDrawer(null)}
  856. fileId={chunkDrawer.fileId}
  857. fileName={chunkDrawer.fileName}
  858. authToken={authToken}
  859. />
  860. )}
  861. <ImportFolderDrawer
  862. isOpen={isImportDrawerOpen}
  863. onClose={() => setIsImportDrawerOpen(false)}
  864. authToken={authToken}
  865. onImportSuccess={() => fetchAndSetFiles()}
  866. />
  867. <ImportTasksDrawer
  868. isOpen={isImportTasksDrawerOpen}
  869. onClose={() => setIsImportTasksDrawerOpen(false)}
  870. authToken={authToken}
  871. />
  872. </div>
  873. );
  874. };