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
|
|
@ -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'))
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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