fix(onboarding): 무진행 구간 UX + 중복 실행 가드
- verifying/manifest 상태(바이트 진행률 없음)에서 인디터미넌트 바 + 친화적 문구 — 9.6GB 검증 ~2분간 멈춘 것처럼 보이던 문제 해소 - OnboardingModal 중복 실행 가드 (runningRef) - LocalLLMService.pullModel: 동일 모델 동시 pull은 기존 promise 합류 - LocalSTTService.downloadModel: /download 409는 실패가 아닌 기존 진행 합류
This commit is contained in:
parent
64c1d3709b
commit
0b9e67afe9
6 changed files with 86 additions and 35 deletions
|
|
@ -98,6 +98,8 @@ function stripReasoningBlocks(text: string): string {
|
||||||
|
|
||||||
class LocalLLMService extends EventEmitter {
|
class LocalLLMService extends EventEmitter {
|
||||||
private _state = LLMState.Unavailable
|
private _state = LLMState.Unavailable
|
||||||
|
/** 모델별 진행 중 pull — 중복 요청은 기존 promise에 합류 */
|
||||||
|
private _pullInFlight = new Map<string, Promise<void>>()
|
||||||
private _pollInterval: ReturnType<typeof setInterval> | null = null
|
private _pollInterval: ReturnType<typeof setInterval> | null = null
|
||||||
private _available = false
|
private _available = false
|
||||||
private _abortController: AbortController | null = null
|
private _abortController: AbortController | null = null
|
||||||
|
|
@ -497,6 +499,20 @@ class LocalLLMService extends EventEmitter {
|
||||||
* @param modelId 예: 'gemma4:e4b'
|
* @param modelId 예: 'gemma4:e4b'
|
||||||
*/
|
*/
|
||||||
async pullModel(modelId: string): Promise<void> {
|
async pullModel(modelId: string): Promise<void> {
|
||||||
|
// 동일 모델 동시 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<void> {
|
||||||
const serverUrl = configGet('ollamaServerUrl')
|
const serverUrl = configGet('ollamaServerUrl')
|
||||||
logger.info(`Pull 시작: ${modelId}`)
|
logger.info(`Pull 시작: ${modelId}`)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -327,7 +327,8 @@ class LocalSTTService extends EventEmitter {
|
||||||
signal: AbortSignal.timeout(10000),
|
signal: AbortSignal.timeout(10000),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!startRes.ok) {
|
// 409 = 이미 다운로드 진행 중 — 실패가 아니라 기존 진행에 합류해 폴링
|
||||||
|
if (!startRes.ok && startRes.status !== 409) {
|
||||||
const text = await startRes.text()
|
const text = await startRes.text()
|
||||||
throw new D3ROError(
|
throw new D3ROError(
|
||||||
ErrorCode.STTModelDownloadFailed,
|
ErrorCode.STTModelDownloadFailed,
|
||||||
|
|
@ -335,10 +336,14 @@ class LocalSTTService extends EventEmitter {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const started = (await startRes.json()) as { status: string }
|
if (startRes.ok) {
|
||||||
if (started.status === 'done') {
|
const started = (await startRes.json()) as { status: string }
|
||||||
this._emitDownloadProgress(modelId, 100, 0, 0, 0)
|
if (started.status === 'done') {
|
||||||
return
|
this._emitDownloadProgress(modelId, 100, 0, 0, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.info(`모델 다운로드 이미 진행 중 — 폴링에 합류: ${modelId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 진행률 폴링 — sidecar 통신 실패가 연속되면 중단
|
// 진행률 폴링 — sidecar 통신 실패가 연속되면 중단
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,10 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
|
||||||
const [status, setStatus] = useState('')
|
const [status, setStatus] = useState('')
|
||||||
const [detail, setDetail] = useState('')
|
const [detail, setDetail] = useState('')
|
||||||
const [errorMsg, setErrorMsg] = useState('')
|
const [errorMsg, setErrorMsg] = useState('')
|
||||||
|
// 진행률 없는 상태(무결성 검증 등) — 인디터미넌트 바로 "멈춘 것처럼" 보이는 문제 방지
|
||||||
|
const [busyStatus, setBusyStatus] = useState(false)
|
||||||
const activeStepRef = useRef<StepKind | null>(null)
|
const activeStepRef = useRef<StepKind | null>(null)
|
||||||
|
const runningRef = useRef(false)
|
||||||
|
|
||||||
const isVisible = open || internalOpen
|
const isVisible = open || internalOpen
|
||||||
|
|
||||||
|
|
@ -127,6 +130,9 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
|
||||||
if (e.modelId !== DEFAULT_LLM_MODEL) return
|
if (e.modelId !== DEFAULT_LLM_MODEL) return
|
||||||
setStatus(e.status)
|
setStatus(e.status)
|
||||||
if (e.percent > 0) setPercent(e.percent)
|
if (e.percent > 0) setPercent(e.percent)
|
||||||
|
// total 없는 상태 라인(pulling manifest / verifying / writing manifest)은
|
||||||
|
// 바이트 진행률이 없음 — 인디터미넌트로 표시
|
||||||
|
setBusyStatus(!e.total || e.total === 0)
|
||||||
})
|
})
|
||||||
return () => {
|
return () => {
|
||||||
unsub()
|
unsub()
|
||||||
|
|
@ -153,40 +159,47 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
|
||||||
}, [t])
|
}, [t])
|
||||||
|
|
||||||
const handleDownload = useCallback(async (): Promise<void> => {
|
const handleDownload = useCallback(async (): Promise<void> => {
|
||||||
|
// 중복 실행 가드 (연타/이중 트리거 방지)
|
||||||
|
if (runningRef.current) return
|
||||||
|
runningRef.current = true
|
||||||
setPhase('downloading')
|
setPhase('downloading')
|
||||||
setErrorMsg('')
|
setErrorMsg('')
|
||||||
|
|
||||||
// 재시도 시 이미 끝난 단계를 스킵하도록 매번 재계산
|
try {
|
||||||
const needed = await computeNeededSteps()
|
// 재시도 시 이미 끝난 단계를 스킵하도록 매번 재계산
|
||||||
setSttModelId(needed.sttModelId)
|
const needed = await computeNeededSteps()
|
||||||
setNeededSteps(needed.steps)
|
setSttModelId(needed.sttModelId)
|
||||||
|
setNeededSteps(needed.steps)
|
||||||
|
|
||||||
for (let i = 0; i < needed.steps.length; i++) {
|
for (let i = 0; i < needed.steps.length; i++) {
|
||||||
const step = needed.steps[i]
|
const step = needed.steps[i]
|
||||||
setStepIndex(i)
|
setStepIndex(i)
|
||||||
setPercent(0)
|
setPercent(0)
|
||||||
setStatus('')
|
setStatus('')
|
||||||
setDetail('')
|
setDetail('')
|
||||||
activeStepRef.current = step
|
setBusyStatus(false)
|
||||||
|
activeStepRef.current = step
|
||||||
|
|
||||||
const result =
|
const result =
|
||||||
step === 'llm'
|
step === 'llm'
|
||||||
? await window.electronAPI.llm.pullModel({ modelId: DEFAULT_LLM_MODEL })
|
? await window.electronAPI.llm.pullModel({ modelId: DEFAULT_LLM_MODEL })
|
||||||
: await window.electronAPI.stt.downloadModel({ modelId: needed.sttModelId })
|
: await window.electronAPI.stt.downloadModel({ modelId: needed.sttModelId })
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
activeStepRef.current = null
|
setPhase('failed')
|
||||||
setPhase('failed')
|
setErrorMsg(result.error?.message ?? 'unknown')
|
||||||
setErrorMsg(result.error?.message ?? 'unknown')
|
return
|
||||||
return
|
}
|
||||||
|
setPercent(100)
|
||||||
}
|
}
|
||||||
setPercent(100)
|
|
||||||
}
|
|
||||||
|
|
||||||
activeStepRef.current = null
|
setPhase('success')
|
||||||
setPhase('success')
|
// 온보딩 완료 플래그 저장
|
||||||
// 온보딩 완료 플래그 저장
|
window.electronAPI.config.set({ key: 'onboardingCompleted', value: true })
|
||||||
window.electronAPI.config.set({ key: 'onboardingCompleted', value: true })
|
} finally {
|
||||||
|
activeStepRef.current = null
|
||||||
|
runningRef.current = false
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
|
|
@ -209,6 +222,15 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
|
||||||
? (neededSteps[stepIndex] ?? null)
|
? (neededSteps[stepIndex] ?? null)
|
||||||
: 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 => (
|
const renderStepInfo = (step: StepKind): React.ReactElement => (
|
||||||
<Box
|
<Box
|
||||||
|
|
@ -287,11 +309,11 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
|
||||||
: t('onboarding.downloading')}
|
: t('onboarding.downloading')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography sx={{ ...typoSx('small'), color: colorAccent }}>
|
<Typography sx={{ ...typoSx('small'), color: colorAccent }}>
|
||||||
{percent}%
|
{busyStatus ? '…' : `${percent}%`}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<LinearProgress
|
<LinearProgress
|
||||||
variant="determinate"
|
variant={busyStatus ? 'indeterminate' : 'determinate'}
|
||||||
value={percent}
|
value={percent}
|
||||||
sx={{ height: 8, borderRadius: d3roRadius.xs }}
|
sx={{ height: 8, borderRadius: d3roRadius.xs }}
|
||||||
/>
|
/>
|
||||||
|
|
@ -306,7 +328,7 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
|
||||||
<Typography
|
<Typography
|
||||||
sx={{ ...typoSx('meta'), color: d3roPalette.text.secondary, mt: 1 }}
|
sx={{ ...typoSx('meta'), color: d3roPalette.text.secondary, mt: 1 }}
|
||||||
>
|
>
|
||||||
{t('onboarding.status', { status })}
|
{t('onboarding.status', { status: formatStatus(status) })}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,9 @@
|
||||||
- 배포 경로: v0.1.3-alpha 태그 → 설치 앱이 자동 업데이트로 수신 ✅
|
- 배포 경로: v0.1.3-alpha 태그 → 설치 앱이 자동 업데이트로 수신 ✅
|
||||||
- **v0.1.3-alpha 릴리스 완료** (파이프라인 #69747 전 잡 그린): Ollama v0.32.1 CPU 전용 가지치기 후 **119MB** (기존 ~1GB), 설치파일 735MB → **225MB**
|
- **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이 없어서였음
|
- **차등 다운로드도 이번엔 동작**: 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에서 유도 — 다음 릴리스에서 명시)
|
1. 설치 디렉토리명 `@d3rodesktop` 정리 (electron-builder가 package name에서 유도 — 다음 릴리스에서 명시)
|
||||||
|
|
|
||||||
|
|
@ -636,6 +636,9 @@
|
||||||
"onboarding.success": "Setup complete! You can now use the app.",
|
"onboarding.success": "Setup complete! You can now use the app.",
|
||||||
"onboarding.failed": "Download failed: {{message}}",
|
"onboarding.failed": "Download failed: {{message}}",
|
||||||
"onboarding.retry": "Retry",
|
"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.sttModelMissing": "Speech recognition model ({{model}}) is not installed.",
|
||||||
"onboarding.sttModelSize": "Size: ~1.6GB — takes 2–10 min depending on network speed",
|
"onboarding.sttModelSize": "Size: ~1.6GB — takes 2–10 min depending on network speed",
|
||||||
"onboarding.step": "Step {{current}}/{{total}}",
|
"onboarding.step": "Step {{current}}/{{total}}",
|
||||||
|
|
|
||||||
|
|
@ -636,6 +636,9 @@
|
||||||
"onboarding.success": "설치 완료! 이제 사용할 수 있습니다.",
|
"onboarding.success": "설치 완료! 이제 사용할 수 있습니다.",
|
||||||
"onboarding.failed": "다운로드 실패: {{message}}",
|
"onboarding.failed": "다운로드 실패: {{message}}",
|
||||||
"onboarding.retry": "재시도",
|
"onboarding.retry": "재시도",
|
||||||
|
"onboarding.statusPreparing": "다운로드 준비 중…",
|
||||||
|
"onboarding.statusVerifying": "무결성 검증 중… (모델이 크면 몇 분 걸릴 수 있어요)",
|
||||||
|
"onboarding.statusFinalizing": "마무리 중…",
|
||||||
"onboarding.sttModelMissing": "음성 인식 모델({{model}})이 설치되어 있지 않습니다.",
|
"onboarding.sttModelMissing": "음성 인식 모델({{model}})이 설치되어 있지 않습니다.",
|
||||||
"onboarding.sttModelSize": "크기: 약 1.6GB — 네트워크 속도에 따라 2~10분 소요",
|
"onboarding.sttModelSize": "크기: 약 1.6GB — 네트워크 속도에 따라 2~10분 소요",
|
||||||
"onboarding.step": "단계 {{current}}/{{total}}",
|
"onboarding.step": "단계 {{current}}/{{total}}",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue