| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787 |
- 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<KnowledgeBaseViewProps> = (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<KnowledgeFile[]>([])
- const [groups, setGroups] = useState<KnowledgeGroup[]>([])
- const [settings, setSettings] = useState<AppSettings>(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<RawFile[]>([])
- const [fileInputRef] = useState<React.RefObject<HTMLInputElement>>(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<number>(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 <ImageIcon className="w-5 h-5 text-purple-500" />;
- if (file.type === 'application/pdf') return <FileType className="w-5 h-5 text-red-500" />;
- return <FileText className="w-5 h-5 text-blue-500" />;
- };
- // --- フィルタリングとページネーションのロジック ---
- 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 (
- <div className='flex items-center justify-center min-h-screen bg-slate-50 w-full'>
- <div className='text-blue-600 animate-spin rounded-full h-12 w-12 border-4 border-t-4 border-blue-300'></div>
- </div>
- )
- }
- return (
- <div className='flex flex-col h-full w-full bg-slate-50 overflow-hidden relative'>
- {/* Header */}
- <div className="bg-white border-b border-slate-200 px-6 py-4 flex items-center justify-between shrink-0">
- <div>
- <h1 className="text-xl font-bold text-slate-800 flex items-center gap-2">
- <LayoutList className="w-6 h-6 text-blue-600" />
- <span className="bg-gradient-to-r from-blue-600 to-purple-600 text-transparent bg-clip-text">
- {t('kbManagement')}
- </span>
- </h1>
- <p className="text-sm text-slate-500 mt-1">{t('kbManagementDesc')}</p>
- </div>
- <div className="flex items-center gap-3">
- {isAdmin && (
- <button
- onClick={() => setIsSettingsOpen(true)}
- className="flex items-center gap-2 px-4 py-2 bg-white border border-slate-200 text-slate-700 rounded-lg hover:bg-slate-50 transition-colors shadow-sm"
- title={t('settings')}
- >
- <Settings className="w-4 h-4 text-blue-600" />
- {t('settings')}
- </button>
- )}
- {isAdmin && (
- <button
- onClick={handleClearAll}
- className="flex items-center gap-2 px-4 py-2 bg-white border border-red-200 text-red-600 rounded-lg hover:bg-red-50 transition-colors shadow-sm"
- title={t('confirmClearKB')}
- >
- <Trash2 className="w-4 h-4" />
- {t('clearAll')}
- </button>
- )}
- </div>
- </div>
- {/* フィルタとアクションバー */}
- <div className="px-6 py-4 flex flex-col md:flex-row md:items-center justify-between gap-4 shrink-0">
- <div className="flex flex-wrap items-center gap-3 flex-1">
- {/* 検索 */}
- <div className="relative w-full md:w-64">
- <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
- <input
- type="text"
- placeholder={t('searchPlaceholder')}
- value={filterName}
- onChange={(e) => 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"
- />
- </div>
- {/* グループフィルタ */}
- <div className="relative w-40">
- <Filter className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
- <select
- value={filterGroup}
- onChange={(e) => setFilterGroup(e.target.value)}
- className="w-full pl-9 pr-8 py-2 bg-white border border-slate-200 rounded-lg text-sm appearance-none focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500"
- >
- <option value="all">{t('allGroups')}</option>
- {groups.map(g => (
- <option key={g.id} value={g.id}>{g.name}</option>
- ))}
- </select>
- </div>
- {/* ステータスフィルタ */}
- <div className="w-32">
- <select
- value={filterStatus}
- onChange={(e) => setFilterStatus(e.target.value as any)}
- className="w-full px-3 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"
- >
- <option value="all">{t('allStatus')}</option>
- <option value="ready">{t('statusReadyFragment')}</option>
- <option value="indexing">{t('statusIndexingFragment')}</option>
- <option value="failed">{t('statusFailedFragment')}</option>
- </select>
- </div>
- {/* Auto-refresh toggle */}
- <div className="flex items-center gap-2">
- <label className="text-sm text-slate-600">{t('autoRefresh')}</label>
- <button
- type="button"
- onClick={() => setIsAutoRefreshEnabled(!isAutoRefreshEnabled)}
- className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none ${isAutoRefreshEnabled ? 'bg-blue-600' : 'bg-slate-300'
- }`}
- >
- <span
- className={`inline-block h-3 w-3 transform rounded-full bg-white transition-transform ${isAutoRefreshEnabled ? 'translate-x-5' : 'translate-x-1'
- }`}
- />
- </button>
- </div>
- {/* Auto-refresh interval selector - only show when enabled */}
- {isAutoRefreshEnabled && (
- <div className="flex items-center gap-2">
- <label className="text-sm text-slate-600">{t('refreshInterval')}</label>
- <select
- value={autoRefreshInterval}
- onChange={(e) => setAutoRefreshInterval(Number(e.target.value))}
- className="px-2 py-1 text-sm bg-white border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500"
- >
- <option value={2000}>2s</option>
- <option value={5000}>5s</option>
- <option value={10000}>10s</option>
- <option value={30000}>30s</option>
- </select>
- </div>
- )}
- </div>
- {/* 上传按钮组 - 保持两个上传选项 */}
- <div className="flex items-center gap-3">
- <input
- type="file"
- ref={fileInputRef}
- onChange={(e) => {
- 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 && (
- <button
- onClick={() => fileInputRef.current?.click()}
- className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg shadow-sm transition-colors font-medium"
- >
- <Upload className="w-4 h-4" />
- {t('uploadFile')}
- </button>
- )}
- </div>
- </div>
- {/* 全局拖拽上传覆盖层 - 只在拖拽文件到页面时显示 */}
- <GlobalDragDropOverlay
- onFilesSelected={handleFileUpload}
- isAdmin={isAdmin}
- />
- {/* リスト表示 */}
- <div className="flex-1 overflow-hidden px-6 pb-4 flex flex-col">
- <div className="bg-white border border-slate-200 rounded-lg shadow-sm flex flex-col h-full overflow-hidden">
- {/* リストヘッダー */}
- <div className="grid grid-cols-12 gap-4 px-6 py-3 bg-slate-50 border-b border-slate-200 text-xs font-semibold text-slate-500 uppercase tracking-wider shrink-0">
- <div className="col-span-4">{t('fileName')}</div>
- <div className="col-span-2">{t('size')}</div>
- <div className="col-span-2">{t('status')}</div>
- <div className="col-span-2">{t('groups')}</div>
- <div className="col-span-2 text-right">{t('actions')}</div>
- </div>
- {/* リストアイテム */}
- <div className="flex-1 overflow-y-auto">
- {paginatedFiles.length > 0 ? (
- paginatedFiles.map((file) => (
- <div key={file.id} className="grid grid-cols-12 gap-4 px-6 py-4 border-b border-slate-100 last:border-0 hover:bg-slate-50 transition-colors items-center group">
- <div className="col-span-4 flex items-center gap-3 min-w-0">
- {getFileIcon(file)}
- <button
- onClick={() => setChunkDrawer({ isOpen: true, fileId: file.id, fileName: file.name })}
- className="truncate font-medium text-blue-600 hover:text-blue-800 hover:underline text-left"
- title={file.title || file.name}
- >
- {file.title || file.name}
- </button>
- </div>
- <div className="col-span-2 text-sm text-slate-500">
- {formatBytes(file.size)}
- </div>
- <div className="col-span-2">
- <span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${file.status === 'ready' || file.status === 'vectorized'
- ? 'bg-emerald-50 text-emerald-700'
- : (file.status === 'failed' || file.status === 'error')
- ? 'bg-red-50 text-red-700'
- : 'bg-amber-50 text-amber-700'
- }`}>
- {file.status === 'ready' || file.status === 'vectorized'
- ? <CheckCircle2 className="w-3 h-3" />
- : (file.status === 'failed' || file.status === 'error')
- ? <CircleDashed className="w-3 h-3 text-red-500" />
- : <CircleDashed className="w-3 h-3 animate-spin" />
- }
- {file.status === 'ready' || file.status === 'vectorized'
- ? t('statusReadyFragment')
- : (file.status === 'failed' || file.status === 'error')
- ? t('statusFailedFragment')
- : t('statusIndexingFragment')
- }
- </span>
- </div>
- <div className="col-span-2 flex items-center min-w-0">
- <div className="flex-1 min-w-0">
- <FileGroupTags
- fileId={file.id}
- groups={groups}
- assignedGroups={file.groups?.map(g => g.id) || []}
- onGroupsChange={(groupIds) => handleFileGroupsChange(file.id, groupIds)}
- isAdmin={isAdmin}
- />
- </div>
- {isAdmin && (
- <button
- onClick={(e) => {
- e.stopPropagation();
- // FileGroupTags を開くためのカスタムイベントを発火
- const event = new CustomEvent('openGroupSelector', { detail: { fileId: file.id } });
- document.dispatchEvent(event);
- }}
- className="p-1 ml-1 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded"
- title={t('addGroup')}
- >
- <FolderPlus className="w-4 h-4" />
- </button>
- )}
- </div>
- <div className="col-span-2 flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
- {isSupportedForPreview(file) && (
- <button
- onClick={() => setPdfPreview({ fileId: file.id, fileName: file.name })}
- className="p-1.5 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded"
- title={t('preview')}
- >
- <Eye className="w-4 h-4" />
- </button>
- )}
- <button
- onClick={() => fetchAndSetFiles()}
- className={`p-1.5 rounded ${isAutoRefreshEnabled
- ? 'text-green-600 hover:bg-green-50'
- : 'text-slate-400 hover:text-blue-600 hover:bg-blue-50'
- }`}
- title={isAutoRefreshEnabled ? t('autoRefresh') : t('refresh')}
- >
- <RefreshCw className={`w-4 h-4 ${isAutoRefreshEnabled ? 'animate-spin' : ''}`} />
- </button>
- {(file.status === 'failed' || file.status === 'error') && isAdmin && (
- <button
- onClick={() => handleRetryFile(file.id)}
- className="p-1.5 text-slate-400 hover:text-green-600 hover:bg-green-50 rounded"
- title={t('retry')}
- >
- <RotateCcw className="w-4 h-4" />
- </button>
- )}
- {isAdmin && (
- <button
- onClick={() => handleRemoveFile(file.id)}
- className="p-1.5 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded"
- title={t('delete')}
- >
- <Trash2 className="w-4 h-4" />
- </button>
- )}
- </div>
- </div>
- ))
- ) : (
- <div className="h-full flex flex-col items-center justify-center p-8">
- {isLoadingFiles ? (
- <div className="flex flex-col items-center gap-4">
- <div className="w-12 h-12 border-4 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
- <p className="text-slate-600 font-medium">{t('loading')}</p>
- </div>
- ) : (
- <div className="w-full max-w-2xl">
- <DragDropUpload
- onFilesSelected={handleFileUpload}
- isAdmin={isAdmin}
- />
- </div>
- )}
- </div>
- )}
- </div>
- {/* Pagination */}
- {totalPages > 1 && (
- <div className="px-6 py-3 border-t border-slate-200 bg-slate-50 flex items-center justify-between shrink-0">
- <span className="text-sm text-slate-500">
- {t('showingRange')
- .replace('$1', `${(currentPage - 1) * pageSize + 1}`)
- .replace('$2', `${Math.min(currentPage * pageSize, filteredFiles.length)}`)
- .replace('$3', `${filteredFiles.length}`)}
- </span>
- <div className="flex items-center gap-2">
- <button
- onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
- disabled={currentPage === 1}
- className="p-2 border border-slate-200 bg-white rounded hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed"
- >
- <ChevronLeft className="w-4 h-4" />
- </button>
- <span className="text-sm font-medium text-slate-700 min-w-[3rem] text-center">
- {currentPage} / {totalPages}
- </span>
- <button
- onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
- disabled={currentPage === totalPages}
- className="p-2 border border-slate-200 bg-white rounded hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed"
- >
- <ChevronRight className="w-4 h-4" />
- </button>
- </div>
- </div>
- )}
- </div>
- </div>
- {/* ドロワーとモーダル */}
- <IndexingModalWithMode
- isOpen={isIndexingModalOpen}
- onClose={() => {
- setPendingFiles([])
- setIsIndexingModalOpen(false)
- }}
- files={pendingFiles}
- embeddingModels={modelConfigs.filter(m => m.type === ModelType.EMBEDDING)}
- defaultEmbeddingId={settings.selectedEmbeddingId}
- onConfirm={handleConfirmIndexing}
- />
- {pdfPreview && (
- <PDFPreview
- fileId={pdfPreview.fileId}
- fileName={pdfPreview.fileName}
- authToken={authToken}
- onClose={() => setPdfPreview(null)}
- />
- )}
- {chunkDrawer && (
- <ChunkInfoDrawer
- isOpen={chunkDrawer.isOpen}
- onClose={() => setChunkDrawer(null)}
- fileId={chunkDrawer.fileId}
- fileName={chunkDrawer.fileName}
- authToken={authToken}
- />
- )}
- <SettingsDrawer
- isOpen={isSettingsOpen}
- onClose={() => setIsSettingsOpen(false)}
- settings={settings}
- models={modelConfigs}
- onSettingsChange={handleUpdateSettings}
- onOpenSettings={() => onNavigate('settings')}
- mode="kb"
- />
- </div>
- )
- }
|