import React, { useEffect, useState, useRef } from 'react'
import { ArrowLeft, Plus, FileText, File as FileIcon, MoreVertical, Trash2, Edit2, MessageSquare, Eye, EyeOff, Database, Book } from 'lucide-react'
import ReactMarkdown from 'react-markdown'
import { KnowledgeGroup, KnowledgeFile } from '../../types'
import { noteService, Note } from '../../services/noteService'
import { knowledgeBaseService } from '../../services/knowledgeBaseService'
import { modelConfigService } from '../../services/modelConfigService'
import { uploadService } from '../../services/uploadService'
import { knowledgeGroupService } from '../../services/knowledgeGroupService'
import { useToast } from '../../contexts/ToastContext'
import { useConfirm } from '../../contexts/ConfirmContext'
import { ModelType, RawFile, IndexingConfig, ModelConfig } from '../../types'
import IndexingModalWithMode from '../IndexingModalWithMode'
import { PDFPreview, PDFPreviewButton } from '../PDFPreview'
import { AICommandDrawer } from '../AICommandDrawer'
import { ImportFolderDrawer } from '../ImportFolderDrawer'
import { NotebookDragDropUpload } from '../NotebookDragDropUpload'
import { NotebookGlobalDragDropOverlay } from '../NotebookGlobalDragDropOverlay'
import { useLanguage } from '../../contexts/LanguageContext'
import { readFile, formatBytes } from '../../utils/fileUtils'
import { Sparkles } from 'lucide-react'
import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
import rehypeKatex from 'rehype-katex'
import mermaid from 'mermaid'
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
import { oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism'
import { Info } from 'lucide-react'
import { GROUP_ALLOWED_EXTENSIONS, IMAGE_MIME_TYPES, isExtensionAllowed, getSupportedFormatsLabel } from '../../constants/fileSupport'
// mermaid の初期化
mermaid.initialize({
startOnLoad: false,
theme: 'default',
securityLevel: 'loose',
})
const Mermaid = ({ chart }: { chart: string }) => {
const [svg, setSvg] = useState('')
const [error, setError] = useState('')
const { t } = useLanguage()
useEffect(() => {
const renderChart = async () => {
try {
const id = `mermaid-${Math.random().toString(36).substr(2, 9)}`
const { svg } = await mermaid.render(id, chart)
setSvg(svg)
setError('')
} catch (err) {
console.error('Mermaid render error:', err)
setError(t('errorRenderFlowchart'))
}
}
if (chart) renderChart()
}, [chart])
if (error) return
{error}
return
}
interface NotebookDetailViewProps {
authToken: string;
notebook: KnowledgeGroup;
onBack: () => void;
onChatWithContext?: (context: { selectedGroups?: string[], selectedFiles?: string[] }) => void;
isAdmin?: boolean;
}
export const NotebookDetailView: React.FC = ({ authToken, notebook, onBack, onChatWithContext, isAdmin = false }) => {
const [activeTab, setActiveTab] = useState<'files' | 'notes'>('files')
const [files, setFiles] = useState([])
const [notes, setNotes] = useState([])
const [isLoading, setIsLoading] = useState(false)
const { showError, showSuccess } = useToast()
const { confirm } = useConfirm()
const { t, language } = useLanguage()
// メモエディタの状態
const [isEditingNote, setIsEditingNote] = useState(false)
const [currentNote, setCurrentNote] = useState>({})
const [showPreview, setShowPreview] = useState(true)
const [isUploading, setIsUploading] = useState(false)
const fileInputRef = React.useRef(null)
const [isImportDrawerOpen, setIsImportDrawerOpen] = useState(false)
// インデックスモーダルの状態
const [isIndexingModalOpen, setIsIndexingModalOpen] = useState(false)
const [pendingFiles, setPendingFiles] = useState([])
const [shouldOpenModal, setShouldOpenModal] = useState(false)
const [models, setModels] = useState([])
// PDFプレビューの状態
const [previewFile, setPreviewFile] = useState<{ id: string, name: string } | null>(null);
// AIアシスタントの状態
const [isAIModalOpen, setIsAIModalOpen] = useState(false)
useEffect(() => {
// モーダル用のモデルを取得
const fetchModels = async () => {
try {
const res = await modelConfigService.getAll(authToken)
setModels(res)
} catch (error) {
console.error('Failed to fetch models', error)
}
}
if (authToken) fetchModels()
}, [authToken])
// Effect to open modal when pending files are set
useEffect(() => {
if (shouldOpenModal && pendingFiles.length > 0) {
setIsIndexingModalOpen(true);
setShouldOpenModal(false);
}
}, [shouldOpenModal, pendingFiles.length]);
const loadData = async () => {
setIsLoading(true)
try {
const [allFiles, notebookNotes] = await Promise.all([
knowledgeBaseService.getAll(authToken),
noteService.getAll(authToken, notebook.id)
])
const notebookFiles = allFiles.filter(f => f.groups?.some(g => g.id === notebook.id))
setFiles(notebookFiles)
setNotes(notebookNotes)
} catch (error) {
console.error(error)
showError(t('errorLoadData'))
} finally {
setIsLoading(false)
}
}
useEffect(() => {
loadData()
}, [authToken, notebook.id, activeTab])
const handleFileUpload = async (fileList: FileList | File[]) => {
if (!fileList || fileList.length === 0) return
const MAX_FILE_SIZE = 104857600; // 100MB
const MAX_SIZE_MB = 100
const newPendingFiles: RawFile[] = []
const errors: string[] = []
const filesArray = Array.from(fileList)
for (let i = 0; i < filesArray.length; i++) {
const file = filesArray[i]
try {
// Check file size
if (file.size > MAX_FILE_SIZE) {
errors.push(t('fileSizeLimitExceeded').replace('$1', file.name).replace('$2', formatBytes(file.size)).replace('$3', MAX_SIZE_MB.toString()))
continue
}
const extension = file.name.split('.').pop() || ''
if (!isExtensionAllowed(extension, 'group')) {
if (!(await confirm(t('confirmUnsupportedFile', extension || 'unknown')))) {
continue
}
}
// Read file
const rawFile = await readFile(file)
newPendingFiles.push(rawFile)
} catch (error: any) {
console.error(`Error processing file ${file.name}:`, error)
errors.push(`${file.name} - ${t('errorReadFile', error.message || t('unknownError'))}`)
}
}
if (errors.length > 0) {
showError(`${t('uploadErrors')}:\n${errors.join('\n')}`)
}
if (newPendingFiles.length > 0) {
setPendingFiles(prev => [...prev, ...newPendingFiles])
setShouldOpenModal(true)
}
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}
const handleConfirmIndexing = async (config: IndexingConfig) => {
setIsUploading(true)
setIsIndexingModalOpen(false)
try {
for (const rawFile of pendingFiles) {
let uploadRes;
if (rawFile.isNote) {
uploadRes = await uploadService.uploadText(rawFile.textContent || rawFile.content, rawFile.name, config, authToken)
} else {
uploadRes = await uploadService.uploadFileWithConfig(rawFile.file, config, authToken)
}
if (uploadRes && uploadRes.id) {
await knowledgeGroupService.addFileToGroups(uploadRes.id, [notebook.id])
}
}
showSuccess(t('successUploadFile'))
loadData()
} catch (error) {
console.error(error)
showError(t('errorUploadFile', error.message || t('unknownError')))
} finally {
setIsUploading(false)
setPendingFiles([])
}
}
const handleIndexNote = async (note: Note) => {
if (!note.title || !note.content) return;
try {
const file = new File([note.content], `${note.title}.md`, { type: 'text/markdown' });
const rawFile = await readFile(file);
rawFile.isNote = true;
rawFile.textContent = note.content;
setPendingFiles([rawFile]);
setIsIndexingModalOpen(true);
} catch (error) {
console.error(error);
showError(t('errorProcessFile'));
}
}
const handleSaveNote = async () => {
if (!currentNote.title || !currentNote.content) {
showError(t('errorTitleContentRequired'))
return
}
try {
if (currentNote.id) {
await noteService.update(authToken, currentNote.id, {
title: currentNote.title,
content: currentNote.content
})
showSuccess(t('successNoteUpdated'))
} else {
await noteService.create(authToken, {
title: currentNote.title,
content: currentNote.content,
groupId: notebook.id
})
showSuccess(t('successNoteCreated'))
}
setIsEditingNote(false)
setCurrentNote({})
loadData()
} catch (error: any) {
console.error('Save note error:', error)
showError(t('errorSaveFailed', error.message || t('unknownError')))
}
}
const handleDeleteNote = async (id: string) => {
if (!(await confirm(t('confirmDeleteNote')))) return
try {
await noteService.delete(authToken, id)
showSuccess(t('successNoteDeleted'))
loadData()
} catch (error) {
showError(t('deleteFailed'))
}
}
const handleRemoveFile = async (fileId: string, fileName: string) => {
if (!(await confirm(t('confirmRemoveFileFromGroup', fileName)))) return;
try {
const { knowledgeGroupService } = await import('../../services/knowledgeGroupService');
await knowledgeGroupService.removeFileFromGroup(fileId, notebook.id);
showSuccess(t('fileDeleted'));
loadData();
} catch (error) {
console.error(error);
showError(t('deleteFailed'));
}
}
// メモエディタのレンダリング
if (isEditingNote) {
return (
{/* エディタヘッダー */}
{currentNote.id ? t('editNote') : t('newNote')}
setCurrentNote(prev => ({ ...prev, title: e.target.value }))}
className="w-full text-2xl font-bold border-none outline-none bg-transparent mb-4 placeholder-slate-300 shrink-0 px-2"
/>
{/* エディタ */}
{/* プレビュー */}
{showPreview && (
{currentNote.content ? (
}
return !inline && match ? (
{String(children).replace(/\n$/, '')}
) : (
{children}
)
}
}}
>
{currentNote.content}
) : (
{t('markdownPreviewArea')}
)}
)}
{/* AIコマンドドロワー - エディタ表示用 */}
setIsAIModalOpen(false)}
context={currentNote.content || ''}
authToken={authToken}
onApply={(newContent) => {
setCurrentNote(prev => ({ ...prev, content: newContent }))
}}
/>
)
}
return (
{/* ヘッダー */}
{notebook.name}
{notebook.description &&
{notebook.description}
}
{/* コンテンツアクション */}
{activeTab === 'notes' && (
)}
{activeTab === 'files' && (
<>
{isAdmin && (
{
if (e.target.files) handleFileUpload(e.target.files)
}}
multiple
disabled={isUploading}
accept={GROUP_ALLOWED_EXTENSIONS.map(ext => `.${ext}`).join(',') + ',' + IMAGE_MIME_TYPES.join(',')}
/>
)}
{isAdmin && (
)}
{isAdmin && (
{t('supportedFormatsInfo')}
)}
>
)}
{/* ノートブック全域ドラッグアップロードオーバーレイ */}
{/* リスト表示 */}
{isLoading ? (
Loading...
) : (
{activeTab === 'files' && files.map(file => (
{file.title || file.name}
{formatBytes(file.size)}
setPreviewFile({ id: file.id, name: file.name })}
/>
{isAdmin && (
)}
))}
{activeTab === 'notes' && notes.map(note => (
{
setCurrentNote(note)
setIsEditingNote(true)
}}>
{note.title}
e.stopPropagation()}>
{isAdmin && (
)}
{note.content}
{note.screenshotPath && (
)}
{new Date(note.updatedAt).toLocaleDateString()}
{note.user && (
{note.user.username}
)}
))}
{((activeTab === 'files' && files.length === 0) || (activeTab === 'notes' && notes.length === 0)) && (
{activeTab === 'files' ? (
) : (
{t('noFilesOrNotes', t('notes'))}
)}
)}
)}
{/* インデックスモーダル */}
{
setIsIndexingModalOpen(false)
setPendingFiles([])
}}
onConfirm={handleConfirmIndexing}
files={pendingFiles}
embeddingModels={models.filter(m => m.type === ModelType.EMBEDDING)}
defaultEmbeddingId={models.find(m => m.type === ModelType.EMBEDDING)?.id || ''}
/>
{/* PDFプレビューモーダル */}
{
previewFile && (
setPreviewFile(null)}
/>
)
}
{/* AIコマンドドロワー */}
setIsAIModalOpen(false)}
context={currentNote.content || ''}
authToken={authToken}
onApply={(newContent) => {
setCurrentNote(prev => ({ ...prev, content: newContent })) // 内容を置換(必要に応じて将来的に追加オプションを検討)
}}
/>
setIsImportDrawerOpen(false)}
authToken={authToken}
initialGroupId={notebook.id}
initialGroupName={notebook.name}
onImportSuccess={loadData}
/>
)
}