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 { useConfirm } from '../../contexts/ConfirmContext' 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, Info } from 'lucide-react' import { KB_ALLOWED_EXTENSIONS, IMAGE_MIME_TYPES, isExtensionAllowed, getSupportedFormatsLabel, DOC_EXTENSIONS, CODE_EXTENSIONS, IMAGE_EXTENSIONS, isFormatSupportedForPreview } from '../../constants/fileSupport' 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 { confirm } = useConfirm() 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 settingsData = isAdmin ? await userSettingService.getGlobal(authToken) : await userSettingService.get(authToken); setSettings({ ...DEFAULT_SETTINGS, ...settingsData }) } catch (error) { console.error('Failed to fetch settings:', error) setSettings(DEFAULT_SETTINGS) } finally { setIsLoadingSettings(false) } }, [authToken, isAdmin]) 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) => { return isFormatSupportedForPreview(file.name); }; const handleUpdateSettings = async (newSettings: AppSettings) => { if (!authToken) return try { if (isAdmin) { await userSettingService.updateGlobal(authToken, newSettings) } else { await userSettingService.update(authToken, newSettings) } setSettings(newSettings) } catch (error: any) { console.error('Failed to update settings:', error) const detail = error.response?.data?.message const errorMessage = Array.isArray(detail) ? detail.join(', ') : detail || error.message || t('mmErrorTitle') showError(`${t('mmErrorTitle')}: ${errorMessage}`) } } 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); const extension = file.name.split('.').pop() || '' if (!isExtensionAllowed(extension, 'kb')) { errors.push(t('unsupportedFileType', file.name, extension)) console.log('DEBUG: File rejected due to type restriction:', file.name, 'extension:', extension); console.log('DEBUG: Loop iteration', i, 'completed due to type check'); continue } if (file.size > MAX_FILE_SIZE) { errors.push(t('fileSizeLimitExceeded', file.name, formatBytes(file.size), MAX_SIZE_MB)) console.log('DEBUG: File rejected due to size limit:', file.name); console.log('DEBUG: Loop iteration', i, 'completed due to size check'); continue; } 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 (!(await 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 (!(await 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={KB_ALLOWED_EXTENSIONS.map(ext => `.${ext}`).join(',') + ',' + IMAGE_MIME_TYPES.join(',')} /> {isAdmin && (
{t('supportedFormatsInfo')}
)}
{/* 全局拖拽上传覆盖层 - 只在拖拽文件到页面时显示 */} {/* リスト表示 */}
{/* リストヘッダー */}
{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="all" isAdmin={isAdmin} />
) }