KnowledgeBaseView.tsx 38 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785
  1. import React, { useCallback, useEffect, useState, useMemo, useRef } from 'react'
  2. import IndexingModalWithMode from '../../components/IndexingModalWithMode'
  3. import { PDFPreview } from '../../components/PDFPreview'
  4. import { DragDropUpload } from '../../components/DragDropUpload'
  5. import { GlobalDragDropOverlay } from '../../components/GlobalDragDropOverlay'
  6. import {
  7. AppSettings,
  8. DEFAULT_MODELS,
  9. DEFAULT_SETTINGS,
  10. IndexingConfig,
  11. KnowledgeFile,
  12. ModelConfig,
  13. ModelType,
  14. RawFile,
  15. KnowledgeGroup,
  16. } from '../../types'
  17. import { readFile, formatBytes } from '../../utils/fileUtils'
  18. import { useLanguage } from '../../contexts/LanguageContext'
  19. import { useToast } from '../../contexts/ToastContext'
  20. import { userSettingService } from '../../services/userSettingService'
  21. import { uploadService } from '../../services/uploadService'
  22. import { knowledgeBaseService } from '../../services/knowledgeBaseService'
  23. import { knowledgeGroupService } from '../../services/knowledgeGroupService'
  24. import { useConfirm } from '../../contexts/ConfirmContext'
  25. import { SettingsDrawer } from '../../components/SettingsDrawer'
  26. import { FileGroupTags } from '../../components/FileGroupTags'
  27. import { ChunkInfoDrawer } from '../../components/ChunkInfoDrawer'
  28. import {
  29. Search,
  30. Filter,
  31. Upload,
  32. FileText,
  33. Image as ImageIcon,
  34. FileType,
  35. CheckCircle2,
  36. CircleDashed,
  37. RefreshCw,
  38. Eye,
  39. Trash2,
  40. FolderPlus,
  41. ChevronLeft,
  42. ChevronRight,
  43. LayoutList,
  44. RotateCcw,
  45. Settings,
  46. Info
  47. } from 'lucide-react'
  48. import { KB_ALLOWED_EXTENSIONS, IMAGE_MIME_TYPES, isExtensionAllowed, getSupportedFormatsLabel, DOC_EXTENSIONS, CODE_EXTENSIONS, IMAGE_EXTENSIONS, isFormatSupportedForPreview } from '../../constants/fileSupport'
  49. interface KnowledgeBaseViewProps {
  50. authToken: string;
  51. onLogout: () => void;
  52. modelConfigs?: ModelConfig[];
  53. onNavigate: (view: any) => void;
  54. isAdmin?: boolean;
  55. }
  56. export const KnowledgeBaseView: React.FC<KnowledgeBaseViewProps> = (props) => {
  57. const { authToken, onLogout, modelConfigs = DEFAULT_MODELS, onNavigate, isAdmin = false } = props;
  58. const { showError, showWarning, showSuccess } = useToast()
  59. const { confirm } = useConfirm()
  60. const { t } = useLanguage()
  61. // Data State
  62. const [files, setFiles] = useState<KnowledgeFile[]>([])
  63. const [groups, setGroups] = useState<KnowledgeGroup[]>([])
  64. const [settings, setSettings] = useState<AppSettings>(DEFAULT_SETTINGS)
  65. const [isLoadingSettings, setIsLoadingSettings] = useState(true)
  66. const [isLoadingFiles, setIsLoadingFiles] = useState(true) // 添加文件加载状态
  67. // UI State
  68. const [isSettingsOpen, setIsSettingsOpen] = useState(false)
  69. const [pdfPreview, setPdfPreview] = useState<{ fileId: string; fileName: string } | null>(null)
  70. const [isIndexingModalOpen, setIsIndexingModalOpen] = useState(false)
  71. const [pendingFiles, setPendingFiles] = useState<RawFile[]>([])
  72. const [fileInputRef] = useState<React.RefObject<HTMLInputElement>>(React.createRef())
  73. // State to track if modal should be opened
  74. const [shouldOpenModal, setShouldOpenModal] = useState(false)
  75. // Effect to open modal when pending files are set
  76. useEffect(() => {
  77. if (shouldOpenModal && pendingFiles.length > 0) {
  78. setIsIndexingModalOpen(true);
  79. setShouldOpenModal(false);
  80. }
  81. }, [shouldOpenModal, pendingFiles.length]);
  82. // Filter & Pagination State
  83. const [filterName, setFilterName] = useState('')
  84. const [filterGroup, setFilterGroup] = useState('all')
  85. const [filterStatus, setFilterStatus] = useState<'all' | 'ready' | 'indexing' | 'failed'>('all')
  86. const [currentPage, setCurrentPage] = useState(1)
  87. const pageSize = 20
  88. const [chunkDrawer, setChunkDrawer] = useState<{ isOpen: boolean; fileId: string; fileName: string } | null>(null)
  89. // Auto-refresh state
  90. const [isAutoRefreshEnabled, setIsAutoRefreshEnabled] = useState(true)
  91. const [autoRefreshInterval, setAutoRefreshInterval] = useState<number>(5000) // 5 seconds default
  92. // --- データ取得 ---
  93. const fetchAndSetSettings = useCallback(async () => {
  94. if (!authToken) return
  95. try {
  96. // 管理者の場合はグローバル設定を取得、そうでない場合は個人設定を取得
  97. // ただし、検索パラメータ自体はグローバル設定から来るべきなので、
  98. // ここでは表示・編集用に、管理者はグローバル設定を直接触るようにする。
  99. const settingsData = isAdmin
  100. ? await userSettingService.getGlobal(authToken)
  101. : await userSettingService.get(authToken);
  102. setSettings({ ...DEFAULT_SETTINGS, ...settingsData })
  103. } catch (error) {
  104. console.error('Failed to fetch settings:', error)
  105. setSettings(DEFAULT_SETTINGS)
  106. } finally {
  107. setIsLoadingSettings(false)
  108. }
  109. }, [authToken, isAdmin])
  110. const fetchAndSetFiles = useCallback(async () => {
  111. if (!authToken) return
  112. try {
  113. const remoteFiles = await knowledgeBaseService.getAll(authToken)
  114. setFiles(remoteFiles)
  115. } catch (error) {
  116. console.error('Failed to fetch files:', error)
  117. } finally {
  118. setIsLoadingFiles(false) // ファイル読み込み完了
  119. }
  120. }, [authToken])
  121. const fetchAndSetGroups = useCallback(async () => {
  122. if (!authToken) return
  123. try {
  124. const remoteGroups = await knowledgeGroupService.getGroups()
  125. setGroups(remoteGroups)
  126. } catch (error) {
  127. console.error('Failed to fetch groups:', error)
  128. }
  129. }, [authToken])
  130. useEffect(() => {
  131. if (authToken) {
  132. fetchAndSetSettings()
  133. fetchAndSetFiles()
  134. fetchAndSetGroups()
  135. }
  136. }, [authToken, fetchAndSetSettings, fetchAndSetFiles, fetchAndSetGroups])
  137. // --- ハンドラー ---
  138. // プレビュー変換に対応しているか確認
  139. const isSupportedForPreview = (file: KnowledgeFile) => {
  140. return isFormatSupportedForPreview(file.name);
  141. };
  142. const handleUpdateSettings = async (newSettings: AppSettings) => {
  143. if (!authToken) return
  144. try {
  145. if (isAdmin) {
  146. await userSettingService.updateGlobal(authToken, newSettings)
  147. } else {
  148. await userSettingService.update(authToken, newSettings)
  149. }
  150. setSettings(newSettings)
  151. } catch (error: any) {
  152. console.error('Failed to update settings:', error)
  153. const detail = error.response?.data?.message
  154. const errorMessage = Array.isArray(detail) ? detail.join(', ') : detail || error.message || t('mmErrorTitle')
  155. showError(`${t('mmErrorTitle')}: ${errorMessage}`)
  156. }
  157. }
  158. const handleFileUpload = async (fileList: FileList) => {
  159. console.log('DEBUG: handleFileUpload called with', fileList.length, 'files');
  160. // Log all files received from the file input
  161. for (let i = 0; i < fileList.length; i++) {
  162. const file = fileList[i];
  163. console.log('DEBUG: File', i, 'details:', {
  164. name: file.name,
  165. size: file.size,
  166. type: file.type,
  167. lastModified: file.lastModified
  168. });
  169. }
  170. if (!authToken) {
  171. showWarning(t('loginRequired'))
  172. return
  173. }
  174. const MAX_FILE_SIZE = 104857600;
  175. const MAX_SIZE_MB = 100;
  176. const rawFiles: RawFile[] = []
  177. const errors: string[] = []
  178. console.log('DEBUG: About to start processing', fileList.length, 'files');
  179. // Process each file sequentially using array methods to avoid potential loop issues
  180. const filesArray = Array.from(fileList); // Convert FileList to Array
  181. for (let i = 0; i < filesArray.length; i++) {
  182. console.log('DEBUG: Loop iteration', i, 'starting');
  183. const file = filesArray[i];
  184. console.log('DEBUG: Processing file', i, ':', file.name, 'size:', file.size, 'type:', file.type);
  185. const extension = file.name.split('.').pop() || ''
  186. if (!isExtensionAllowed(extension, 'kb')) {
  187. errors.push(t('unsupportedFileType', file.name, extension))
  188. console.log('DEBUG: File rejected due to type restriction:', file.name, 'extension:', extension);
  189. console.log('DEBUG: Loop iteration', i, 'completed due to type check');
  190. continue
  191. }
  192. if (file.size > MAX_FILE_SIZE) {
  193. errors.push(t('fileSizeLimitExceeded', file.name, formatBytes(file.size), MAX_SIZE_MB))
  194. console.log('DEBUG: File rejected due to size limit:', file.name);
  195. console.log('DEBUG: Loop iteration', i, 'completed due to size check');
  196. continue;
  197. }
  198. try {
  199. console.log('DEBUG: Attempting to read file:', file.name);
  200. const rawFile = await readFile(file)
  201. rawFiles.push(rawFile)
  202. console.log('DEBUG: Successfully added file to rawFiles:', file.name);
  203. console.log('DEBUG: Loop iteration', i, 'completed successfully');
  204. } catch (error) {
  205. console.error(`Error reading file ${file.name}:`, error);
  206. errors.push(`${file.name} - ${t('readingFailed')}`);
  207. console.log('DEBUG: Failed to read file:', file.name);
  208. console.log('DEBUG: Loop iteration', i, 'completed due to error in readFile');
  209. }
  210. }
  211. console.log('DEBUG: Finished processing all files');
  212. console.log('DEBUG: Final rawFiles array:', rawFiles.map(f => f.name));
  213. console.log('DEBUG: Errors array:', errors);
  214. console.log('DEBUG: Number of valid files:', rawFiles.length);
  215. console.log('DEBUG: Number of errors:', errors.length);
  216. if (errors.length > 0) showError(`${t('uploadErrors')}:\n${errors.join('\n')}`);
  217. if (rawFiles.length === 0) {
  218. console.log('DEBUG: No valid files to process, returning early');
  219. return;
  220. }
  221. if (errors.length > 0 && rawFiles.length > 0) showWarning(t('uploadWarning').replace('$1', `${rawFiles.length}`).replace('$2', `${errors.length}`));
  222. console.log('DEBUG: Setting pendingFiles with', rawFiles.length, 'files');
  223. setPendingFiles(rawFiles);
  224. console.log('DEBUG: Current pendingFiles state:', pendingFiles.length); // This won't reflect the update yet
  225. setShouldOpenModal(true);
  226. console.log('DEBUG: Set shouldOpenModal to true');
  227. }
  228. const handleRetryFile = async (fileId: string) => {
  229. if (!authToken) return
  230. try {
  231. showSuccess(t('retrying'))
  232. await knowledgeBaseService.retryFile(fileId, authToken)
  233. showSuccess(t('retrySuccess'))
  234. await fetchAndSetFiles()
  235. } catch (error) {
  236. console.error('Failed to retry file:', error)
  237. showError(t('retryFailed'))
  238. }
  239. }
  240. const handleConfirmIndexing = async (config: IndexingConfig) => {
  241. console.log('DEBUG: handleConfirmIndexing called with config and', pendingFiles.length, 'pending files');
  242. if (!authToken) {
  243. console.log('DEBUG: No auth token, returning');
  244. return
  245. }
  246. let hasSuccess = false
  247. for (const rawFile of pendingFiles) {
  248. console.log('DEBUG: Uploading file:', rawFile.name);
  249. try {
  250. await uploadService.uploadFileWithConfig(rawFile.file, config, authToken)
  251. hasSuccess = true
  252. console.log('DEBUG: Successfully uploaded file:', rawFile.name);
  253. } catch (error) {
  254. console.error(`Error uploading file ${rawFile.name}:`, error)
  255. showError(`${t('uploadFailed')}: ${rawFile.name} - ${error.message}`)
  256. }
  257. }
  258. if (hasSuccess) await fetchAndSetFiles()
  259. console.log('DEBUG: Clearing pending files');
  260. setPendingFiles([])
  261. setIsIndexingModalOpen(false)
  262. console.log('DEBUG: Closed indexing modal');
  263. }
  264. const handleRemoveFile = async (id: string) => {
  265. if (!(await confirm(t('confirmDeleteFile')))) return
  266. if (!authToken) return
  267. try {
  268. await knowledgeBaseService.deleteFile(id, authToken)
  269. setFiles(prev => prev.filter(f => f.id !== id))
  270. showSuccess(t('fileDeleted'))
  271. } catch (error) {
  272. console.error('Failed to delete file:', error)
  273. showError(`${t('deleteFailed')}: ` + error.message)
  274. }
  275. }
  276. const handleClearAll = async () => {
  277. if (!(await confirm(t('confirmClearKB')))) return
  278. if (!authToken) return
  279. try {
  280. await knowledgeBaseService.clearAll(authToken)
  281. setFiles([])
  282. showSuccess(t('kbCleared'))
  283. } catch (error) {
  284. console.error('Failed to clear knowledge base:', error)
  285. showError(`${t('clearFailed')}: ` + error.message)
  286. }
  287. }
  288. const handleFileGroupsChange = (fileId: string, groupIds: string[]) => {
  289. setFiles(prev => prev.map(file =>
  290. file.id === fileId
  291. ? { ...file, groups: groups.filter(g => groupIds.includes(g.id)) }
  292. : file
  293. ))
  294. }
  295. // --- ヘルパー ---
  296. const getFileIcon = (file: KnowledgeFile) => {
  297. if (file.type.startsWith('image/')) return <ImageIcon className="w-5 h-5 text-purple-500" />;
  298. if (file.type === 'application/pdf') return <FileType className="w-5 h-5 text-red-500" />;
  299. return <FileText className="w-5 h-5 text-blue-500" />;
  300. };
  301. // --- フィルタリングとページネーションのロジック ---
  302. const filteredFiles = useMemo(() => {
  303. return files.filter(file => {
  304. const matchName = file.name.toLowerCase().includes(filterName.toLowerCase());
  305. const matchGroup = filterGroup === 'all' || file.groups?.some(g => g.id === filterGroup);
  306. const matchStatus = filterStatus === 'all' ||
  307. (filterStatus === 'ready' && (file.status === 'ready' || file.status === 'vectorized')) ||
  308. (filterStatus === 'indexing' && (file.status === 'indexing' || file.status === 'pending' || file.status === 'extracted')) ||
  309. (filterStatus === 'failed' && (file.status === 'failed' || file.status === 'error'));
  310. return matchName && matchGroup && matchStatus;
  311. });
  312. }, [files, filterName, filterGroup, filterStatus]);
  313. const totalPages = Math.ceil(filteredFiles.length / pageSize);
  314. const paginatedFiles = useMemo(() => {
  315. const start = (currentPage - 1) * pageSize;
  316. return filteredFiles.slice(start, start + pageSize);
  317. }, [filteredFiles, currentPage, pageSize]);
  318. // フィルタが変更されたときにページをリセット
  319. useEffect(() => {
  320. setCurrentPage(1);
  321. }, [filterName, filterGroup, filterStatus]);
  322. // Auto-refresh functionality for indexing files
  323. useEffect(() => {
  324. let intervalId: NodeJS.Timeout | null = null;
  325. const hasIndexingFiles = () => {
  326. return files.some(file =>
  327. file.status === 'pending' ||
  328. file.status === 'indexing' ||
  329. file.status === 'extracted' ||
  330. file.status === 'vectorized'
  331. );
  332. };
  333. const manageAutoRefresh = () => {
  334. if (intervalId) {
  335. clearInterval(intervalId);
  336. intervalId = null;
  337. }
  338. if (isAutoRefreshEnabled && hasIndexingFiles()) {
  339. intervalId = setInterval(() => {
  340. fetchAndSetFiles();
  341. }, autoRefreshInterval);
  342. }
  343. };
  344. // Manage auto-refresh based on current conditions
  345. manageAutoRefresh();
  346. // Cleanup on unmount
  347. return () => {
  348. if (intervalId) {
  349. clearInterval(intervalId);
  350. }
  351. };
  352. }, [isAutoRefreshEnabled, files, autoRefreshInterval, fetchAndSetFiles]);
  353. if (isLoadingSettings) {
  354. return (
  355. <div className='flex items-center justify-center min-h-screen bg-slate-50 w-full'>
  356. <div className='text-blue-600 animate-spin rounded-full h-12 w-12 border-4 border-t-4 border-blue-300'></div>
  357. </div>
  358. )
  359. }
  360. return (
  361. <div className='flex flex-col h-full w-full bg-slate-50 overflow-hidden relative'>
  362. {/* Header */}
  363. <div className="bg-white border-b border-slate-200 px-6 py-4 flex items-center justify-between shrink-0">
  364. <div>
  365. <h1 className="text-xl font-bold text-slate-800 flex items-center gap-2">
  366. <LayoutList className="w-6 h-6 text-blue-600" />
  367. <span className="bg-gradient-to-r from-blue-600 to-purple-600 text-transparent bg-clip-text">
  368. {t('kbManagement')}
  369. </span>
  370. </h1>
  371. <p className="text-sm text-slate-500 mt-1">{t('kbManagementDesc')}</p>
  372. </div>
  373. <div className="flex items-center gap-3">
  374. {isAdmin && (
  375. <button
  376. onClick={() => setIsSettingsOpen(true)}
  377. 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"
  378. title={t('settings')}
  379. >
  380. <Settings className="w-4 h-4 text-blue-600" />
  381. {t('settings')}
  382. </button>
  383. )}
  384. {isAdmin && (
  385. <button
  386. onClick={handleClearAll}
  387. 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"
  388. title={t('confirmClearKB')}
  389. >
  390. <Trash2 className="w-4 h-4" />
  391. {t('clearAll')}
  392. </button>
  393. )}
  394. </div>
  395. </div>
  396. {/* フィルタとアクションバー */}
  397. <div className="px-6 py-4 flex flex-col md:flex-row md:items-center justify-between gap-4 shrink-0">
  398. <div className="flex flex-wrap items-center gap-3 flex-1">
  399. {/* 検索 */}
  400. <div className="relative w-full md:w-64">
  401. <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
  402. <input
  403. type="text"
  404. placeholder={t('searchPlaceholder')}
  405. value={filterName}
  406. onChange={(e) => setFilterName(e.target.value)}
  407. 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"
  408. />
  409. </div>
  410. {/* グループフィルタ */}
  411. <div className="relative w-40">
  412. <Filter className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
  413. <select
  414. value={filterGroup}
  415. onChange={(e) => setFilterGroup(e.target.value)}
  416. 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"
  417. >
  418. <option value="all">{t('allGroups')}</option>
  419. {groups.map(g => (
  420. <option key={g.id} value={g.id}>{g.name}</option>
  421. ))}
  422. </select>
  423. </div>
  424. {/* ステータスフィルタ */}
  425. <div className="w-32">
  426. <select
  427. value={filterStatus}
  428. onChange={(e) => setFilterStatus(e.target.value as any)}
  429. 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"
  430. >
  431. <option value="all">{t('allStatus')}</option>
  432. <option value="ready">{t('statusReadyFragment')}</option>
  433. <option value="indexing">{t('statusIndexingFragment')}</option>
  434. <option value="failed">{t('statusFailedFragment')}</option>
  435. </select>
  436. </div>
  437. {/* Auto-refresh toggle */}
  438. <div className="flex items-center gap-2">
  439. <label className="text-sm text-slate-600">{t('autoRefresh')}</label>
  440. <button
  441. type="button"
  442. onClick={() => setIsAutoRefreshEnabled(!isAutoRefreshEnabled)}
  443. className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none ${isAutoRefreshEnabled ? 'bg-blue-600' : 'bg-slate-300'
  444. }`}
  445. >
  446. <span
  447. className={`inline-block h-3 w-3 transform rounded-full bg-white transition-transform ${isAutoRefreshEnabled ? 'translate-x-5' : 'translate-x-1'
  448. }`}
  449. />
  450. </button>
  451. </div>
  452. {/* Auto-refresh interval selector - only show when enabled */}
  453. {isAutoRefreshEnabled && (
  454. <div className="flex items-center gap-2">
  455. <label className="text-sm text-slate-600">{t('refreshInterval')}</label>
  456. <select
  457. value={autoRefreshInterval}
  458. onChange={(e) => setAutoRefreshInterval(Number(e.target.value))}
  459. 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"
  460. >
  461. <option value={2000}>2s</option>
  462. <option value={5000}>5s</option>
  463. <option value={10000}>10s</option>
  464. <option value={30000}>30s</option>
  465. </select>
  466. </div>
  467. )}
  468. </div>
  469. {/* 上传按钮组 - 保持两个上传选项 */}
  470. <div className="flex items-center gap-3">
  471. <input
  472. type="file"
  473. ref={fileInputRef}
  474. onChange={(e) => {
  475. if (e.target.files && e.target.files.length > 0) handleFileUpload(e.target.files)
  476. if (fileInputRef.current) fileInputRef.current.value = ''
  477. }}
  478. multiple
  479. className="hidden"
  480. accept={KB_ALLOWED_EXTENSIONS.map(ext => `.${ext}`).join(',') + ',' + IMAGE_MIME_TYPES.join(',')}
  481. />
  482. {isAdmin && (
  483. <div className="flex items-center gap-2">
  484. <div className="group relative">
  485. <Info className="w-5 h-5 text-slate-400 cursor-help hover:text-blue-500 transition-colors" />
  486. <div className="absolute bottom-full right-0 mb-2 w-max bg-slate-900 text-white text-xs px-3 py-2 rounded-lg opacity-0 group-hover:opacity-100 transition-all pointer-events-none z-[100] shadow-xl border border-slate-700 whitespace-nowrap">
  487. {t('supportedFormatsInfo')}
  488. </div>
  489. </div>
  490. <button
  491. onClick={() => fileInputRef.current?.click()}
  492. 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"
  493. >
  494. <Upload className="w-4 h-4" />
  495. {t('uploadFile')}
  496. </button>
  497. </div>
  498. )}
  499. </div>
  500. </div>
  501. {/* 全局拖拽上传覆盖层 - 只在拖拽文件到页面时显示 */}
  502. <GlobalDragDropOverlay
  503. onFilesSelected={handleFileUpload}
  504. isAdmin={isAdmin}
  505. />
  506. {/* リスト表示 */}
  507. <div className="flex-1 overflow-hidden px-6 pb-4 flex flex-col">
  508. <div className="bg-white border border-slate-200 rounded-lg shadow-sm flex flex-col h-full overflow-hidden">
  509. {/* リストヘッダー */}
  510. <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">
  511. <div className="col-span-4">{t('fileName')}</div>
  512. <div className="col-span-2">{t('size')}</div>
  513. <div className="col-span-2">{t('status')}</div>
  514. <div className="col-span-2">{t('groups')}</div>
  515. <div className="col-span-2 text-right">{t('actions')}</div>
  516. </div>
  517. {/* リストアイテム */}
  518. <div className="flex-1 overflow-y-auto">
  519. {paginatedFiles.length > 0 ? (
  520. paginatedFiles.map((file) => (
  521. <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">
  522. <div className="col-span-4 flex items-center gap-3 min-w-0">
  523. {getFileIcon(file)}
  524. <button
  525. onClick={() => setChunkDrawer({ isOpen: true, fileId: file.id, fileName: file.name })}
  526. className="truncate font-medium text-blue-600 hover:text-blue-800 hover:underline text-left"
  527. title={file.name}
  528. >
  529. {file.name}
  530. </button>
  531. </div>
  532. <div className="col-span-2 text-sm text-slate-500">
  533. {formatBytes(file.size)}
  534. </div>
  535. <div className="col-span-2">
  536. <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'
  537. ? 'bg-emerald-50 text-emerald-700'
  538. : (file.status === 'failed' || file.status === 'error')
  539. ? 'bg-red-50 text-red-700'
  540. : 'bg-amber-50 text-amber-700'
  541. }`}>
  542. {file.status === 'ready' || file.status === 'vectorized'
  543. ? <CheckCircle2 className="w-3 h-3" />
  544. : (file.status === 'failed' || file.status === 'error')
  545. ? <CircleDashed className="w-3 h-3 text-red-500" />
  546. : <CircleDashed className="w-3 h-3 animate-spin" />
  547. }
  548. {file.status === 'ready' || file.status === 'vectorized'
  549. ? t('statusReadyFragment')
  550. : (file.status === 'failed' || file.status === 'error')
  551. ? t('statusFailedFragment')
  552. : t('statusIndexingFragment')
  553. }
  554. </span>
  555. </div>
  556. <div className="col-span-2 flex items-center min-w-0">
  557. <div className="flex-1 min-w-0">
  558. <FileGroupTags
  559. fileId={file.id}
  560. groups={groups}
  561. assignedGroups={file.groups?.map(g => g.id) || []}
  562. onGroupsChange={(groupIds) => handleFileGroupsChange(file.id, groupIds)}
  563. isAdmin={isAdmin}
  564. />
  565. </div>
  566. {isAdmin && (
  567. <button
  568. onClick={(e) => {
  569. e.stopPropagation();
  570. // FileGroupTags を開くためのカスタムイベントを発火
  571. const event = new CustomEvent('openGroupSelector', { detail: { fileId: file.id } });
  572. document.dispatchEvent(event);
  573. }}
  574. className="p-1 ml-1 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded"
  575. title={t('addGroup')}
  576. >
  577. <FolderPlus className="w-4 h-4" />
  578. </button>
  579. )}
  580. </div>
  581. <div className="col-span-2 flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
  582. {isSupportedForPreview(file) && (
  583. <button
  584. onClick={() => setPdfPreview({ fileId: file.id, fileName: file.name })}
  585. className="p-1.5 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded"
  586. title={t('preview')}
  587. >
  588. <Eye className="w-4 h-4" />
  589. </button>
  590. )}
  591. <button
  592. onClick={() => fetchAndSetFiles()}
  593. className={`p-1.5 rounded ${isAutoRefreshEnabled
  594. ? 'text-green-600 hover:bg-green-50'
  595. : 'text-slate-400 hover:text-blue-600 hover:bg-blue-50'
  596. }`}
  597. title={isAutoRefreshEnabled ? t('autoRefresh') : t('refresh')}
  598. >
  599. <RefreshCw className={`w-4 h-4 ${isAutoRefreshEnabled ? 'animate-spin' : ''}`} />
  600. </button>
  601. {(file.status === 'failed' || file.status === 'error') && isAdmin && (
  602. <button
  603. onClick={() => handleRetryFile(file.id)}
  604. className="p-1.5 text-slate-400 hover:text-green-600 hover:bg-green-50 rounded"
  605. title={t('retry')}
  606. >
  607. <RotateCcw className="w-4 h-4" />
  608. </button>
  609. )}
  610. {isAdmin && (
  611. <button
  612. onClick={() => handleRemoveFile(file.id)}
  613. className="p-1.5 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded"
  614. title={t('delete')}
  615. >
  616. <Trash2 className="w-4 h-4" />
  617. </button>
  618. )}
  619. </div>
  620. </div>
  621. ))
  622. ) : (
  623. <div className="h-full flex flex-col items-center justify-center p-8">
  624. {isLoadingFiles ? (
  625. <div className="flex flex-col items-center gap-4">
  626. <div className="w-12 h-12 border-4 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
  627. <p className="text-slate-600 font-medium">{t('loading')}</p>
  628. </div>
  629. ) : (
  630. <div className="w-full max-w-2xl">
  631. <DragDropUpload
  632. onFilesSelected={handleFileUpload}
  633. isAdmin={isAdmin}
  634. />
  635. </div>
  636. )}
  637. </div>
  638. )}
  639. </div>
  640. {/* Pagination */}
  641. {totalPages > 1 && (
  642. <div className="px-6 py-3 border-t border-slate-200 bg-slate-50 flex items-center justify-between shrink-0">
  643. <span className="text-sm text-slate-500">
  644. {t('showingRange')
  645. .replace('$1', `${(currentPage - 1) * pageSize + 1}`)
  646. .replace('$2', `${Math.min(currentPage * pageSize, filteredFiles.length)}`)
  647. .replace('$3', `${filteredFiles.length}`)}
  648. </span>
  649. <div className="flex items-center gap-2">
  650. <button
  651. onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
  652. disabled={currentPage === 1}
  653. className="p-2 border border-slate-200 bg-white rounded hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed"
  654. >
  655. <ChevronLeft className="w-4 h-4" />
  656. </button>
  657. <span className="text-sm font-medium text-slate-700 min-w-[3rem] text-center">
  658. {currentPage} / {totalPages}
  659. </span>
  660. <button
  661. onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
  662. disabled={currentPage === totalPages}
  663. className="p-2 border border-slate-200 bg-white rounded hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed"
  664. >
  665. <ChevronRight className="w-4 h-4" />
  666. </button>
  667. </div>
  668. </div>
  669. )}
  670. </div>
  671. </div>
  672. {/* ドロワーとモーダル */}
  673. <IndexingModalWithMode
  674. isOpen={isIndexingModalOpen}
  675. onClose={() => {
  676. setPendingFiles([])
  677. setIsIndexingModalOpen(false)
  678. }}
  679. files={pendingFiles}
  680. embeddingModels={modelConfigs.filter(m => m.type === ModelType.EMBEDDING)}
  681. defaultEmbeddingId={settings.selectedEmbeddingId}
  682. onConfirm={handleConfirmIndexing}
  683. />
  684. {pdfPreview && (
  685. <PDFPreview
  686. fileId={pdfPreview.fileId}
  687. fileName={pdfPreview.fileName}
  688. authToken={authToken}
  689. onClose={() => setPdfPreview(null)}
  690. />
  691. )}
  692. {chunkDrawer && (
  693. <ChunkInfoDrawer
  694. isOpen={chunkDrawer.isOpen}
  695. onClose={() => setChunkDrawer(null)}
  696. fileId={chunkDrawer.fileId}
  697. fileName={chunkDrawer.fileName}
  698. authToken={authToken}
  699. />
  700. )}
  701. <SettingsDrawer
  702. isOpen={isSettingsOpen}
  703. onClose={() => setIsSettingsOpen(false)}
  704. settings={settings}
  705. models={modelConfigs}
  706. onSettingsChange={handleUpdateSettings}
  707. onOpenSettings={() => onNavigate('settings')}
  708. mode="all"
  709. isAdmin={isAdmin}
  710. />
  711. </div>
  712. )
  713. }