NotebookDetailView.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309
  1. import React, { useEffect, useState, useCallback, useMemo } from 'react'
  2. import { ArrowLeft, Plus, MessageSquare, BookOpen, Trash2, Eye, FileText, FileType, Image as ImageIcon, Search, RefreshCw } from 'lucide-react'
  3. import { KnowledgeGroup, KnowledgeFile } from '../../types'
  4. import { knowledgeBaseService } from '../../services/knowledgeBaseService'
  5. import { modelConfigService } from '../../services/modelConfigService'
  6. import { uploadService } from '../../services/uploadService'
  7. import { knowledgeGroupService } from '../../services/knowledgeGroupService'
  8. import { useToast } from '../../contexts/ToastContext'
  9. import { useConfirm } from '../../contexts/ConfirmContext'
  10. import { RawFile, IndexingConfig, ModelConfig } from '../../types'
  11. import IndexingModalWithMode from '../IndexingModalWithMode'
  12. import { PDFPreview } from '../PDFPreview'
  13. import { NotebookGlobalDragDropOverlay } from '../NotebookGlobalDragDropOverlay'
  14. import { useLanguage } from '../../contexts/LanguageContext'
  15. import { readFile, formatBytes } from '../../utils/fileUtils'
  16. import { isExtensionAllowed, isFormatSupportedForPreview } from '../../constants/fileSupport'
  17. import { motion, AnimatePresence } from 'framer-motion'
  18. interface NotebookDetailViewProps {
  19. authToken: string;
  20. notebook: KnowledgeGroup;
  21. onBack: () => void;
  22. onChatWithContext?: (context: { selectedGroups?: string[], selectedFiles?: string[] }) => void;
  23. isAdmin?: boolean;
  24. }
  25. export const NotebookDetailView: React.FC<NotebookDetailViewProps> = ({ authToken, notebook, onBack, onChatWithContext, isAdmin = false }) => {
  26. const [files, setFiles] = useState<KnowledgeFile[]>([])
  27. const [isLoading, setIsLoading] = useState(false)
  28. const { showError, showSuccess } = useToast()
  29. const { confirm } = useConfirm()
  30. const { t } = useLanguage()
  31. const [isIndexingModalOpen, setIsIndexingModalOpen] = useState(false)
  32. const [pendingFiles, setPendingFiles] = useState<RawFile[]>([])
  33. const [shouldOpenModal, setShouldOpenModal] = useState(false)
  34. const [models, setModels] = useState<ModelConfig[]>([])
  35. const [pdfPreview, setPdfPreview] = useState<{ fileId: string; fileName: string } | null>(null)
  36. const [filterName, setFilterName] = useState('')
  37. const fileInputRef = React.useRef<HTMLInputElement>(null)
  38. useEffect(() => {
  39. const fetchModels = async () => {
  40. try {
  41. const res = await modelConfigService.getAll(authToken)
  42. setModels(res)
  43. } catch (error) {
  44. console.error('Failed to fetch models', error)
  45. }
  46. }
  47. if (authToken) fetchModels()
  48. }, [authToken])
  49. useEffect(() => {
  50. if (shouldOpenModal && pendingFiles.length > 0) {
  51. setIsIndexingModalOpen(true);
  52. setShouldOpenModal(false);
  53. }
  54. }, [shouldOpenModal, pendingFiles.length]);
  55. const loadData = useCallback(async () => {
  56. setIsLoading(true)
  57. try {
  58. const allFiles = await knowledgeBaseService.getAll(authToken)
  59. const notebookFiles = allFiles.filter(f => f.groups?.some(g => g.id === notebook.id))
  60. setFiles(notebookFiles)
  61. } catch (error) {
  62. console.error(error)
  63. showError(t('errorLoadData'))
  64. } finally {
  65. setIsLoading(false)
  66. }
  67. }, [authToken, notebook.id, t, showError])
  68. useEffect(() => {
  69. loadData()
  70. }, [loadData])
  71. const handleFileUpload = async (fileList: FileList | File[]) => {
  72. if (!fileList || fileList.length === 0) return
  73. const errors: string[] = []
  74. const newPendingFiles: RawFile[] = []
  75. for (let i = 0; i < fileList.length; i++) {
  76. const file = fileList[i]
  77. try {
  78. if (file.size > 104857600) {
  79. errors.push(`${file.name} - ${t('fileSizeLimitExceeded', file.name, formatBytes(file.size), 100)}`)
  80. continue
  81. }
  82. const extension = file.name.split('.').pop() || ''
  83. if (!isExtensionAllowed(extension, 'group')) {
  84. if (!(await confirm(t('confirmUnsupportedFile', extension || 'unknown')))) continue
  85. }
  86. const rawFile = await readFile(file)
  87. newPendingFiles.push(rawFile)
  88. } catch (error: any) {
  89. errors.push(`${file.name} - ${t('readingFailed')}`)
  90. }
  91. }
  92. if (errors.length > 0) showError(`${t('uploadErrors')}:\n${errors.join('\n')}`)
  93. if (newPendingFiles.length > 0) {
  94. setPendingFiles(prev => [...prev, ...newPendingFiles])
  95. setShouldOpenModal(true)
  96. }
  97. }
  98. const handleConfirmIndexing = async (config: IndexingConfig) => {
  99. setIsIndexingModalOpen(false)
  100. try {
  101. for (const rawFile of pendingFiles) {
  102. const uploadRes = await uploadService.uploadFileWithConfig(rawFile.file, config, authToken)
  103. if (uploadRes && uploadRes.id) {
  104. await knowledgeGroupService.addFileToGroups(uploadRes.id, [notebook.id])
  105. }
  106. }
  107. showSuccess(t('successUploadFile'))
  108. loadData()
  109. } catch (error: any) {
  110. showError(t('errorUploadFile', error.message || t('unknownError')))
  111. } finally {
  112. setPendingFiles([])
  113. }
  114. }
  115. const handleRemoveFile = async (fileId: string, fileName: string) => {
  116. if (!(await confirm(t('confirmRemoveFileFromGroup', fileName)))) return;
  117. try {
  118. await knowledgeGroupService.removeFileFromGroup(fileId, notebook.id);
  119. setFiles(prev => prev.filter(f => f.id !== fileId));
  120. showSuccess(t('fileDeleted'));
  121. } catch (error) {
  122. showError(t('deleteFailed'));
  123. }
  124. }
  125. const filteredFiles = useMemo(() => {
  126. return files.filter(file => file.name.toLowerCase().includes(filterName.toLowerCase()));
  127. }, [files, filterName]);
  128. const getFileIcon = (file: KnowledgeFile) => {
  129. if (file.type.startsWith('image/')) return <ImageIcon size={20} className="text-slate-500" />;
  130. if (file.type === 'application/pdf') return <FileType size={20} className="text-blue-500" />;
  131. return <FileText size={20} className="text-blue-500" />;
  132. };
  133. return (
  134. <div className="flex flex-col h-full bg-transparent overflow-hidden">
  135. <NotebookGlobalDragDropOverlay onFilesSelected={handleFileUpload} isAdmin={isAdmin} />
  136. <input
  137. type="file"
  138. ref={fileInputRef}
  139. onChange={(e) => e.target.files && handleFileUpload(e.target.files)}
  140. multiple
  141. className="hidden"
  142. />
  143. {/* Header */}
  144. <div className="px-8 pt-8 pb-6 flex items-start justify-between shrink-0">
  145. <div className="flex items-start gap-4 min-w-0">
  146. <button
  147. onClick={onBack}
  148. className="mt-1 p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-lg transition-colors active:scale-90"
  149. >
  150. <ArrowLeft size={20} />
  151. </button>
  152. <div className="min-w-0">
  153. <div className="flex items-center gap-2">
  154. <div className="p-1.5 bg-blue-50 rounded-lg text-blue-600 border border-blue-100/30">
  155. <BookOpen size={18} />
  156. </div>
  157. <h1 className="text-2xl font-bold text-slate-900 truncate leading-tight">
  158. {notebook.name}
  159. </h1>
  160. </div>
  161. <p className="text-[15px] text-slate-500 mt-1 truncate max-w-2xl">
  162. {notebook.description || "Browse and manage files within this group."}
  163. </p>
  164. </div>
  165. </div>
  166. <div className="flex items-center gap-3">
  167. <button
  168. onClick={() => onChatWithContext?.({ selectedGroups: [notebook.id] })}
  169. className="flex items-center gap-2 px-5 py-2.5 bg-white border border-slate-200 text-slate-700 rounded-lg font-semibold text-sm hover:bg-slate-50 transition-all shadow-sm"
  170. >
  171. <MessageSquare size={18} className="text-blue-600" />
  172. {t('chatWithGroup')}
  173. </button>
  174. {isAdmin && (
  175. <button
  176. onClick={() => fileInputRef.current?.click()}
  177. 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"
  178. >
  179. <Plus size={18} />
  180. {t('addFile')}
  181. </button>
  182. )}
  183. </div>
  184. </div>
  185. {/* Filter Bar */}
  186. <div className="px-8 pb-6 flex items-center justify-between gap-4 shrink-0">
  187. <div className="relative max-w-xs w-full">
  188. <Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={16} />
  189. <input
  190. type="text"
  191. placeholder="Filter group files..."
  192. value={filterName}
  193. onChange={(e) => setFilterName(e.target.value)}
  194. 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"
  195. />
  196. </div>
  197. <button
  198. onClick={() => loadData()}
  199. className="p-2 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-all"
  200. >
  201. <RefreshCw size={18} />
  202. </button>
  203. </div>
  204. {/* Content Area */}
  205. <div className="flex-1 overflow-y-auto px-8 pb-8">
  206. {isLoading ? (
  207. <div className="flex items-center justify-center py-20">
  208. <div className="w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
  209. </div>
  210. ) : filteredFiles.length > 0 ? (
  211. <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-6">
  212. <AnimatePresence mode="popLayout">
  213. {filteredFiles.map((file) => (
  214. <motion.div
  215. key={file.id}
  216. layout
  217. initial={{ opacity: 0, y: 10 }}
  218. animate={{ opacity: 1, y: 0 }}
  219. className="bg-white rounded-xl border border-slate-200/80 p-5 shadow-sm hover:shadow-md transition-all group relative overflow-hidden flex flex-col h-full"
  220. >
  221. <div className="flex items-start justify-between mb-4">
  222. <div className="p-2.5 bg-blue-50 rounded-lg text-blue-600 shadow-sm border border-blue-100/30">
  223. {getFileIcon(file)}
  224. </div>
  225. <div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
  226. {isFormatSupportedForPreview(file.name) && (
  227. <button onClick={() => setPdfPreview({ fileId: file.id, fileName: file.name })} className="p-1.5 text-slate-400 hover:text-blue-600 rounded-md">
  228. <Eye size={16} />
  229. </button>
  230. )}
  231. {isAdmin && (
  232. <button onClick={() => handleRemoveFile(file.id, file.name)} className="p-1.5 text-slate-400 hover:text-red-500 rounded-md">
  233. <Plus size={16} className="rotate-45" />
  234. </button>
  235. )}
  236. </div>
  237. </div>
  238. <div className="flex-1">
  239. <h3 className="font-bold text-slate-900 text-[15px] mb-2 leading-snug line-clamp-2">
  240. {file.name}
  241. </h3>
  242. <div className="flex items-center gap-2 mt-2">
  243. <span className="px-2 py-0.5 bg-slate-100 text-slate-500 rounded text-[10px] font-bold tracking-wider uppercase">
  244. {file.status}
  245. </span>
  246. </div>
  247. </div>
  248. <div className="mt-auto pt-4 border-t border-slate-50 flex items-center justify-between">
  249. <div className="text-[12px] font-medium text-slate-400">
  250. {formatBytes(file.size)}
  251. </div>
  252. <span className="text-[10px] font-bold text-slate-300 uppercase tracking-widest">
  253. {file.name.split('.').pop()?.toUpperCase() || 'FILE'}
  254. </span>
  255. </div>
  256. </motion.div>
  257. ))}
  258. </AnimatePresence>
  259. </div>
  260. ) : (
  261. <div className="flex flex-col items-center justify-center py-32 border-2 border-dashed border-slate-200 rounded-2xl bg-white/50 text-center">
  262. <BookOpen className="w-12 h-12 text-slate-200 mx-auto mb-4" />
  263. <h3 className="text-slate-900 font-bold">{t('noFiles')}</h3>
  264. <p className="text-slate-500 text-sm mt-1">{t('noFilesDesc')}</p>
  265. </div>
  266. )}
  267. </div>
  268. <IndexingModalWithMode
  269. isOpen={isIndexingModalOpen}
  270. onClose={() => { setPendingFiles([]); setIsIndexingModalOpen(false); }}
  271. files={pendingFiles}
  272. embeddingModels={models.filter(m => m.type === 'embedding')}
  273. defaultEmbeddingId={models.find(m => m.isDefault)?.id || ''}
  274. onConfirm={handleConfirmIndexing}
  275. />
  276. {pdfPreview && (
  277. <PDFPreview
  278. fileId={pdfPreview.fileId}
  279. fileName={pdfPreview.fileName}
  280. authToken={authToken}
  281. onClose={() => setPdfPreview(null)}
  282. />
  283. )}
  284. </div>
  285. )
  286. }