// src/renderer/pages/KnowledgeBasePage.tsx // Local RAG Knowledge Base & Semantic Memory Intelligence import React, { useState, useEffect, useCallback, useRef } from 'react' import { Box, TextField, IconButton, Tooltip, LinearProgress } from '@mui/material' import { Plus, Trash2, RefreshCw, Search, Send, FileText, Database, Sparkles } from 'lucide-react' import { MetalCard, PhosphorText, Led, PhysicalButton, DoubleBezelCard, TactileBadge } from '@d3ro/ui/components/ds' import { PageHeader, EmptyStateCard } from '../components/shared' import { isImeComposingEvent } from '../utils/keyboard' import { d3roPalette, d3roFontSans, d3roTypo, d3roRadius, d3roShadow } 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 isAddingRef = useRef(false) const handleAddDocument = useCallback(async () => { if (isAddingRef.current || adding) return isAddingRef.current = true setAdding(true) setAddError(null) try { const resp = await window.electronAPI.rag.addDocument() if (resp.success) { loadDocuments() } else { if (!resp.error.message.includes('cancelled') && !resp.error.message.includes('already active')) { setAddError(resp.error.message) } } } finally { setAdding(false) isAddingRef.current = false } }, [adding, 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')} } /> {/* Indexing Progress Card */} {indexProgress && ( {t('rag.indexing')}: {indexProgress.fileName} {indexProgress.currentChunk} / {indexProgress.totalChunks} Chunks ({indexProgress.percent}%) )} {/* Error Card */} {addError && ( {addError} )} {/* Asking / Semantic Query Bar */} setQuery(e.target.value)} onKeyDown={handleQueryKeyDown} placeholder={t('rag.queryPlaceholder')} size="small" fullWidth sx={{ '& .MuiOutlinedInput-root': { fontFamily: d3roFontSans, fontSize: d3roTypo.compact.size, bgcolor: d3roPalette.bg.inset, borderRadius: d3roRadius.inner, border: `1px solid ${d3roPalette.glass.hairline}`, '& fieldset': { border: 'none' }, '&:hover': { borderColor: d3roPalette.glass.hairlineStrong }, '&.Mui-focused': { borderColor: d3roPalette.accent.main }, }, '& .MuiOutlinedInput-input': { color: d3roPalette.text.primary, py: 1.25 }, }} /> {querying ? t('common.loading') : 'Search'} {/* Quick Suggestion Prompt Chips */} {documents.length > 0 && !result && !querying && ( PROMPTS: {[ '이 문서들의 핵심 내용 3줄 요약해줘', '주요 일정 및 마일스톤 정리해줘', '보안 및 개인정보 관련 정책은?', ].map((sample) => ( { setQuery(sample) setTimeout(handleQuery, 50) }} sx={{ cursor: 'pointer' }} > {sample} ))} )} {/* Answer Result Display */} {querying && ( {t('rag.searching')} )} {result && ( Semantic Synthesis {result.answer} {/* Sources Attribution */} {result.results.length > 0 && ( {t('rag.sources').toUpperCase()} ({result.results.length}) {result.results.slice(0, 3).map((r, i) => ( [{i + 1}] {r.fileName} {Math.round(r.similarity * 100)}% {r.content.slice(0, 120)}... ))} )} )} {/* Document Library Section */} {t('rag.documents').toUpperCase()} ({documents.length}) {documents.length === 0 && !loading ? ( } action={ {t('rag.addDocument')} } /> ) : ( {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={{ p: 0.6, color: d3roPalette.text.inactive, bgcolor: d3roPalette.glass.raised, border: `1px solid ${d3roPalette.glass.hairline}`, '&:hover': { color: d3roPalette.accent.light, bgcolor: d3roPalette.glass.hairlineStrong }, }} > handleRemoveDocument(doc.id)} sx={{ p: 0.6, color: d3roPalette.text.inactive, bgcolor: d3roPalette.glass.raised, border: `1px solid ${d3roPalette.glass.hairline}`, '&:hover': { color: d3roPalette.tag.red, bgcolor: d3roPalette.tag.redBg }, }} > ))} )} ) }