400 lines
15 KiB
TypeScript
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>
|
|
)
|
|
}
|