d3ro-voice/apps/desktop/src/renderer/components/OnboardingModal.tsx
Yun Chan 1af3cf75c7
Some checks failed
deploy-site / deploy (push) Failing after 14m16s
fix(release): stop shipping native modules built for the wrong runtime
The released installer could not start: it carried a better-sqlite3 build for the
host Node runtime instead of Electron, so the app died immediately with a module
version mismatch when it opened its database.

Packaging now proves the Electron build of every runtime-sensitive native module
before an installer or archive exists, and installers are produced only from that
verified tree, so the mistake cannot pass silently. The release pipelines run the
same check.

The default local model also pointed at a retired model: a *.gguf name that
Ollama cannot serve, while the settings, onboarding, and guide screens
recommended an older model. All of them now use the model the service code
already preferred.
2026-09-18 15:45:03 +09:00

624 lines
24 KiB
TypeScript

// 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<Phase>('select_mode')
// Local Ollama State
const [ollamaStatus, setOllamaStatus] = useState<LLMStatus | null>(null)
const [installedModels, setInstalledModels] = useState<LLMModel[]>([])
const [activeModel, setActiveModel] = useState<string>('gemma4:e4b')
const [checkingOllama, setCheckingOllama] = useState(false)
const [startingOllama, setStartingOllama] = useState(false)
const [ollamaMsg, setOllamaMsg] = useState<string | null>(null)
// Model download state
const [pullingModel, setPullingModel] = useState<string | null>(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 (
<Dialog
open={isVisible}
onClose={() => {}}
maxWidth="md"
fullWidth
PaperProps={{
sx: {
borderRadius: d3roRadius.card,
backgroundColor: d3roPalette.bg.card,
border: `1px solid ${d3roPalette.border.default}`,
p: 1,
},
}}
>
<DialogTitle
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.5,
...typoSx('heading'),
color: d3roPalette.text.primary,
borderBottom: `1px solid ${d3roPalette.border.subtle}`,
pb: 1.5,
}}
>
{phase === 'success' ? (
<CheckCircle2 size={24} style={{ color: d3roPalette.tag.green }} />
) : phase === 'failed' ? (
<AlertCircle size={24} style={{ color: d3roPalette.tag.red }} />
) : (
<Sparkles size={24} style={{ color: d3roPalette.accent.main }} />
)}
D3RO Voice
</DialogTitle>
<DialogContent sx={{ py: 3 }}>
{/* ── 1. 사용 모드 선택 ── */}
{phase === 'select_mode' && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
<Typography sx={{ ...typoSx('body'), color: d3roPalette.text.secondary }}>
D3RO Voice를 ? .
</Typography>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2 }}>
{/* 로컬 AI 독립 모드 (강력 권장) */}
<Paper
elevation={0}
onClick={handleSelectLocalMode}
sx={{
p: 2.5,
cursor: 'pointer',
borderRadius: d3roRadius.card,
bgcolor: d3roPalette.bg.elevated,
border: `2px solid ${d3roPalette.accent.main}`,
transition: 'all 150ms ease-out',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
'&:hover': {
bgcolor: d3roPalette.bg.inset,
transform: 'translateY(-2px)',
boxShadow: d3roShadow.dialog,
},
}}
>
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Cpu size={22} color={d3roPalette.accent.main} />
<Typography sx={{ fontWeight: 600, fontSize: '15px', color: d3roPalette.text.primary }}>
AI
</Typography>
</Box>
<Chip label="100% 무료 / 강력 추천" size="small" sx={{ fontWeight: 500, fontSize: '10px', height: 20, bgcolor: d3roPalette.tag.greenBg, color: d3roPalette.tag.green }} />
</Box>
<Typography sx={{ fontSize: '12px', color: d3roPalette.text.secondary, mb: 2, lineHeight: 1.5 }}>
<b>Ollama</b> <b>Whisper STT</b> PC .
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
{[
'인터넷 연결 없이 오프라인 작동',
'음성/텍스트 데이터 외부 유출 제로 (완벽한 보안)',
'API 비용 없이 무제한 전사 및 AI 다듬기',
].map((item) => (
<Box key={item} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<ShieldCheck size={14} color={d3roPalette.tag.green} />
<Typography sx={{ fontSize: '11px', color: d3roPalette.text.primary }}>
{item}
</Typography>
</Box>
))}
</Box>
</Box>
<Button
variant="contained"
fullWidth
endIcon={<ArrowRight size={14} />}
sx={{
mt: 2.5,
fontFamily: d3roFontSans,
fontWeight: 500,
bgcolor: d3roPalette.accent.main,
color: d3roPalette.bg.app,
'&:hover': { bgcolor: d3roPalette.accent.light },
}}
>
AI
</Button>
</Paper>
{/* 온라인 클라우드 모드 */}
<Paper
elevation={0}
onClick={() => 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,
},
}}
>
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Cloud size={22} color={d3roPalette.tag.blue} />
<Typography sx={{ fontWeight: 600, fontSize: '15px', color: d3roPalette.text.primary }}>
</Typography>
</Box>
<Chip label="계정 로그인" size="small" sx={{ fontSize: '10px', height: 20 }} />
</Box>
<Typography sx={{ fontSize: '12px', color: d3roPalette.text.secondary, mb: 2, lineHeight: 1.5 }}>
AI .
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
{[
'로컬 모델 설치 불필요 (저사양 PC용)',
'클라우드 동기화 및 다중 기기 연동',
'D3RO 계정 로그인 필요',
].map((item) => (
<Box key={item} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CheckCircle2 size={14} color={d3roPalette.tag.blue} />
<Typography sx={{ fontSize: '11px', color: d3roPalette.text.primary }}>
{item}
</Typography>
</Box>
))}
</Box>
</Box>
<Button
variant="outlined"
fullWidth
endIcon={<ArrowRight size={14} />}
sx={{
mt: 2.5,
fontFamily: d3roFontSans,
borderColor: d3roPalette.border.default,
color: d3roPalette.text.primary,
}}
>
</Button>
</Paper>
</Box>
</Box>
)}
{/* ── 2. 로컬 Ollama 온보딩 스텝 ── */}
{phase === 'local_ollama_setup' && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
{/* 연결 상태 박스 */}
<Paper
elevation={0}
sx={{
p: 2,
bgcolor: d3roPalette.bg.inset,
border: `1px solid ${isOllamaConnected ? d3roPalette.tag.green : d3roPalette.tag.orange}`,
borderRadius: d3roRadius.small,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Led color={isOllamaConnected ? 'green' : 'amber'} pulse={!isOllamaConnected} size={9} />
<Box>
<Typography sx={{ fontWeight: 500, fontSize: '13px', color: isOllamaConnected ? d3roPalette.tag.green : d3roPalette.tag.orange }}>
{isOllamaConnected
? `Ollama 로컬 엔진 연결 완료 ${ollamaStatus?.serverVersion ? `(v${ollamaStatus.serverVersion})` : ''}`
: 'Ollama 로컬 엔진 확인 필요'}
</Typography>
<Typography sx={{ fontSize: '11px', color: d3roPalette.text.secondary }}>
{isOllamaConnected
? `설치된 모델: ${installedModels.length}개 | 활성 모델: ${activeModel}`
: 'Ollama가 실행되어 있지 않거나 설치되지 않았습니다.'}
</Typography>
</Box>
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
size="small"
variant="outlined"
startIcon={<RefreshCw size={13} className={checkingOllama ? 'animate-spin' : ''} />}
onClick={checkOllama}
disabled={checkingOllama}
sx={{ fontSize: '11px' }}
>
</Button>
{!isOllamaConnected && (
<Button
size="small"
variant="contained"
startIcon={<Play size={13} />}
onClick={handleStartOllama}
disabled={startingOllama}
sx={{ fontSize: '11px', bgcolor: d3roPalette.accent.main, color: d3roPalette.bg.app, fontWeight: 500 }}
>
Ollama
</Button>
)}
</Box>
</Paper>
{ollamaMsg && (
<Alert severity={isOllamaConnected ? 'success' : 'info'} sx={{ py: 0.5 }}>
{ollamaMsg}
</Alert>
)}
{/* Ollama 미설치 시 가이드 */}
{!isOllamaConnected && (
<Box sx={{ p: 2, bgcolor: d3roPalette.bg.elevated, borderRadius: d3roRadius.small, border: `1px solid ${d3roPalette.border.subtle}` }}>
<Typography sx={{ fontWeight: 500, fontSize: '13px', mb: 1, color: d3roPalette.accent.main }}>
Ollama가 :
</Typography>
<Typography sx={{ fontSize: '12px', color: d3roPalette.text.secondary, mb: 1.5 }}>
1. Ollama를 .<br />
2. '연결 재확인' D3RO Voice가 .
</Typography>
<Button
variant="outlined"
size="small"
endIcon={<ExternalLink size={14} />}
onClick={() => window.electronAPI.system.openExternal({ url: 'https://ollama.com/download' })}
sx={{ fontFamily: d3roFontMono, fontSize: '11px', borderColor: d3roPalette.accent.main, color: d3roPalette.accent.light }}
>
ollama.com/download
</Button>
</Box>
)}
{/* 추천 모델 설치 섹션 */}
<Box>
<Typography sx={{ fontWeight: 500, fontSize: '13px', mb: 1, color: d3roPalette.text.primary }}>
AI (Gemma 2 2B)
</Typography>
<Typography sx={{ fontSize: '12px', color: d3roPalette.text.secondary, mb: 1.5 }}>
, , . (크기: 1.6GB)
</Typography>
{pullingModel ? (
<Box sx={{ p: 2, bgcolor: d3roPalette.bg.inset, borderRadius: d3roRadius.small }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography sx={{ fontSize: '12px', color: d3roPalette.accent.light }}>
{pullProgress.status || '모델 다운로드 중...'}
</Typography>
<Typography sx={{ fontSize: '12px', fontFamily: d3roFontMono, color: d3roPalette.text.primary }}>
{pullProgress.percent}%
</Typography>
</Box>
<LinearProgress variant="determinate" value={pullProgress.percent} sx={{ height: 6, borderRadius: 3 }} />
</Box>
) : (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Button
variant="contained"
startIcon={<Download size={14} />}
onClick={() => handlePullModel('gemma4:e4b')}
disabled={!isOllamaConnected}
sx={{
fontWeight: 500,
bgcolor: d3roPalette.accent.main,
color: d3roPalette.bg.app,
'&:hover': { bgcolor: d3roPalette.accent.light },
}}
>
{hasInstalledLlm ? 'Gemma 2 (2B) 추가 다운로드' : 'Gemma 2 (2B) 1-클릭 다운로드'}
</Button>
{hasInstalledLlm && (
<Typography sx={{ fontSize: '12px', color: d3roPalette.tag.green, display: 'flex', alignItems: 'center', gap: 0.5 }}>
<CheckCircle2 size={15} /> AI .
</Typography>
)}
</Box>
)}
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', pt: 1, borderTop: `1px solid ${d3roPalette.border.subtle}` }}>
<Button variant="text" size="small" onClick={() => setPhase('select_mode')}>
</Button>
<Button
variant="contained"
onClick={handleCompleteLocalSetup}
disabled={!isOllamaConnected}
sx={{ fontWeight: 500 }}
>
</Button>
</Box>
</Box>
)}
{/* ── 3. 온라인 계정 인증 ── */}
{phase === 'online_auth' && (
<Box component="form" onSubmit={handleAuthSubmit}>
<Typography sx={{ ...typoSx('body'), color: d3roPalette.text.secondary, mb: 2 }}>
D3RO .
</Typography>
<Tabs
value={authTab}
onChange={(_, v) => setAuthTab(v)}
sx={{ mb: 2, borderBottom: `1px solid ${d3roPalette.border.subtle}` }}
>
<Tab label="로그인" value="login" />
<Tab label="회원가입" value="register" />
</Tabs>
{authError && (
<Typography sx={{ color: d3roPalette.tag.red, mb: 2, ...typoSx('small') }}>
{authError}
</Typography>
)}
<TextField
fullWidth
label="이메일 주소"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
sx={{ mb: 2 }}
/>
<TextField
fullWidth
label="비밀번호"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
sx={{ mb: 3 }}
/>
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Button variant="text" onClick={() => setPhase('select_mode')}>
</Button>
<Button type="submit" variant="contained" disabled={authLoading}>
{authLoading ? '처리 중...' : authTab === 'login' ? '로그인 완료' : '회원가입 완료'}
</Button>
</Box>
</Box>
)}
{/* ── 4. 설정 완료 ── */}
{phase === 'success' && (
<Box sx={{ textAlign: 'center', py: 3 }}>
<CheckCircle2 size={48} style={{ color: d3roPalette.tag.green, margin: '0 auto 16px' }} />
<Typography sx={{ ...typoSx('heading'), color: d3roPalette.tag.green, mb: 1, fontSize: '18px' }}>
D3RO Voice !
</Typography>
<Typography sx={{ ...typoSx('body'), color: d3roPalette.text.secondary, maxWidth: 440, mx: 'auto' }}>
<b></b> STT Ollama .
</Typography>
</Box>
)}
{phase === 'failed' && (
<Box sx={{ py: 2 }}>
<Typography sx={{ ...typoSx('body'), color: d3roPalette.tag.red, mb: 2 }}>
: {errorMsg}
</Typography>
<Button variant="contained" onClick={() => setPhase('select_mode')}>
</Button>
</Box>
)}
</DialogContent>
{phase === 'success' && (
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button variant="contained" onClick={handleClose} fullWidth sx={{ py: 1.2, fontWeight: 500 }}>
D3RO Voice
</Button>
</DialogActions>
)}
</Dialog>
)
}