KnowledgeBaseView.tsx 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465
  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. } from 'lucide-react'
  42. import { motion, AnimatePresence } from 'framer-motion'
  43. import { KB_ALLOWED_EXTENSIONS, IMAGE_MIME_TYPES, isExtensionAllowed, isFormatSupportedForPreview } from '../../constants/fileSupport'
  44. interface KnowledgeBaseViewProps {
  45. authToken: string;
  46. onLogout: () => void;
  47. modelConfigs?: ModelConfig[];
  48. onNavigate: (view: any) => void;
  49. isAdmin?: boolean;
  50. }
  51. export const KnowledgeBaseView: React.FC<KnowledgeBaseViewProps> = (props) => {
  52. const { authToken, modelConfigs = DEFAULT_MODELS, isAdmin = false } = props;
  53. const { showError, showWarning, showSuccess } = useToast()
  54. const { confirm } = useConfirm()
  55. const { t } = useLanguage()
  56. // Data State
  57. const [files, setFiles] = useState<KnowledgeFile[]>([])
  58. const [groups, setGroups] = useState<KnowledgeGroup[]>([])
  59. const [settings, setSettings] = useState<AppSettings>(DEFAULT_SETTINGS)
  60. const [isLoadingSettings, setIsLoadingSettings] = useState(true)
  61. const [isLoadingFiles, setIsLoadingFiles] = useState(true)
  62. // UI State
  63. const [pdfPreview, setPdfPreview] = useState<{ fileId: string; fileName: string } | null>(null)
  64. const [isIndexingModalOpen, setIsIndexingModalOpen] = useState(false)
  65. const [pendingFiles, setPendingFiles] = useState<RawFile[]>([])
  66. const [fileInputRef] = useState<React.RefObject<HTMLInputElement>>(React.createRef())
  67. const [shouldOpenModal, setShouldOpenModal] = useState(false)
  68. // Filter & Pagination State
  69. const [filterName, setFilterName] = useState('')
  70. const [filterGroup, setFilterGroup] = useState('all')
  71. const [filterStatus, setFilterStatus] = useState<'all' | 'ready' | 'indexing' | 'failed'>('all')
  72. const [currentPage, setCurrentPage] = useState(1)
  73. const pageSize = 12 // Adjusted for card grid
  74. const [chunkDrawer, setChunkDrawer] = useState<{ isOpen: boolean; fileId: string; fileName: string } | null>(null)
  75. // Auto-refresh state
  76. const [isAutoRefreshEnabled, setIsAutoRefreshEnabled] = useState(true)
  77. const [autoRefreshInterval] = useState<number>(5000)
  78. const fetchAndSetSettings = useCallback(async () => {
  79. if (!authToken) return
  80. try {
  81. const settingsData = isAdmin
  82. ? await userSettingService.getGlobal(authToken)
  83. : await userSettingService.get(authToken);
  84. setSettings({ ...DEFAULT_SETTINGS, ...settingsData })
  85. } catch (error) {
  86. console.error('Failed to fetch settings:', error)
  87. } finally {
  88. setIsLoadingSettings(false)
  89. }
  90. }, [authToken, isAdmin])
  91. const fetchAndSetFiles = useCallback(async () => {
  92. if (!authToken) return
  93. try {
  94. const remoteFiles = await knowledgeBaseService.getAll(authToken)
  95. setFiles(remoteFiles)
  96. } catch (error) {
  97. console.error('Failed to fetch files:', error)
  98. } finally {
  99. setIsLoadingFiles(false)
  100. }
  101. }, [authToken])
  102. const fetchAndSetGroups = useCallback(async () => {
  103. if (!authToken) return
  104. try {
  105. const remoteGroups = await knowledgeGroupService.getGroups()
  106. setGroups(remoteGroups)
  107. } catch (error) {
  108. console.error('Failed to fetch groups:', error)
  109. }
  110. }, [authToken])
  111. useEffect(() => {
  112. if (authToken) {
  113. fetchAndSetSettings()
  114. fetchAndSetFiles()
  115. fetchAndSetGroups()
  116. }
  117. }, [authToken, fetchAndSetSettings, fetchAndSetFiles, fetchAndSetGroups])
  118. useEffect(() => {
  119. if (shouldOpenModal && pendingFiles.length > 0) {
  120. setIsIndexingModalOpen(true);
  121. setShouldOpenModal(false);
  122. }
  123. }, [shouldOpenModal, pendingFiles.length]);
  124. const handleFileUpload = async (fileList: FileList) => {
  125. if (!authToken) {
  126. showWarning(t('loginRequired'))
  127. return
  128. }
  129. const MAX_FILE_SIZE = 104857600;
  130. const rawFiles: RawFile[] = []
  131. const errors: string[] = []
  132. const filesArray = Array.from(fileList);
  133. for (const file of filesArray) {
  134. const extension = file.name.split('.').pop() || ''
  135. if (!isExtensionAllowed(extension, 'kb')) {
  136. errors.push(t('unsupportedFileType', file.name, extension))
  137. continue
  138. }
  139. if (file.size > MAX_FILE_SIZE) {
  140. errors.push(t('fileSizeLimitExceeded', file.name, formatBytes(file.size), 100))
  141. continue;
  142. }
  143. try {
  144. const rawFile = await readFile(file)
  145. rawFiles.push(rawFile)
  146. } catch (error) {
  147. errors.push(`${file.name} - ${t('readingFailed')}`);
  148. }
  149. }
  150. if (errors.length > 0) showError(`${t('uploadErrors')}:\n${errors.join('\n')}`);
  151. if (rawFiles.length === 0) return;
  152. setPendingFiles(rawFiles);
  153. setShouldOpenModal(true);
  154. }
  155. const handleConfirmIndexing = async (config: IndexingConfig) => {
  156. if (!authToken) return
  157. let hasSuccess = false
  158. for (const rawFile of pendingFiles) {
  159. try {
  160. await uploadService.uploadFileWithConfig(rawFile.file, config, authToken)
  161. hasSuccess = true
  162. } catch (error: any) {
  163. showError(`${t('uploadFailed')}: ${rawFile.name} - ${error.message}`)
  164. }
  165. }
  166. if (hasSuccess) await fetchAndSetFiles()
  167. setPendingFiles([])
  168. setIsIndexingModalOpen(false)
  169. }
  170. const handleRemoveFile = async (id: string) => {
  171. if (!(await confirm(t('confirmDeleteFile')))) return
  172. if (!authToken) return
  173. try {
  174. await knowledgeBaseService.deleteFile(id, authToken)
  175. setFiles(prev => prev.filter(f => f.id !== id))
  176. showSuccess(t('fileDeleted'))
  177. } catch (error: any) {
  178. showError(`${t('deleteFailed')}: ` + error.message)
  179. }
  180. }
  181. const handleClearAll = async () => {
  182. if (!(await confirm(t('confirmClearKB')))) return
  183. if (!authToken) return
  184. try {
  185. await knowledgeBaseService.clearAll(authToken)
  186. setFiles([])
  187. showSuccess(t('kbCleared'))
  188. } catch (error: any) {
  189. showError(`${t('clearFailed')}: ` + error.message)
  190. }
  191. }
  192. const filteredFiles = useMemo(() => {
  193. return files.filter(file => {
  194. const matchName = file.name.toLowerCase().includes(filterName.toLowerCase());
  195. const matchGroup = filterGroup === 'all' || file.groups?.some(g => g.id === filterGroup);
  196. const matchStatus = filterStatus === 'all' ||
  197. (filterStatus === 'ready' && (file.status === 'ready' || file.status === 'vectorized')) ||
  198. (filterStatus === 'indexing' && (file.status === 'indexing' || file.status === 'pending' || file.status === 'extracted')) ||
  199. (filterStatus === 'failed' && (file.status === 'failed' || file.status === 'error'));
  200. return matchName && matchGroup && matchStatus;
  201. });
  202. }, [files, filterName, filterGroup, filterStatus]);
  203. const totalPages = Math.ceil(filteredFiles.length / pageSize);
  204. const paginatedFiles = useMemo(() => {
  205. const start = (currentPage - 1) * pageSize;
  206. return filteredFiles.slice(start, start + pageSize);
  207. }, [filteredFiles, currentPage, pageSize]);
  208. useEffect(() => {
  209. setCurrentPage(1);
  210. }, [filterName, filterGroup, filterStatus]);
  211. useEffect(() => {
  212. let intervalId: NodeJS.Timeout | null = null;
  213. const hasIndexingFiles = files.some(file => ['pending', 'indexing', 'extracted', 'vectorized'].includes(file.status));
  214. if (isAutoRefreshEnabled && hasIndexingFiles) {
  215. intervalId = setInterval(() => {
  216. fetchAndSetFiles();
  217. }, autoRefreshInterval);
  218. }
  219. return () => { if (intervalId) clearInterval(intervalId); };
  220. }, [isAutoRefreshEnabled, files, autoRefreshInterval, fetchAndSetFiles]);
  221. const getFileIcon = (file: KnowledgeFile) => {
  222. if (file.type.startsWith('image/')) return <ImageIcon size={20} className="text-slate-500" />;
  223. if (file.type === 'application/pdf') return <FileType size={20} className="text-blue-500" />;
  224. return <FileText size={20} className="text-blue-500" />;
  225. };
  226. if (isLoadingSettings) {
  227. return (
  228. <div className='flex items-center justify-center min-h-[400px] w-full'>
  229. <div className='text-blue-600 animate-spin rounded-full h-8 w-8 border-2 border-t-transparent border-blue-600'></div>
  230. </div>
  231. )
  232. }
  233. return (
  234. <div className='flex flex-col h-full w-full bg-transparent overflow-hidden'>
  235. <input
  236. type="file"
  237. ref={fileInputRef}
  238. onChange={(e) => {
  239. if (e.target.files && e.target.files.length > 0) handleFileUpload(e.target.files)
  240. }}
  241. multiple
  242. className="hidden"
  243. accept={KB_ALLOWED_EXTENSIONS.map(ext => `.${ext}`).join(',') + ',' + IMAGE_MIME_TYPES.join(',')}
  244. />
  245. <GlobalDragDropOverlay onFilesSelected={handleFileUpload} isAdmin={isAdmin} />
  246. {/* Header Section */}
  247. <div className="px-8 pt-8 pb-6 flex items-start justify-between shrink-0">
  248. <div>
  249. <h1 className="text-2xl font-bold text-slate-900 leading-tight">Knowledge Base</h1>
  250. <p className="text-[15px] text-slate-500 mt-1">Manage your documents and data sources.</p>
  251. </div>
  252. <div className="flex items-center gap-3">
  253. {isAdmin && (
  254. <button
  255. onClick={() => fileInputRef.current?.click()}
  256. 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"
  257. >
  258. <Plus size={18} />
  259. Add Document
  260. </button>
  261. )}
  262. </div>
  263. </div>
  264. {/* Filter Bar */}
  265. <div className="px-8 pb-6 flex items-center justify-between gap-4 shrink-0">
  266. <div className="flex items-center gap-3 flex-1">
  267. <div className="relative max-w-xs w-full">
  268. <Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={16} />
  269. <input
  270. type="text"
  271. placeholder="Filter by name..."
  272. value={filterName}
  273. onChange={(e) => setFilterName(e.target.value)}
  274. 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"
  275. />
  276. </div>
  277. <select
  278. value={filterGroup}
  279. onChange={(e) => setFilterGroup(e.target.value)}
  280. className="h-9 px-3 bg-white border border-slate-200 rounded-lg text-sm outline-none focus:ring-1 focus:ring-blue-500 min-w-[140px]"
  281. >
  282. <option value="all">All Groups</option>
  283. {groups.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
  284. </select>
  285. </div>
  286. <div className="flex items-center gap-3">
  287. <button
  288. onClick={() => fetchAndSetFiles()}
  289. className="p-2 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-all"
  290. title="Refresh"
  291. >
  292. <RefreshCw size={18} className={isAutoRefreshEnabled ? 'animate-spin' : ''} />
  293. </button>
  294. </div>
  295. </div>
  296. {/* Card Grid */}
  297. <div className="flex-1 overflow-y-auto px-8 pb-8">
  298. {isLoadingFiles ? (
  299. <div className="flex flex-col items-center justify-center py-20 gap-4">
  300. <div className="w-10 h-10 border-2 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
  301. </div>
  302. ) : paginatedFiles.length > 0 ? (
  303. <div className="flex flex-col gap-3">
  304. <AnimatePresence mode="popLayout">
  305. {paginatedFiles.map((file) => (
  306. <motion.div
  307. key={file.id}
  308. layout
  309. initial={{ opacity: 0, y: 10 }}
  310. animate={{ opacity: 1, y: 0 }}
  311. exit={{ opacity: 0, scale: 0.95 }}
  312. className="bg-white rounded-xl border border-slate-200/80 p-4 shadow-sm hover:shadow-md transition-all group relative overflow-hidden flex items-center gap-4"
  313. >
  314. {/* Icon */}
  315. <div className="p-2.5 bg-blue-50 rounded-lg text-blue-600 shadow-sm border border-blue-100/30 shrink-0">
  316. {getFileIcon(file)}
  317. </div>
  318. {/* Name & Desc */}
  319. <div className="flex-1 min-w-0">
  320. <div className="flex items-center gap-3 mb-1">
  321. <h3
  322. onClick={() => setChunkDrawer({ isOpen: true, fileId: file.id, fileName: file.name })}
  323. className="font-bold text-slate-900 text-[15px] cursor-pointer hover:text-blue-600 transition-colors truncate"
  324. >
  325. {file.name}
  326. </h3>
  327. <span className="px-2 py-0.5 bg-slate-100 text-slate-500 rounded text-[10px] font-bold tracking-wider uppercase shrink-0">
  328. {file.name.split('.').pop()?.toUpperCase() || 'FILE'}
  329. </span>
  330. </div>
  331. <p className="text-[13px] text-slate-500 truncate">
  332. {file.status === 'ready' || file.status === 'vectorized'
  333. ? `Document processed and ready.`
  334. : `Processing... Currently in ${file.status} state.`
  335. }
  336. </p>
  337. </div>
  338. {/* Meta & Actions */}
  339. <div className="flex items-center gap-6 shrink-0">
  340. <div className="text-[12px] font-medium text-slate-400 flex flex-col items-end">
  341. <span>{new Date().toLocaleDateString('ja-JP')}</span>
  342. <span>{formatBytes(file.size)}</span>
  343. </div>
  344. <div className="flex items-center gap-2">
  345. {file.status !== 'ready' && file.status !== 'vectorized' ? (
  346. <CircleDashed size={16} className="text-blue-400 animate-spin mr-2" />
  347. ) : null}
  348. </div>
  349. <div className="flex items-center gap-1 opacity-100 md:opacity-0 group-hover:opacity-100 transition-opacity">
  350. {isFormatSupportedForPreview(file.name) && (
  351. <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'}>
  352. <Eye size={16} />
  353. </button>
  354. )}
  355. {isAdmin && (
  356. <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'}>
  357. <Trash2 size={16} />
  358. </button>
  359. )}
  360. </div>
  361. </div>
  362. </motion.div>
  363. ))}
  364. </AnimatePresence>
  365. </div>
  366. ) : (
  367. <div className="max-w-4xl mx-auto w-full pt-12">
  368. <DragDropUpload onFilesSelected={handleFileUpload} isAdmin={isAdmin} />
  369. </div>
  370. )}
  371. </div>
  372. {/* Pagination */}
  373. {totalPages > 1 && (
  374. <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">
  375. <button
  376. onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
  377. disabled={currentPage === 1}
  378. className="p-2 border border-slate-200 rounded-lg hover:bg-slate-50 disabled:opacity-30 transition-all font-medium text-slate-600"
  379. >
  380. Previous
  381. </button>
  382. <div className="px-3 py-2 text-sm font-semibold text-slate-700">
  383. {currentPage} of {totalPages}
  384. </div>
  385. <button
  386. onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
  387. disabled={currentPage === totalPages}
  388. className="p-2 border border-slate-200 rounded-lg hover:bg-slate-50 disabled:opacity-30 transition-all font-medium text-slate-600"
  389. >
  390. Next
  391. </button>
  392. </div>
  393. )}
  394. <IndexingModalWithMode
  395. isOpen={isIndexingModalOpen}
  396. onClose={() => { setPendingFiles([]); setIsIndexingModalOpen(false); }}
  397. files={pendingFiles}
  398. embeddingModels={modelConfigs.filter(m => m.type === ModelType.EMBEDDING)}
  399. defaultEmbeddingId={settings.selectedEmbeddingId}
  400. onConfirm={handleConfirmIndexing}
  401. authToken={authToken}
  402. />
  403. {pdfPreview && (
  404. <PDFPreview
  405. fileId={pdfPreview.fileId}
  406. fileName={pdfPreview.fileName}
  407. authToken={authToken}
  408. onClose={() => setPdfPreview(null)}
  409. />
  410. )}
  411. {chunkDrawer && (
  412. <ChunkInfoDrawer
  413. isOpen={chunkDrawer.isOpen}
  414. onClose={() => setChunkDrawer(null)}
  415. fileId={chunkDrawer.fileId}
  416. fileName={chunkDrawer.fileName}
  417. authToken={authToken}
  418. />
  419. )}
  420. </div>
  421. )
  422. }