Phase 10 킬러 피처: - MemoService: 태그 CRUD + 마크다운 내보내기 (memo_tags DB) - VoiceCommandService: 키워드→명령어 매칭, 프리셋 4종 - ScreenContextService: PowerShell 활성 윈도우 + Ctrl+C 선택 텍스트 - ChainService: LLM 명령어 순차 실행 파이프라인 - CaptionService: 6초 청크 연속 전사 + 시스템 오디오 루프백 VoiceModeService 파이프라인 통합: - 녹음 시작 → 컨텍스트 캡처 → STT → 키워드 매칭 → LLM(체인/컨텍스트 주입) → 삽입 시스템 오디오 캡처: - setDisplayMediaRequestHandler + audio: 'loopback' (IPC 브릿지) - electron-audio-loopback 패키지 contextIsolation 호환 불가 → 직접 구현 Phase 11 수익화: - LicenseService: Free/Pro/Pro+ 3티어, LemonSqueezy API - Feature Gate: requireFeature/checkFeature/consumeFeature - 일일 쿼터: Free dictation 20/일, LLM 10/일 (SQLite daily_usage) - LicenseModal, ProBadge, UpgradePromptModal UI 디자인 보강: - d3roTypo(13종), d3roShadow(10종), d3roRadius(7종) 토큰 시스템 - ScreenPanel, ButtonGroup DS 컴포넌트 신규 - PhosphorText 4→13종 변형, MetalDial conic-gradient 광택 - 공유 컴포넌트: EmptyStateCard, SearchInput, PageHeader, HistoryEntryCard 기타: - 자막 핫키 SSOT 전체 연동 (Config→Hotkey→VoiceMode→Caption→Settings) - StatusBar 자막 LED + 효과음, 자막 로딩 UI - LLM 상태 이벤트 전파 수정 (폴링 제거 → onStatusChanged) - 커맨드 팝업 "선택 해제" 항목 추가
270 lines
11 KiB
TypeScript
270 lines
11 KiB
TypeScript
// src/renderer/components/OnboardingModal.tsx
|
|
// 첫 실행 시 마이크 + 핫키 설정 안내
|
|
|
|
import { useState, useEffect } from 'react'
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
Box,
|
|
Typography,
|
|
Button,
|
|
Stack,
|
|
Chip,
|
|
} from '@mui/material'
|
|
import MicIcon from '@mui/icons-material/Mic'
|
|
import KeyboardIcon from '@mui/icons-material/Keyboard'
|
|
import CheckCircleIcon from '@mui/icons-material/CheckCircle'
|
|
import OpenInNewIcon from '@mui/icons-material/OpenInNew'
|
|
import { d3roPalette, d3roFontMono, d3roShadow } from '../theme'
|
|
import { Led } from './ds'
|
|
import { HotkeyRecordModal } from './HotkeyRecordModal'
|
|
import { useI18n } from '../i18n'
|
|
import type { HotkeyBinding, AudioDevice } from '@shared/types'
|
|
|
|
interface OnboardingModalProps {
|
|
open: boolean
|
|
onClose: () => void
|
|
}
|
|
|
|
export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.ReactElement {
|
|
const { t } = useI18n()
|
|
const [step, setStep] = useState(0) // 0: 환영, 1: 마이크, 2: 핫키, 3: Ollama, 4: 완료
|
|
const [devices, setDevices] = useState<AudioDevice[]>([])
|
|
const [selectedDevice, setSelectedDevice] = useState('default')
|
|
const [hotkeyBinding, setHotkeyBinding] = useState<HotkeyBinding | null>(null)
|
|
const [hotkeyModalOpen, setHotkeyModalOpen] = useState(false)
|
|
|
|
useEffect(() => {
|
|
if (!open) return
|
|
setStep(0)
|
|
window.electronAPI.audio.getDevices().then((r) => {
|
|
if (r.success) setDevices(r.data)
|
|
})
|
|
window.electronAPI.hotkey.getDictationShortcut().then((r) => {
|
|
if (r.success && r.data) setHotkeyBinding(r.data)
|
|
})
|
|
}, [open])
|
|
|
|
const handleFinish = () => {
|
|
// 온보딩 완료 플래그 저장
|
|
window.electronAPI.config.set({ key: 'onboardingCompleted' as keyof import('@shared/types').AppConfig, value: true as never })
|
|
onClose()
|
|
}
|
|
|
|
const handleHotkeySave = (binding: HotkeyBinding) => {
|
|
setHotkeyBinding(binding)
|
|
window.electronAPI.hotkey.setDictationShortcut({ binding })
|
|
window.electronAPI.hotkey.setEnabled({ enabled: true })
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Dialog
|
|
open={open}
|
|
maxWidth="sm"
|
|
fullWidth
|
|
PaperProps={{
|
|
sx: {
|
|
bgcolor: d3roPalette.bg.chassis,
|
|
backgroundImage: 'none',
|
|
border: `1px solid ${d3roPalette.border.subtle}`,
|
|
boxShadow: d3roShadow.chassis,
|
|
},
|
|
}}
|
|
>
|
|
<DialogContent sx={{ p: 4 }}>
|
|
{/* Step 0: 환영 */}
|
|
{step === 0 && (
|
|
<Box sx={{ textAlign: 'center', py: 3 }}>
|
|
<Led color="amber" pulse size={16} />
|
|
<Typography
|
|
sx={{
|
|
fontFamily: d3roFontMono,
|
|
fontSize: '24px',
|
|
fontWeight: 300,
|
|
color: d3roPalette.accent.amber,
|
|
mt: 3,
|
|
mb: 1,
|
|
}}
|
|
>
|
|
D3RO-VOICE
|
|
</Typography>
|
|
<Typography sx={{ color: d3roPalette.text.secondary, mb: 4 }}>
|
|
{t('onboarding.welcome.desc')}
|
|
</Typography>
|
|
<Button variant="contained" onClick={() => setStep(1)} fullWidth>
|
|
{t('onboarding.welcome.start')}
|
|
</Button>
|
|
</Box>
|
|
)}
|
|
|
|
{/* Step 1: 마이크 */}
|
|
{step === 1 && (
|
|
<Box>
|
|
<Stack direction="row" alignItems="center" gap={1} mb={3}>
|
|
<MicIcon sx={{ color: d3roPalette.accent.amber }} />
|
|
<Typography sx={{ fontFamily: d3roFontMono, fontWeight: 700, fontSize: '14px' }}>
|
|
{t('onboarding.mic.title')}
|
|
</Typography>
|
|
</Stack>
|
|
<Typography variant="body2" sx={{ color: d3roPalette.text.secondary, mb: 2 }}>
|
|
{t('onboarding.mic.desc')}
|
|
</Typography>
|
|
<Stack spacing={1} mb={3}>
|
|
{devices.map((d, idx) => (
|
|
<Box
|
|
key={`${d.deviceId}-${idx}`}
|
|
onClick={() => {
|
|
setSelectedDevice(d.deviceId)
|
|
window.electronAPI.audio.setSelectedDevice({ deviceId: d.deviceId })
|
|
}}
|
|
sx={{
|
|
p: 1.5,
|
|
borderRadius: '8px',
|
|
cursor: 'pointer',
|
|
bgcolor: selectedDevice === d.deviceId ? d3roPalette.accent.amberDim : d3roPalette.bg.inset,
|
|
border: selectedDevice === d.deviceId
|
|
? `1px solid ${d3roPalette.accent.amber}`
|
|
: `1px solid ${d3roPalette.border.subtle}`,
|
|
'&:hover': { bgcolor: d3roPalette.bg.cardHover },
|
|
}}
|
|
>
|
|
<Typography variant="body2" sx={{ fontSize: '13px' }}>
|
|
{d.label}{d.isDefault ? ` ${t('settings.deviceDefault')}` : ''}
|
|
</Typography>
|
|
</Box>
|
|
))}
|
|
</Stack>
|
|
<Stack direction="row" justifyContent="space-between">
|
|
<Button onClick={() => setStep(0)} sx={{ color: d3roPalette.text.inactive }}>{t('onboarding.back')}</Button>
|
|
<Button variant="contained" onClick={() => setStep(2)}>{t('onboarding.next')}</Button>
|
|
</Stack>
|
|
</Box>
|
|
)}
|
|
|
|
{/* Step 2: 핫키 */}
|
|
{step === 2 && (
|
|
<Box>
|
|
<Stack direction="row" alignItems="center" gap={1} mb={3}>
|
|
<KeyboardIcon sx={{ color: d3roPalette.accent.amber }} />
|
|
<Typography sx={{ fontFamily: d3roFontMono, fontWeight: 700, fontSize: '14px' }}>
|
|
{t('onboarding.hotkey.title')}
|
|
</Typography>
|
|
</Stack>
|
|
<Typography variant="body2" sx={{ color: d3roPalette.text.secondary, mb: 2 }}>
|
|
{t('onboarding.hotkey.desc')}
|
|
</Typography>
|
|
<Box
|
|
sx={{
|
|
p: 2,
|
|
borderRadius: '10px',
|
|
bgcolor: d3roPalette.bg.inset,
|
|
boxShadow: d3roShadow.inset,
|
|
textAlign: 'center',
|
|
mb: 3,
|
|
}}
|
|
>
|
|
{hotkeyBinding ? (
|
|
<Stack direction="row" spacing={1} justifyContent="center" alignItems="center">
|
|
<Led color="green" size={8} />
|
|
{hotkeyBinding.displayLabel.split(' + ').map((key) => (
|
|
<Chip
|
|
key={key}
|
|
label={key}
|
|
sx={{
|
|
fontFamily: d3roFontMono,
|
|
fontWeight: 700,
|
|
bgcolor: d3roPalette.bg.chassis,
|
|
color: d3roPalette.text.primary,
|
|
border: `1px solid ${d3roPalette.border.default}`,
|
|
}}
|
|
/>
|
|
))}
|
|
</Stack>
|
|
) : (
|
|
<Typography sx={{ color: d3roPalette.text.inactive, fontSize: '13px' }}>
|
|
{t('onboarding.hotkey.notSet')}
|
|
</Typography>
|
|
)}
|
|
</Box>
|
|
<Button
|
|
variant="outlined"
|
|
fullWidth
|
|
onClick={() => setHotkeyModalOpen(true)}
|
|
sx={{ mb: 3, fontFamily: d3roFontMono }}
|
|
>
|
|
{hotkeyBinding ? t('onboarding.hotkey.change') : t('onboarding.hotkey.set')}
|
|
</Button>
|
|
<Stack direction="row" justifyContent="space-between">
|
|
<Button onClick={() => setStep(1)} sx={{ color: d3roPalette.text.inactive }}>{t('onboarding.back')}</Button>
|
|
<Button variant="contained" onClick={() => setStep(3)}>{t('onboarding.next')}</Button>
|
|
</Stack>
|
|
</Box>
|
|
)}
|
|
|
|
{/* Step 3: Ollama 설치 */}
|
|
{step === 3 && (
|
|
<Box>
|
|
<Stack direction="row" alignItems="center" gap={1} mb={3}>
|
|
<Led color="amber" size={12} />
|
|
<Typography sx={{ fontFamily: d3roFontMono, fontWeight: 700, fontSize: '14px' }}>
|
|
{t('onboarding.ollama.title')}
|
|
</Typography>
|
|
</Stack>
|
|
<Typography variant="body2" sx={{ color: d3roPalette.text.secondary, mb: 2 }}>
|
|
{t('onboarding.ollama.desc')}
|
|
</Typography>
|
|
<Button
|
|
variant="outlined"
|
|
endIcon={<OpenInNewIcon sx={{ fontSize: 14 }} />}
|
|
onClick={() => window.electronAPI.system.openExternal({ url: 'https://ollama.com/download' })}
|
|
fullWidth
|
|
sx={{ mb: 1.5, fontFamily: d3roFontMono }}
|
|
>
|
|
{t('onboarding.ollama.download')}
|
|
</Button>
|
|
<Box sx={{ p: 1.5, borderRadius: '8px', bgcolor: d3roPalette.bg.inset, boxShadow: d3roShadow.inset, mb: 3 }}>
|
|
<Typography sx={{ fontFamily: d3roFontMono, fontSize: '11px', color: d3roPalette.accent.amber }}>
|
|
$ ollama pull qwen3:4b
|
|
</Typography>
|
|
<Typography sx={{ fontSize: '10px', color: d3roPalette.text.inactive, mt: 0.5 }}>
|
|
{t('onboarding.ollama.modelHint')}
|
|
</Typography>
|
|
</Box>
|
|
<Stack direction="row" justifyContent="space-between">
|
|
<Button onClick={() => setStep(2)} sx={{ color: d3roPalette.text.inactive }}>{t('onboarding.back')}</Button>
|
|
<Button variant="contained" onClick={() => setStep(4)}>{t('onboarding.next')}</Button>
|
|
</Stack>
|
|
</Box>
|
|
)}
|
|
|
|
{/* Step 4: 완료 */}
|
|
{step === 4 && (
|
|
<Box sx={{ textAlign: 'center', py: 3 }}>
|
|
<CheckCircleIcon sx={{ fontSize: 48, color: d3roPalette.tag.green, mb: 2 }} />
|
|
<Typography sx={{ fontFamily: d3roFontMono, fontSize: '18px', fontWeight: 700, mb: 1 }}>
|
|
{t('onboarding.done.title')}
|
|
</Typography>
|
|
<Typography variant="body2" sx={{ color: d3roPalette.text.secondary, mb: 4 }}>
|
|
{hotkeyBinding
|
|
? t('onboarding.done.descWithKey', { key: hotkeyBinding.displayLabel })
|
|
: t('onboarding.done.descNoKey')}
|
|
</Typography>
|
|
<Button variant="contained" onClick={handleFinish} fullWidth>
|
|
{t('onboarding.done.start')}
|
|
</Button>
|
|
</Box>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<HotkeyRecordModal
|
|
open={hotkeyModalOpen}
|
|
onClose={() => setHotkeyModalOpen(false)}
|
|
onSave={handleHotkeySave}
|
|
currentBinding={hotkeyBinding}
|
|
title={t('hotkey.dictationTitle')}
|
|
/>
|
|
</>
|
|
)
|
|
}
|