KnowledgeBaseView.tsx 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711
  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. Filter,
  29. Plus,
  30. FileText,
  31. Image as ImageIcon,
  32. FileType,
  33. CheckCircle2,
  34. CircleDashed,
  35. RefreshCw,
  36. Eye,
  37. Trash2,
  38. RotateCcw,
  39. Settings,
  40. MoreVertical,
  41. Folder,
  42. Hash,
  43. ChevronRight,
  44. Tag,
  45. Layers,
  46. MoreHorizontal
  47. } from 'lucide-react'
  48. import { motion, AnimatePresence } from 'framer-motion'
  49. import { KB_ALLOWED_EXTENSIONS, IMAGE_MIME_TYPES, isExtensionAllowed, isFormatSupportedForPreview } from '../../constants/fileSupport'
  50. interface KnowledgeBaseViewProps {
  51. authToken: string;
  52. onLogout: () => void;
  53. modelConfigs?: ModelConfig[];
  54. onNavigate: (view: any) => void;
  55. isAdmin?: boolean;
  56. }
  57. export const KnowledgeBaseView: React.FC<KnowledgeBaseViewProps> = (props) => {
  58. const { authToken, modelConfigs = DEFAULT_MODELS, isAdmin = false } = props;
  59. const { showError, showWarning, showSuccess } = useToast()
  60. const { confirm } = useConfirm()
  61. const { t } = useLanguage()
  62. // Data State
  63. const [files, setFiles] = useState<KnowledgeFile[]>([])
  64. const [groups, setGroups] = useState<KnowledgeGroup[]>([])
  65. const [settings, setSettings] = useState<AppSettings>(DEFAULT_SETTINGS)
  66. const [isLoadingSettings, setIsLoadingSettings] = useState(true)
  67. const [isLoadingFiles, setIsLoadingFiles] = useState(true)
  68. // UI State
  69. const [pdfPreview, setPdfPreview] = useState<{ fileId: string; fileName: string } | null>(null)
  70. const [isIndexingModalOpen, setIsIndexingModalOpen] = useState(false)
  71. const [pendingFiles, setPendingFiles] = useState<RawFile[]>([])
  72. const [fileInputRef] = useState<React.RefObject<HTMLInputElement>>(React.createRef())
  73. const [shouldOpenModal, setShouldOpenModal] = useState(false)
  74. // Filter & Pagination State
  75. const [filterName, setFilterName] = useState('')
  76. const [filterGroup, setFilterGroup] = useState('all')
  77. const [filterStatus, setFilterStatus] = useState<'all' | 'ready' | 'indexing' | 'failed'>('all')
  78. const [currentPage, setCurrentPage] = useState(1)
  79. const pageSize = 12 // Adjusted for card grid
  80. const [chunkDrawer, setChunkDrawer] = useState<{ isOpen: boolean; fileId: string; fileName: string } | null>(null)
  81. const [isAutoRefreshEnabled, setIsAutoRefreshEnabled] = useState(true)
  82. const [autoRefreshInterval] = useState<number>(5000)
  83. // Sidebar State
  84. const [selectedSidebarFilter, setSelectedSidebarFilter] = useState<{ type: 'all' | 'uncategorized' | 'group'; groupId?: string }>({ type: 'all' })
  85. const [isGroupModalOpen, setIsGroupModalOpen] = useState(false)
  86. const [editingGroup, setEditingGroup] = useState<KnowledgeGroup | null>(null)
  87. const [newGroupName, setNewGroupName] = useState('')
  88. const fetchAndSetSettings = useCallback(async () => {
  89. if (!authToken) return
  90. try {
  91. const settingsData = isAdmin
  92. ? await userSettingService.getGlobal(authToken)
  93. : await userSettingService.get(authToken);
  94. setSettings({ ...DEFAULT_SETTINGS, ...settingsData })
  95. } catch (error) {
  96. console.error('Failed to fetch settings:', error)
  97. } finally {
  98. setIsLoadingSettings(false)
  99. }
  100. }, [authToken, isAdmin])
  101. const fetchAndSetFiles = useCallback(async () => {
  102. if (!authToken) return
  103. try {
  104. const remoteFiles = await knowledgeBaseService.getAll(authToken)
  105. setFiles(remoteFiles)
  106. } catch (error) {
  107. console.error('Failed to fetch files:', error)
  108. } finally {
  109. setIsLoadingFiles(false)
  110. }
  111. }, [authToken])
  112. const fetchAndSetGroups = useCallback(async () => {
  113. if (!authToken) return
  114. try {
  115. const remoteGroups = await knowledgeGroupService.getGroups()
  116. setGroups(remoteGroups)
  117. } catch (error) {
  118. console.error('Failed to fetch groups:', error)
  119. }
  120. }, [authToken])
  121. useEffect(() => {
  122. if (authToken) {
  123. fetchAndSetSettings()
  124. fetchAndSetFiles()
  125. fetchAndSetGroups()
  126. }
  127. }, [authToken, fetchAndSetSettings, fetchAndSetFiles, fetchAndSetGroups])
  128. useEffect(() => {
  129. if (shouldOpenModal && pendingFiles.length > 0) {
  130. setIsIndexingModalOpen(true);
  131. setShouldOpenModal(false);
  132. }
  133. }, [shouldOpenModal, pendingFiles.length]);
  134. const handleFileUpload = async (fileList: FileList) => {
  135. if (!authToken) {
  136. showWarning(t('loginRequired'))
  137. return
  138. }
  139. const MAX_FILE_SIZE = 104857600;
  140. const rawFiles: RawFile[] = []
  141. const errors: string[] = []
  142. const filesArray = Array.from(fileList);
  143. for (const file of filesArray) {
  144. const extension = file.name.split('.').pop() || ''
  145. if (!isExtensionAllowed(extension, 'kb')) {
  146. errors.push(t('unsupportedFileType', file.name, extension))
  147. continue
  148. }
  149. if (file.size > MAX_FILE_SIZE) {
  150. errors.push(t('fileSizeLimitExceeded', file.name, formatBytes(file.size), 100))
  151. continue;
  152. }
  153. try {
  154. const rawFile = await readFile(file)
  155. rawFiles.push(rawFile)
  156. } catch (error) {
  157. errors.push(`${file.name} - ${t('readingFailed')}`);
  158. }
  159. }
  160. if (errors.length > 0) showError(`${t('uploadErrors')}:\n${errors.join('\n')}`);
  161. if (rawFiles.length === 0) return;
  162. setPendingFiles(rawFiles);
  163. setShouldOpenModal(true);
  164. }
  165. const handleConfirmIndexing = async (config: IndexingConfig) => {
  166. if (!authToken) return
  167. let hasSuccess = false
  168. for (const rawFile of pendingFiles) {
  169. try {
  170. const indexingConfig = {
  171. ...config,
  172. groupIds: selectedSidebarFilter.type === 'group' ? [selectedSidebarFilter.groupId!] : []
  173. };
  174. await uploadService.uploadFileWithConfig(rawFile.file, indexingConfig, authToken)
  175. hasSuccess = true
  176. } catch (error: any) {
  177. showError(`${t('uploadFailed')}: ${rawFile.name} - ${error.message}`)
  178. }
  179. }
  180. if (hasSuccess) await fetchAndSetFiles()
  181. setPendingFiles([])
  182. setIsIndexingModalOpen(false)
  183. }
  184. const handleRemoveFile = async (id: string) => {
  185. if (!(await confirm(t('confirmDeleteFile')))) return
  186. if (!authToken) return
  187. try {
  188. await knowledgeBaseService.deleteFile(id, authToken)
  189. setFiles(prev => prev.filter(f => f.id !== id))
  190. showSuccess(t('fileDeleted'))
  191. } catch (error: any) {
  192. showError(`${t('deleteFailed')}: ` + error.message)
  193. }
  194. }
  195. const handleToggleFileCategory = async (file: KnowledgeFile, groupId: string) => {
  196. try {
  197. const currentGroupIds = file.groups?.map(g => g.id) || [];
  198. const isAssigned = currentGroupIds.includes(groupId);
  199. let newGroupIds: string[];
  200. if (isAssigned) {
  201. newGroupIds = currentGroupIds.filter(id => id !== groupId);
  202. } else {
  203. newGroupIds = [...currentGroupIds, groupId];
  204. }
  205. await knowledgeGroupService.addFileToGroups(file.id, newGroupIds);
  206. await fetchAndSetFiles();
  207. } catch (error: any) {
  208. console.error('Failed to toggle category:', error);
  209. showError(t('actionFailed') + ': ' + error.message);
  210. }
  211. }
  212. const handleClearAll = async () => {
  213. if (!(await confirm(t('confirmClearKB')))) return
  214. if (!authToken) return
  215. try {
  216. await knowledgeBaseService.clearAll(authToken)
  217. setFiles([])
  218. showSuccess(t('kbCleared'))
  219. } catch (error: any) {
  220. showError(`${t('clearFailed')}: ` + error.message)
  221. }
  222. }
  223. const filteredFiles = useMemo(() => {
  224. return files.filter(file => {
  225. const matchName = file.name.toLowerCase().includes(filterName.toLowerCase());
  226. // Updated filtering logic for sidebar
  227. let matchGroup = true;
  228. if (selectedSidebarFilter.type === 'uncategorized') {
  229. matchGroup = !file.groups || file.groups.length === 0;
  230. } else if (selectedSidebarFilter.type === 'group') {
  231. matchGroup = file.groups?.some(g => g.id === selectedSidebarFilter.groupId) || false;
  232. }
  233. const matchStatus = filterStatus === 'all' ||
  234. (filterStatus === 'ready' && (file.status === 'ready' || file.status === 'vectorized')) ||
  235. (filterStatus === 'indexing' && (file.status === 'indexing' || file.status === 'pending' || file.status === 'extracted')) ||
  236. (filterStatus === 'failed' && (file.status === 'failed' || file.status === 'error'));
  237. return matchName && matchGroup && matchStatus;
  238. });
  239. }, [files, filterName, selectedSidebarFilter, filterStatus]);
  240. const totalPages = Math.ceil(filteredFiles.length / pageSize);
  241. const paginatedFiles = useMemo(() => {
  242. const start = (currentPage - 1) * pageSize;
  243. return filteredFiles.slice(start, start + pageSize);
  244. }, [filteredFiles, currentPage, pageSize]);
  245. useEffect(() => {
  246. setCurrentPage(1);
  247. }, [filterName, filterGroup, filterStatus]);
  248. useEffect(() => {
  249. let intervalId: NodeJS.Timeout | null = null;
  250. const hasIndexingFiles = files.some(file => ['pending', 'indexing', 'extracted', 'vectorized'].includes(file.status));
  251. if (isAutoRefreshEnabled && hasIndexingFiles) {
  252. intervalId = setInterval(() => {
  253. fetchAndSetFiles();
  254. }, autoRefreshInterval);
  255. }
  256. return () => { if (intervalId) clearInterval(intervalId); };
  257. }, [isAutoRefreshEnabled, files, autoRefreshInterval, fetchAndSetFiles]);
  258. const getFileIcon = (file: KnowledgeFile) => {
  259. if (file.type.startsWith('image/')) return <ImageIcon size={20} className="text-slate-500" />;
  260. if (file.type === 'application/pdf') return <FileType size={20} className="text-blue-500" />;
  261. return <FileText size={20} className="text-blue-500" />;
  262. };
  263. const handleCreateOrUpdateGroup = async () => {
  264. if (!newGroupName.trim()) return
  265. try {
  266. if (editingGroup) {
  267. await knowledgeGroupService.updateGroup(editingGroup.id, { name: newGroupName })
  268. showSuccess(t('groupUpdated'))
  269. } else {
  270. await knowledgeGroupService.createGroup({ name: newGroupName })
  271. showSuccess(t('groupCreated'))
  272. }
  273. fetchAndSetGroups()
  274. setIsGroupModalOpen(false)
  275. setEditingGroup(null)
  276. setNewGroupName('')
  277. } catch (error: any) {
  278. showError(t('actionFailed') + ': ' + error.message)
  279. }
  280. }
  281. const handleDeleteGroup = async (group: KnowledgeGroup) => {
  282. if (!(await confirm(t('confirmDeleteGroup')))) return
  283. try {
  284. await knowledgeGroupService.deleteGroup(group.id)
  285. showSuccess(t('groupDeleted'))
  286. if (selectedSidebarFilter.groupId === group.id) {
  287. setSelectedSidebarFilter({ type: 'all' })
  288. }
  289. fetchAndSetGroups()
  290. } catch (error: any) {
  291. showError(t('deleteFailed') + ': ' + error.message)
  292. }
  293. }
  294. if (isLoadingSettings) {
  295. return (
  296. <div className='flex items-center justify-center min-h-[400px] w-full'>
  297. <div className='text-blue-600 animate-spin rounded-full h-8 w-8 border-2 border-t-transparent border-blue-600'></div>
  298. </div>
  299. )
  300. }
  301. return (
  302. <div className='flex flex-row h-full w-full bg-slate-50 overflow-hidden'>
  303. {/* Sidebar */}
  304. <div className="w-64 bg-white border-r border-slate-200 flex flex-col shrink-0">
  305. <div className="p-6">
  306. <h2 className="text-sm font-semibold text-slate-400 uppercase tracking-wider mb-4">Catalog</h2>
  307. <nav className="space-y-1">
  308. <button
  309. onClick={() => setSelectedSidebarFilter({ type: 'all' })}
  310. 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'}`}
  311. >
  312. <div className="flex items-center gap-2">
  313. <Layers size={16} />
  314. <span>All Documents</span>
  315. </div>
  316. <span className="text-xs bg-slate-100 text-slate-500 px-1.5 py-0.5 rounded-full">{files.length}</span>
  317. </button>
  318. <button
  319. onClick={() => setSelectedSidebarFilter({ type: 'uncategorized' })}
  320. 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'}`}
  321. >
  322. <div className="flex items-center gap-2">
  323. <FileText size={16} />
  324. <span>Uncategorized</span>
  325. </div>
  326. <span className="text-xs bg-slate-100 text-slate-500 px-1.5 py-0.5 rounded-full">
  327. {files.filter(f => !f.groups || f.groups.length === 0).length}
  328. </span>
  329. </button>
  330. </nav>
  331. <div className="mt-8 flex items-center justify-between mb-4">
  332. <h2 className="text-sm font-semibold text-slate-400 uppercase tracking-wider">Categories</h2>
  333. {isAdmin && (
  334. <button
  335. onClick={() => { setEditingGroup(null); setNewGroupName(''); setIsGroupModalOpen(true); }}
  336. className="p-1 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded transition-all"
  337. >
  338. <Plus size={14} />
  339. </button>
  340. )}
  341. </div>
  342. <div className="space-y-1 overflow-y-auto max-h-[calc(100vh-320px)] pr-1">
  343. {groups.map(group => (
  344. <div key={group.id} className="group flex items-center justify-between group">
  345. <button
  346. onClick={() => setSelectedSidebarFilter({ type: 'group', groupId: group.id })}
  347. className={`flex-1 flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-colors text-left ${selectedSidebarFilter.groupId === group.id ? 'bg-blue-50 text-blue-700' : 'text-slate-600 hover:bg-slate-50 hover:text-slate-900'}`}
  348. >
  349. <Hash size={16} className={selectedSidebarFilter.groupId === group.id ? 'text-blue-500' : 'text-slate-400'} />
  350. <span className="truncate">{group.name}</span>
  351. </button>
  352. {isAdmin && (
  353. <div className="opacity-0 group-hover:opacity-100 flex items-center gap-0.5 pr-2">
  354. <button
  355. onClick={() => { setEditingGroup(group); setNewGroupName(group.name); setIsGroupModalOpen(true); }}
  356. className="p-1 text-slate-400 hover:text-blue-600"
  357. >
  358. <Settings size={12} />
  359. </button>
  360. <button
  361. onClick={() => handleDeleteGroup(group)}
  362. className="p-1 text-slate-400 hover:text-red-500"
  363. >
  364. <Trash2 size={12} />
  365. </button>
  366. </div>
  367. )}
  368. </div>
  369. ))}
  370. </div>
  371. </div>
  372. </div>
  373. {/* Main Content Area */}
  374. <div className='flex flex-col flex-1 h-full w-full bg-transparent overflow-hidden'>
  375. <input
  376. type="file"
  377. ref={fileInputRef}
  378. onChange={(e) => {
  379. if (e.target.files && e.target.files.length > 0) handleFileUpload(e.target.files)
  380. }}
  381. multiple
  382. className="hidden"
  383. accept={KB_ALLOWED_EXTENSIONS.map(ext => `.${ext}`).join(',') + ',' + IMAGE_MIME_TYPES.join(',')}
  384. />
  385. <GlobalDragDropOverlay onFilesSelected={handleFileUpload} isAdmin={isAdmin} />
  386. {/* Header Section */}
  387. <div className="px-8 pt-8 pb-6 flex items-start justify-between shrink-0">
  388. <div>
  389. <h1 className="text-2xl font-bold text-slate-900 leading-tight">
  390. {selectedSidebarFilter.type === 'all' ? 'Knowledge Base' :
  391. selectedSidebarFilter.type === 'uncategorized' ? 'Uncategorized Files' :
  392. groups.find(g => g.id === selectedSidebarFilter.groupId)?.name || 'Category'}
  393. </h1>
  394. <p className="text-[15px] text-slate-500 mt-1">
  395. {selectedSidebarFilter.type === 'group'
  396. ? groups.find(g => g.id === selectedSidebarFilter.groupId)?.description || 'Documents in this category.'
  397. : 'Manage your documents and data sources.'}
  398. </p>
  399. </div>
  400. <div className="flex items-center gap-3">
  401. {isAdmin && (
  402. <button
  403. onClick={() => fileInputRef.current?.click()}
  404. 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"
  405. >
  406. <Plus size={18} />
  407. Add Document
  408. </button>
  409. )}
  410. </div>
  411. </div>
  412. {/* Filter Bar */}
  413. <div className="px-8 pb-6 flex items-center justify-between gap-4 shrink-0">
  414. <div className="flex items-center gap-3 flex-1">
  415. <div className="relative max-w-xs w-full">
  416. <Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={16} />
  417. <input
  418. type="text"
  419. placeholder="Filter by name..."
  420. value={filterName}
  421. onChange={(e) => setFilterName(e.target.value)}
  422. 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"
  423. />
  424. </div>
  425. </div>
  426. <div className="flex items-center gap-3">
  427. <button
  428. onClick={() => fetchAndSetFiles()}
  429. className="p-2 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-all"
  430. title="Refresh"
  431. >
  432. <RefreshCw size={18} className={isAutoRefreshEnabled ? 'animate-spin' : ''} />
  433. </button>
  434. </div>
  435. </div>
  436. {/* Card Grid */}
  437. <div className="flex-1 overflow-y-auto px-8 pb-8">
  438. {isLoadingFiles ? (
  439. <div className="flex flex-col items-center justify-center py-20 gap-4">
  440. <div className="w-10 h-10 border-2 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
  441. </div>
  442. ) : paginatedFiles.length > 0 ? (
  443. <div className="flex flex-col gap-3">
  444. <AnimatePresence mode="popLayout">
  445. {paginatedFiles.map((file) => (
  446. <motion.div
  447. key={file.id}
  448. layout
  449. initial={{ opacity: 0, y: 10 }}
  450. animate={{ opacity: 1, y: 0 }}
  451. exit={{ opacity: 0, scale: 0.95 }}
  452. 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"
  453. >
  454. {/* Icon */}
  455. <div className="p-2.5 bg-blue-50 rounded-lg text-blue-600 shadow-sm border border-blue-100/30 shrink-0">
  456. {getFileIcon(file)}
  457. </div>
  458. {/* Name & Desc */}
  459. <div className="flex-1 min-w-0">
  460. <div className="flex items-center gap-3 mb-1">
  461. <h3
  462. onClick={() => setChunkDrawer({ isOpen: true, fileId: file.id, fileName: file.name })}
  463. className="font-bold text-slate-900 text-[15px] cursor-pointer hover:text-blue-600 transition-colors truncate"
  464. >
  465. {file.name}
  466. </h3>
  467. <span className="px-2 py-0.5 bg-slate-100 text-slate-500 rounded text-[10px] font-bold tracking-wider uppercase shrink-0">
  468. {file.name.split('.').pop()?.toUpperCase() || 'FILE'}
  469. </span>
  470. </div>
  471. <div className="flex items-center gap-2">
  472. <p className="text-[13px] text-slate-500 truncate">
  473. {file.status === 'ready' || file.status === 'vectorized'
  474. ? `Document processed and ready.`
  475. : `Processing... Currently in ${file.status} state.`
  476. }
  477. </p>
  478. {file.groups && file.groups.length > 0 && (
  479. <div className="flex gap-1 ml-2">
  480. {file.groups.map(g => (
  481. <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">
  482. {g.name}
  483. </span>
  484. ))}
  485. </div>
  486. )}
  487. </div>
  488. </div>
  489. {/* Meta & Actions */}
  490. <div className="flex items-center gap-6 shrink-0">
  491. <div className="text-[12px] font-medium text-slate-400 flex flex-col items-end">
  492. <span>{new Date(file.createdAt || Date.now()).toLocaleDateString('ja-JP')}</span>
  493. <span>{formatBytes(file.size)}</span>
  494. </div>
  495. <div className="flex items-center gap-2">
  496. {file.status !== 'ready' && file.status !== 'vectorized' ? (
  497. <CircleDashed size={16} className="text-blue-400 animate-spin mr-2" />
  498. ) : null}
  499. </div>
  500. <div className="flex items-center gap-1 opacity-100 md:opacity-0 group-hover:opacity-100 transition-opacity">
  501. {isFormatSupportedForPreview(file.name) && (
  502. <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'}>
  503. <Eye size={16} />
  504. </button>
  505. )}
  506. <div className="relative group">
  507. <button className="p-1.5 text-slate-400 hover:text-blue-600 rounded-md bg-slate-50" title="Move to Category">
  508. <Tag size={16} />
  509. </button>
  510. <div className="absolute right-0 top-full mt-1 w-48 bg-white border border-slate-200 rounded-lg shadow-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all z-20 overflow-hidden">
  511. <div className="p-2 border-b border-slate-100 bg-slate-50 text-[10px] font-bold text-slate-400 uppercase tracking-wider">
  512. Select Category
  513. </div>
  514. <div className="max-h-40 overflow-y-auto">
  515. <button
  516. onClick={() => knowledgeGroupService.addFileToGroups(file.id, []).then(fetchAndSetFiles)}
  517. className="w-full text-left px-3 py-2 text-xs text-slate-600 hover:bg-slate-50 flex items-center gap-2"
  518. >
  519. <Layers size={12} />
  520. None (Uncategorized)
  521. </button>
  522. {groups.map(g => (
  523. <button
  524. key={g.id}
  525. onClick={() => handleToggleFileCategory(file, g.id)}
  526. className="w-full text-left px-3 py-2 text-xs text-slate-600 hover:bg-slate-50 border-t border-slate-50 flex items-center justify-between"
  527. >
  528. <div className="flex items-center gap-2 truncate">
  529. <Hash size={12} className={file.groups?.some(fg => fg.id === g.id) ? 'text-blue-500' : 'text-slate-400'} />
  530. <span className={file.groups?.some(fg => fg.id === g.id) ? 'text-blue-600 font-medium' : ''}>{g.name}</span>
  531. </div>
  532. {file.groups?.some(fg => fg.id === g.id) && <CheckCircle2 size={12} className="text-blue-600" />}
  533. </button>
  534. ))}
  535. </div>
  536. </div>
  537. </div>
  538. {isAdmin && (
  539. <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'}>
  540. <Trash2 size={16} />
  541. </button>
  542. )}
  543. </div>
  544. </div>
  545. </motion.div>
  546. ))}
  547. </AnimatePresence>
  548. </div>
  549. ) : (
  550. <div className="max-w-4xl mx-auto w-full pt-12">
  551. <DragDropUpload onFilesSelected={handleFileUpload} isAdmin={isAdmin} />
  552. </div>
  553. )}
  554. </div>
  555. {/* Pagination */}
  556. {totalPages > 1 && (
  557. <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">
  558. <button
  559. onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
  560. disabled={currentPage === 1}
  561. className="p-2 border border-slate-200 rounded-lg hover:bg-slate-50 disabled:opacity-30 transition-all font-medium text-slate-600"
  562. >
  563. Previous
  564. </button>
  565. <div className="px-3 py-2 text-sm font-semibold text-slate-700">
  566. {currentPage} of {totalPages}
  567. </div>
  568. <button
  569. onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
  570. disabled={currentPage === totalPages}
  571. className="p-2 border border-slate-200 rounded-lg hover:bg-slate-50 disabled:opacity-30 transition-all font-medium text-slate-600"
  572. >
  573. Next
  574. </button>
  575. </div>
  576. )}
  577. </div>
  578. <IndexingModalWithMode
  579. isOpen={isIndexingModalOpen}
  580. onClose={() => { setPendingFiles([]); setIsIndexingModalOpen(false); }}
  581. files={pendingFiles}
  582. embeddingModels={modelConfigs.filter(m => m.type === ModelType.EMBEDDING)}
  583. defaultEmbeddingId={settings.selectedEmbeddingId}
  584. onConfirm={handleConfirmIndexing}
  585. authToken={authToken}
  586. />
  587. {/* Group Modal */}
  588. <AnimatePresence>
  589. {isGroupModalOpen && (
  590. <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/40 backdrop-blur-sm">
  591. <motion.div
  592. initial={{ opacity: 0, scale: 0.95 }}
  593. animate={{ opacity: 1, scale: 1 }}
  594. exit={{ opacity: 0, scale: 0.95 }}
  595. className="bg-white rounded-2xl shadow-xl w-full max-w-md overflow-hidden"
  596. >
  597. <div className="p-6">
  598. <h2 className="text-xl font-bold text-slate-900 mb-2">
  599. {editingGroup ? 'Edit Category' : 'Create New Category'}
  600. </h2>
  601. <p className="text-slate-500 text-sm mb-6">
  602. Organize your documents with descriptive categories.
  603. </p>
  604. <div className="space-y-4">
  605. <div>
  606. <label className="block text-sm font-medium text-slate-700 mb-1">Category Name</label>
  607. <input
  608. type="text"
  609. value={newGroupName}
  610. onChange={(e) => setNewGroupName(e.target.value)}
  611. placeholder="e.g. Finance, Projects..."
  612. 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"
  613. />
  614. </div>
  615. </div>
  616. </div>
  617. <div className="px-6 py-4 bg-slate-50 border-t border-slate-100 flex justify-end gap-3">
  618. <button
  619. onClick={() => setIsGroupModalOpen(false)}
  620. className="px-4 py-2 text-slate-600 font-medium hover:bg-slate-200/50 rounded-lg transition-all"
  621. >
  622. Cancel
  623. </button>
  624. <button
  625. onClick={handleCreateOrUpdateGroup}
  626. 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"
  627. >
  628. {editingGroup ? 'Save Changes' : 'Create Category'}
  629. </button>
  630. </div>
  631. </motion.div>
  632. </div>
  633. )}
  634. </AnimatePresence>
  635. {pdfPreview && (
  636. <PDFPreview
  637. fileId={pdfPreview.fileId}
  638. fileName={pdfPreview.fileName}
  639. authToken={authToken}
  640. onClose={() => setPdfPreview(null)}
  641. />
  642. )}
  643. {chunkDrawer && (
  644. <ChunkInfoDrawer
  645. isOpen={chunkDrawer.isOpen}
  646. onClose={() => setChunkDrawer(null)}
  647. fileId={chunkDrawer.fileId}
  648. fileName={chunkDrawer.fileName}
  649. authToken={authToken}
  650. />
  651. )}
  652. </div>
  653. )
  654. }