d3ro-voice/apps/desktop/src/renderer/pages/KnowledgeBasePage.tsx
2026-08-29 18:33:45 +09:00

400 lines
15 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 } 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<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 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 (
<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>
}
/>
{/* 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>
)}
{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>
)
}