Phase 12~13 전체 구현: Pro+ 피처 6종 + 음성 대화 + RAG + OS 자동화

Phase 12:
- FileTranscriptionService: ffmpeg PCM 변환 + 30초 청크 순차 STT
- MeetingSummaryService: 자막 세션 → LLM 자동 요약 + DB summaryText
- DictationTemplateService: 필드별 음성 입력 상태 머신 + 프리셋 3개

Phase 13.1:
- VoiceConversationService: STT→Ollama /api/chat→TTS 대화 루프 (10턴)
- TTSPlaybackService: Windows SAPI 문장 단위 큐 재생
- LocalLLMService.chatStream: Ollama /api/chat 스트리밍

Phase 13.2:
- RAGService: Ollama 임베딩 + SQLite 벡터 + 코사인 유사도 검색
- KnowledgeBasePage: 문서 관리 + 질문/답변 UI
- PDF 파서: zlib FlateDecode 해제 + BT/ET 텍스트 추출

Phase 13.3:
- VoiceActionService: LLM JSON 액션 플랜 생성 + 실행
- 프리셋 6개 (크롬/메모장/탐색기/볼륨), 위험 명령 차단

공통: IPC ~70채널, 에러코드 780-878, i18n 100+키
버그픽스: 라이선스 로컬 키 우선, i18n featureLabel, DOM 중첩
This commit is contained in:
Yun Chan 2026-04-05 23:52:14 +09:00
parent a31f96bbb8
commit eb83682269
38 changed files with 5678 additions and 19 deletions

View file

@ -0,0 +1,262 @@
// src/renderer/pages/KnowledgeBasePage.tsx
// Phase 13.2: 로컬 RAG Knowledge Base UI
// 문서 관리 + 질문 입력 + 답변 표시
import { useState, useEffect, useCallback } from 'react'
import { Box, TextField, IconButton, Tooltip, LinearProgress } from '@mui/material'
import AddIcon from '@mui/icons-material/Add'
import DeleteIcon from '@mui/icons-material/Delete'
import RefreshIcon from '@mui/icons-material/Refresh'
import SearchIcon from '@mui/icons-material/Search'
import SendIcon from '@mui/icons-material/Send'
import DescriptionIcon from '@mui/icons-material/Description'
import { MetalCard, PhosphorText, Led, PhysicalButton, ScreenPanel } from '../components/ds'
import { PageHeader, EmptyStateCard } from '../components/shared'
import { d3roPalette, d3roFontMono, d3roTypo } from '../theme'
import { useI18n } from '../i18n'
import type { RAGDocument, RAGQueryResult, RAGIndexProgress } from '@shared/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 handleAddDocument = useCallback(async () => {
setAdding(true)
setAddError(null)
const resp = await window.electronAPI.rag.addDocument()
setAdding(false)
if (resp.success) {
loadDocuments()
} else {
if (!resp.error.message.includes('cancelled')) {
setAddError(resp.error.message)
}
}
}, [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 (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleQuery()
}
}, [handleQuery])
return (
<Box sx={{ maxWidth: 900, mx: 'auto', p: 4, pb: 8 }}>
<PageHeader
title={t('rag.title').toUpperCase()}
action={
<PhysicalButton size="small" onClick={handleAddDocument} disabled={adding}>
<AddIcon sx={{ fontSize: 14, mr: 0.5 }} /> {adding ? t('rag.adding') : t('rag.addDocument')}
</PhysicalButton>
}
/>
{/* 인덱싱 진행률 */}
{indexProgress && (
<MetalCard sx={{ mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<Led color="amber" pulse />
<PhosphorText variant="body">
{t('rag.indexing')}: {indexProgress.fileName}
</PhosphorText>
</Box>
<LinearProgress
variant="determinate"
value={indexProgress.percent}
sx={{
height: 4,
borderRadius: 2,
bgcolor: d3roPalette.bg.inset,
'& .MuiLinearProgress-bar': { bgcolor: d3roPalette.accent.amber },
}}
/>
<PhosphorText variant="dim" sx={{ mt: 0.5 }}>
{indexProgress.currentChunk}/{indexProgress.totalChunks}
</PhosphorText>
</MetalCard>
)}
{/* 에러 표시 */}
{addError && (
<MetalCard sx={{ mb: 2, border: `1px solid ${d3roPalette.tag.red}` }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Led color="red" />
<PhosphorText variant="body" sx={{ color: d3roPalette.tag.red }}>
{addError}
</PhosphorText>
</Box>
</MetalCard>
)}
{/* 추가 중 인디케이터 */}
{adding && !indexProgress && (
<MetalCard sx={{ mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Led color="amber" pulse />
<PhosphorText variant="body">{t('rag.parsing')}</PhosphorText>
</Box>
</MetalCard>
)}
{/* 문서 목록 */}
<PhosphorText variant="label" sx={{ mb: 1.5, display: 'block', color: d3roPalette.text.inactive }}>
{t('rag.documents').toUpperCase()} ({documents.length})
</PhosphorText>
{documents.length === 0 && !loading ? (
<EmptyStateCard message={t('rag.noDocuments')} />
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mb: 3 }}>
{documents.map((doc) => (
<MetalCard key={doc.id}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<DescriptionIcon sx={{ fontSize: 20, color: d3roPalette.text.inactive }} />
<Box sx={{ flex: 1 }}>
<PhosphorText variant="body">{doc.fileName}</PhosphorText>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.25 }}>
<PhosphorText variant="dim">
{doc.fileType.toUpperCase()} · {doc.chunkCount} {t('rag.chunks')} ·
</PhosphorText>
<Led color={doc.indexed ? 'green' : 'amber'} size={6} />
<PhosphorText variant="dim">
{doc.indexed ? t('rag.indexed') : t('rag.pending')}
</PhosphorText>
</Box>
</Box>
<Tooltip title={t('rag.reindex')}>
<IconButton size="small" onClick={() => handleReindex(doc.id)}
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.accent.amber } }}>
<RefreshIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
<Tooltip title={t('common.delete')}>
<IconButton size="small" onClick={() => handleRemoveDocument(doc.id)}
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.tag.red } }}>
<DeleteIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
</Box>
</MetalCard>
))}
</Box>
)}
{/* 질문 입력 */}
<PhosphorText variant="label" sx={{ mb: 1.5, display: 'block', color: d3roPalette.text.inactive }}>
{t('rag.askQuestion').toUpperCase()}
</PhosphorText>
<MetalCard>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<SearchIcon sx={{ fontSize: 20, color: d3roPalette.text.inactive }} />
<TextField
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleQueryKeyDown}
placeholder={t('rag.queryPlaceholder')}
size="small"
fullWidth
sx={{
'& .MuiOutlinedInput-root': {
fontFamily: d3roFontMono,
fontSize: d3roTypo.compact.size,
bgcolor: d3roPalette.bg.inset,
'& fieldset': { borderColor: d3roPalette.border.subtle },
'&:hover fieldset': { borderColor: d3roPalette.accent.amber },
},
'& .MuiOutlinedInput-input': { color: d3roPalette.text.primary },
}}
/>
<IconButton onClick={handleQuery} disabled={!query.trim() || querying}
sx={{ color: query.trim() ? d3roPalette.accent.amber : d3roPalette.text.inactive }}>
<SendIcon sx={{ fontSize: 20 }} />
</IconButton>
</Box>
</MetalCard>
{/* 답변 */}
{querying && (
<Box sx={{ mt: 2, textAlign: 'center' }}>
<Led color="amber" pulse />
<PhosphorText variant="dim" sx={{ ml: 1 }}>{t('rag.searching')}</PhosphorText>
</Box>
)}
{result && (
<Box sx={{ mt: 2 }}>
<ScreenPanel sx={{ p: 3 }}>
<PhosphorText variant="body" sx={{ whiteSpace: 'pre-wrap', lineHeight: 1.8 }}>
{result.answer}
</PhosphorText>
</ScreenPanel>
{result.results.length > 0 && (
<Box sx={{ mt: 2 }}>
<PhosphorText variant="label" sx={{ color: d3roPalette.text.dimLabel, mb: 1, display: 'block' }}>
{t('rag.sources').toUpperCase()}
</PhosphorText>
{result.results.slice(0, 3).map((r, i) => (
<MetalCard key={i} sx={{ mb: 1 }}>
<PhosphorText variant="dim">
[{i + 1}] {r.fileName} ({Math.round(r.similarity * 100)}%)
</PhosphorText>
<PhosphorText variant="compact" sx={{ mt: 0.5, opacity: 0.7 }}>
{r.content.slice(0, 150)}...
</PhosphorText>
</MetalCard>
))}
</Box>
)}
</Box>
)}
</Box>
)
}