// src/renderer/components/OnboardingModal.tsx // 첫 실행 온보딩 모달 — 사용 방식 선택 (로컬 Ollama 독립 모드 vs 온라인 클라우드 API) import { useState, useEffect, useCallback } from 'react' import { Dialog, DialogTitle, DialogContent, DialogActions, Button, Typography, Box, LinearProgress, TextField, Tab, Tabs, Paper, Chip, Alert, } from '@mui/material' import { CheckCircle2, AlertCircle, Cloud, Download, Play, RefreshCw, ExternalLink, Cpu, Sparkles, ArrowRight, ShieldCheck, } from 'lucide-react' import { d3roPalette, d3roRadius, d3roShadow, typoSx, d3roFontMono, d3roFontSans } from '@d3ro/ui/theme' import { Led } from '@d3ro/ui/components/ds' import type { LLMModel, LLMStatus } from '@d3ro/core/types' type Phase = 'select_mode' | 'local_ollama_setup' | 'online_auth' | 'success' | 'failed' interface OnboardingModalProps { open: boolean onClose: () => void } export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.ReactElement { const [internalOpen, setInternalOpen] = useState(false) const [phase, setPhase] = useState('select_mode') // Local Ollama State const [ollamaStatus, setOllamaStatus] = useState(null) const [installedModels, setInstalledModels] = useState([]) const [activeModel, setActiveModel] = useState('gemma4:e4b') const [checkingOllama, setCheckingOllama] = useState(false) const [startingOllama, setStartingOllama] = useState(false) const [ollamaMsg, setOllamaMsg] = useState(null) // Model download state const [pullingModel, setPullingModel] = useState(null) const [pullProgress, setPullProgress] = useState({ status: '', percent: 0 }) // Auth State const [authTab, setAuthTab] = useState<'login' | 'register'>('login') const [email, setEmail] = useState('') const [password, setPassword] = useState('') const [authError, setAuthError] = useState('') const [authLoading, setAuthLoading] = useState(false) // Error State const errorMsg = '' const isVisible = open || internalOpen const checkOllama = useCallback(async () => { setCheckingOllama(true) try { const res = await window.electronAPI.llm.checkConnection() if (res.success && res.data) { setInstalledModels(res.data.models) } const st = await window.electronAPI.llm.getStatus() if (st.success) { setOllamaStatus(st.data) } } finally { setCheckingOllama(false) } }, []) useEffect(() => { window.electronAPI.config.getAll().then((res) => { if (res.success && !res.data.onboardingCompleted) { setInternalOpen(true) setPhase('select_mode') } }) }, []) useEffect(() => { if (!isVisible) return const unsubPull = window.electronAPI.llm.onPullProgress((e) => { setPullingModel(e.modelId) setPullProgress({ status: e.status, percent: e.percent }) if (e.percent >= 100 || e.status === 'success') { setTimeout(() => { setPullingModel(null) checkOllama() }, 1200) } }) const unsubStatus = window.electronAPI.llm.onStatusChanged((e) => { setOllamaStatus(e.status) }) return () => { unsubPull() unsubStatus() } }, [isVisible, checkOllama]) const handleSelectLocalMode = async () => { setPhase('local_ollama_setup') await window.electronAPI.config.set({ key: 'appUsageMode', value: 'local' }) await window.electronAPI.config.set({ key: 'llmBackend', value: 'local' }) await window.electronAPI.config.set({ key: 'sttProvider', value: 'local' }) await checkOllama() // 자동으로 Ollama 기동 시도 await window.electronAPI.llm.startServer() setTimeout(() => checkOllama(), 1500) } const handleStartOllama = async () => { setStartingOllama(true) setOllamaMsg(null) try { const res = await window.electronAPI.llm.startServer() if (res.success) { if (res.data === 'running') { setOllamaMsg('Ollama가 이미 실행 중입니다.') } else if (res.data === 'starting') { setOllamaMsg('Ollama 서버를 시작했습니다. 연결 확인 중...') setTimeout(() => checkOllama(), 2000) } else if (res.data === 'not-installed') { setOllamaMsg('Ollama가 설치되어 있지 않습니다. 다운로드 버튼을 눌러 설치해 주세요.') } else { setOllamaMsg('Ollama 실행에 실패했습니다. 수동으로 Ollama를 실행해 주세요.') } } } finally { setStartingOllama(false) } } const handlePullModel = async (modelId: string) => { setPullingModel(modelId) setPullProgress({ status: '다운로드 시작 중...', percent: 0 }) try { await window.electronAPI.llm.pullModel({ modelId }) await window.electronAPI.llm.setModel({ modelId }) await window.electronAPI.config.set({ key: 'llmModelId', value: modelId }) setActiveModel(modelId) } catch { setPullingModel(null) } } const handleCompleteLocalSetup = async () => { await window.electronAPI.config.set({ key: 'onboardingCompleted', value: true }) setPhase('success') } const handleAuthSubmit = async (e: React.FormEvent) => { e.preventDefault() setAuthError('') setAuthLoading(true) try { const res = authTab === 'login' ? await window.electronAPI.onlineAuth.login({ email, password }) : await window.electronAPI.onlineAuth.register({ email, password }) if (res.success) { await window.electronAPI.config.set({ key: 'appUsageMode', value: 'online' }) await window.electronAPI.config.set({ key: 'llmBackend', value: 'online' }) await window.electronAPI.config.set({ key: 'onboardingCompleted', value: true }) setPhase('success') } else { setAuthError(res.error?.message ?? '인증에 실패하였습니다.') } } catch (err) { setAuthError(`오류: ${err instanceof Error ? err.message : String(err)}`) } finally { setAuthLoading(false) } } const handleClose = () => { setInternalOpen(false) onClose() } const isOllamaConnected = ollamaStatus?.connectionState === 'connected' const hasInstalledLlm = installedModels.length > 0 if (!isVisible) return <> return ( {}} maxWidth="md" fullWidth PaperProps={{ sx: { borderRadius: d3roRadius.card, backgroundColor: d3roPalette.bg.card, border: `1px solid ${d3roPalette.border.default}`, p: 1, }, }} > {phase === 'success' ? ( ) : phase === 'failed' ? ( ) : ( )} D3RO Voice 시작 가이드 {/* ── 1. 사용 모드 선택 ── */} {phase === 'select_mode' && ( D3RO Voice를 어떤 환경으로 사용하시겠습니까? 언제든지 환경설정에서 변경할 수 있습니다. {/* 로컬 AI 독립 모드 (강력 권장) */} 로컬 AI 모드 Ollama와 로컬 Whisper STT를 사용하여 PC 내부에서 완전히 독립 실행됩니다. {[ '인터넷 연결 없이 오프라인 작동', '음성/텍스트 데이터 외부 유출 제로 (완벽한 보안)', 'API 비용 없이 무제한 전사 및 AI 다듬기', ].map((item) => ( {item} ))} {/* 온라인 클라우드 모드 */} setPhase('online_auth')} sx={{ p: 2.5, cursor: 'pointer', borderRadius: d3roRadius.card, bgcolor: d3roPalette.bg.inset, border: `1px solid ${d3roPalette.border.default}`, transition: 'all 150ms ease-out', display: 'flex', flexDirection: 'column', justifyContent: 'space-between', '&:hover': { bgcolor: d3roPalette.bg.elevated, borderColor: d3roPalette.border.subtle, }, }} > 온라인 클라우드 모드 클라우드 서버 계정으로 로그인하여 AI 음성인식 및 고급 모델을 사용합니다. {[ '로컬 모델 설치 불필요 (저사양 PC용)', '클라우드 동기화 및 다중 기기 연동', 'D3RO 계정 로그인 필요', ].map((item) => ( {item} ))} )} {/* ── 2. 로컬 Ollama 온보딩 스텝 ── */} {phase === 'local_ollama_setup' && ( {/* 연결 상태 박스 */} {isOllamaConnected ? `Ollama 로컬 엔진 연결 완료 ${ollamaStatus?.serverVersion ? `(v${ollamaStatus.serverVersion})` : ''}` : 'Ollama 로컬 엔진 확인 필요'} {isOllamaConnected ? `설치된 모델: ${installedModels.length}개 | 활성 모델: ${activeModel}` : 'Ollama가 실행되어 있지 않거나 설치되지 않았습니다.'} {!isOllamaConnected && ( )} {ollamaMsg && ( {ollamaMsg} )} {/* Ollama 미설치 시 가이드 */} {!isOllamaConnected && ( Ollama가 아직 설치되어 있지 않다면: 1. 아래 버튼을 눌러 공식 웹사이트에서 Ollama를 다운로드하여 설치해 주세요.
2. 설치 후 '연결 재확인' 버튼을 누르면 D3RO Voice가 자동으로 감지합니다.
)} {/* 추천 모델 설치 섹션 */} 기본 한국어 AI 모델 설치 (Gemma 2 2B) 텍스트 정제, 요약, 번역에 최적화된 초경량 모델입니다. (크기: 약 1.6GB) {pullingModel ? ( {pullProgress.status || '모델 다운로드 중...'} {pullProgress.percent}% ) : ( {hasInstalledLlm && ( 이미 로컬 AI 모델이 준비되어 있습니다. )} )}
)} {/* ── 3. 온라인 계정 인증 ── */} {phase === 'online_auth' && ( 온라인 서비스를 이용하기 위해 D3RO 계정으로 로그인해 주세요. setAuthTab(v)} sx={{ mb: 2, borderBottom: `1px solid ${d3roPalette.border.subtle}` }} > {authError && ( {authError} )} setEmail(e.target.value)} required sx={{ mb: 2 }} /> setPassword(e.target.value)} required sx={{ mb: 3 }} /> )} {/* ── 4. 설정 완료 ── */} {phase === 'success' && ( D3RO Voice 설정이 완료되었습니다! 언제든지 우측 상단 설정에서 STT 공급자 및 Ollama 모델을 변경할 수 있습니다. )} {phase === 'failed' && ( 설정 중 오류가 발생했습니다: {errorMsg} )}
{phase === 'success' && ( )}
) }