d3ro-voice/apps/desktop/src/renderer/components/OnboardingModal.tsx
Yun Chan 078304dda9 refactor(ui): deprecated accent.amber->main 91건 + 매직 리터럴 토큰화 (WS3)
- accent.amber/amberDim/amberGlow -> accent.main/dim/glow 91건 (34파일).
  theme.ts의 @deprecated 별칭 참조 제거 (본체 main 사용).
- inline fontSize/fontWeight -> d3roTypo, borderRadius -> d3roRadius 토큰 37건.
- P4: 화면 고유 수치(스탯 fontSize, 레이아웃 width)는 토큰화 제외.
정책: docs/REFACTOR_POLICY.md DP1, 이식 인사이트 P4
2026-07-22 01:54:37 +09:00

397 lines
12 KiB
TypeScript

// 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<NeededSteps> {
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<Phase>('prompt')
const [neededSteps, setNeededSteps] = useState<StepKind[]>([])
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<StepKind | null>(null)
const runningRef = useRef(false)
const isVisible = open || internalOpen
// 모델 존재 여부 체크 — 없으면 auto-open
const checkModels = useCallback(async (): Promise<void> => {
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<void> => {
// 중복 실행 가드 (연타/이중 트리거 방지)
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 => (
<Box
key={step}
sx={{
p: 2,
borderRadius: d3roRadius.inner,
backgroundColor: d3roPalette.bg.card,
border: `1px solid ${d3roPalette.border.subtle}`,
mb: 2,
opacity: currentStep === null || currentStep === step ? 1 : 0.5,
}}
>
<Typography sx={{ ...typoSx('body'), color: d3roPalette.text.primary, mb: 1 }}>
{step === 'llm'
? t('onboarding.llmModelMissing', { model: DEFAULT_LLM_MODEL })
: t('onboarding.sttModelMissing', { model: sttModelId })}
</Typography>
<Typography sx={{ ...typoSx('small'), color: d3roPalette.text.secondary }}>
{step === 'llm' ? t('onboarding.llmModelSize') : t('onboarding.sttModelSize')}
</Typography>
</Box>
)
return (
<Dialog
open={isVisible}
onClose={handleClose}
maxWidth="sm"
fullWidth
disableEscapeKeyDown={isDownloading}
PaperProps={{
sx: {
borderRadius: d3roRadius.card,
backgroundColor: d3roPalette.bg.elevated,
border: `1px solid ${d3roPalette.border.subtle}`,
},
}}
>
<DialogTitle
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.5,
...typoSx('heading'),
color: d3roPalette.text.primary,
borderBottom: `1px solid ${d3roPalette.border.subtle}`,
}}
>
{isSuccess ? (
<CheckCircle2 size={24} style={{ color: colorSuccess }} />
) : isFailed ? (
<AlertCircle size={24} style={{ color: colorDanger }} />
) : (
<CloudDownload size={24} style={{ color: colorAccent }} />
)}
{t('onboarding.title')}
</DialogTitle>
<DialogContent sx={{ py: 3 }}>
<Typography sx={{ ...typoSx('body'), color: d3roPalette.text.secondary, mb: 2 }}>
{t('onboarding.subtitle')}
</Typography>
{(phase === 'prompt' || isDownloading) && neededSteps.map(renderStepInfo)}
{isDownloading && (
<Box sx={{ mt: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography sx={{ ...typoSx('small'), color: d3roPalette.text.secondary }}>
{neededSteps.length > 1
? `${t('onboarding.step', {
current: String(stepIndex + 1),
total: String(neededSteps.length),
})} — ${t('onboarding.downloading')}`
: t('onboarding.downloading')}
</Typography>
<Typography sx={{ ...typoSx('small'), color: colorAccent }}>
{busyStatus ? '…' : `${percent}%`}
</Typography>
</Box>
<LinearProgress
variant={busyStatus ? 'indeterminate' : 'determinate'}
value={percent}
sx={{ height: 8, borderRadius: d3roRadius.xs }}
/>
{detail && (
<Typography
sx={{ ...typoSx('meta'), color: d3roPalette.text.secondary, mt: 1 }}
>
{detail}
</Typography>
)}
{status && (
<Typography
sx={{ ...typoSx('meta'), color: d3roPalette.text.secondary, mt: 1 }}
>
{t('onboarding.status', { status: formatStatus(status) })}
</Typography>
)}
</Box>
)}
{isSuccess && (
<Typography sx={{ ...typoSx('body'), color: colorSuccess, mt: 2 }}>
{t('onboarding.success')}
</Typography>
)}
{isFailed && (
<Typography sx={{ ...typoSx('body'), color: colorDanger, mt: 2 }}>
{t('onboarding.failed', { message: errorMsg })}
</Typography>
)}
</DialogContent>
<DialogActions sx={{ px: 3, py: 2, gap: 1 }}>
{phase === 'prompt' && (
<>
<Button onClick={handleClose} sx={{ color: d3roPalette.text.secondary }}>
{t('onboarding.cancel')}
</Button>
<Button
onClick={handleDownload}
variant="contained"
sx={{ backgroundColor: colorAccent }}
>
{t('onboarding.download')}
</Button>
</>
)}
{isDownloading && (
<Button disabled sx={{ color: d3roPalette.text.disabled }}>
{t('onboarding.downloading')}
</Button>
)}
{isSuccess && (
<Button
onClick={handleClose}
variant="contained"
sx={{ backgroundColor: colorSuccess }}
>
{t('onboarding.close')}
</Button>
)}
{isFailed && (
<>
<Button onClick={handleClose} sx={{ color: d3roPalette.text.secondary }}>
{t('onboarding.close')}
</Button>
<Button
onClick={handleDownload}
variant="contained"
sx={{ backgroundColor: colorAccent }}
>
{t('onboarding.retry')}
</Button>
</>
)}
</DialogActions>
</Dialog>
)
}