import React, { useCallback, useEffect, useState, useMemo, useRef } from 'react' import IndexingModalWithMode from '../../components/IndexingModalWithMode' import { PDFPreview } from '../../components/PDFPreview' import { DragDropUpload } from '../../components/DragDropUpload' import { GlobalDragDropOverlay } from '../../components/GlobalDragDropOverlay' import { AppSettings, DEFAULT_MODELS, DEFAULT_SETTINGS, IndexingConfig, KnowledgeFile, ModelConfig, ModelType, RawFile, KnowledgeGroup, } from '../../types' import { readFile, formatBytes } from '../../utils/fileUtils' import { useLanguage } from '../../contexts/LanguageContext' import { useToast } from '../../contexts/ToastContext' import { userSettingService } from '../../services/userSettingService' import { uploadService } from '../../services/uploadService' import { knowledgeBaseService } from '../../services/knowledgeBaseService' import { knowledgeGroupService } from '../../services/knowledgeGroupService' import { SettingsDrawer } from '../../components/SettingsDrawer' import { FileGroupTags } from '../../components/FileGroupTags' import { ChunkInfoDrawer } from '../../components/ChunkInfoDrawer' import { Search, Filter, Upload, FileText, Image as ImageIcon, FileType, CheckCircle2, CircleDashed, RefreshCw, Eye, Trash2, FolderPlus, ChevronLeft, ChevronRight, LayoutList, RotateCcw, Settings } from 'lucide-react' interface KnowledgeBaseViewProps { authToken: string; onLogout: () => void; modelConfigs?: ModelConfig[]; onNavigate: (view: any) => void; isAdmin?: boolean; } export const KnowledgeBaseView: React.FC = (props) => { const { authToken, onLogout, modelConfigs = DEFAULT_MODELS, onNavigate, isAdmin = false } = props; const { showError, showWarning, showSuccess } = useToast() const { t } = useLanguage() // Data State const [files, setFiles] = useState([]) const [groups, setGroups] = useState([]) const [settings, setSettings] = useState(DEFAULT_SETTINGS) const [isLoadingSettings, setIsLoadingSettings] = useState(true) const [isLoadingFiles, setIsLoadingFiles] = useState(true) // 添加文件加载状态 // UI State const [isSettingsOpen, setIsSettingsOpen] = useState(false) const [pdfPreview, setPdfPreview] = useState<{ fileId: string; fileName: string } | null>(null) const [isIndexingModalOpen, setIsIndexingModalOpen] = useState(false) const [pendingFiles, setPendingFiles] = useState([]) const [fileInputRef] = useState>(React.createRef()) // State to track if modal should be opened const [shouldOpenModal, setShouldOpenModal] = useState(false) // Effect to open modal when pending files are set useEffect(() => { if (shouldOpenModal && pendingFiles.length > 0) { setIsIndexingModalOpen(true); setShouldOpenModal(false); } }, [shouldOpenModal, pendingFiles.length]); // Filter & Pagination State const [filterName, setFilterName] = useState('') const [filterGroup, setFilterGroup] = useState('all') const [filterStatus, setFilterStatus] = useState<'all' | 'ready' | 'indexing' | 'failed'>('all') const [currentPage, setCurrentPage] = useState(1) const pageSize = 20 const [chunkDrawer, setChunkDrawer] = useState<{ isOpen: boolean; fileId: string; fileName: string } | null>(null) // Auto-refresh state const [isAutoRefreshEnabled, setIsAutoRefreshEnabled] = useState(true) const [autoRefreshInterval, setAutoRefreshInterval] = useState(5000) // 5 seconds default // --- データ取得 --- const fetchAndSetSettings = useCallback(async () => { if (!authToken) return try { const userSettings = await userSettingService.get(authToken) setSettings({ ...DEFAULT_SETTINGS, ...userSettings }) } catch (error) { console.error('Failed to fetch user settings:', error) setSettings(DEFAULT_SETTINGS) } finally { setIsLoadingSettings(false) } }, [authToken]) const fetchAndSetFiles = useCallback(async () => { if (!authToken) return try { const remoteFiles = await knowledgeBaseService.getAll(authToken) setFiles(remoteFiles) } catch (error) { console.error('Failed to fetch files:', error) } finally { setIsLoadingFiles(false) // 文件加载完成 } }, [authToken]) const fetchAndSetGroups = useCallback(async () => { if (!authToken) return try { const remoteGroups = await knowledgeGroupService.getGroups() setGroups(remoteGroups) } catch (error) { console.error('Failed to fetch groups:', error) } }, [authToken]) useEffect(() => { if (authToken) { fetchAndSetSettings() fetchAndSetFiles() fetchAndSetGroups() } }, [authToken, fetchAndSetSettings, fetchAndSetFiles, fetchAndSetGroups]) // --- ハンドラー --- // 检查文件是否支持预览转换 const isSupportedForPreview = (file: KnowledgeFile) => { // 支持PDF和文档类型文件的预览转换,现在也包括图片 const supportedExtensions = ['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.txt', '.md', '.html', '.csv', '.rtf', '.odt', '.ods', '.odp', '.jpg', '.jpeg', '.png', '.bmp', '.gif', '.tiff', '.webp']; const fileExtension = '.' + file.name.toLowerCase().split('.').pop(); return supportedExtensions.includes(fileExtension); }; const handleUpdateSettings = async (newSettings: AppSettings) => { if (!authToken) return try { await userSettingService.update(authToken, newSettings) setSettings(newSettings) } catch (error) { console.error('Failed to update user settings:', error) showError(t('mmErrorTitle')) // Using existing error title or generic } } const handleFileUpload = async (fileList: FileList) => { console.log('DEBUG: handleFileUpload called with', fileList.length, 'files'); // Log all files received from the file input for (let i = 0; i < fileList.length; i++) { const file = fileList[i]; console.log('DEBUG: File', i, 'details:', { name: file.name, size: file.size, type: file.type, lastModified: file.lastModified }); } if (!authToken) { showWarning(t('loginRequired')) return } const MAX_FILE_SIZE = 104857600; const MAX_SIZE_MB = 100; const rawFiles: RawFile[] = [] const errors: string[] = [] console.log('DEBUG: About to start processing', fileList.length, 'files'); // Process each file sequentially using array methods to avoid potential loop issues const filesArray = Array.from(fileList); // Convert FileList to Array for (let i = 0; i < filesArray.length; i++) { console.log('DEBUG: Loop iteration', i, 'starting'); const file = filesArray[i]; console.log('DEBUG: Processing file', i, ':', file.name, 'size:', file.size, 'type:', file.type); if (file.size > MAX_FILE_SIZE) { const sizeMB = (file.size / 1024 / 1024).toFixed(2); errors.push(`${file.name} (${sizeMB}MB - ${t('max')} ${MAX_SIZE_MB}MB)`); // "max" used as approximation console.log('DEBUG: File rejected due to size limit:', file.name); console.log('DEBUG: Loop iteration', i, 'completed due to size check'); continue; } // ファイル検証ロジックを維持、または標準として簡潔にする const allowedTypes = [ 'application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation', 'application/vnd.oasis.opendocument.text', 'application/vnd.oasis.opendocument.spreadsheet', 'application/vnd.oasis.opendocument.presentation', 'application/vnd.oasis.opendocument.graphics', 'text/plain', 'text/markdown', 'text/html', 'text/csv', 'text/xml', 'application/xml', 'application/json', 'text/x-python', 'text/x-java', 'text/x-c', 'text/x-c++', 'text/javascript', 'text/typescript', 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/tiff', 'image/bmp', 'image/svg+xml', 'application/zip', 'application/x-tar', 'application/gzip', 'application/x-7z-compressed', 'application/rtf', 'application/epub+zip', 'application/x-mobipocket-ebook', ]; const ext = file.name.toLowerCase().split('.').pop(); const allowedExtensions = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'md', 'html', 'csv', 'rtf', 'odt', 'ods', 'odp', 'json', 'xml']; const isAllowed = allowedTypes.includes(file.type) || file.type.startsWith('text/') || file.type.startsWith('application/vnd.') || file.type.startsWith('application/x-') || file.type === '' || allowedExtensions.includes(ext || ''); if (!isAllowed) { errors.push(`${file.name} - ${t('errorGeneric')}(${file.type || 'unknown'})`); console.log('DEBUG: File rejected due to type restriction:', file.name, 'type:', file.type, 'extension:', ext); console.log('DEBUG: Loop iteration', i, 'completed due to type check'); continue; } else { console.log('DEBUG: File passed validation:', file.name); } try { console.log('DEBUG: Attempting to read file:', file.name); const rawFile = await readFile(file) rawFiles.push(rawFile) console.log('DEBUG: Successfully added file to rawFiles:', file.name); console.log('DEBUG: Loop iteration', i, 'completed successfully'); } catch (error) { console.error(`Error reading file ${file.name}:`, error); errors.push(`${file.name} - ${t('readingFailed')}`); console.log('DEBUG: Failed to read file:', file.name); console.log('DEBUG: Loop iteration', i, 'completed due to error in readFile'); } } console.log('DEBUG: Finished processing all files'); console.log('DEBUG: Final rawFiles array:', rawFiles.map(f => f.name)); console.log('DEBUG: Errors array:', errors); console.log('DEBUG: Number of valid files:', rawFiles.length); console.log('DEBUG: Number of errors:', errors.length); if (errors.length > 0) showError(`${t('uploadErrors')}:\n${errors.join('\n')}`); if (rawFiles.length === 0) { console.log('DEBUG: No valid files to process, returning early'); return; } if (errors.length > 0 && rawFiles.length > 0) showWarning(t('uploadWarning').replace('$1', `${rawFiles.length}`).replace('$2', `${errors.length}`)); console.log('DEBUG: Setting pendingFiles with', rawFiles.length, 'files'); setPendingFiles(rawFiles); console.log('DEBUG: Current pendingFiles state:', pendingFiles.length); // This won't reflect the update yet setShouldOpenModal(true); console.log('DEBUG: Set shouldOpenModal to true'); } const handleRetryFile = async (fileId: string) => { if (!authToken) return try { showSuccess(t('retrying')) await knowledgeBaseService.retryFile(fileId, authToken) showSuccess(t('retrySuccess')) await fetchAndSetFiles() } catch (error) { console.error('Failed to retry file:', error) showError(t('retryFailed')) } } const handleConfirmIndexing = async (config: IndexingConfig) => { console.log('DEBUG: handleConfirmIndexing called with config and', pendingFiles.length, 'pending files'); if (!authToken) { console.log('DEBUG: No auth token, returning'); return } let hasSuccess = false for (const rawFile of pendingFiles) { console.log('DEBUG: Uploading file:', rawFile.name); try { await uploadService.uploadFileWithConfig(rawFile.file, config, authToken) hasSuccess = true console.log('DEBUG: Successfully uploaded file:', rawFile.name); } catch (error) { console.error(`Error uploading file ${rawFile.name}:`, error) showError(`${t('uploadFailed')}: ${rawFile.name} - ${error.message}`) } } if (hasSuccess) await fetchAndSetFiles() console.log('DEBUG: Clearing pending files'); setPendingFiles([]) setIsIndexingModalOpen(false) console.log('DEBUG: Closed indexing modal'); } const handleRemoveFile = async (id: string) => { if (!window.confirm(t('confirmDeleteFile'))) return if (!authToken) return try { await knowledgeBaseService.deleteFile(id, authToken) setFiles(prev => prev.filter(f => f.id !== id)) showSuccess(t('fileDeleted')) } catch (error) { console.error('Failed to delete file:', error) showError(`${t('deleteFailed')}: ` + error.message) } } const handleClearAll = async () => { if (!window.confirm(t('confirmClearKB'))) return if (!authToken) return try { await knowledgeBaseService.clearAll(authToken) setFiles([]) showSuccess(t('kbCleared')) } catch (error) { console.error('Failed to clear knowledge base:', error) showError(`${t('clearFailed')}: ` + error.message) } } const handleFileGroupsChange = (fileId: string, groupIds: string[]) => { setFiles(prev => prev.map(file => file.id === fileId ? { ...file, groups: groups.filter(g => groupIds.includes(g.id)) } : file )) } // --- ヘルパー --- const getFileIcon = (file: KnowledgeFile) => { if (file.type.startsWith('image/')) return ; if (file.type === 'application/pdf') return ; return ; }; // --- フィルタリングとページネーションのロジック --- const filteredFiles = useMemo(() => { return files.filter(file => { const matchName = file.name.toLowerCase().includes(filterName.toLowerCase()); const matchGroup = filterGroup === 'all' || file.groups?.some(g => g.id === filterGroup); const matchStatus = filterStatus === 'all' || (filterStatus === 'ready' && (file.status === 'ready' || file.status === 'vectorized')) || (filterStatus === 'indexing' && (file.status === 'indexing' || file.status === 'pending' || file.status === 'extracted')) || (filterStatus === 'failed' && (file.status === 'failed' || file.status === 'error')); return matchName && matchGroup && matchStatus; }); }, [files, filterName, filterGroup, filterStatus]); const totalPages = Math.ceil(filteredFiles.length / pageSize); const paginatedFiles = useMemo(() => { const start = (currentPage - 1) * pageSize; return filteredFiles.slice(start, start + pageSize); }, [filteredFiles, currentPage, pageSize]); // フィルタが変更されたときにページをリセット useEffect(() => { setCurrentPage(1); }, [filterName, filterGroup, filterStatus]); // Auto-refresh functionality for indexing files useEffect(() => { let intervalId: NodeJS.Timeout | null = null; const hasIndexingFiles = () => { return files.some(file => file.status === 'pending' || file.status === 'indexing' || file.status === 'extracted' || file.status === 'vectorized' ); }; const manageAutoRefresh = () => { if (intervalId) { clearInterval(intervalId); intervalId = null; } if (isAutoRefreshEnabled && hasIndexingFiles()) { intervalId = setInterval(() => { fetchAndSetFiles(); }, autoRefreshInterval); } }; // Manage auto-refresh based on current conditions manageAutoRefresh(); // Cleanup on unmount return () => { if (intervalId) { clearInterval(intervalId); } }; }, [isAutoRefreshEnabled, files, autoRefreshInterval, fetchAndSetFiles]); if (isLoadingSettings) { return (
) } return (
{/* Header */}

{t('kbManagement')}

{t('kbManagementDesc')}

{isAdmin && ( )} {isAdmin && ( )}
{/* フィルタとアクションバー */}
{/* 検索 */}
setFilterName(e.target.value)} className="w-full pl-9 pr-4 py-2 bg-white border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500" />
{/* グループフィルタ */}
{/* ステータスフィルタ */}
{/* Auto-refresh toggle */}
{/* Auto-refresh interval selector - only show when enabled */} {isAutoRefreshEnabled && (
)}
{/* 上传按钮组 - 保持两个上传选项 */}
{ if (e.target.files && e.target.files.length > 0) handleFileUpload(e.target.files) if (fileInputRef.current) fileInputRef.current.value = '' }} multiple className="hidden" accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.md,.html,.csv,.rtf,.odt,.ods,.odp,.json,.js,.jsx,.ts,.tsx,.css,.xml,image/*" /> {isAdmin && ( )}
{/* 全局拖拽上传覆盖层 - 只在拖拽文件到页面时显示 */} {/* リスト表示 */}
{/* リストヘッダー */}
{t('fileName')}
{t('size')}
{t('status')}
{t('groups')}
{t('actions')}
{/* リストアイテム */}
{paginatedFiles.length > 0 ? ( paginatedFiles.map((file) => (
{getFileIcon(file)}
{formatBytes(file.size)}
{file.status === 'ready' || file.status === 'vectorized' ? : (file.status === 'failed' || file.status === 'error') ? : } {file.status === 'ready' || file.status === 'vectorized' ? t('statusReadyFragment') : (file.status === 'failed' || file.status === 'error') ? t('statusFailedFragment') : t('statusIndexingFragment') }
g.id) || []} onGroupsChange={(groupIds) => handleFileGroupsChange(file.id, groupIds)} isAdmin={isAdmin} />
{isAdmin && ( )}
{isSupportedForPreview(file) && ( )} {(file.status === 'failed' || file.status === 'error') && isAdmin && ( )} {isAdmin && ( )}
)) ) : (
{isLoadingFiles ? (

{t('loading')}

) : (
)}
)}
{/* Pagination */} {totalPages > 1 && (
{t('showingRange') .replace('$1', `${(currentPage - 1) * pageSize + 1}`) .replace('$2', `${Math.min(currentPage * pageSize, filteredFiles.length)}`) .replace('$3', `${filteredFiles.length}`)}
{currentPage} / {totalPages}
)}
{/* ドロワーとモーダル */} { setPendingFiles([]) setIsIndexingModalOpen(false) }} files={pendingFiles} embeddingModels={modelConfigs.filter(m => m.type === ModelType.EMBEDDING)} defaultEmbeddingId={settings.selectedEmbeddingId} onConfirm={handleConfirmIndexing} /> {pdfPreview && ( setPdfPreview(null)} /> )} {chunkDrawer && ( setChunkDrawer(null)} fileId={chunkDrawer.fileId} fileName={chunkDrawer.fileName} authToken={authToken} /> )} setIsSettingsOpen(false)} settings={settings} models={modelConfigs} onSettingsChange={handleUpdateSettings} onOpenSettings={() => onNavigate('settings')} mode="kb" />
) }