KnowledgeBaseView.tsx 39 KB

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