- 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
397 lines
12 KiB
TypeScript
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>
|
|
)
|
|
}
|