Bug 10 fix(MeetingModePage 인라인 가드)를 isImeComposingEvent helper로 추출하고, 한글 위험도 있는 나머지 7개 Enter 핸들러에 일괄 적용. 총 8곳이 이제 동일 helper 경유. 신규: - apps/desktop/src/renderer/utils/keyboard.ts — isImeComposingEvent(e) JSDoc에 Bug 10 원리(Chromium이 IME 조합 중 Enter를 2번 발화) + 권장 사용 패턴 포함 적용 8곳: - pages/MeetingModePage.tsx:164 회의 메모 (기존 인라인 가드 4줄 교체) - pages/KnowledgeBasePage.tsx:84 RAG 쿼리 - pages/VoiceConversationPage.tsx:113 텍스트 채팅 - pages/CommandsPage.tsx:336 키워드 추가 (Enter+Esc) - components/meeting/MeetingChatPanel.tsx:111 미팅 챗 - components/meeting/EditableSegment.tsx:70 전사 세그먼트 편집 (Enter+Esc) - components/meeting/MeetingDetailTabs.tsx:242 미팅 타이틀 (인라인 arrow → 블록) - components/shared/HistoryEntryCard.tsx:64 태그 추가 (Enter+Esc) /simplify 패스 품질 리뷰: - Phase 3.3 CloudSyncService.pushOne 훅 8곳은 이미 fire-and-forget 1줄로 일관. 내부 try-catch가 에러 삼켜 로컬 write 차단 금지 철학 준수 → 수정 없음, 현 상태가 최적. 검증: - desktop tsc --noEmit EXIT=0 - Vite HMR로 dev 프로세스 자동 반영 (재기동 없음) - 한글 Enter 시연은 사용자 실측 대기 (VoiceConversationPage / MeetingDetailTabs 대표 2곳)
264 lines
10 KiB
TypeScript
264 lines
10 KiB
TypeScript
// 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 '@d3ro/ui/components/ds'
|
|
import { PageHeader, EmptyStateCard } from '../components/shared'
|
|
import { isImeComposingEvent } from '../utils/keyboard'
|
|
import { d3roPalette, d3roFontMono, d3roTypo } 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 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 (isImeComposingEvent(e)) return
|
|
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>
|
|
)
|
|
}
|