NotebookDetailView.tsx 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635
  1. import React, { useEffect, useState, useRef } from 'react'
  2. import { ArrowLeft, Plus, FileText, File as FileIcon, MoreVertical, Trash2, Edit2, MessageSquare, Eye, EyeOff, Database, Book } from 'lucide-react'
  3. import ReactMarkdown from 'react-markdown'
  4. import { KnowledgeGroup, KnowledgeFile } from '../../types'
  5. import { noteService, Note } from '../../services/noteService'
  6. import { knowledgeBaseService } from '../../services/knowledgeBaseService'
  7. import { modelConfigService } from '../../services/modelConfigService'
  8. import { uploadService } from '../../services/uploadService'
  9. import { knowledgeGroupService } from '../../services/knowledgeGroupService'
  10. import { useToast } from '../../contexts/ToastContext'
  11. import { ModelType, RawFile, IndexingConfig, ModelConfig } from '../../types'
  12. import IndexingModalWithMode from '../IndexingModalWithMode'
  13. import { PDFPreview, PDFPreviewButton } from '../PDFPreview'
  14. import { AICommandDrawer } from '../AICommandDrawer'
  15. import { ImportFolderDrawer } from '../ImportFolderDrawer'
  16. import { NotebookGlobalDragDropOverlay } from '../NotebookGlobalDragDropOverlay'
  17. import { useLanguage } from '../../contexts/LanguageContext'
  18. import { readFile } from '../../utils/fileUtils'
  19. import { Sparkles } from 'lucide-react'
  20. import remarkGfm from 'remark-gfm'
  21. import remarkMath from 'remark-math'
  22. import rehypeKatex from 'rehype-katex'
  23. import mermaid from 'mermaid'
  24. import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
  25. import { oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism'
  26. // mermaid の初期化
  27. mermaid.initialize({
  28. startOnLoad: false,
  29. theme: 'default',
  30. securityLevel: 'loose',
  31. })
  32. const Mermaid = ({ chart }: { chart: string }) => {
  33. const [svg, setSvg] = useState('')
  34. const [error, setError] = useState('')
  35. const { t } = useLanguage()
  36. useEffect(() => {
  37. const renderChart = async () => {
  38. try {
  39. const id = `mermaid-${Math.random().toString(36).substr(2, 9)}`
  40. const { svg } = await mermaid.render(id, chart)
  41. setSvg(svg)
  42. setError('')
  43. } catch (err) {
  44. console.error('Mermaid render error:', err)
  45. setError(t('errorRenderFlowchart'))
  46. }
  47. }
  48. if (chart) renderChart()
  49. }, [chart])
  50. if (error) return <div className="text-red-500 text-sm bg-red-50 p-2 rounded">{error}</div>
  51. return <div className="mermaid-chart my-4 flex justify-center" dangerouslySetInnerHTML={{ __html: svg }} />
  52. }
  53. interface NotebookDetailViewProps {
  54. authToken: string;
  55. notebook: KnowledgeGroup;
  56. onBack: () => void;
  57. onChatWithContext?: (context: { selectedGroups?: string[], selectedFiles?: string[] }) => void;
  58. isAdmin?: boolean;
  59. }
  60. export const NotebookDetailView: React.FC<NotebookDetailViewProps> = ({ authToken, notebook, onBack, onChatWithContext, isAdmin = false }) => {
  61. const [activeTab, setActiveTab] = useState<'files' | 'notes'>('files')
  62. const [files, setFiles] = useState<KnowledgeFile[]>([])
  63. const [notes, setNotes] = useState<Note[]>([])
  64. const [isLoading, setIsLoading] = useState(false)
  65. const { showError, showSuccess } = useToast()
  66. const { t, language } = useLanguage()
  67. // メモエディタの状態
  68. const [isEditingNote, setIsEditingNote] = useState(false)
  69. const [currentNote, setCurrentNote] = useState<Partial<Note>>({})
  70. const [showPreview, setShowPreview] = useState(true)
  71. const [isUploading, setIsUploading] = useState(false)
  72. const fileInputRef = React.useRef<HTMLInputElement>(null)
  73. const [isImportDrawerOpen, setIsImportDrawerOpen] = useState(false)
  74. // インデックスモーダルの状態
  75. const [isIndexingModalOpen, setIsIndexingModalOpen] = useState(false)
  76. const [pendingFiles, setPendingFiles] = useState<RawFile[]>([])
  77. const [models, setModels] = useState<ModelConfig[]>([])
  78. // PDFプレビューの状態
  79. const [previewFile, setPreviewFile] = useState<{ id: string, name: string } | null>(null);
  80. // AIアシスタントの状態
  81. const [isAIModalOpen, setIsAIModalOpen] = useState(false)
  82. useEffect(() => {
  83. // モーダル用のモデルを取得
  84. const fetchModels = async () => {
  85. try {
  86. const res = await modelConfigService.getAll(authToken)
  87. setModels(res)
  88. } catch (error) {
  89. console.error('Failed to fetch models', error)
  90. }
  91. }
  92. if (authToken) fetchModels()
  93. }, [authToken])
  94. const loadData = async () => {
  95. setIsLoading(true)
  96. try {
  97. const [allFiles, notebookNotes] = await Promise.all([
  98. knowledgeBaseService.getAll(authToken),
  99. noteService.getAll(authToken, notebook.id)
  100. ])
  101. const notebookFiles = allFiles.filter(f => f.groups?.some(g => g.id === notebook.id))
  102. setFiles(notebookFiles)
  103. setNotes(notebookNotes)
  104. } catch (error) {
  105. console.error(error)
  106. showError(t('errorLoadData'))
  107. } finally {
  108. setIsLoading(false)
  109. }
  110. }
  111. useEffect(() => {
  112. loadData()
  113. }, [authToken, notebook.id, activeTab])
  114. const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
  115. const file = e.target.files?.[0]
  116. if (!file) return
  117. try {
  118. // ファイルの有効性をチェック
  119. const ext = file.name.split('.').pop()?.toLowerCase()
  120. const allowedExts = ['pdf', 'doc', 'docx', 'txt', 'md', 'html', 'json']
  121. if (!ext || !allowedExts.includes(ext)) {
  122. if (!window.confirm(t('confirmUnsupportedFile', ext))) {
  123. if (fileInputRef.current) fileInputRef.current.value = ''
  124. return;
  125. }
  126. }
  127. // ファイルを読み込んでモーダルを開く
  128. const rawFile = await readFile(file)
  129. setPendingFiles([rawFile])
  130. setIsIndexingModalOpen(true)
  131. } catch (error) {
  132. console.error(error)
  133. showError(t('errorReadFile', error.message || t('unknownError')))
  134. } finally {
  135. if (fileInputRef.current) {
  136. fileInputRef.current.value = ''
  137. }
  138. }
  139. }
  140. const handleConfirmIndexing = async (config: IndexingConfig) => {
  141. setIsUploading(true)
  142. setIsIndexingModalOpen(false)
  143. try {
  144. for (const rawFile of pendingFiles) {
  145. let uploadRes;
  146. if (rawFile.isNote) {
  147. uploadRes = await uploadService.uploadText(rawFile.textContent || rawFile.content, rawFile.name, config, authToken)
  148. } else {
  149. uploadRes = await uploadService.uploadFileWithConfig(rawFile.file, config, authToken)
  150. }
  151. if (uploadRes && uploadRes.id) {
  152. await knowledgeGroupService.addFileToGroups(uploadRes.id, [notebook.id])
  153. }
  154. }
  155. showSuccess(t('successUploadFile'))
  156. loadData()
  157. } catch (error) {
  158. console.error(error)
  159. showError(t('errorUploadFile', error.message || t('unknownError')))
  160. } finally {
  161. setIsUploading(false)
  162. setPendingFiles([])
  163. }
  164. }
  165. const handleIndexNote = async (note: Note) => {
  166. if (!note.title || !note.content) return;
  167. try {
  168. const file = new File([note.content], `${note.title}.md`, { type: 'text/markdown' });
  169. const rawFile = await readFile(file);
  170. rawFile.isNote = true;
  171. rawFile.textContent = note.content;
  172. setPendingFiles([rawFile]);
  173. setIsIndexingModalOpen(true);
  174. } catch (error) {
  175. console.error(error);
  176. showError(t('errorProcessFile'));
  177. }
  178. }
  179. const handleSaveNote = async () => {
  180. if (!currentNote.title || !currentNote.content) {
  181. showError(t('errorTitleContentRequired'))
  182. return
  183. }
  184. try {
  185. if (currentNote.id) {
  186. await noteService.update(authToken, currentNote.id, {
  187. title: currentNote.title,
  188. content: currentNote.content
  189. })
  190. showSuccess(t('successNoteUpdated'))
  191. } else {
  192. await noteService.create(authToken, {
  193. title: currentNote.title,
  194. content: currentNote.content,
  195. groupId: notebook.id
  196. })
  197. showSuccess(t('successNoteCreated'))
  198. }
  199. setIsEditingNote(false)
  200. setCurrentNote({})
  201. loadData()
  202. } catch (error: any) {
  203. console.error('Save note error:', error)
  204. showError(t('errorSaveFailed', error.message || t('unknownError')))
  205. }
  206. }
  207. const handleDeleteNote = async (id: string) => {
  208. if (!window.confirm(t('confirmDeleteNote'))) return
  209. try {
  210. await noteService.delete(authToken, id)
  211. showSuccess(t('successNoteDeleted'))
  212. loadData()
  213. } catch (error) {
  214. showError(t('deleteFailed'))
  215. }
  216. }
  217. const handleRemoveFile = async (fileId: string, fileName: string) => {
  218. if (!window.confirm(t('confirmRemoveFileFromGroup', fileName))) return;
  219. try {
  220. const { knowledgeGroupService } = await import('../../services/knowledgeGroupService');
  221. await knowledgeGroupService.removeFileFromGroup(fileId, notebook.id);
  222. showSuccess(t('fileDeleted'));
  223. loadData();
  224. } catch (error) {
  225. console.error(error);
  226. showError(t('deleteFailed'));
  227. }
  228. }
  229. // メモエディタのレンダリング
  230. if (isEditingNote) {
  231. return (
  232. <div className="flex flex-col h-full bg-slate-50">
  233. {/* エディタヘッダー */}
  234. <div className="h-14 bg-white border-b border-slate-200 flex items-center px-4 justify-between shrink-0">
  235. <div className="flex items-center gap-2">
  236. <button onClick={() => setIsEditingNote(false)} className="p-2 hover:bg-slate-100 rounded-full">
  237. <ArrowLeft size={20} className="text-slate-500" />
  238. </button>
  239. <h2 className="font-semibold text-slate-800">{currentNote.id ? t('editNote') : t('newNote')}</h2>
  240. </div>
  241. <div className="flex items-center gap-2">
  242. <button
  243. onClick={handleSaveNote}
  244. className="px-4 py-1.5 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 shadow-sm"
  245. >
  246. {t('save')}
  247. </button>
  248. <div className="h-6 w-px bg-slate-200 mx-1"></div>
  249. <button
  250. onClick={() => setShowPreview(!showPreview)}
  251. className={`p-2 rounded-lg transition-colors ${showPreview ? 'bg-blue-50 text-blue-600' : 'text-slate-400 hover:bg-slate-100 hover:text-slate-600'}`}
  252. title={showPreview ? t('togglePreviewClose') : t('togglePreviewOpen')}
  253. >
  254. {showPreview ? <Eye size={20} /> : <EyeOff size={20} />}
  255. </button>
  256. <button
  257. onClick={() => setIsAIModalOpen(true)}
  258. className={`p-2 rounded-lg transition-colors ${isAIModalOpen ? 'bg-purple-100 text-purple-600' : 'text-slate-400 hover:text-purple-600 hover:bg-purple-50'}`}
  259. title={t('aiAssistant')}
  260. >
  261. <Sparkles size={20} />
  262. </button>
  263. </div>
  264. </div>
  265. <div className="flex-1 overflow-hidden w-full max-w-[95%] mx-auto flex flex-col p-4">
  266. <input
  267. type="text"
  268. placeholder={t('noteTitlePlaceholder')}
  269. value={currentNote.title || ''}
  270. onChange={e => setCurrentNote(prev => ({ ...prev, title: e.target.value }))}
  271. className="w-full text-2xl font-bold border-none outline-none bg-transparent mb-4 placeholder-slate-300 shrink-0 px-2"
  272. />
  273. <div className="flex-1 flex gap-4 min-h-0">
  274. {/* エディタ */}
  275. <div className={`flex flex-col transition-all duration-300 ${showPreview ? 'w-1/2' : 'w-full'}`}>
  276. <textarea
  277. placeholder={t('noteContentPlaceholder')}
  278. value={currentNote.content || ''}
  279. onChange={e => setCurrentNote(prev => ({ ...prev, content: e.target.value }))}
  280. className="w-full h-full resize-none border border-slate-200 rounded-lg bg-white p-4 text-base leading-relaxed placeholder-slate-300 font-mono focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none shadow-sm"
  281. />
  282. </div>
  283. {/* プレビュー */}
  284. {showPreview && (
  285. <div className="w-1/2 bg-white border border-slate-200 rounded-lg p-6 overflow-y-auto shadow-sm prose prose-slate max-w-none">
  286. {currentNote.content ? (
  287. <ReactMarkdown
  288. remarkPlugins={[remarkGfm, remarkMath]}
  289. rehypePlugins={[rehypeKatex]}
  290. components={{
  291. code({ node, inline, className, children, ...props }: any) {
  292. const match = /language-(\w+)/.exec(className || '')
  293. const isMermaid = match && match[1] === 'mermaid'
  294. if (!inline && isMermaid) {
  295. return <Mermaid chart={String(children).replace(/\n$/, '')} />
  296. }
  297. return !inline && match ? (
  298. <SyntaxHighlighter
  299. style={oneLight}
  300. language={match[1]}
  301. PreTag="div"
  302. {...props}
  303. >
  304. {String(children).replace(/\n$/, '')}
  305. </SyntaxHighlighter>
  306. ) : (
  307. <code className={className} {...props}>
  308. {children}
  309. </code>
  310. )
  311. }
  312. }}
  313. >
  314. {currentNote.content}
  315. </ReactMarkdown>
  316. ) : (
  317. <div className="text-slate-300 text-center mt-10 italic">{t('markdownPreviewArea')}</div>
  318. )}
  319. </div>
  320. )}
  321. </div>
  322. </div>
  323. {/* AIコマンドドロワー - エディタ表示用 */}
  324. <AICommandDrawer
  325. isOpen={isAIModalOpen}
  326. onClose={() => setIsAIModalOpen(false)}
  327. context={currentNote.content || ''}
  328. authToken={authToken}
  329. onApply={(newContent) => {
  330. setCurrentNote(prev => ({ ...prev, content: newContent }))
  331. }}
  332. />
  333. </div>
  334. )
  335. }
  336. return (
  337. <div className="flex flex-col h-full bg-slate-50">
  338. {/* ヘッダー */}
  339. <div className="bg-white border-b border-slate-200 px-6 py-4 flex items-center justify-between shrink-0 shadow-sm z-10">
  340. <div className="flex items-center gap-4">
  341. <button onClick={onBack} className="p-2 -ml-2 hover:bg-slate-100 rounded-full transition-colors" title={t('back')}>
  342. <ArrowLeft size={20} className="text-slate-500" />
  343. </button>
  344. <div>
  345. <h1 className="font-bold text-slate-800 text-xl flex items-center gap-2">
  346. <Book className="w-6 h-6 text-blue-600" />
  347. <span className="bg-gradient-to-r from-blue-600 to-purple-600 text-transparent bg-clip-text">
  348. {notebook.name}
  349. </span>
  350. </h1>
  351. {notebook.description && <p className="text-sm text-slate-500 max-w-[300px] truncate mt-1">{notebook.description}</p>}
  352. </div>
  353. <div className="h-6 w-px bg-slate-200 mx-2"></div>
  354. <button
  355. onClick={() => onChatWithContext?.({ selectedGroups: [notebook.id] })}
  356. className="flex items-center gap-2 px-3 py-1.5 text-blue-600 bg-blue-50 hover:bg-blue-100 rounded-lg transition-colors"
  357. title={t('chatWithGroup')}
  358. >
  359. <MessageSquare size={18} />
  360. <span className="text-sm font-medium">{t('chatWithGroup')}</span>
  361. </button>
  362. </div>
  363. <div className="flex bg-slate-100 p-1 rounded-lg">
  364. <button
  365. onClick={() => setActiveTab('files')}
  366. className={`px-4 py-1.5 text-sm font-medium rounded-md transition-all duration-200 ${activeTab === 'files'
  367. ? 'bg-white text-slate-800 shadow-sm'
  368. : 'text-slate-500 hover:text-slate-700'
  369. }`}
  370. >
  371. {t('filesCountLabel', files.length)}
  372. </button>
  373. <button
  374. onClick={() => setActiveTab('notes')}
  375. className={`px-4 py-1.5 text-sm font-medium rounded-md transition-all duration-200 ${activeTab === 'notes'
  376. ? 'bg-white text-slate-800 shadow-sm'
  377. : 'text-slate-500 hover:text-slate-700'
  378. }`}
  379. >
  380. {t('notesCountLabel', notes.length)}
  381. </button>
  382. </div>
  383. </div>
  384. {/* コンテンツアクション */}
  385. <div className="p-4 flex justify-end">
  386. {activeTab === 'notes' && (
  387. <button
  388. onClick={() => {
  389. setCurrentNote({})
  390. setIsEditingNote(true)
  391. }}
  392. className="flex items-center gap-2 px-3 py-1.5 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700"
  393. >
  394. <Plus size={16} />
  395. <span>{t('newNote')}</span>
  396. </button>
  397. )}
  398. {activeTab === 'files' && (
  399. <>
  400. {isAdmin && (
  401. <input
  402. type="file"
  403. ref={fileInputRef}
  404. className="hidden"
  405. onChange={handleFileUpload}
  406. disabled={isUploading}
  407. />
  408. )}
  409. {isAdmin && (
  410. <button
  411. className="flex items-center gap-2 px-3 py-1.5 bg-white text-slate-600 border border-slate-200 text-sm rounded-lg hover:bg-slate-50 mr-2"
  412. onClick={() => setIsImportDrawerOpen(true)}
  413. >
  414. <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 2H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z" /><path d="M12 10v6" /><path d="m9 13 3-3 3 3" /></svg>
  415. <span>{t('importFolder')}</span>
  416. </button>
  417. )}
  418. {isAdmin && (
  419. <button
  420. className="flex items-center gap-2 px-3 py-1.5 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 disabled:opacity-50"
  421. onClick={() => fileInputRef.current?.click()}
  422. disabled={isUploading}
  423. >
  424. <Plus size={16} />
  425. <span>{isUploading ? t('uploading') : t('addFile')}</span>
  426. </button>
  427. )}
  428. </>
  429. )}
  430. </div>
  431. {/* 笔记本全局拖拽上传覆盖层 */}
  432. <NotebookGlobalDragDropOverlay
  433. onFilesSelected={async (files) => {
  434. // 处理拖拽上传的文件
  435. for (let i = 0; i < files.length; i++) {
  436. const file = files[i];
  437. // 创建一个临时input元素来触发上传
  438. const input = document.createElement('input');
  439. input.type = 'file';
  440. // 使用DataTransfer来创建一个包含单个文件的FileList
  441. const dt = new DataTransfer();
  442. dt.items.add(file);
  443. // 创建自定义事件对象
  444. const event = {
  445. target: {
  446. files: dt.files
  447. }
  448. } as React.ChangeEvent<HTMLInputElement>;
  449. await handleFileUpload(event);
  450. }
  451. }}
  452. isAdmin={isAdmin}
  453. />
  454. {/* リスト表示 */}
  455. <div className="flex-1 overflow-y-auto px-6 pb-6">
  456. {isLoading ? (
  457. <div className="text-center py-10 text-slate-400">Loading...</div>
  458. ) : (
  459. <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
  460. {activeTab === 'files' && files.map(file => (
  461. <div key={file.id} className="bg-white p-4 rounded-xl border border-slate-200 flex items-start gap-3 hover:shadow-md transition-shadow group relative">
  462. <div className="p-2 bg-slate-100 rounded-lg text-slate-500">
  463. <FileIcon size={24} />
  464. </div>
  465. <div className="flex-1 min-w-0">
  466. <h3 className="font-medium text-slate-800 truncate" title={file.name}>{file.name}</h3>
  467. <p className="text-xs text-slate-400 mt-1">{(file.size / 1024 / 1024).toFixed(2)} MB</p>
  468. </div>
  469. <div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1">
  470. <PDFPreviewButton
  471. fileId={file.id}
  472. fileName={file.name}
  473. onPreview={() => setPreviewFile({ id: file.id, name: file.name })}
  474. />
  475. <button
  476. onClick={(e) => {
  477. e.stopPropagation();
  478. onChatWithContext?.({ selectedGroups: [notebook.id], selectedFiles: [file.id] });
  479. }}
  480. className="p-1.5 text-slate-400 hover:text-purple-500 hover:bg-purple-50 rounded-lg transition-colors"
  481. title={t('chatWithFile')}
  482. >
  483. <MessageSquare size={16} />
  484. </button>
  485. <button
  486. onClick={(e) => {
  487. e.stopPropagation();
  488. handleRemoveFile(file.id, file.name);
  489. }}
  490. className="p-1.5 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
  491. title={t('confirmRemoveFileFromGroup', '')}
  492. >
  493. <Trash2 size={16} />
  494. </button>
  495. </div>
  496. </div>
  497. ))}
  498. {activeTab === 'notes' && notes.map(note => (
  499. <div key={note.id} className="bg-white p-5 rounded-xl border border-slate-200 hover:shadow-md transition-all group flex flex-col cursor-pointer" onClick={() => {
  500. setCurrentNote(note)
  501. setIsEditingNote(true)
  502. }}>
  503. <div className="flex justify-between items-start mb-2 flex-shrink-0">
  504. <h3 className="font-bold text-slate-800 line-clamp-1">{note.title}</h3>
  505. <div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity" onClick={e => e.stopPropagation()}>
  506. <button onClick={() => handleIndexNote(note)} className="p-1 hover:bg-blue-50 text-slate-400 hover:text-blue-500 rounded" title={t('indexIntoKB')}>
  507. <Database size={16} />
  508. </button>
  509. <button onClick={() => handleDeleteNote(note.id)} className="p-1 hover:bg-red-50 text-slate-400 hover:text-red-500 rounded">
  510. <Trash2 size={16} />
  511. </button>
  512. </div>
  513. </div>
  514. <p className="text-slate-500 text-sm line-clamp-3 mb-2 whitespace-pre-line flex-shrink-0">
  515. {note.content}
  516. </p>
  517. {note.screenshotPath && (
  518. <div className="mt-2 mb-2 rounded overflow-hidden border border-slate-100 flex-shrink-0 h-32">
  519. <img
  520. src={`/uploads/${note.screenshotPath}`}
  521. alt="Screenshot"
  522. className="w-full h-full object-cover"
  523. />
  524. </div>
  525. )}
  526. <div className="text-xs text-slate-400 mt-auto pt-3 border-t border-slate-100 flex-shrink-0">
  527. {new Date(note.updatedAt).toLocaleDateString()}
  528. </div>
  529. </div>
  530. ))}
  531. {((activeTab === 'files' && files.length === 0) || (activeTab === 'notes' && notes.length === 0)) && (
  532. <div className="col-span-full text-center py-20 text-slate-400">
  533. {t('noFilesOrNotes', activeTab === 'files' ? t('files') : t('notes'))}
  534. </div>
  535. )}
  536. </div>
  537. )}
  538. </div>
  539. {/* インデックスモーダル */}
  540. <IndexingModalWithMode
  541. isOpen={isIndexingModalOpen}
  542. onClose={() => {
  543. setIsIndexingModalOpen(false)
  544. setPendingFiles([])
  545. }}
  546. onConfirm={handleConfirmIndexing}
  547. files={pendingFiles}
  548. embeddingModels={models.filter(m => m.type === ModelType.EMBEDDING)}
  549. defaultEmbeddingId={models.find(m => m.type === ModelType.EMBEDDING)?.id || ''}
  550. />
  551. {/* PDFプレビューモーダル */}
  552. {
  553. previewFile && (
  554. <PDFPreview
  555. fileId={previewFile.id}
  556. fileName={previewFile.name}
  557. authToken={authToken}
  558. groupId={notebook.id}
  559. onClose={() => setPreviewFile(null)}
  560. />
  561. )
  562. }
  563. {/* AIコマンドドロワー */}
  564. <AICommandDrawer
  565. isOpen={isAIModalOpen}
  566. onClose={() => setIsAIModalOpen(false)}
  567. context={currentNote.content || ''}
  568. authToken={authToken}
  569. onApply={(newContent) => {
  570. setCurrentNote(prev => ({ ...prev, content: newContent })) // 内容を置換(必要に応じて将来的に追加オプションを検討)
  571. }}
  572. />
  573. <ImportFolderDrawer
  574. isOpen={isImportDrawerOpen}
  575. onClose={() => setIsImportDrawerOpen(false)}
  576. authToken={authToken}
  577. initialGroupId={notebook.id}
  578. initialGroupName={notebook.name}
  579. onImportSuccess={loadData}
  580. />
  581. </div >
  582. )
  583. }