feat(bootstrap): Whisper large-v3-turbo 기본 전환 + 온보딩 2단계 다운로드 진행률
- 기본 STT 모델 base → large-v3-turbo (6배 빠름, 1.6GB) - 사이드카: /download, /download/status, /download/cancel + --models-dir - LocalSTTService: downloadModel/cancelDownload + download-progress 이벤트 - IPC: 설계서 02의 stt:downloadModel/cancelDownload/downloadProgress 구현 - OnboardingModal: LLM(gemma4:e4b) → STT(turbo) 2단계 순차 다운로드 UI - SettingsModal turbo 선택지 + settings.model.largeTurbo 12 locale - 테스트: 모노레포 잔재 import 수정 (src/shared → @d3ro/core), 41/41 통과
This commit is contained in:
parent
9dc8b26c11
commit
983c60cda2
27 changed files with 688 additions and 76 deletions
|
|
@ -1,10 +1,14 @@
|
|||
// src/renderer/components/OnboardingModal.tsx
|
||||
// 첫 실행 온보딩 모달 — 기본 LLM 모델(gemma4:e4b) 미설치 시 다운로드 유도.
|
||||
// 첫 실행 온보딩 모달 — 기본 모델 미설치 시 다운로드 유도.
|
||||
//
|
||||
// 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)
|
||||
// 다운로드 성공 시 config.onboardingCompleted=true로 저장.
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import {
|
||||
|
|
@ -23,36 +27,77 @@ import CloudDownloadIcon from '@mui/icons-material/CloudDownload'
|
|||
import { d3roPalette, d3roRadius, typoSx } from '@d3ro/ui/theme'
|
||||
import { useI18n } from '@d3ro/i18n'
|
||||
|
||||
const DEFAULT_MODEL = 'gemma4:e4b'
|
||||
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 unsubRef = useRef<(() => void) | null>(null)
|
||||
const activeStepRef = useRef<StepKind | null>(null)
|
||||
|
||||
const isVisible = open || internalOpen
|
||||
|
||||
// 모델 존재 여부 체크 — 없으면 auto-open
|
||||
const checkModels = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
const result = await window.electronAPI.llm.getModels()
|
||||
if (!result.success) {
|
||||
setPhase('prompt')
|
||||
setInternalOpen(true)
|
||||
return
|
||||
}
|
||||
const hasDefault = result.data.some((m) => m.id === DEFAULT_MODEL)
|
||||
if (!hasDefault) {
|
||||
const needed = await computeNeededSteps()
|
||||
setSttModelId(needed.sttModelId)
|
||||
if (needed.steps.length > 0) {
|
||||
setNeededSteps(needed.steps)
|
||||
setPhase('prompt')
|
||||
setInternalOpen(true)
|
||||
} else {
|
||||
|
|
@ -75,36 +120,73 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
|
|||
}
|
||||
}, [checkModels, phase])
|
||||
|
||||
// pull 진행률 구독
|
||||
// LLM pull 진행률 구독
|
||||
useEffect(() => {
|
||||
const unsub = window.electronAPI.llm.onPullProgress((e) => {
|
||||
if (e.modelId !== DEFAULT_MODEL) return
|
||||
if (activeStepRef.current !== 'llm') return
|
||||
if (e.modelId !== DEFAULT_LLM_MODEL) return
|
||||
setStatus(e.status)
|
||||
if (e.percent > 0) setPercent(e.percent)
|
||||
})
|
||||
unsubRef.current = unsub
|
||||
return () => {
|
||||
unsub()
|
||||
unsubRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 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> => {
|
||||
setPhase('downloading')
|
||||
setPercent(0)
|
||||
setStatus('')
|
||||
setErrorMsg('')
|
||||
|
||||
const result = await window.electronAPI.llm.pullModel({ modelId: DEFAULT_MODEL })
|
||||
if (result.success) {
|
||||
setPhase('success')
|
||||
// 재시도 시 이미 끝난 단계를 스킵하도록 매번 재계산
|
||||
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('')
|
||||
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) {
|
||||
activeStepRef.current = null
|
||||
setPhase('failed')
|
||||
setErrorMsg(result.error?.message ?? 'unknown')
|
||||
return
|
||||
}
|
||||
setPercent(100)
|
||||
// 온보딩 완료 플래그 저장
|
||||
window.electronAPI.config.set({ key: 'onboardingCompleted', value: true })
|
||||
} else {
|
||||
setPhase('failed')
|
||||
setErrorMsg(result.error?.message ?? 'unknown')
|
||||
}
|
||||
|
||||
activeStepRef.current = null
|
||||
setPhase('success')
|
||||
// 온보딩 완료 플래그 저장
|
||||
window.electronAPI.config.set({ key: 'onboardingCompleted', value: true })
|
||||
}, [])
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
|
|
@ -123,6 +205,34 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
|
|||
const colorDanger = d3roPalette.tag.red
|
||||
const colorAccent = d3roPalette.accent.amber
|
||||
|
||||
const currentStep: StepKind | null = isDownloading
|
||||
? (neededSteps[stepIndex] ?? null)
|
||||
: null
|
||||
|
||||
/** 단계별 안내 박스 렌더 */
|
||||
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}
|
||||
|
|
@ -163,30 +273,18 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
|
|||
{t('onboarding.subtitle')}
|
||||
</Typography>
|
||||
|
||||
{(phase === 'prompt' || isDownloading) && (
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: d3roRadius.inner,
|
||||
backgroundColor: d3roPalette.bg.card,
|
||||
border: `1px solid ${d3roPalette.border.subtle}`,
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ ...typoSx('body'), color: d3roPalette.text.primary, mb: 1 }}>
|
||||
{t('onboarding.llmModelMissing', { model: DEFAULT_MODEL })}
|
||||
</Typography>
|
||||
<Typography sx={{ ...typoSx('small'), color: d3roPalette.text.secondary }}>
|
||||
{t('onboarding.llmModelSize')}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{(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 }}>
|
||||
{t('onboarding.downloading')}
|
||||
{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 }}>
|
||||
{percent}%
|
||||
|
|
@ -197,6 +295,13 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
|
|||
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 }}
|
||||
|
|
|
|||
|
|
@ -629,6 +629,7 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
|
|||
<MenuItem value="small">{t('settings.model.small')}</MenuItem>
|
||||
<MenuItem value="medium">{t('settings.model.medium')}</MenuItem>
|
||||
<MenuItem value="large-v3">{t('settings.model.large')}</MenuItem>
|
||||
<MenuItem value="large-v3-turbo">{t('settings.model.largeTurbo')}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue