d3ro-voice/apps/desktop/src/renderer/pages/KnowledgeBasePage.tsx

459 lines
18 KiB
TypeScript

// 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<KnowledgeView>('kb')
const { t } = useI18n()
const [documents, setDocuments] = useState<RAGDocument[]>([])
const [loading, setLoading] = useState(true)
const [query, setQuery] = useState('')
const [querying, setQuerying] = useState(false)
const [result, setResult] = useState<RAGQueryResult | null>(null)
const [indexProgress, setIndexProgress] = useState<RAGIndexProgress | null>(null)
const [adding, setAdding] = useState(false)
const [addError, setAddError] = useState<string | null>(null)
const [queryError, setQueryError] = useState<string | null>(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 (
<Box sx={{ maxWidth: 1060, mx: 'auto', p: { xs: 2.5, md: 4 }, pb: 10 }}>
<PageHeader
title={t('rag.title')}
count={`${documents.length} ${t('rag.documents')}`}
action={
<PhysicalButton
tone="accent"
size="small"
onClick={handleAddDocument}
disabled={adding}
trailingIcon={<Plus size={14} />}
>
{adding ? t('rag.adding') : t('rag.addDocument')}
</PhysicalButton>
}
/>
<Tabs
value={view}
onChange={(_event, next: KnowledgeView) => 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 }
}}
>
<Tab value="kb" label={t('input.kbView.knowledge')} />
<Tab value="insights" label={t('input.kbView.insights')} />
</Tabs>
{view === 'insights' ? (
<InputInsightsView />
) : (
<>
{/* Indexing Progress Card */}
{indexProgress && (
<MetalCard sx={{ mb: 2.5, p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.25 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Led color="amber" pulse />
<PhosphorText variant="body">
{t('rag.indexing')}: {indexProgress.fileName}
</PhosphorText>
</Box>
<PhosphorText variant="meta">
{indexProgress.currentChunk} / {indexProgress.totalChunks} Chunks ({indexProgress.percent}%)
</PhosphorText>
</Box>
<LinearProgress
variant="determinate"
value={indexProgress.percent}
sx={{
height: 6,
borderRadius: d3roRadius.pill,
bgcolor: d3roPalette.bg.inset,
'& .MuiLinearProgress-bar': { bgcolor: d3roPalette.accent.main },
}}
/>
</MetalCard>
)}
{/* Error Card */}
{addError && (
<MetalCard sx={{ mb: 2.5, p: 2, border: `1px solid ${d3roPalette.tag.redBg}` }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Led color="red" />
<PhosphorText variant="body" sx={{ color: d3roPalette.tag.red }}>
{addError}
</PhosphorText>
</Box>
</MetalCard>
)}
{/* Asking / Semantic Query Bar */}
<DoubleBezelCard innerPadding={2.5} sx={{ mb: 3.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Search size={18} style={{ color: d3roPalette.accent.light }} />
<TextField
value={query}
onChange={(e) => 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 },
}}
/>
<PhysicalButton
tone="accent"
onClick={handleQuery}
disabled={!query.trim() || querying}
sx={{ height: 40, px: 2.5 }}
>
<Send size={15} style={{ marginRight: 4 }} />
{querying ? t('common.loading') : 'Search'}
</PhysicalButton>
</Box>
{/* Quick Suggestion Prompt Chips */}
{documents.length > 0 && !result && !querying && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 2, flexWrap: 'wrap' }}>
<PhosphorText variant="meta" sx={{ color: d3roPalette.text.dimLabel, mr: 0.5 }}>
PROMPTS:
</PhosphorText>
{[
'이 문서들의 핵심 내용 3줄 요약해줘',
'주요 일정 및 마일스톤 정리해줘',
'보안 및 개인정보 관련 정책은?',
].map((sample) => (
<TactileBadge
key={sample}
onClick={() => {
setQuery(sample)
setTimeout(handleQuery, 50)
}}
sx={{ cursor: 'pointer' }}
>
{sample}
</TactileBadge>
))}
</Box>
)}
{/* Answer Result Display */}
{querying && (
<Box sx={{ mt: 3, textAlign: 'center', py: 2 }}>
<Led color="amber" pulse size={8} />
<PhosphorText variant="dim" sx={{ ml: 1.5 }}>
{t('rag.searching')}
</PhosphorText>
</Box>
)}
{queryError && (
<Box sx={{ mt: 2.5, p: 2, borderRadius: d3roRadius.inner, bgcolor: d3roPalette.bg.inset, border: `1px solid ${d3roPalette.tag.redBg}`, display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Led color="red" />
<PhosphorText variant="body" sx={{ color: d3roPalette.tag.red, fontSize: '13px' }}>
{queryError}
</PhosphorText>
</Box>
)}
{result && (
<Box sx={{ mt: 3, pt: 2.5, borderTop: `1px solid ${d3roPalette.glass.hairline}` }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Sparkles size={16} style={{ color: d3roPalette.accent.light }} />
<PhosphorText variant="heading" sx={{ color: d3roPalette.accent.light }}>
Semantic Synthesis
</PhosphorText>
</Box>
<Box
sx={{
p: 2.5,
borderRadius: d3roRadius.inner,
bgcolor: d3roPalette.bg.inset,
border: `1px solid ${d3roPalette.glass.hairlineStrong}`,
boxShadow: d3roShadow.inset,
fontFamily: d3roFontSans,
fontSize: '14px',
lineHeight: 1.7,
color: d3roPalette.text.primary,
whiteSpace: 'pre-wrap',
mb: 2,
}}
>
{result.answer}
</Box>
{/* Sources Attribution */}
{result.results.length > 0 && (
<Box>
<PhosphorText variant="meta" sx={{ color: d3roPalette.text.dimLabel, mb: 1.25, display: 'block' }}>
{t('rag.sources').toUpperCase()} ({result.results.length})
</PhosphorText>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: 'repeat(3, 1fr)' }, gap: 1.5 }}>
{result.results.slice(0, 3).map((r, i) => (
<MetalCard key={i} sx={{ p: 1.75 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.75 }}>
<PhosphorText variant="compact" sx={{ fontWeight: 600, color: d3roPalette.text.primary }}>
[{i + 1}] {r.fileName}
</PhosphorText>
<TactileBadge mono tone="accent">
{Math.round(r.similarity * 100)}%
</TactileBadge>
</Box>
<PhosphorText variant="small" sx={{ color: d3roPalette.text.secondary, opacity: 0.85, lineHeight: 1.5 }}>
{r.content.slice(0, 120)}...
</PhosphorText>
</MetalCard>
))}
</Box>
</Box>
)}
</Box>
)}
</DoubleBezelCard>
{/* Document Library Section */}
<Box sx={{ mt: 4 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<PhosphorText variant="label" sx={{ color: d3roPalette.text.dimLabel }}>
{t('rag.documents').toUpperCase()} ({documents.length})
</PhosphorText>
</Box>
{documents.length === 0 && !loading ? (
<EmptyStateCard
message={t('rag.noDocuments')}
icon={<Database />}
action={
<PhysicalButton tone="accent" onClick={handleAddDocument}>
<Plus size={15} style={{ marginRight: 4 }} />
{t('rag.addDocument')}
</PhysicalButton>
}
/>
) : (
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' }, gap: 1.75 }}>
{documents.map((doc) => (
<MetalCard key={doc.id} sx={{ p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2 }}>
<Box
sx={{
width: 40,
height: 40,
borderRadius: d3roRadius.inner,
bgcolor: d3roPalette.glass.raised,
border: `1px solid ${d3roPalette.glass.hairline}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: d3roPalette.accent.light,
flexShrink: 0,
}}
>
<FileText size={20} />
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<PhosphorText
variant="heading"
sx={{
mb: 0.5,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{doc.fileName}
</PhosphorText>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.25, flexWrap: 'wrap', mt: 1 }}>
<TactileBadge mono tone="mono">
{doc.fileType.toUpperCase()}
</TactileBadge>
<TactileBadge mono tone="default">
{doc.chunkCount} {t('rag.chunks')}
</TactileBadge>
<TactileBadge
ledColor={doc.indexed ? 'green' : 'amber'}
tone={doc.indexed ? 'success' : 'warning'}
>
{doc.indexed ? t('rag.indexed') : t('rag.pending')}
</TactileBadge>
</Box>
</Box>
<Box sx={{ display: 'flex', gap: 0.5 }}>
<Tooltip title={t('rag.reindex')}>
<IconButton
size="small"
onClick={() => 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 },
}}
>
<RefreshCw size={14} />
</IconButton>
</Tooltip>
<Tooltip title={t('common.delete')}>
<IconButton
size="small"
onClick={() => 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 },
}}
>
<Trash2 size={14} />
</IconButton>
</Tooltip>
</Box>
</Box>
</MetalCard>
))}
</Box>
)}
</Box>
</>
)}
</Box>
)
}