// 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, Tab, Tabs } 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 { InputInsightsView } from '../components/input-insights/InputInsightsView' 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' import { ErrorCode } from '@d3ro/core/errors' type KnowledgeView = 'kb' | 'insights' export function KnowledgeBasePage(): React.ReactElement { // 이 화면은 두 축을 가진다: 수집된 지식 문서(RAG)와 자동 수집된 입력 데이터 통계. const [view, setView] = useState('kb') 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 [queryError, setQueryError] = 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) if (data.currentChunk <= 1) { loadDocuments() } }) // 색인 실행이 끝나면(성공·실패 무관) 진행 표시를 내리고, 문서가 끝내 색인되지 않았으면 알린다. const unsubComplete = window.electronAPI.rag.onIndexComplete((data) => { setIndexProgress(null) void window.electronAPI.rag.getDocuments().then((resp) => { if (!resp.success) return setDocuments(resp.data) const doc = resp.data.find((d) => d.id === data.documentId) if (doc && !doc.indexed) setAddError(t('mobile.knowledge.error.indexUnavailable')) }) }) return () => { unsubProgress() unsubComplete() } }, [loadDocuments, t]) 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) => { setAddError(null) const resp = await window.electronAPI.rag.reindex(docId) // 임베딩 실패는 rag:indexComplete 뒤 문서 상태로 알린다. 그 밖의 실패(문서 없음·파싱 실패)만 여기서 보인다. if (!resp.success && resp.error.code !== ErrorCode.RAGEmbeddingFailed) setAddError(resp.error.message) }, []) const handleQuery = useCallback(async () => { if (!query.trim()) return setQuerying(true) setResult(null) setQueryError(null) const resp = await window.electronAPI.rag.query({ query: query.trim() }) if (resp.success) { setResult(resp.data) } else { setQueryError(resp.error?.message || '검색 중 오류가 발생했습니다.') } 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')} } /> setView(next)} sx={{ mb: 2.5, minHeight: 38, borderBottom: `1px solid ${d3roPalette.border.subtle}`, '& .MuiTab-root': { minHeight: 38, py: 0.5, textTransform: 'none', color: d3roPalette.text.inactive, fontSize: d3roTypo.label.size, '&.Mui-selected': { color: d3roPalette.accent.main } }, '& .MuiTabs-indicator': { bgcolor: d3roPalette.accent.main, height: 2 } }} > {view === 'insights' ? ( ) : ( <> {/* 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')} )} {queryError && ( {queryError} )} {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 }, }} > ))} )} )} ) }