KnowledgeBaseView.tsx 46 KB

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