feat: complete release preparation, 10+ ad mediation, CI/CD, and docker deployment
Some checks failed
CI Pipeline / Code Quality & Typecheck (push) Waiting to run
CI Pipeline / Test Suite (macos-latest) (push) Blocked by required conditions
CI Pipeline / Test Suite (ubuntu-latest) (push) Blocked by required conditions
CI Pipeline / Test Suite (windows-latest) (push) Blocked by required conditions
CI Pipeline / Build Validation (admin) (push) Blocked by required conditions
CI Pipeline / Build Validation (desktop) (push) Blocked by required conditions
Deploy Landing Page / deploy (push) Blocked by required conditions
Deploy Landing Page / build (push) Waiting to run
Release & Packaging Pipeline / Build & Publish Admin Docker Image (push) Failing after 8s
Release & Code Signing CA Pipeline / build-and-sign-windows (push) Failing after 1m51s
Build macOS / Build & Package (macOS) (push) Failing after 4s
Build macOS / Build & Package (macOS)-1 (push) Failing after 5s
Release & Code Signing CA Pipeline / build-and-sign-macos (push) Failing after 3s
Release & Packaging Pipeline / Package macOS Desktop App (push) Failing after 4s
Release & Packaging Pipeline / Package Windows Desktop App (push) Failing after 2m28s
Release & Packaging Pipeline / Publish Official GitHub Release (push) Has been skipped

This commit is contained in:
Yun Chan 2026-08-20 11:12:05 +09:00
parent 5cd1de6859
commit 708e20f747
406 changed files with 42464 additions and 6199 deletions

View file

@ -1,14 +1,13 @@
// src/renderer/pages/KnowledgeBasePage.tsx
// Phase 13.2: 로컬 RAG Knowledge Base UI
// 문서 관리 + 질문 입력 + 답변 표시
// Local RAG Knowledge Base & Semantic Memory Intelligence
import { useState, useEffect, useCallback } from 'react'
import React, { useState, useEffect, useCallback, useRef } from 'react'
import { Box, TextField, IconButton, Tooltip, LinearProgress } from '@mui/material'
import { Plus, Trash2, RefreshCw, Search, Send, FileText } from 'lucide-react'
import { MetalCard, PhosphorText, Led, PhysicalButton, ScreenPanel } from '@d3ro/ui/components/ds'
import { Plus, Trash2, RefreshCw, Search, Send, FileText, Database, Sparkles, CheckCircle2 } from 'lucide-react'
import { MetalCard, PhosphorText, Led, PhysicalButton, ScreenPanel, DoubleBezelCard, TactileBadge } 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 { d3roPalette, d3roFontSans, d3roFontMono, d3roTypo, d3roRadius, d3roShadow } from '@d3ro/ui/theme'
import { useI18n } from '@d3ro/i18n'
import type { RAGDocument, RAGQueryResult, RAGIndexProgress } from '@d3ro/core/types'
@ -30,7 +29,9 @@ export function KnowledgeBasePage(): React.ReactElement {
setLoading(false)
}, [])
useEffect(() => { loadDocuments() }, [loadDocuments])
useEffect(() => {
loadDocuments()
}, [loadDocuments])
useEffect(() => {
const unsubProgress = window.electronAPI.rag.onIndexProgress((data) => {
@ -40,27 +41,41 @@ export function KnowledgeBasePage(): React.ReactElement {
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)
}
return () => {
unsubProgress()
unsubComplete()
}
}, [loadDocuments])
const handleRemoveDocument = useCallback(async (docId: string) => {
await window.electronAPI.rag.removeDocument({ documentId: docId })
loadDocuments()
}, [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)
@ -77,53 +92,65 @@ export function KnowledgeBasePage(): React.ReactElement {
setQuerying(false)
}, [query])
const handleQueryKeyDown = useCallback((e: React.KeyboardEvent) => {
if (isImeComposingEvent(e)) return
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleQuery()
}
}, [handleQuery])
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 }}>
<Box sx={{ maxWidth: 1060, mx: 'auto', p: { xs: 2.5, md: 4 }, pb: 10 }}>
<PageHeader
title={t('rag.title').toUpperCase()}
title={t('rag.title')}
count={`${documents.length} ${t('rag.documents')}`}
action={
<PhysicalButton size="small" onClick={handleAddDocument} disabled={adding}>
<Plus size={14} style={{ marginRight: 4 }} /> {adding ? t('rag.adding') : t('rag.addDocument')}
<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 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<Led color="amber" pulse />
<PhosphorText variant="body">
{t('rag.indexing')}: {indexProgress.fileName}
<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: 4,
borderRadius: 2,
height: 6,
borderRadius: d3roRadius.pill,
bgcolor: d3roPalette.bg.inset,
'& .MuiLinearProgress-bar': { bgcolor: d3roPalette.accent.main },
}}
/>
<PhosphorText variant="dim" sx={{ mt: 0.5 }}>
{indexProgress.currentChunk}/{indexProgress.totalChunks}
</PhosphorText>
</MetalCard>
)}
{/* 에러 표시 */}
{/* Error Card */}
{addError && (
<MetalCard sx={{ mb: 2, border: `1px solid ${d3roPalette.tag.red}` }}>
<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 }}>
@ -133,67 +160,10 @@ export function KnowledgeBasePage(): React.ReactElement {
</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 }}>
<FileText size={20} style={{ 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.main } }}>
<RefreshCw size={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 } }}>
<Trash2 size={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 }}>
<Search size={20} style={{ color: d3roPalette.text.inactive }} />
{/* 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)}
@ -203,57 +173,228 @@ export function KnowledgeBasePage(): React.ReactElement {
fullWidth
sx={{
'& .MuiOutlinedInput-root': {
fontFamily: d3roFontMono,
fontFamily: d3roFontSans,
fontSize: d3roTypo.compact.size,
bgcolor: d3roPalette.bg.inset,
'& fieldset': { borderColor: d3roPalette.border.subtle },
'&:hover fieldset': { borderColor: d3roPalette.accent.main },
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 },
'& .MuiOutlinedInput-input': { color: d3roPalette.text.primary, py: 1.25 },
}}
/>
<IconButton onClick={handleQuery} disabled={!query.trim() || querying}
sx={{ color: query.trim() ? d3roPalette.accent.main : d3roPalette.text.inactive }}>
<Send size={20} />
</IconButton>
<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>
</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}
{/* 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>
</ScreenPanel>
{[
'이 문서들의 핵심 내용 3줄 요약해줘',
'주요 일정 및 마일스톤 정리해줘',
'보안 및 개인정보 관련 정책은?',
].map((sample) => (
<TactileBadge
key={sample}
onClick={() => {
setQuery(sample)
setTimeout(handleQuery, 50)
}}
sx={{ cursor: 'pointer' }}
>
{sample}
</TactileBadge>
))}
</Box>
)}
{result.results.length > 0 && (
<Box sx={{ mt: 2 }}>
<PhosphorText variant="label" sx={{ color: d3roPalette.text.dimLabel, mb: 1, display: 'block' }}>
{t('rag.sources').toUpperCase()}
{/* 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>
{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
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>
)
}