// src/renderer/pages/KnowledgeBasePage.tsx // Phase 13.2: 로컬 RAG Knowledge Base UI // 문서 관리 + 질문 입력 + 답변 표시 import { useState, useEffect, useCallback } from 'react' import { Box, TextField, IconButton, Tooltip, LinearProgress } from '@mui/material' import AddIcon from '@mui/icons-material/Add' import DeleteIcon from '@mui/icons-material/Delete' import RefreshIcon from '@mui/icons-material/Refresh' import SearchIcon from '@mui/icons-material/Search' import SendIcon from '@mui/icons-material/Send' import DescriptionIcon from '@mui/icons-material/Description' import { MetalCard, PhosphorText, Led, PhysicalButton, ScreenPanel } from '@d3ro/ui/components/ds' import { PageHeader, EmptyStateCard } from '../components/shared' import { isImeComposingEvent } from '../utils/keyboard' import { d3roPalette, d3roFontMono, d3roTypo } from '@d3ro/ui/theme' import { useI18n } from '@d3ro/i18n' import type { RAGDocument, RAGQueryResult, RAGIndexProgress } from '@d3ro/core/types' export function KnowledgeBasePage(): React.ReactElement { const { t } = useI18n() const [documents, setDocuments] = useState([]) const [loading, setLoading] = useState(true) const [query, setQuery] = useState('') const [querying, setQuerying] = useState(false) const [result, setResult] = useState(null) const [indexProgress, setIndexProgress] = useState(null) const [adding, setAdding] = useState(false) const [addError, setAddError] = useState(null) const loadDocuments = useCallback(async () => { setLoading(true) const resp = await window.electronAPI.rag.getDocuments() if (resp.success) setDocuments(resp.data) setLoading(false) }, []) useEffect(() => { loadDocuments() }, [loadDocuments]) useEffect(() => { const unsubProgress = window.electronAPI.rag.onIndexProgress((data) => { setIndexProgress(data) }) const unsubComplete = window.electronAPI.rag.onIndexComplete(() => { setIndexProgress(null) loadDocuments() }) return () => { unsubProgress(); unsubComplete() } }, [loadDocuments]) const handleAddDocument = useCallback(async () => { setAdding(true) setAddError(null) const resp = await window.electronAPI.rag.addDocument() setAdding(false) if (resp.success) { loadDocuments() } else { if (!resp.error.message.includes('cancelled')) { setAddError(resp.error.message) } } }, [loadDocuments]) const handleRemoveDocument = useCallback(async (docId: string) => { await window.electronAPI.rag.removeDocument({ documentId: docId }) loadDocuments() }, [loadDocuments]) const handleReindex = useCallback(async (docId: string) => { await window.electronAPI.rag.reindex(docId) }, []) const handleQuery = useCallback(async () => { if (!query.trim()) return setQuerying(true) setResult(null) const resp = await window.electronAPI.rag.query({ query: query.trim() }) if (resp.success) { setResult(resp.data) } setQuerying(false) }, [query]) const handleQueryKeyDown = useCallback((e: React.KeyboardEvent) => { if (isImeComposingEvent(e)) return if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault() handleQuery() } }, [handleQuery]) return ( {adding ? t('rag.adding') : t('rag.addDocument')} } /> {/* 인덱싱 진행률 */} {indexProgress && ( {t('rag.indexing')}: {indexProgress.fileName} {indexProgress.currentChunk}/{indexProgress.totalChunks} )} {/* 에러 표시 */} {addError && ( {addError} )} {/* 추가 중 인디케이터 */} {adding && !indexProgress && ( {t('rag.parsing')} )} {/* 문서 목록 */} {t('rag.documents').toUpperCase()} ({documents.length}) {documents.length === 0 && !loading ? ( ) : ( {documents.map((doc) => ( {doc.fileName} {doc.fileType.toUpperCase()} · {doc.chunkCount} {t('rag.chunks')} · {doc.indexed ? t('rag.indexed') : t('rag.pending')} handleReindex(doc.id)} sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.accent.amber } }}> handleRemoveDocument(doc.id)} sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.tag.red } }}> ))} )} {/* 질문 입력 */} {t('rag.askQuestion').toUpperCase()} setQuery(e.target.value)} onKeyDown={handleQueryKeyDown} placeholder={t('rag.queryPlaceholder')} size="small" fullWidth sx={{ '& .MuiOutlinedInput-root': { fontFamily: d3roFontMono, fontSize: d3roTypo.compact.size, bgcolor: d3roPalette.bg.inset, '& fieldset': { borderColor: d3roPalette.border.subtle }, '&:hover fieldset': { borderColor: d3roPalette.accent.amber }, }, '& .MuiOutlinedInput-input': { color: d3roPalette.text.primary }, }} /> {/* 답변 */} {querying && ( {t('rag.searching')} )} {result && ( {result.answer} {result.results.length > 0 && ( {t('rag.sources').toUpperCase()} {result.results.slice(0, 3).map((r, i) => ( [{i + 1}] {r.fileName} ({Math.round(r.similarity * 100)}%) {r.content.slice(0, 150)}... ))} )} )} ) }