| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312 |
- import React, { useEffect, useState, useCallback, useMemo } from 'react'
- import { ArrowLeft, Plus, MessageSquare, BookOpen, Trash2, Eye, FileText, FileType, Image as ImageIcon, Search, RefreshCw } from 'lucide-react'
- import { KnowledgeGroup, KnowledgeFile } from '../../types'
- import { knowledgeBaseService } from '../../services/knowledgeBaseService'
- import { modelConfigService } from '../../services/modelConfigService'
- import { uploadService } from '../../services/uploadService'
- import { knowledgeGroupService } from '../../services/knowledgeGroupService'
- import { useToast } from '../../contexts/ToastContext'
- import { useConfirm } from '../../contexts/ConfirmContext'
- import { RawFile, IndexingConfig, ModelConfig } from '../../types'
- import IndexingModalWithMode from '../IndexingModalWithMode'
- import { PDFPreview } from '../PDFPreview'
- import { NotebookGlobalDragDropOverlay } from '../NotebookGlobalDragDropOverlay'
- import { useLanguage } from '../../contexts/LanguageContext'
- import { readFile, formatBytes } from '../../utils/fileUtils'
- import { isExtensionAllowed, isFormatSupportedForPreview } from '../../constants/fileSupport'
- import { motion, AnimatePresence } from 'framer-motion'
- interface NotebookDetailViewProps {
- authToken: string;
- notebook: KnowledgeGroup;
- onBack: () => void;
- onChatWithContext?: (context: { selectedGroups?: string[], selectedFiles?: string[] }) => void;
- isAdmin?: boolean;
- }
- export const NotebookDetailView: React.FC<NotebookDetailViewProps> = ({ authToken, notebook, onBack, onChatWithContext, isAdmin = false }) => {
- const [files, setFiles] = useState<KnowledgeFile[]>([])
- const [isLoading, setIsLoading] = useState(false)
- const { showError, showSuccess } = useToast()
- const { confirm } = useConfirm()
- const { t } = useLanguage()
- const [isIndexingModalOpen, setIsIndexingModalOpen] = useState(false)
- const [pendingFiles, setPendingFiles] = useState<RawFile[]>([])
- const [shouldOpenModal, setShouldOpenModal] = useState(false)
- const [models, setModels] = useState<ModelConfig[]>([])
- const [pdfPreview, setPdfPreview] = useState<{ fileId: string; fileName: string } | null>(null)
- const [filterName, setFilterName] = useState('')
- const fileInputRef = React.useRef<HTMLInputElement>(null)
- useEffect(() => {
- const fetchModels = async () => {
- try {
- const res = await modelConfigService.getAll(authToken)
- setModels(res)
- } catch (error) {
- console.error('Failed to fetch models', error)
- }
- }
- if (authToken) fetchModels()
- }, [authToken])
- useEffect(() => {
- if (shouldOpenModal && pendingFiles.length > 0) {
- setIsIndexingModalOpen(true);
- setShouldOpenModal(false);
- }
- }, [shouldOpenModal, pendingFiles.length]);
- const loadData = useCallback(async () => {
- setIsLoading(true)
- try {
- const allFiles = await knowledgeBaseService.getAll(authToken)
- const notebookFiles = allFiles.filter(f => f.groups?.some(g => g.id === notebook.id))
- setFiles(notebookFiles)
- } catch (error) {
- console.error(error)
- showError(t('errorLoadData'))
- } finally {
- setIsLoading(false)
- }
- }, [authToken, notebook.id, t, showError])
- useEffect(() => {
- loadData()
- }, [loadData])
- const handleFileUpload = async (fileList: FileList | File[]) => {
- if (!fileList || fileList.length === 0) return
- const errors: string[] = []
- const newPendingFiles: RawFile[] = []
- for (let i = 0; i < fileList.length; i++) {
- const file = fileList[i]
- try {
- if (file.size > 104857600) {
- errors.push(`${file.name} - ${t('fileSizeLimitExceeded', file.name, formatBytes(file.size), 100)}`)
- continue
- }
- const extension = file.name.split('.').pop() || ''
- if (!isExtensionAllowed(extension, 'group')) {
- if (!(await confirm(t('confirmUnsupportedFile', extension || t('unknown'))))) continue
- }
- const rawFile = await readFile(file)
- newPendingFiles.push(rawFile)
- } catch (error: any) {
- errors.push(`${file.name} - ${t('readingFailed')}`)
- }
- }
- if (errors.length > 0) showError(`${t('uploadErrors')}:\n${errors.join('\n')}`)
- if (newPendingFiles.length > 0) {
- setPendingFiles(prev => [...prev, ...newPendingFiles])
- setShouldOpenModal(true)
- }
- }
- const handleConfirmIndexing = async (config: IndexingConfig) => {
- setIsIndexingModalOpen(false)
- try {
- for (const rawFile of pendingFiles) {
- const uploadRes = await uploadService.uploadFileWithConfig(rawFile.file, config, authToken)
- if (uploadRes && uploadRes.id) {
- await knowledgeGroupService.addFileToGroups(uploadRes.id, [notebook.id])
- }
- }
- showSuccess(t('successUploadFile'))
- loadData()
- } catch (error: any) {
- showError(t('errorUploadFile', error.message || t('unknownError')))
- } finally {
- setPendingFiles([])
- }
- }
- const handleRemoveFile = async (fileId: string, fileName: string) => {
- if (!(await confirm(t('confirmRemoveFileFromGroup', fileName)))) return;
- try {
- await knowledgeGroupService.removeFileFromGroup(fileId, notebook.id);
- setFiles(prev => prev.filter(f => f.id !== fileId));
- showSuccess(t('fileDeleted'));
- } catch (error) {
- showError(t('deleteFailed'));
- }
- }
- const filteredFiles = useMemo(() => {
- return files.filter(file => file.name.toLowerCase().includes(filterName.toLowerCase()));
- }, [files, filterName]);
- const getFileIcon = (file: KnowledgeFile) => {
- if (file.type.startsWith('image/')) return <ImageIcon size={20} className="text-slate-500" />;
- if (file.type === 'application/pdf') return <FileType size={20} className="text-blue-500" />;
- return <FileText size={20} className="text-blue-500" />;
- };
- return (
- <div className="flex flex-col h-full bg-transparent overflow-hidden">
- <NotebookGlobalDragDropOverlay onFilesSelected={handleFileUpload} isAdmin={isAdmin} />
- <input
- type="file"
- ref={fileInputRef}
- onChange={(e) => e.target.files && handleFileUpload(e.target.files)}
- multiple
- className="hidden"
- />
- {/* Header */}
- <div className="px-8 pt-8 pb-6 flex items-start justify-between shrink-0">
- <div className="flex items-start gap-4 min-w-0">
- <button
- onClick={onBack}
- className="mt-1 p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-lg transition-colors active:scale-90"
- >
- <ArrowLeft size={20} />
- </button>
- <div className="min-w-0">
- <div className="flex items-center gap-2">
- <div className="p-1.5 bg-blue-50 rounded-lg text-blue-600 border border-blue-100/30">
- <BookOpen size={18} />
- </div>
- <h1 className="text-2xl font-bold text-slate-900 truncate leading-tight">
- {notebook.name}
- </h1>
- </div>
- <p className="text-[15px] text-slate-500 mt-1 truncate max-w-2xl">
- {notebook.description || t('browseManageFiles')}
- </p>
- </div>
- </div>
- <div className="flex items-center gap-3">
- <button
- onClick={() => onChatWithContext?.({ selectedGroups: [notebook.id] })}
- 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"
- >
- <MessageSquare size={18} className="text-blue-600" />
- {t('chatWithGroup')}
- </button>
- {isAdmin && (
- <button
- onClick={() => fileInputRef.current?.click()}
- 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"
- >
- <Plus size={18} />
- {t('addFile')}
- </button>
- )}
- </div>
- </div>
- {/* Filter Bar */}
- <div className="px-8 pb-6 flex items-center justify-between gap-4 shrink-0">
- <div className="relative max-w-xs w-full">
- <Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={16} />
- <input
- type="text"
- placeholder={t('filterGroupFiles')}
- value={filterName}
- onChange={(e) => setFilterName(e.target.value)}
- 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"
- />
- </div>
- <button
- onClick={() => loadData()}
- className="p-2 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-all"
- >
- <RefreshCw size={18} />
- </button>
- </div>
- {/* Content Area */}
- <div className="flex-1 overflow-y-auto px-8 pb-8">
- {isLoading ? (
- <div className="flex items-center justify-center py-20">
- <div className="w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
- </div>
- ) : filteredFiles.length > 0 ? (
- <div className="flex flex-col gap-3">
- <AnimatePresence mode="popLayout">
- {filteredFiles.map((file) => (
- <motion.div
- key={file.id}
- layout
- initial={{ opacity: 0, y: 10 }}
- animate={{ opacity: 1, y: 0 }}
- 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"
- >
- {/* Icon */}
- <div className="p-2.5 bg-blue-50 rounded-lg text-blue-600 shadow-sm border border-blue-100/30 shrink-0">
- {getFileIcon(file)}
- </div>
- {/* Main info */}
- <div className="flex-1 min-w-0">
- <div className="flex items-center gap-3 mb-1">
- <h3 className="font-bold text-slate-900 text-[15px] truncate">
- {file.name}
- </h3>
- <span className="px-2 py-0.5 bg-slate-100 text-slate-500 rounded text-[10px] font-bold tracking-wider uppercase shrink-0">
- {file.status}
- </span>
- </div>
- </div>
- {/* Meta info & Actions */}
- <div className="flex items-center gap-6 shrink-0">
- <div className="text-[12px] font-medium text-slate-400 flex flex-col items-end">
- <span>{formatBytes(file.size)}</span>
- <span className="text-[10px] font-bold text-slate-300 uppercase tracking-widest mt-0.5">
- {file.name.split('.').pop()?.toUpperCase() || 'FILE'}
- </span>
- </div>
- <div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
- {isFormatSupportedForPreview(file.name) && (
- <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">
- <Eye size={16} />
- </button>
- )}
- {isAdmin && (
- <button onClick={() => handleRemoveFile(file.id, file.name)} className="p-1.5 text-slate-400 hover:text-red-500 rounded-md bg-slate-50">
- <Plus size={16} className="rotate-45" />
- </button>
- )}
- </div>
- </div>
- </motion.div>
- ))}
- </AnimatePresence>
- </div>
- ) : (
- <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">
- <BookOpen className="w-12 h-12 text-slate-200 mx-auto mb-4" />
- <h3 className="text-slate-900 font-bold">{t('noFiles')}</h3>
- <p className="text-slate-500 text-sm mt-1">{t('noFilesDesc')}</p>
- </div>
- )}
- </div>
- <IndexingModalWithMode
- isOpen={isIndexingModalOpen}
- onClose={() => { setPendingFiles([]); setIsIndexingModalOpen(false); }}
- files={pendingFiles}
- embeddingModels={models.filter(m => m.type === 'embedding')}
- defaultEmbeddingId={models.find(m => m.isDefault)?.id || ''}
- onConfirm={handleConfirmIndexing}
- authToken={authToken}
- />
- {pdfPreview && (
- <PDFPreview
- fileId={pdfPreview.fileId}
- fileName={pdfPreview.fileName}
- authToken={authToken}
- onClose={() => setPdfPreview(null)}
- />
- )}
- </div>
- )
- }
|