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:
Yun Chan 2026-07-21 11:59:49 +09:00
parent 9dc8b26c11
commit 983c60cda2
27 changed files with 688 additions and 76 deletions

View file

@ -2,12 +2,29 @@
import { ipcMain } from 'electron'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import { ipcSuccess, ipcError, ErrorCode } from '@d3ro/core/errors'
import { ipcSuccess, ipcError, ErrorCode, D3ROError } from '@d3ro/core/errors'
import { getLocalSTTService } from '../services/LocalSTTService'
import { configGet, configSet } from '../services/ConfigService'
import type { SetSTTModelParams, SetSTTLanguageParams } from '@d3ro/core/types'
import { getMainWindow } from '../windows/WindowManager'
import type {
SetSTTModelParams,
SetSTTLanguageParams,
DownloadModelParams,
} from '@d3ro/core/types'
function safeSendToRenderer(channel: string, data: unknown): void {
const win = getMainWindow()
if (win && !win.isDestroyed()) {
win.webContents.send(channel, data)
}
}
export function registerSTTHandlers(): void {
// 다운로드 진행률 → 렌더러
getLocalSTTService().on('download-progress', (payload) => {
safeSendToRenderer(IPC_CHANNELS.STT.DOWNLOAD_PROGRESS, payload)
})
ipcMain.handle(IPC_CHANNELS.STT.GET_STATUS, async () => {
try {
const stt = getLocalSTTService()
@ -41,6 +58,27 @@ export function registerSTTHandlers(): void {
}
})
ipcMain.handle(
IPC_CHANNELS.STT.DOWNLOAD_MODEL,
async (_event, params: DownloadModelParams) => {
try {
await getLocalSTTService().downloadModel(params.modelId)
return ipcSuccess(undefined)
} catch (err) {
if (err instanceof D3ROError) {
return ipcError(err.code, err.message)
}
const msg = err instanceof Error ? err.message : String(err)
return ipcError(ErrorCode.STTModelDownloadFailed, `Model download failed: ${msg}`)
}
},
)
ipcMain.handle(IPC_CHANNELS.STT.CANCEL_DOWNLOAD, async () => {
await getLocalSTTService().cancelDownload()
return ipcSuccess(undefined)
})
ipcMain.handle(IPC_CHANNELS.STT.GET_LANGUAGE, async () => {
return ipcSuccess(configGet('sttLanguage'))
})

View file

@ -21,7 +21,8 @@ const CONFIG_DEFAULTS: AppConfig = {
autoLaunch: false,
soundEnabled: true,
selectedDeviceId: null,
sttModelId: 'base',
// large-v3 대비 6배 빠르고 정확도 손실 1~2%, 다운로드 1.6GB (온보딩에서 사전 다운로드)
sttModelId: 'large-v3-turbo',
sttLanguage: 'auto',
ttsVoiceId: null,
ttsSpeed: 1.0,

View file

@ -6,11 +6,18 @@
import { EventEmitter } from 'events'
import { type ChildProcess, spawn } from 'child_process'
import { createServer } from 'net'
import { existsSync } from 'fs'
import { join } from 'path'
import { getLogger } from './LoggerService'
import { configGet } from './ConfigService'
import { getSidecarCommand } from '../utils/paths'
import { getSidecarCommand, getWhisperModelsDir } from '../utils/paths'
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import type { STTModel, STTStatus, STTEngineState } from '@d3ro/core/types'
import type {
STTModel,
STTStatus,
STTEngineState,
DownloadProgressEvent,
} from '@d3ro/core/types'
// ── 내부 타입 정의 ────────────────────────────────────────
@ -61,6 +68,17 @@ interface LoadResponse {
load_time_ms: number
}
/** sidecar /download/status 응답 */
interface DownloadStatusResponse {
status: 'idle' | 'downloading' | 'done' | 'cancelled' | 'error'
model_id: string | null
percent: number
downloaded_bytes: number
total_bytes: number
bytes_per_second: number
message: string | null
}
/** sidecar /transcribe 응답 */
interface TranscribeResponse {
text: string
@ -80,6 +98,7 @@ export interface LocalSTTEvents {
'transcription-delta': { text: string; isFinal: boolean }
'transcription-complete': { result: TranscriptionResult }
'model-loaded': { model: STTModel; loadTimeMs: number }
'download-progress': DownloadProgressEvent
'error': { error: D3ROError }
}
@ -138,6 +157,15 @@ const MODEL_CATALOG: STTModel[] = [
accuracy: 5,
speed: 1,
},
{
id: 'large-v3-turbo',
name: 'Large V3 Turbo',
sizeBytes: 1_600_000_000,
downloaded: false,
languages: ['auto', 'ko', 'en', 'ja', 'zh'],
accuracy: 5,
speed: 3,
},
]
// ── 서비스 구현 ───────────────────────────────────────────
@ -201,10 +229,7 @@ class LocalSTTService extends EventEmitter {
try {
// sidecar가 아직 실행 중이 아니면 시작
if (!this._sidecarProcess || this._sidecarProcess.exitCode !== null) {
await this._spawnSidecar()
await this._waitForHealth()
}
await this._ensureSidecarRunning()
// 모델 로딩
await this._loadModel(targetModel)
@ -269,16 +294,116 @@ class LocalSTTService extends EventEmitter {
/**
* .
* sidecar에 (faster-whisper가 ).
* models-dir + downloaded를 .
*/
getModels(): STTModel[] {
const modelsDir = getWhisperModelsDir()
return MODEL_CATALOG.map((m) => ({
...m,
// 현재 로딩된 모델은 downloaded=true로 표시
downloaded: m.id === this._currentModelId ? true : m.downloaded,
downloaded:
m.id === this._currentModelId ||
existsSync(join(modelsDir, m.id, 'model.bin')),
}))
}
/**
* (sidecar /download + ).
* 'download-progress' emit하며, resolve.
*/
async downloadModel(modelId: string): Promise<void> {
if (this._disposed) {
throw new D3ROError(
ErrorCode.STTModelDownloadFailed,
'LocalSTTService가 이미 dispose되었습니다',
)
}
await this._ensureSidecarRunning()
const startRes = await fetch(`http://localhost:${this._port}/download`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model_id: modelId }),
signal: AbortSignal.timeout(10000),
})
if (!startRes.ok) {
const text = await startRes.text()
throw new D3ROError(
ErrorCode.STTModelDownloadFailed,
`다운로드 시작 실패 (HTTP ${startRes.status}): ${text}`,
)
}
const started = (await startRes.json()) as { status: string }
if (started.status === 'done') {
this._emitDownloadProgress(modelId, 100, 0, 0, 0)
return
}
// 진행률 폴링 — sidecar 통신 실패가 연속되면 중단
let consecutiveFailures = 0
for (;;) {
await this._sleep(500)
let status: DownloadStatusResponse
try {
const res = await fetch(`http://localhost:${this._port}/download/status`, {
signal: AbortSignal.timeout(3000),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
status = (await res.json()) as DownloadStatusResponse
consecutiveFailures = 0
} catch (err) {
consecutiveFailures++
if (consecutiveFailures >= 5) {
throw new D3ROError(
ErrorCode.STTModelDownloadFailed,
`다운로드 상태 조회 실패: ${err instanceof Error ? err.message : String(err)}`,
)
}
continue
}
this._emitDownloadProgress(
modelId,
status.percent,
status.downloaded_bytes,
status.total_bytes,
status.bytes_per_second,
)
if (status.status === 'done') {
logger.info(`모델 다운로드 완료: ${modelId}`)
return
}
if (status.status === 'cancelled') {
throw new D3ROError(
ErrorCode.STTModelDownloadCancelled,
`모델 다운로드 취소: ${modelId}`,
)
}
if (status.status === 'error') {
throw new D3ROError(
ErrorCode.STTModelDownloadFailed,
`모델 다운로드 실패: ${status.message ?? 'unknown'}`,
)
}
}
}
/** 진행 중인 모델 다운로드 취소 요청 */
async cancelDownload(): Promise<void> {
try {
await fetch(`http://localhost:${this._port}/download/cancel`, {
method: 'POST',
signal: AbortSignal.timeout(3000),
})
} catch {
// sidecar 미기동/통신 불가 — 취소할 다운로드가 없음
}
}
/** 현재 상태 조회 */
getStatus(): STTStatus {
const stateMap: Record<STTState, STTEngineState> = {
@ -359,6 +484,30 @@ class LocalSTTService extends EventEmitter {
// ── Sidecar 관리 ──
/** sidecar가 실행 중이 아니면 spawn + 헬스체크 대기 */
private async _ensureSidecarRunning(): Promise<void> {
if (!this._sidecarProcess || this._sidecarProcess.exitCode !== null) {
await this._spawnSidecar()
await this._waitForHealth()
}
}
private _emitDownloadProgress(
modelId: string,
percent: number,
downloadedBytes: number,
totalBytes: number,
bytesPerSecond: number,
): void {
this.emit('download-progress', {
modelId,
percent,
downloadedBytes,
totalBytes,
bytesPerSecond,
})
}
/**
* maxAttempts개 free .
* dev mode sidecar가 orphan으로
@ -409,7 +558,13 @@ class LocalSTTService extends EventEmitter {
this._port = await this._findFreePort(SIDECAR_PORT, 20)
const { command, args } = getSidecarCommand()
const fullArgs = [...args, '--port', String(this._port)]
const fullArgs = [
...args,
'--port',
String(this._port),
'--models-dir',
getWhisperModelsDir(),
]
logger.info(`Sidecar 시작: ${command} ${fullArgs.join(' ')}`)
return new Promise<void>((resolve, reject) => {

View file

@ -143,3 +143,11 @@ export function getFfmpegPath(): string {
export function getUserDataPath(): string {
return app.getPath('userData')
}
/**
* Whisper .
* sidecar --models-dir로 , downloaded .
*/
export function getWhisperModelsDir(): string {
return path.join(app.getPath('userData'), 'whisper-models')
}

View file

@ -35,6 +35,8 @@ import type {
SetSTTModelParams,
SetSTTLanguageParams,
STTStatusChangedEvent,
DownloadModelParams,
DownloadProgressEvent,
HotkeyBinding,
SetHotkeyParams,
SetEnabledParams,
@ -251,11 +253,16 @@ const electronAPI = {
getActiveModel: () => invoke<string | null>(IPC_CHANNELS.STT.GET_ACTIVE_MODEL),
setModel: (params: SetSTTModelParams) =>
invoke<void>(IPC_CHANNELS.STT.SET_MODEL, params),
downloadModel: (params: DownloadModelParams) =>
invoke<void>(IPC_CHANNELS.STT.DOWNLOAD_MODEL, params),
cancelDownload: () => invoke<void>(IPC_CHANNELS.STT.CANCEL_DOWNLOAD),
getLanguage: () => invoke<string>(IPC_CHANNELS.STT.GET_LANGUAGE),
setLanguage: (params: SetSTTLanguageParams) =>
invoke<void>(IPC_CHANNELS.STT.SET_LANGUAGE, params),
onStatusChanged: (cb: (e: STTStatusChangedEvent) => void): Unsubscribe =>
on(IPC_CHANNELS.STT.STATUS_CHANGED, cb)
on(IPC_CHANNELS.STT.STATUS_CHANGED, cb),
onDownloadProgress: (cb: (e: DownloadProgressEvent) => void): Unsubscribe =>
on(IPC_CHANNELS.STT.DOWNLOAD_PROGRESS, cb)
},
// ── Hotkey ─────────────────────────────────────────────

View file

@ -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 }}

View file

@ -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>