Phase 7~8 구현: 테스트 + 빌드 + CI/CD + SoundEffect + AutoLaunch + UI 리디자인

- vitest 41개 단위 테스트 (HistoryService, DictionaryService, CustomInstructionService, VoiceModeService, D3ROError)
- electron-builder.yml (NSIS, asarUnpack, extraResources)
- .gitlab-ci.yml (lint, typecheck, test, build, release)
- SoundEffectService: WAV 프리로드 + PowerShell 재생 + VoiceMode 연동
- AutoLaunchService: app.setLoginItemSettings + ConfigService 동기화
- TextInsertService: 간이 삽입 검증 (EditMonitor 경량)
- 번들링 인프라: SoX 다운로드 스크립트, PyInstaller 빌드, 경로 해상도 유틸
- AudioCaptureService/LocalSTTService: 번들 경로 자동 감지
- 08-design-system.md 기반 MUI 테마 (다크+라이트+auto 테마 시스템)
- 전체 UI 컴포넌트 리디자인: AppLayout, Dashboard, StatusBar, History, Dictionary, Commands, Settings
- 효과음 WAV 생성: recording-start, recording-stop, error
- EPIPE 에러 핸들링 추가
This commit is contained in:
Yun Chan 2026-04-05 09:12:56 +09:00
parent ed5541f769
commit 3f4d0c5828
40 changed files with 6034 additions and 580 deletions

View file

@ -1,4 +1,5 @@
// src/renderer/components/AppLayout.tsx
// 08-design-system.md SSOT 적용. 앰버 악센트, LED, 다크 카드.
import { useState } from 'react'
import {
@ -9,14 +10,14 @@ import {
ListItemIcon,
ListItemText,
Divider,
Typography,
Chip
Typography
} from '@mui/material'
import DashboardIcon from '@mui/icons-material/Dashboard'
import HistoryIcon from '@mui/icons-material/History'
import MenuBookIcon from '@mui/icons-material/MenuBook'
import ExtensionIcon from '@mui/icons-material/Extension'
import SettingsIcon from '@mui/icons-material/Settings'
import { d3roPalette, d3roFontMono } from '../theme'
import { DashboardPage } from '../pages/DashboardPage'
import { HistoryPage } from '../pages/HistoryPage'
import { DictionaryPage } from '../pages/DictionaryPage'
@ -41,73 +42,139 @@ export function AppLayout(): React.ReactElement {
return (
<Box sx={{ display: 'flex', height: '100vh', flexDirection: 'column' }}>
<Box sx={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
{/* Sidebar Drawer */}
<Drawer
variant="permanent"
sx={{
width: DRAWER_WIDTH,
flexShrink: 0,
'& .MuiDrawer-paper': {
<Box sx={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
{/* Sidebar Drawer */}
<Drawer
variant="permanent"
sx={{
width: DRAWER_WIDTH,
boxSizing: 'border-box'
}
}}
>
{/* Header */}
<Box sx={{ p: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="h6" noWrap sx={{ fontWeight: 700 }}>
D3RO Voice
</Typography>
<Chip label="v1.0" size="small" variant="outlined" />
</Box>
<Divider />
{/* Navigation */}
<List sx={{ flex: 1, pt: 1 }}>
{NAV_ITEMS.map((item) => (
<ListItemButton
key={item.route}
selected={currentRoute === item.route}
onClick={() => setCurrentRoute(item.route)}
sx={{ my: 0.5 }}
flexShrink: 0,
'& .MuiDrawer-paper': {
width: DRAWER_WIDTH,
boxSizing: 'border-box'
}
}}
>
{/* Header — 앰버 악센트 로고 */}
<Box sx={{ p: 2.5, display: 'flex', alignItems: 'center', gap: 1.5 }}>
{/* LED indicator */}
<Box
sx={{
width: 8,
height: 8,
borderRadius: '50%',
bgcolor: d3roPalette.accent.amber,
boxShadow: `0 0 6px ${d3roPalette.accent.amberGlow}, 0 0 16px ${d3roPalette.accent.amberDim}`,
animation: 'led-pulse 1.5s ease-in-out infinite',
'@keyframes led-pulse': {
'0%, 100%': { opacity: 1 },
'50%': { opacity: 0.5 },
},
flexShrink: 0,
}}
/>
<Typography
variant="h6"
noWrap
sx={{
fontWeight: 700,
fontSize: '14px',
letterSpacing: '0.05em',
color: d3roPalette.text.primary,
}}
>
<ListItemIcon sx={{ minWidth: 40 }}>{item.icon}</ListItemIcon>
<ListItemText primary={item.label} />
D3RO VOICE
</Typography>
<Typography
sx={{
fontFamily: d3roFontMono,
fontSize: '10px',
color: d3roPalette.text.label,
letterSpacing: '0.05em',
}}
>
v1.0
</Typography>
</Box>
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
{/* Navigation label */}
<Typography
sx={{
px: 2.5,
pt: 2,
pb: 1,
fontSize: '11px',
fontWeight: 600,
letterSpacing: '0.1em',
textTransform: 'uppercase',
color: d3roPalette.text.label,
}}
>
Navigation
</Typography>
<List sx={{ flex: 1, pt: 0 }}>
{NAV_ITEMS.map((item) => (
<ListItemButton
key={item.route}
selected={currentRoute === item.route}
onClick={() => setCurrentRoute(item.route)}
sx={{ my: 0.5 }}
>
<ListItemIcon
sx={{
minWidth: 36,
color: currentRoute === item.route
? d3roPalette.accent.amber
: d3roPalette.text.label,
}}
>
{item.icon}
</ListItemIcon>
<ListItemText
primary={item.label}
primaryTypographyProps={{
fontSize: '14px',
fontWeight: currentRoute === item.route ? 600 : 400,
}}
/>
</ListItemButton>
))}
</List>
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
{/* Bottom settings */}
<List sx={{ pb: 1 }}>
<ListItemButton sx={{ my: 0.5 }} onClick={() => setSettingsOpen(true)}>
<ListItemIcon sx={{ minWidth: 36, color: d3roPalette.text.label }}>
<SettingsIcon />
</ListItemIcon>
<ListItemText
primary="Settings"
primaryTypographyProps={{ fontSize: '14px' }}
/>
</ListItemButton>
))}
</List>
</List>
</Drawer>
<Divider />
{/* Bottom */}
<List>
<ListItemButton sx={{ my: 0.5 }} onClick={() => setSettingsOpen(true)}>
<ListItemIcon sx={{ minWidth: 40 }}>
<SettingsIcon />
</ListItemIcon>
<ListItemText primary="Settings" />
</ListItemButton>
</List>
</Drawer>
{/* Content Area */}
<Box
component="main"
sx={{
flexGrow: 1,
p: 3,
overflow: 'auto',
bgcolor: 'background.default'
}}
>
{currentRoute === 'dashboard' && <DashboardPage />}
{currentRoute === 'history' && <HistoryPage />}
{currentRoute === 'dictionary' && <DictionaryPage />}
{currentRoute === 'commands' && <CommandsPage />}
{/* Content Area */}
<Box
component="main"
sx={{
flexGrow: 1,
overflow: 'auto',
bgcolor: d3roPalette.bg.app,
}}
>
{currentRoute === 'dashboard' && <DashboardPage />}
{currentRoute === 'history' && <HistoryPage />}
{currentRoute === 'dictionary' && <DictionaryPage />}
{currentRoute === 'commands' && <CommandsPage />}
</Box>
</Box>
</Box>
<StatusBar />
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
</Box>

View file

@ -21,6 +21,7 @@ import {
FormControl
} from '@mui/material'
import CloseIcon from '@mui/icons-material/Close'
import { d3roPalette } from '../theme'
import type { ThemeMode, AppConfig } from '@shared/types'
interface SettingsModalProps {
@ -66,13 +67,13 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
return (
<Dialog open={open} onClose={onClose} fullWidth maxWidth="sm">
<DialogTitle sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<DialogTitle sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontWeight: 700 }}>
Settings
<IconButton onClick={onClose} size="small">
<IconButton onClick={onClose} size="small" sx={{ color: d3roPalette.text.label }}>
<CloseIcon />
</IconButton>
</DialogTitle>
<Divider />
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
<DialogContent>
<Tabs value={activeTab} onChange={(_, v) => setActiveTab(v)}>
<Tab label="General" />

View file

@ -1,26 +1,40 @@
// src/renderer/components/StatusBar.tsx
// 하단 상태 표시: Ollama 연결 상태
// 하단 상태 표시: LED 인디케이터 + 태그 시스템. 08-design-system.md SSOT.
import { useState, useEffect } from 'react'
import { Box, Chip } from '@mui/material'
import CircleIcon from '@mui/icons-material/Circle'
import { Box, Typography, Chip } from '@mui/material'
import { d3roPalette, d3roFontMono } from '../theme'
import type { LLMStatus } from '@shared/types'
function Led({ active, color }: { active: boolean; color?: string }): React.ReactElement {
const c = color ?? d3roPalette.tag.green
return (
<Box
sx={{
width: 6,
height: 6,
borderRadius: '50%',
bgcolor: active ? c : d3roPalette.text.disabled,
boxShadow: active ? `0 0 4px ${c}, 0 0 8px ${c}40` : 'none',
flexShrink: 0,
transition: 'background 0.3s ease, box-shadow 0.3s ease',
}}
/>
)
}
export function StatusBar(): React.ReactElement {
const [llmStatus, setLlmStatus] = useState<LLMStatus | null>(null)
useEffect(() => {
// 초기 상태 조회
window.electronAPI.llm.getStatus().then((result) => {
if (result.success) setLlmStatus(result.data)
})
// 상태 변경 구독
const unsub = window.electronAPI.llm.onStatusChanged((event) => {
setLlmStatus(event.status)
})
// 5초마다 폴링 (main에서 이벤트를 보내지 않을 수 있으므로)
const interval = setInterval(() => {
window.electronAPI.llm.getStatus().then((result) => {
if (result.success) setLlmStatus(result.data)
@ -40,30 +54,53 @@ export function StatusBar(): React.ReactElement {
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
gap: 2,
px: 2,
py: 0.5,
borderTop: 1,
borderColor: 'divider',
bgcolor: 'background.paper'
py: 0.75,
borderTop: `1px solid ${d3roPalette.border.subtle}`,
bgcolor: 'background.paper',
minHeight: 32,
}}
>
<Chip
icon={<CircleIcon sx={{ fontSize: 8 }} />}
label={connected ? 'Ollama Connected' : 'Ollama Offline'}
size="small"
variant="outlined"
color={connected ? 'success' : 'default'}
sx={{ height: 22, '& .MuiChip-label': { fontSize: 11 } }}
/>
{/* Ollama 상태 */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Led active={connected} color={connected ? d3roPalette.tag.green : d3roPalette.tag.red} />
<Typography
sx={{
fontFamily: d3roFontMono,
fontSize: '11px',
color: d3roPalette.text.secondary,
letterSpacing: '0.02em',
}}
>
{connected ? 'OLLAMA' : 'OFFLINE'}
</Typography>
</Box>
{/* 활성 모델 태그 */}
{llmStatus?.activeModel && (
<Chip
label={llmStatus.activeModel}
size="small"
variant="outlined"
sx={{ height: 22, '& .MuiChip-label': { fontSize: 11 } }}
color="primary"
sx={{ height: 20, '& .MuiChip-label': { px: 1, fontSize: '10px' } }}
/>
)}
{/* 스페이서 */}
<Box sx={{ flex: 1 }} />
{/* 핫키 힌트 */}
<Typography
sx={{
fontFamily: d3roFontMono,
fontSize: '10px',
color: d3roPalette.text.label,
letterSpacing: '0.05em',
}}
>
RIGHT ALT DICTATE
</Typography>
</Box>
)
}