KnowledgeBaseView.tsx 46 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929
  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 = isAdmin
  247. ? await userSettingService.getGlobal(authToken)
  248. : await userSettingService.get(authToken);
  249. setSettings({ ...DEFAULT_SETTINGS, ...settingsData })
  250. } catch (error) {
  251. console.error('Failed to fetch settings:', error)
  252. } finally {
  253. setIsLoadingSettings(false)
  254. }
  255. }, [authToken, isAdmin])
  256. const fetchAndSetFiles = useCallback(async () => {
  257. if (!authToken) return
  258. try {
  259. const remoteFiles = await knowledgeBaseService.getAll(authToken)
  260. setFiles(remoteFiles)
  261. } catch (error) {
  262. console.error('Failed to fetch files:', error)
  263. } finally {
  264. setIsLoadingFiles(false)
  265. }
  266. }, [authToken])
  267. const fetchAndSetGroups = useCallback(async () => {
  268. if (!authToken) return
  269. try {
  270. const remoteGroups = await knowledgeGroupService.getGroups()
  271. setGroups(remoteGroups)
  272. } catch (error) {
  273. console.error('Failed to fetch groups:', error)
  274. }
  275. }, [authToken])
  276. useEffect(() => {
  277. if (authToken) {
  278. fetchAndSetSettings()
  279. fetchAndSetFiles()
  280. fetchAndSetGroups()
  281. }
  282. }, [authToken, fetchAndSetSettings, fetchAndSetFiles, fetchAndSetGroups])
  283. useEffect(() => {
  284. if (shouldOpenModal && pendingFiles.length > 0) {
  285. setIsIndexingModalOpen(true);
  286. setShouldOpenModal(false);
  287. }
  288. }, [shouldOpenModal, pendingFiles.length]);
  289. const handleFileUpload = async (fileList: FileList) => {
  290. if (!authToken) {
  291. showWarning(t('loginRequired'))
  292. return
  293. }
  294. const MAX_FILE_SIZE = 104857600;
  295. const rawFiles: RawFile[] = []
  296. const errors: string[] = []
  297. const filesArray = Array.from(fileList);
  298. for (const file of filesArray) {
  299. const extension = file.name.split('.').pop() || ''
  300. if (!isExtensionAllowed(extension, 'kb')) {
  301. errors.push(t('unsupportedFileType', file.name, extension))
  302. continue
  303. }
  304. if (file.size > MAX_FILE_SIZE) {
  305. errors.push(t('fileSizeLimitExceeded', file.name, formatBytes(file.size), 100))
  306. continue;
  307. }
  308. try {
  309. const rawFile = await readFile(file)
  310. rawFiles.push(rawFile)
  311. } catch (error) {
  312. errors.push(`${file.name} - ${t('readingFailed')}`);
  313. }
  314. }
  315. if (errors.length > 0) showError(`${t('uploadErrors')}:\n${errors.join('\n')}`);
  316. if (rawFiles.length === 0) return;
  317. setPendingFiles(rawFiles);
  318. setShouldOpenModal(true);
  319. }
  320. const handleConfirmIndexing = async (config: IndexingConfig) => {
  321. if (!authToken) return
  322. let hasSuccess = false
  323. for (const rawFile of pendingFiles) {
  324. try {
  325. const indexingConfig = {
  326. ...config,
  327. groupIds: selectedSidebarFilter.type === 'group' ? [selectedSidebarFilter.groupId!] : []
  328. };
  329. await uploadService.uploadFileWithConfig(rawFile.file, indexingConfig, authToken)
  330. hasSuccess = true
  331. } catch (error: any) {
  332. showError(`${t('uploadFailed')}: ${rawFile.name} - ${error.message}`)
  333. }
  334. }
  335. if (hasSuccess) await fetchAndSetFiles()
  336. setPendingFiles([])
  337. setIsIndexingModalOpen(false)
  338. }
  339. const handleRemoveFile = async (id: string) => {
  340. if (!(await confirm(t('confirmDeleteFile')))) return
  341. if (!authToken) return
  342. try {
  343. await knowledgeBaseService.deleteFile(id, authToken)
  344. setFiles(prev => prev.filter(f => f.id !== id))
  345. showSuccess(t('fileDeleted'))
  346. } catch (error: any) {
  347. showError(`${t('deleteFailed')}: ` + error.message)
  348. }
  349. }
  350. const handleToggleFileCategory = async (file: KnowledgeFile, groupId: string) => {
  351. try {
  352. const currentGroupIds = file.groups?.map(g => g.id) || [];
  353. const isAssigned = currentGroupIds.includes(groupId);
  354. let newGroupIds: string[];
  355. if (isAssigned) {
  356. newGroupIds = currentGroupIds.filter(id => id !== groupId);
  357. } else {
  358. newGroupIds = [...currentGroupIds, groupId];
  359. }
  360. await knowledgeGroupService.addFileToGroups(file.id, newGroupIds);
  361. await fetchAndSetFiles();
  362. } catch (error: any) {
  363. console.error('Failed to toggle category:', error);
  364. showError(t('actionFailed') + ': ' + error.message);
  365. }
  366. }
  367. const handleClearAll = async () => {
  368. if (!(await confirm(t('confirmClearKB')))) return
  369. if (!authToken) return
  370. try {
  371. await knowledgeBaseService.clearAll(authToken)
  372. setFiles([])
  373. showSuccess(t('kbCleared'))
  374. } catch (error: any) {
  375. showError(`${t('clearFailed')}: ` + error.message)
  376. }
  377. }
  378. // Filtering: when a group is selected, include files in that group AND all descendant groups
  379. const filteredFiles = useMemo(() => {
  380. return files.filter(file => {
  381. const matchName = file.name.toLowerCase().includes(filterName.toLowerCase());
  382. let matchGroup = true;
  383. if (selectedSidebarFilter.type === 'uncategorized') {
  384. matchGroup = !file.groups || file.groups.length === 0;
  385. } else if (selectedSidebarFilter.type === 'group' && selectedSidebarFilter.groupId) {
  386. // Find the selected group in the tree to collect all descendant IDs
  387. const selectedGroup = flatGroups.find(g => g.id === selectedSidebarFilter.groupId);
  388. const allIds = selectedGroup ? collectGroupIds(selectedGroup) : [selectedSidebarFilter.groupId];
  389. matchGroup = file.groups?.some(g => allIds.includes(g.id)) || false;
  390. }
  391. const matchStatus = filterStatus === 'all' ||
  392. (filterStatus === 'ready' && (file.status === 'ready' || file.status === 'vectorized')) ||
  393. (filterStatus === 'indexing' && (file.status === 'indexing' || file.status === 'pending' || file.status === 'extracted')) ||
  394. (filterStatus === 'failed' && (file.status === 'failed' || file.status === 'error'));
  395. return matchName && matchGroup && matchStatus;
  396. });
  397. }, [files, filterName, selectedSidebarFilter, filterStatus, flatGroups]);
  398. const totalPages = Math.ceil(filteredFiles.length / pageSize);
  399. const paginatedFiles = useMemo(() => {
  400. const start = (currentPage - 1) * pageSize;
  401. return filteredFiles.slice(start, start + pageSize);
  402. }, [filteredFiles, currentPage, pageSize]);
  403. useEffect(() => {
  404. setCurrentPage(1);
  405. }, [filterName, filterStatus, selectedSidebarFilter]);
  406. useEffect(() => {
  407. let intervalId: NodeJS.Timeout | null = null;
  408. const hasIndexingFiles = files.some(file => ['pending', 'indexing', 'extracted', 'vectorized'].includes(file.status));
  409. if (isAutoRefreshEnabled && hasIndexingFiles) {
  410. intervalId = setInterval(() => {
  411. fetchAndSetFiles();
  412. }, autoRefreshInterval);
  413. }
  414. return () => { if (intervalId) clearInterval(intervalId); };
  415. }, [isAutoRefreshEnabled, files, autoRefreshInterval, fetchAndSetFiles]);
  416. const getFileIcon = (file: KnowledgeFile) => {
  417. if (file.type.startsWith('image/')) return <ImageIcon size={20} className="text-slate-500" />;
  418. if (file.type === 'application/pdf') return <FileType size={20} className="text-blue-500" />;
  419. return <FileText size={20} className="text-blue-500" />;
  420. };
  421. const handleCreateOrUpdateGroup = async () => {
  422. if (!newGroupName.trim()) return
  423. try {
  424. if (editingGroup) {
  425. await knowledgeGroupService.updateGroup(editingGroup.id, { name: newGroupName, parentId: newGroupParentId })
  426. showSuccess(t('groupUpdated'))
  427. } else {
  428. await knowledgeGroupService.createGroup({ name: newGroupName, parentId: newGroupParentId })
  429. showSuccess(t('groupCreated'))
  430. }
  431. fetchAndSetGroups()
  432. setIsGroupModalOpen(false)
  433. setEditingGroup(null)
  434. setNewGroupName('')
  435. setNewGroupParentId(null)
  436. } catch (error: any) {
  437. showError(t('actionFailed') + ': ' + error.message)
  438. }
  439. }
  440. const handleDeleteGroup = async (group: KnowledgeGroup) => {
  441. if (!(await confirm(t('confirmDeleteGroup').replace('$1', group.name)))) return
  442. try {
  443. await knowledgeGroupService.deleteGroup(group.id)
  444. showSuccess(t('groupDeleted'))
  445. if (selectedSidebarFilter.groupId === group.id) {
  446. setSelectedSidebarFilter({ type: 'all' })
  447. }
  448. fetchAndSetGroups()
  449. } catch (error: any) {
  450. showError(t('deleteFailed') + ': ' + error.message)
  451. }
  452. }
  453. const openCreateGroup = (parentId?: string | null) => {
  454. setEditingGroup(null);
  455. setNewGroupName('');
  456. setNewGroupParentId(parentId ?? null);
  457. setIsGroupModalOpen(true);
  458. }
  459. const openEditGroup = (group: KnowledgeGroup) => {
  460. setEditingGroup(group);
  461. setNewGroupName(group.name);
  462. setNewGroupParentId(group.parentId ?? null);
  463. setIsGroupModalOpen(true);
  464. }
  465. const selectedGroupObj = selectedSidebarFilter.type === 'group'
  466. ? flatGroups.find(g => g.id === selectedSidebarFilter.groupId)
  467. : null;
  468. if (isLoadingSettings) {
  469. return (
  470. <div className='flex items-center justify-center min-h-[400px] w-full'>
  471. <div className='text-blue-600 animate-spin rounded-full h-8 w-8 border-2 border-t-transparent border-blue-600'></div>
  472. </div>
  473. )
  474. }
  475. return (
  476. <div className='flex flex-row h-full w-full bg-slate-50 overflow-hidden'>
  477. {/* Sidebar */}
  478. <div className="w-64 bg-white border-r border-slate-200 flex flex-col shrink-0">
  479. <div className="p-6 flex flex-col min-h-0">
  480. <h2 className="text-sm font-semibold text-slate-400 uppercase tracking-wider mb-4">{t('navCatalog')}</h2>
  481. <nav className="space-y-1">
  482. <button
  483. onClick={() => setSelectedSidebarFilter({ type: 'all' })}
  484. 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'}`}
  485. >
  486. <div className="flex items-center gap-2">
  487. <Layers size={16} />
  488. <span>{t('allDocuments')}</span>
  489. </div>
  490. <span className="text-xs bg-slate-100 text-slate-500 px-1.5 py-0.5 rounded-full">{files.length}</span>
  491. </button>
  492. <button
  493. onClick={() => setSelectedSidebarFilter({ type: 'uncategorized' })}
  494. 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'}`}
  495. >
  496. <div className="flex items-center gap-2">
  497. <FileText size={16} />
  498. <span>{t('uncategorized')}</span>
  499. </div>
  500. <span className="text-xs bg-slate-100 text-slate-500 px-1.5 py-0.5 rounded-full">
  501. {files.filter(f => !f.groups || f.groups.length === 0).length}
  502. </span>
  503. </button>
  504. </nav>
  505. <div className="mt-6 flex items-center justify-between mb-3">
  506. <h2 className="text-sm font-semibold text-slate-400 uppercase tracking-wider">{t('categories')}</h2>
  507. {isAdmin && (
  508. <button
  509. onClick={() => openCreateGroup(null)}
  510. className="p-1 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded transition-all"
  511. title={t('createCategory') as string}
  512. >
  513. <Plus size={14} />
  514. </button>
  515. )}
  516. </div>
  517. <div className="space-y-0.5 overflow-y-auto overflow-x-auto flex-1 pr-1 pb-4">
  518. {groups.map(group => (
  519. <GroupTreeNode
  520. key={group.id}
  521. group={group}
  522. selectedGroupId={selectedSidebarFilter.type === 'group' ? selectedSidebarFilter.groupId : undefined}
  523. onSelect={(gId) => setSelectedSidebarFilter({ type: 'group', groupId: gId })}
  524. isAdmin={isAdmin}
  525. onEdit={openEditGroup}
  526. onDelete={handleDeleteGroup}
  527. depth={0}
  528. />
  529. ))}
  530. </div>
  531. </div>
  532. </div>
  533. {/* Main Content Area */}
  534. <div className='flex flex-col flex-1 h-full w-full bg-transparent overflow-hidden'>
  535. <input
  536. type="file"
  537. ref={fileInputRef}
  538. onChange={(e) => {
  539. if (e.target.files && e.target.files.length > 0) handleFileUpload(e.target.files)
  540. }}
  541. multiple
  542. className="hidden"
  543. accept={KB_ALLOWED_EXTENSIONS.map(ext => `.${ext}`).join(',') + ',' + IMAGE_MIME_TYPES.join(',')}
  544. />
  545. <GlobalDragDropOverlay onFilesSelected={handleFileUpload} isAdmin={isAdmin} />
  546. {/* Header Section */}
  547. <div className="px-8 pt-8 pb-6 flex items-start justify-between shrink-0">
  548. <div>
  549. <h1 className="text-2xl font-bold text-slate-900 leading-tight">
  550. {selectedSidebarFilter.type === 'all' ? t('kbManagement') :
  551. selectedSidebarFilter.type === 'uncategorized' ? t('uncategorizedFiles') :
  552. selectedGroupObj?.name || t('category')}
  553. </h1>
  554. <p className="text-[15px] text-slate-500 mt-1">
  555. {selectedSidebarFilter.type === 'group'
  556. ? selectedGroupObj?.description || t('kbManagementDesc')
  557. : t('kbManagementDesc')}
  558. </p>
  559. </div>
  560. <div className="flex items-center gap-3">
  561. {isAdmin && (
  562. <>
  563. {selectedSidebarFilter.type === 'group' && (
  564. <button
  565. onClick={() => openCreateGroup(selectedSidebarFilter.groupId)}
  566. 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"
  567. >
  568. <Plus size={16} />
  569. {t('addSubcategory')}
  570. </button>
  571. )}
  572. <button
  573. onClick={() => setIsImportTasksDrawerOpen(true)}
  574. 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"
  575. >
  576. <Box size={18} className="text-indigo-600" />
  577. {t('importTasksTitle')}
  578. </button>
  579. <button
  580. onClick={() => setIsImportDrawerOpen(true)}
  581. 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"
  582. >
  583. <FolderInput size={18} className="text-blue-600" />
  584. {t('importFolder')}
  585. </button>
  586. <button
  587. onClick={() => fileInputRef.current?.click()}
  588. 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"
  589. >
  590. <Plus size={18} />
  591. {t('addFile')}
  592. </button>
  593. </>
  594. )}
  595. </div>
  596. </div>
  597. {/* Filter Bar */}
  598. <div className="px-8 pb-6 flex items-center justify-between gap-4 shrink-0">
  599. <div className="flex items-center gap-3 flex-1">
  600. <div className="relative max-w-xs w-full">
  601. <Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={16} />
  602. <input
  603. type="text"
  604. placeholder={t('searchPlaceholder')}
  605. value={filterName}
  606. onChange={(e) => setFilterName(e.target.value)}
  607. 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"
  608. />
  609. </div>
  610. </div>
  611. <div className="flex items-center gap-3">
  612. <button
  613. onClick={() => fetchAndSetFiles()}
  614. className="p-2 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-all"
  615. title="Refresh"
  616. >
  617. <RefreshCw size={18} />
  618. </button>
  619. </div>
  620. </div>
  621. {/* File List */}
  622. <div className="flex-1 overflow-y-auto px-8 pb-4">
  623. {isLoadingFiles ? (
  624. <div className="flex flex-col items-center justify-center py-20 gap-4">
  625. <div className="w-10 h-10 border-2 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
  626. </div>
  627. ) : paginatedFiles.length > 0 ? (
  628. <div className="flex flex-col gap-3">
  629. <AnimatePresence mode="popLayout">
  630. {paginatedFiles.map((file) => (
  631. <motion.div
  632. key={file.id}
  633. layout
  634. initial={{ opacity: 0, y: 10 }}
  635. animate={{ opacity: 1, y: 0 }}
  636. exit={{ opacity: 0, scale: 0.95 }}
  637. 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"
  638. >
  639. {/* Icon */}
  640. <div className="p-2.5 bg-blue-50 rounded-lg text-blue-600 shadow-sm border border-blue-100/30 shrink-0">
  641. {getFileIcon(file)}
  642. </div>
  643. {/* Name & Desc */}
  644. <div className="flex-1 min-w-0">
  645. <div className="flex items-center gap-3 mb-1">
  646. <h3
  647. onClick={() => setChunkDrawer({ isOpen: true, fileId: file.id, fileName: file.name })}
  648. className="font-bold text-slate-900 text-[15px] cursor-pointer hover:text-blue-600 transition-colors truncate"
  649. >
  650. {file.name}
  651. </h3>
  652. <span className="px-2 py-0.5 bg-slate-100 text-slate-500 rounded text-[10px] font-bold tracking-wider uppercase shrink-0">
  653. {file.name.split('.').pop()?.toUpperCase() || 'FILE'}
  654. </span>
  655. </div>
  656. <div className="flex items-center gap-2">
  657. <p className="text-[13px] text-slate-500 truncate">
  658. {file.status === 'ready' || file.status === 'vectorized'
  659. ? t('statusReadyDesc')
  660. : t('statusIndexingDesc', file.status)
  661. }
  662. </p>
  663. {file.groups && file.groups.length > 0 && (
  664. <div className="flex gap-1 ml-2">
  665. {file.groups.map(g => (
  666. <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">
  667. {g.name}
  668. </span>
  669. ))}
  670. </div>
  671. )}
  672. </div>
  673. </div>
  674. {/* Meta & Actions */}
  675. <div className="flex items-center gap-6 shrink-0">
  676. <div className="text-[12px] font-medium text-slate-400 flex flex-col items-end">
  677. <span>{new Date(file.createdAt || Date.now()).toLocaleDateString()}</span>
  678. <span>{formatBytes(file.size)}</span>
  679. </div>
  680. <div className="flex items-center gap-2">
  681. {file.status !== 'ready' && file.status !== 'vectorized' ? (
  682. <CircleDashed size={16} className="text-blue-400 animate-spin mr-2" />
  683. ) : null}
  684. </div>
  685. <div className="flex items-center gap-1 opacity-100 md:opacity-0 group-hover:opacity-100 transition-opacity">
  686. {isFormatSupportedForPreview(file.name) && (
  687. <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'}>
  688. <Eye size={16} />
  689. </button>
  690. )}
  691. <div className="relative group/tag">
  692. <button className="p-1.5 text-slate-400 hover:text-blue-600 rounded-md bg-slate-50" title={t('groups') as string || 'Groups'}>
  693. <Tag size={16} />
  694. </button>
  695. <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">
  696. <div className="p-2 border-b border-slate-100 bg-slate-50 text-[10px] font-bold text-slate-400 uppercase tracking-wider">
  697. {t('selectCategory')}
  698. </div>
  699. <div className="max-h-48 overflow-y-auto">
  700. <button
  701. onClick={() => knowledgeGroupService.addFileToGroups(file.id, []).then(fetchAndSetFiles)}
  702. className="w-full text-left px-3 py-2 text-xs text-slate-600 hover:bg-slate-50 flex items-center gap-2"
  703. >
  704. <Layers size={12} />
  705. {t('noneUncategorized')}
  706. </button>
  707. {flatGroups.map(g => (
  708. <button
  709. key={g.id}
  710. onClick={() => handleToggleFileCategory(file, g.id)}
  711. style={{ paddingLeft: `${(g.depth || 0) * 12 + 12}px` }}
  712. 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"
  713. >
  714. <div className="flex items-center gap-1.5 truncate">
  715. <Hash size={12} className={file.groups?.some(fg => fg.id === g.id) ? 'text-blue-500 shrink-0' : 'text-slate-400 shrink-0'} />
  716. <span className={file.groups?.some(fg => fg.id === g.id) ? 'text-blue-600 font-medium truncate' : 'truncate'}>{g.name}</span>
  717. </div>
  718. {file.groups?.some(fg => fg.id === g.id) && <CheckCircle2 size={12} className="text-blue-600 shrink-0 ml-2" />}
  719. </button>
  720. ))}
  721. </div>
  722. </div>
  723. </div>
  724. {isAdmin && (
  725. <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'}>
  726. <Trash2 size={16} />
  727. </button>
  728. )}
  729. </div>
  730. </div>
  731. </motion.div>
  732. ))}
  733. </AnimatePresence>
  734. </div>
  735. ) : (
  736. <div className="max-w-4xl mx-auto w-full pt-12">
  737. <DragDropUpload onFilesSelected={handleFileUpload} isAdmin={isAdmin} />
  738. </div>
  739. )}
  740. </div>
  741. {/* Pagination */}
  742. <Pagination
  743. currentPage={currentPage}
  744. totalPages={totalPages}
  745. totalItems={filteredFiles.length}
  746. pageSize={pageSize}
  747. onPageChange={setCurrentPage}
  748. t={t}
  749. />
  750. </div>
  751. <IndexingModalWithMode
  752. isOpen={isIndexingModalOpen}
  753. onClose={() => { setPendingFiles([]); setIsIndexingModalOpen(false); }}
  754. files={pendingFiles}
  755. embeddingModels={modelConfigs.filter(m => m.type === ModelType.EMBEDDING)}
  756. defaultEmbeddingId={settings.selectedEmbeddingId}
  757. onConfirm={handleConfirmIndexing}
  758. authToken={authToken}
  759. />
  760. {/* Group Create/Edit Modal */}
  761. <AnimatePresence>
  762. {isGroupModalOpen && (
  763. <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/40 backdrop-blur-sm">
  764. <motion.div
  765. initial={{ opacity: 0, scale: 0.95 }}
  766. animate={{ opacity: 1, scale: 1 }}
  767. exit={{ opacity: 0, scale: 0.95 }}
  768. className="bg-white rounded-2xl shadow-xl w-full max-w-md overflow-hidden"
  769. >
  770. <div className="p-6">
  771. <h2 className="text-xl font-bold text-slate-900 mb-2">
  772. {editingGroup ? t('editCategory') : t('createCategory')}
  773. </h2>
  774. <p className="text-slate-500 text-sm mb-6">
  775. {t('categoryDesc')}
  776. </p>
  777. <div className="space-y-4">
  778. <div>
  779. <label className="block text-sm font-medium text-slate-700 mb-1">{t('categoryName')}</label>
  780. <input
  781. type="text"
  782. value={newGroupName}
  783. onChange={(e) => setNewGroupName(e.target.value)}
  784. placeholder={t('exampleResearch')}
  785. 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"
  786. autoFocus
  787. onKeyDown={(e) => e.key === 'Enter' && handleCreateOrUpdateGroup()}
  788. />
  789. </div>
  790. {/* Parent category selector */}
  791. <div>
  792. <label className="block text-sm font-medium text-slate-700 mb-1">{t('parentCategory')}</label>
  793. <select
  794. value={newGroupParentId ?? ''}
  795. onChange={(e) => setNewGroupParentId(e.target.value || null)}
  796. 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"
  797. >
  798. <option value="">{t('noParentTopLevel')}</option>
  799. {flatGroups
  800. .filter(g => g.id !== editingGroup?.id) // don't allow self as parent
  801. .map(g => (
  802. <option key={g.id} value={g.id}>
  803. {'\u00A0'.repeat((g.depth || 0) * 4)}{g.name}
  804. </option>
  805. ))}
  806. </select>
  807. </div>
  808. </div>
  809. </div>
  810. <div className="px-6 py-4 bg-slate-50 border-t border-slate-100 flex justify-end gap-3">
  811. <button
  812. onClick={() => { setIsGroupModalOpen(false); setEditingGroup(null); setNewGroupParentId(null); }}
  813. className="px-4 py-2 text-slate-600 font-medium hover:bg-slate-200/50 rounded-lg transition-all"
  814. >
  815. {t('cancel')}
  816. </button>
  817. <button
  818. onClick={handleCreateOrUpdateGroup}
  819. 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"
  820. >
  821. {editingGroup ? t('saveChanges') : t('createCategoryBtn')}
  822. </button>
  823. </div>
  824. </motion.div>
  825. </div>
  826. )}
  827. </AnimatePresence>
  828. {pdfPreview && (
  829. <PDFPreview
  830. fileId={pdfPreview.fileId}
  831. fileName={pdfPreview.fileName}
  832. authToken={authToken}
  833. onClose={() => setPdfPreview(null)}
  834. />
  835. )}
  836. {chunkDrawer && (
  837. <ChunkInfoDrawer
  838. isOpen={chunkDrawer.isOpen}
  839. onClose={() => setChunkDrawer(null)}
  840. fileId={chunkDrawer.fileId}
  841. fileName={chunkDrawer.fileName}
  842. authToken={authToken}
  843. />
  844. )}
  845. <ImportFolderDrawer
  846. isOpen={isImportDrawerOpen}
  847. onClose={() => setIsImportDrawerOpen(false)}
  848. authToken={authToken}
  849. onImportSuccess={() => fetchAndSetFiles()}
  850. />
  851. <ImportTasksDrawer
  852. isOpen={isImportTasksDrawerOpen}
  853. onClose={() => setIsImportTasksDrawerOpen(false)}
  854. authToken={authToken}
  855. />
  856. </div>
  857. )
  858. }