// src/renderer/components/OnboardingModal.tsx // 첫 실행 온보딩 모달 — 기본 모델 미설치 시 다운로드 유도. // // 2단계 부트스트랩: // 1) LLM: gemma4:e4b (Ollama pull, ~9.6GB) // 2) STT: Whisper large-v3-turbo (sidecar 사전 다운로드, ~1.6GB) // 필요한 단계만 실행하며(멱등), 모두 성공 시 config.onboardingCompleted=true 저장. // // 두 경로로 열림: // 1) AppLayout의 첫 실행 감지(onboardingCompleted=false) // 2) 런타임 중 모델 미설치 감지 (주기적 polling) import { useState, useEffect, useCallback, useRef } from 'react' import { Dialog, DialogTitle, DialogContent, DialogActions, Button, Typography, Box, LinearProgress, } from '@mui/material' import { CheckCircle2, AlertCircle, CloudDownload } from 'lucide-react' import { d3roPalette, d3roRadius, typoSx } from '@d3ro/ui/theme' import { useI18n } from '@d3ro/i18n' const DEFAULT_LLM_MODEL = 'gemma4:e4b' const FALLBACK_STT_MODEL = 'large-v3-turbo' type StepKind = 'llm' | 'stt' type Phase = 'prompt' | 'downloading' | 'success' | 'failed' interface NeededSteps { steps: StepKind[] sttModelId: string } interface OnboardingModalProps { open: boolean onClose: () => void } /** 설치가 필요한 단계 목록을 계산한다 (멱등 — 이미 설치된 단계는 제외) */ async function computeNeededSteps(): Promise { const steps: StepKind[] = [] // 1) LLM 모델 존재 여부 try { const llmResult = await window.electronAPI.llm.getModels() const hasLlm = llmResult.success && llmResult.data.some((m) => m.id === DEFAULT_LLM_MODEL) if (!hasLlm) steps.push('llm') } catch { steps.push('llm') } // 2) STT 모델 다운로드 여부 (현재 설정된 모델 기준) let sttModelId = FALLBACK_STT_MODEL try { const activeResult = await window.electronAPI.stt.getActiveModel() if (activeResult.success && activeResult.data) { sttModelId = activeResult.data } const modelsResult = await window.electronAPI.stt.getModels() const downloaded = modelsResult.success && modelsResult.data.some((m) => m.id === sttModelId && m.downloaded) if (!downloaded) steps.push('stt') } catch { steps.push('stt') } return { steps, sttModelId } } export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.ReactElement { const { t } = useI18n() const [internalOpen, setInternalOpen] = useState(false) const [phase, setPhase] = useState('prompt') const [neededSteps, setNeededSteps] = useState([]) const [stepIndex, setStepIndex] = useState(0) const [sttModelId, setSttModelId] = useState(FALLBACK_STT_MODEL) const [percent, setPercent] = useState(0) const [status, setStatus] = useState('') const [detail, setDetail] = useState('') const [errorMsg, setErrorMsg] = useState('') // 진행률 없는 상태(무결성 검증 등) — 인디터미넌트 바로 "멈춘 것처럼" 보이는 문제 방지 const [busyStatus, setBusyStatus] = useState(false) const activeStepRef = useRef(null) const runningRef = useRef(false) const isVisible = open || internalOpen // 모델 존재 여부 체크 — 없으면 auto-open const checkModels = useCallback(async (): Promise => { try { const needed = await computeNeededSteps() setSttModelId(needed.sttModelId) if (needed.steps.length > 0) { setNeededSteps(needed.steps) setPhase('prompt') setInternalOpen(true) } else { setInternalOpen(false) } } catch { setPhase('prompt') setInternalOpen(true) } }, []) useEffect(() => { const initialCheck = setTimeout(() => void checkModels(), 2000) const interval = setInterval(() => { if (phase === 'prompt') void checkModels() }, 15000) return () => { clearTimeout(initialCheck) clearInterval(interval) } }, [checkModels, phase]) // LLM pull 진행률 구독 useEffect(() => { const unsub = window.electronAPI.llm.onPullProgress((e) => { if (activeStepRef.current !== 'llm') return if (e.modelId !== DEFAULT_LLM_MODEL) return setStatus(e.status) if (e.percent > 0) setPercent(e.percent) // total 없는 상태 라인(pulling manifest / verifying / writing manifest)은 // 바이트 진행률이 없음 — 인디터미넌트로 표시 setBusyStatus(!e.total || e.total === 0) }) return () => { unsub() } }, []) // STT 다운로드 진행률 구독 useEffect(() => { const unsub = window.electronAPI.stt.onDownloadProgress((e) => { if (activeStepRef.current !== 'stt') return setPercent(e.percent) if (e.totalBytes > 0) { setDetail( t('onboarding.progressDetail', { downloaded: String(Math.round(e.downloadedBytes / 1e6)), total: String(Math.round(e.totalBytes / 1e6)), }), ) } }) return () => { unsub() } }, [t]) const handleDownload = useCallback(async (): Promise => { // 중복 실행 가드 (연타/이중 트리거 방지) if (runningRef.current) return runningRef.current = true setPhase('downloading') setErrorMsg('') try { // 재시도 시 이미 끝난 단계를 스킵하도록 매번 재계산 const needed = await computeNeededSteps() setSttModelId(needed.sttModelId) setNeededSteps(needed.steps) for (let i = 0; i < needed.steps.length; i++) { const step = needed.steps[i] setStepIndex(i) setPercent(0) setStatus('') setDetail('') setBusyStatus(false) activeStepRef.current = step const result = step === 'llm' ? await window.electronAPI.llm.pullModel({ modelId: DEFAULT_LLM_MODEL }) : await window.electronAPI.stt.downloadModel({ modelId: needed.sttModelId }) if (!result.success) { setPhase('failed') setErrorMsg(result.error?.message ?? 'unknown') return } setPercent(100) } setPhase('success') // 온보딩 완료 플래그 저장 window.electronAPI.config.set({ key: 'onboardingCompleted', value: true }) } finally { activeStepRef.current = null runningRef.current = false } }, []) const handleClose = useCallback(() => { if (phase === 'downloading') return setInternalOpen(false) onClose() }, [phase, onClose]) if (!isVisible) return <> const isDownloading = phase === 'downloading' const isSuccess = phase === 'success' const isFailed = phase === 'failed' const colorSuccess = d3roPalette.tag.green const colorDanger = d3roPalette.tag.red const colorAccent = d3roPalette.accent.main const currentStep: StepKind | null = isDownloading ? (neededSteps[stepIndex] ?? null) : null // Ollama 원시 status 문자열 → 친화적 문구 (알려진 것만 매핑, 그 외 원문) const formatStatus = (raw: string): string => { if (raw.startsWith('pulling manifest')) return t('onboarding.statusPreparing') if (raw.startsWith('verifying')) return t('onboarding.statusVerifying') if (raw.startsWith('writing manifest') || raw === 'success') return t('onboarding.statusFinalizing') return raw } /** 단계별 안내 박스 렌더 */ const renderStepInfo = (step: StepKind): React.ReactElement => ( {step === 'llm' ? t('onboarding.llmModelMissing', { model: DEFAULT_LLM_MODEL }) : t('onboarding.sttModelMissing', { model: sttModelId })} {step === 'llm' ? t('onboarding.llmModelSize') : t('onboarding.sttModelSize')} ) return ( {isSuccess ? ( ) : isFailed ? ( ) : ( )} {t('onboarding.title')} {t('onboarding.subtitle')} {(phase === 'prompt' || isDownloading) && neededSteps.map(renderStepInfo)} {isDownloading && ( {neededSteps.length > 1 ? `${t('onboarding.step', { current: String(stepIndex + 1), total: String(neededSteps.length), })} — ${t('onboarding.downloading')}` : t('onboarding.downloading')} {busyStatus ? '…' : `${percent}%`} {detail && ( {detail} )} {status && ( {t('onboarding.status', { status: formatStatus(status) })} )} )} {isSuccess && ( {t('onboarding.success')} )} {isFailed && ( {t('onboarding.failed', { message: errorMsg })} )} {phase === 'prompt' && ( <> )} {isDownloading && ( )} {isSuccess && ( )} {isFailed && ( <> )} ) }