diff --git a/apps/desktop/src/main/services/LocalLLMService.ts b/apps/desktop/src/main/services/LocalLLMService.ts index 6467fbc..737521f 100644 --- a/apps/desktop/src/main/services/LocalLLMService.ts +++ b/apps/desktop/src/main/services/LocalLLMService.ts @@ -98,6 +98,8 @@ function stripReasoningBlocks(text: string): string { class LocalLLMService extends EventEmitter { private _state = LLMState.Unavailable + /** 모델별 진행 중 pull — 중복 요청은 기존 promise에 합류 */ + private _pullInFlight = new Map>() private _pollInterval: ReturnType | null = null private _available = false private _abortController: AbortController | null = null @@ -497,6 +499,20 @@ class LocalLLMService extends EventEmitter { * @param modelId 예: 'gemma4:e4b' */ async pullModel(modelId: string): Promise { + // 동일 모델 동시 pull 방지 — 진행 중이면 같은 promise에 합류 + const inFlight = this._pullInFlight.get(modelId) + if (inFlight) { + logger.info(`Pull 진행 중 재요청 — 기존 작업에 합류: ${modelId}`) + return inFlight + } + const task = this._doPullModel(modelId).finally(() => { + this._pullInFlight.delete(modelId) + }) + this._pullInFlight.set(modelId, task) + return task + } + + private async _doPullModel(modelId: string): Promise { const serverUrl = configGet('ollamaServerUrl') logger.info(`Pull 시작: ${modelId}`) diff --git a/apps/desktop/src/main/services/LocalSTTService.ts b/apps/desktop/src/main/services/LocalSTTService.ts index 6711e34..e699412 100644 --- a/apps/desktop/src/main/services/LocalSTTService.ts +++ b/apps/desktop/src/main/services/LocalSTTService.ts @@ -327,7 +327,8 @@ class LocalSTTService extends EventEmitter { signal: AbortSignal.timeout(10000), }) - if (!startRes.ok) { + // 409 = 이미 다운로드 진행 중 — 실패가 아니라 기존 진행에 합류해 폴링 + if (!startRes.ok && startRes.status !== 409) { const text = await startRes.text() throw new D3ROError( ErrorCode.STTModelDownloadFailed, @@ -335,10 +336,14 @@ class LocalSTTService extends EventEmitter { ) } - const started = (await startRes.json()) as { status: string } - if (started.status === 'done') { - this._emitDownloadProgress(modelId, 100, 0, 0, 0) - return + if (startRes.ok) { + const started = (await startRes.json()) as { status: string } + if (started.status === 'done') { + this._emitDownloadProgress(modelId, 100, 0, 0, 0) + return + } + } else { + logger.info(`모델 다운로드 이미 진행 중 — 폴링에 합류: ${modelId}`) } // 진행률 폴링 — sidecar 통신 실패가 연속되면 중단 diff --git a/apps/desktop/src/renderer/components/OnboardingModal.tsx b/apps/desktop/src/renderer/components/OnboardingModal.tsx index 48a9fe7..28cc1ee 100644 --- a/apps/desktop/src/renderer/components/OnboardingModal.tsx +++ b/apps/desktop/src/renderer/components/OnboardingModal.tsx @@ -87,7 +87,10 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React. const [status, setStatus] = useState('') const [detail, setDetail] = useState('') const [errorMsg, setErrorMsg] = useState('') + // 진행률 없는 상태(무결성 검증 등) — 인디터미넌트 바로 "멈춘 것처럼" 보이는 문제 방지 + const [busyStatus, setBusyStatus] = useState(false) const activeStepRef = useRef(null) + const runningRef = useRef(false) const isVisible = open || internalOpen @@ -127,6 +130,9 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React. 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() @@ -153,40 +159,47 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React. }, [t]) const handleDownload = useCallback(async (): Promise => { + // 중복 실행 가드 (연타/이중 트리거 방지) + if (runningRef.current) return + runningRef.current = true setPhase('downloading') setErrorMsg('') - // 재시도 시 이미 끝난 단계를 스킵하도록 매번 재계산 - const needed = await computeNeededSteps() - setSttModelId(needed.sttModelId) - setNeededSteps(needed.steps) + 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('') - activeStepRef.current = step + 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 }) + 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 + if (!result.success) { + setPhase('failed') + setErrorMsg(result.error?.message ?? 'unknown') + return + } + setPercent(100) } - setPercent(100) - } - activeStepRef.current = null - setPhase('success') - // 온보딩 완료 플래그 저장 - window.electronAPI.config.set({ key: 'onboardingCompleted', value: true }) + setPhase('success') + // 온보딩 완료 플래그 저장 + window.electronAPI.config.set({ key: 'onboardingCompleted', value: true }) + } finally { + activeStepRef.current = null + runningRef.current = false + } }, []) const handleClose = useCallback(() => { @@ -209,6 +222,15 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React. ? (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 => ( - {percent}% + {busyStatus ? '…' : `${percent}%`} @@ -306,7 +328,7 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React. - {t('onboarding.status', { status })} + {t('onboarding.status', { status: formatStatus(status) })} )} diff --git a/memory/project_status.md b/memory/project_status.md index c533171..e156af2 100644 --- a/memory/project_status.md +++ b/memory/project_status.md @@ -42,7 +42,9 @@ - 배포 경로: v0.1.3-alpha 태그 → 설치 앱이 자동 업데이트로 수신 ✅ - **v0.1.3-alpha 릴리스 완료** (파이프라인 #69747 전 잡 그린): Ollama v0.32.1 CPU 전용 가지치기 후 **119MB** (기존 ~1GB), 설치파일 735MB → **225MB** - **차등 다운로드도 이번엔 동작**: 0.1.2→0.1.3 델타 27MB만 다운로드(12%) — updater 캐시의 current.blockmap 사용. 이전 404는 첫 업데이트라 old blockmap이 없어서였음 -- 남은 검증: 사용자가 재시작 다이얼로그 수락 → v0.1.3에서 온보딩 gemma4:e4b pull(9.6GB) + Whisper turbo 다운로드 실측 +- **v0.1.3 실측 결과 (2026-07-21 17:55~17:58)**: 온보딩 전체 성공 — gemma4:e4b pull 완료(레이어가 로컬 캐시에 있어 검증만 ~2분), Whisper `medium` 1.5GB 다운로드 25초, onboardingCompleted 저장. 사용자는 "조용한 실패"로 인지 → 원인은 **verifying(무진행) 구간 ~2분간 UI 피드백 부재** (UX 버그, 기능 정상) +- **G: 온보딩 UX 수정 (v0.1.4-alpha)**: ①total 없는 상태(pulling/verifying/writing manifest)에서 인디터미넌트 바 + 친화적 문구(i18n 3키), ②중복 클릭 가드(runningRef), ③LocalLLMService.pullModel 동시 호출 dedupe(in-flight map), ④STT /download 409 시 기존 진행 폴링 합류 +- 참고: 온보딩 STT 단계는 사용자의 기존 설정 모델(medium)을 존중해 다운로드함 — turbo 강제 아님 (설계 의도) #### 남은 것 1. 설치 디렉토리명 `@d3rodesktop` 정리 (electron-builder가 package name에서 유도 — 다음 릴리스에서 명시) diff --git a/packages/i18n/src/locales/en.json b/packages/i18n/src/locales/en.json index 2e18565..7d2f3b5 100644 --- a/packages/i18n/src/locales/en.json +++ b/packages/i18n/src/locales/en.json @@ -636,6 +636,9 @@ "onboarding.success": "Setup complete! You can now use the app.", "onboarding.failed": "Download failed: {{message}}", "onboarding.retry": "Retry", + "onboarding.statusPreparing": "Preparing download…", + "onboarding.statusVerifying": "Verifying integrity… (large models may take a few minutes)", + "onboarding.statusFinalizing": "Finalizing…", "onboarding.sttModelMissing": "Speech recognition model ({{model}}) is not installed.", "onboarding.sttModelSize": "Size: ~1.6GB — takes 2–10 min depending on network speed", "onboarding.step": "Step {{current}}/{{total}}", diff --git a/packages/i18n/src/locales/ko.json b/packages/i18n/src/locales/ko.json index 7df9f13..16a9b71 100644 --- a/packages/i18n/src/locales/ko.json +++ b/packages/i18n/src/locales/ko.json @@ -636,6 +636,9 @@ "onboarding.success": "설치 완료! 이제 사용할 수 있습니다.", "onboarding.failed": "다운로드 실패: {{message}}", "onboarding.retry": "재시도", + "onboarding.statusPreparing": "다운로드 준비 중…", + "onboarding.statusVerifying": "무결성 검증 중… (모델이 크면 몇 분 걸릴 수 있어요)", + "onboarding.statusFinalizing": "마무리 중…", "onboarding.sttModelMissing": "음성 인식 모델({{model}})이 설치되어 있지 않습니다.", "onboarding.sttModelSize": "크기: 약 1.6GB — 네트워크 속도에 따라 2~10분 소요", "onboarding.step": "단계 {{current}}/{{total}}",