fix(ollama): stop pull spinner on failure and only activate pulled models

This commit is contained in:
Yun Chan 2026-09-28 02:16:25 +09:00
parent ed8b585804
commit 6533084a0d
5 changed files with 613 additions and 68 deletions

View file

@ -43,6 +43,7 @@ import {
import { Led } from '@d3ro/ui/components/ds'
import { useI18n } from '@d3ro/i18n'
import type { LLMModel, LLMStatus } from '@d3ro/core/types'
import { useOllamaModelPull } from '../hooks/useOllamaModelPull'
export interface OllamaGuideModalProps {
open: boolean
@ -151,15 +152,6 @@ export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): Reac
const [starting, setStarting] = useState(false)
const [startMessage, setStartMessage] = useState<string | null>(null)
// Download state
const [pullingModelId, setPullingModelId] = useState<string | null>(null)
const [pullProgress, setPullProgress] = useState<{
status: string
percent: number
completed: number
total: number
}>({ status: '', percent: 0, completed: 0, total: 0 })
// Test prompt state
const [testPrompt, setTestPrompt] = useState('안녕하세요 오늘의 날씨를 알려줘')
const [testResult, setTestResult] = useState<string | null>(null)
@ -185,32 +177,24 @@ export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): Reac
}
}, [])
// 모델 받기 — 받기만 하고 선택은 별도 버튼(handleSelectModel)
const {
pullingModelId,
progress: pullProgress,
error: pullError,
pull,
select,
} = useOllamaModelPull({ enabled: open, onPulled: refreshStatus })
useEffect(() => {
if (!open) return
refreshStatus()
const unsubPull = window.electronAPI.llm.onPullProgress((e) => {
setPullingModelId(e.modelId)
setPullProgress({
status: e.status,
percent: e.percent,
completed: e.completed,
total: e.total,
})
if (e.percent >= 100 || e.status === 'success') {
setTimeout(() => {
setPullingModelId(null)
refreshStatus()
}, 1200)
}
})
const unsubStatus = window.electronAPI.llm.onStatusChanged((e) => {
setLlmStatus(e.status)
})
return () => {
unsubPull()
unsubStatus()
}
}, [open, refreshStatus])
@ -242,21 +226,13 @@ export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): Reac
}, [refreshStatus])
const handlePullModel = useCallback(async (modelId: string) => {
setPullingModelId(modelId)
setPullProgress({ status: '다운로드 시작 중...', percent: 0, completed: 0, total: 0 })
try {
await window.electronAPI.llm.pullModel({ modelId })
} catch {
setPullingModelId(null)
}
}, [])
await pull(modelId)
}, [pull])
const handleSelectModel = useCallback(async (modelId: string) => {
await window.electronAPI.llm.setModel({ modelId })
await window.electronAPI.config.set({ key: 'llmModelId', value: modelId })
setActiveModel(modelId)
if (await select(modelId)) setActiveModel(modelId)
await refreshStatus()
}, [refreshStatus])
}, [select, refreshStatus])
const handleRunTest = useCallback(async () => {
if (!testPrompt.trim()) return
@ -270,7 +246,7 @@ export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): Reac
if (res.success && res.data) {
setTestResult(res.data.processedText)
} else {
setTestResult(`오류: ${res.error?.message ?? '응답 실패'}`)
setTestResult(`오류: ${res.success ? '응답 실패' : res.error.message}`)
}
} catch (err) {
setTestResult(`실패: ${err instanceof Error ? err.message : String(err)}`)
@ -476,6 +452,14 @@ export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): Reac
음성 인식 후 텍스트 다듬기, 번역, 요약에 가장 안정적인 경량 AI 모델들입니다.
</Typography>
{pullError && (
<Alert severity="error" sx={{ py: 0.5, ml: 1 }}>
{pullError.stage === 'pull'
? `${pullError.modelId} — ${t('onboarding.failed', { message: pullError.message })}`
: `${pullError.modelId} — ${pullError.message}`}
</Alert>
)}
{/* 모델 카드 그리드 */}
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 1.5, pl: 1 }}>
{RECOMMENDED_MODELS.map((model) => {
@ -520,7 +504,7 @@ export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): Reac
<Box sx={{ mt: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<Typography sx={{ fontSize: '10px', color: d3roPalette.accent.light }}>
{pullProgress.status || '다운로드 중...'}
{pullProgress.status || t('onboarding.statusPreparing')}
</Typography>
<Typography sx={{ fontSize: '10px', fontFamily: d3roFontMono, color: d3roPalette.text.primary }}>
{pullProgress.percent}%

View file

@ -33,7 +33,9 @@ import {
} from 'lucide-react'
import { d3roPalette, d3roRadius, d3roShadow, typoSx, d3roFontMono, d3roFontSans } from '@d3ro/ui/theme'
import { Led } from '@d3ro/ui/components/ds'
import { useI18n } from '@d3ro/i18n'
import type { LLMModel, LLMStatus } from '@d3ro/core/types'
import { useOllamaModelPull } from '../hooks/useOllamaModelPull'
type Phase = 'select_mode' | 'local_ollama_setup' | 'online_auth' | 'success' | 'failed'
@ -43,6 +45,7 @@ interface OnboardingModalProps {
}
export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.ReactElement {
const { t } = useI18n()
const [internalOpen, setInternalOpen] = useState(false)
const [phase, setPhase] = useState<Phase>('select_mode')
@ -54,10 +57,6 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
const [startingOllama, setStartingOllama] = useState(false)
const [ollamaMsg, setOllamaMsg] = useState<string | null>(null)
// Model download state
const [pullingModel, setPullingModel] = useState<string | null>(null)
const [pullProgress, setPullProgress] = useState({ status: '', percent: 0 })
// Auth State
const [authTab, setAuthTab] = useState<'login' | 'register'>('login')
const [email, setEmail] = useState('')
@ -95,29 +94,25 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
})
}, [])
// 모델 받기 — 받기에 성공한 경우에만 활성 모델로 저장 (autoSelect)
const {
pullingModelId: pullingModel,
progress: pullProgress,
error: pullError,
pull,
} = useOllamaModelPull({ enabled: isVisible, autoSelect: true, onPulled: checkOllama })
useEffect(() => {
if (!isVisible) return
const unsubPull = window.electronAPI.llm.onPullProgress((e) => {
setPullingModel(e.modelId)
setPullProgress({ status: e.status, percent: e.percent })
if (e.percent >= 100 || e.status === 'success') {
setTimeout(() => {
setPullingModel(null)
checkOllama()
}, 1200)
}
})
const unsubStatus = window.electronAPI.llm.onStatusChanged((e) => {
setOllamaStatus(e.status)
})
return () => {
unsubPull()
unsubStatus()
}
}, [isVisible, checkOllama])
}, [isVisible])
const handleSelectLocalMode = async () => {
setPhase('local_ollama_setup')
@ -153,16 +148,8 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
}
const handlePullModel = async (modelId: string) => {
setPullingModel(modelId)
setPullProgress({ status: '다운로드 시작 중...', percent: 0 })
try {
await window.electronAPI.llm.pullModel({ modelId })
await window.electronAPI.llm.setModel({ modelId })
await window.electronAPI.config.set({ key: 'llmModelId', value: modelId })
setActiveModel(modelId)
} catch {
setPullingModel(null)
}
const outcome = await pull(modelId)
if (outcome === 'selected') setActiveModel(modelId)
}
const handleCompleteLocalSetup = async () => {
@ -485,7 +472,7 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
<Box sx={{ p: 2, bgcolor: d3roPalette.bg.inset, borderRadius: d3roRadius.small }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography sx={{ fontSize: '12px', color: d3roPalette.accent.light }}>
{pullProgress.status || '모델 다운로드 중...'}
{pullProgress.status || t('onboarding.statusPreparing')}
</Typography>
<Typography sx={{ fontSize: '12px', fontFamily: d3roFontMono, color: d3roPalette.text.primary }}>
{pullProgress.percent}%
@ -494,6 +481,14 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
<LinearProgress variant="determinate" value={pullProgress.percent} sx={{ height: 6, borderRadius: 3 }} />
</Box>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{pullError && (
<Alert severity="error" sx={{ py: 0.5 }}>
{pullError.stage === 'pull'
? t('onboarding.failed', { message: pullError.message })
: pullError.message}
</Alert>
)}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Button
variant="contained"
@ -516,6 +511,7 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
</Typography>
)}
</Box>
</Box>
)}
</Box>

View file

@ -0,0 +1,73 @@
// src/renderer/hooks/useOllamaModelPull.ts
// Ollama 모델 받기/선택 훅 — OnboardingModal·OllamaGuideModal 공용.
//
// 얇은 React 어댑터다:
// - 완료 판정·실패 처리·autoSelect 정책 → services/ollamaModelPullController.ts
// - IO → window.electronAPI (OllamaModelPullPort)
import { useEffect, useState, useSyncExternalStore } from 'react'
import {
OllamaModelPullController,
type OllamaModelPullPort,
type OllamaPullError,
type OllamaPullOutcome,
type OllamaPullProgress,
} from '../services/ollamaModelPullController'
export type { OllamaPullError, OllamaPullOutcome, OllamaPullProgress }
export interface UseOllamaModelPullOptions {
/** 모달이 보일 때만 pull-progress를 구독한다 */
enabled: boolean
/** 받기에 성공하면 곧바로 활성 모델로 선택·저장한다 */
autoSelect?: boolean
/** 받기 성공 후(표시 지연 뒤) 호출 — 연결/모델 목록 재조회 */
onPulled?: (modelId: string) => void
/** 테스트/스토리 주입용 */
port?: OllamaModelPullPort
}
export interface UseOllamaModelPullResult {
pullingModelId: string | null
progress: OllamaPullProgress
error: OllamaPullError | null
pull: (modelId: string) => Promise<OllamaPullOutcome>
select: (modelId: string) => Promise<boolean>
clearError: () => void
}
export function createElectronOllamaModelPullPort(): OllamaModelPullPort {
const api = window.electronAPI
return {
pullModel: (modelId) => api.llm.pullModel({ modelId }),
setModel: (modelId) => api.llm.setModel({ modelId }),
persistModelId: (modelId) => api.config.set({ key: 'llmModelId', value: modelId }),
onPullProgress: (cb) => api.llm.onPullProgress(cb),
}
}
export function useOllamaModelPull(options: UseOllamaModelPullOptions): UseOllamaModelPullResult {
const { enabled, autoSelect = false, onPulled, port } = options
const [controller] = useState(
() => new OllamaModelPullController(port ?? createElectronOllamaModelPullPort(), { autoSelect }),
)
const snapshot = useSyncExternalStore(controller.subscribe, controller.getSnapshot)
useEffect(() => {
controller.setOnPulled(onPulled ?? null)
}, [controller, onPulled])
useEffect(() => {
if (!enabled) return
return controller.attach()
}, [controller, enabled])
return {
pullingModelId: snapshot.pullingModelId,
progress: snapshot.progress,
error: snapshot.error,
pull: controller.pull,
select: controller.select,
clearError: controller.clearError,
}
}

View file

@ -0,0 +1,214 @@
// src/renderer/services/ollamaModelPullController.ts
// Ollama 모델 받기(pull)/선택 정책 컨트롤러 (React 비의존).
//
// - 완료 판정은 pullModel IPC 결과(IPCResult.success)로만 한다.
// pull-progress의 percent는 레이어(digest)마다 계산되므로 percent>=100을 완료로 보지 않는다.
// - main은 pull 실패를 throw하지 않고 {success:false}로 resolve한다. 결과를 확인해
// 실패하면 진행 상태를 즉시 풀고 error를 남긴다 (영구 '다운로드 중' 방지).
// - autoSelect면 받기에 성공한 경우에만 setModel + llmModelId 저장을 한다
// (받지 못한 모델이 활성 모델로 저장되지 않게).
// - 진행 이벤트는 이 컨트롤러가 시작한 진행 중 pull과 modelId가 같을 때만 반영한다.
// - 상태는 불변 스냅샷으로 노출해 useSyncExternalStore로 구독할 수 있다.
import type { IPCResult } from '@d3ro/core/errors'
export interface OllamaPullProgressEvent {
modelId: string
status: string
digest: string | null
total: number
completed: number
percent: number
}
export interface OllamaPullProgress {
status: string
percent: number
completed: number
total: number
}
/** IO 포트 — 기본 구현은 preload의 window.electronAPI (hooks/useOllamaModelPull.ts) */
export interface OllamaModelPullPort {
pullModel: (modelId: string) => Promise<IPCResult<void>>
setModel: (modelId: string) => Promise<IPCResult<void>>
/** config 'llmModelId' 저장 */
persistModelId: (modelId: string) => Promise<IPCResult<void>>
onPullProgress: (cb: (e: OllamaPullProgressEvent) => void) => () => void
}
export type OllamaPullErrorStage = 'pull' | 'select'
export interface OllamaPullError {
modelId: string
stage: OllamaPullErrorStage
/** main/IPC가 돌려준 오류 메시지 (표시 문구는 UI에서 t()로 감싼다) */
message: string
}
export interface OllamaModelPullSnapshot {
pullingModelId: string | null
progress: OllamaPullProgress
error: OllamaPullError | null
}
/**
* - 'pulled': 받기 성공 (autoSelect=false)
* - 'selected': 받기 + 활성 모델 저장 성공 (autoSelect=true)
* - 'failed': 받기 또는 선택 실패 (snapshot.error에 사유)
* - 'busy': 이미 다른 받기가 진행 중이라 무시함
*/
export type OllamaPullOutcome = 'pulled' | 'selected' | 'failed' | 'busy'
export interface OllamaModelPullControllerOptions {
/** 받기에 성공하면 곧바로 활성 모델로 선택·저장한다 (온보딩) */
autoSelect?: boolean
/** 성공 후 100% 표시를 유지했다가 진행 UI를 푸는 지연 (기본 1200ms) */
settleDelayMs?: number
/** 테스트 주입용 타이머 (기본 setTimeout) */
schedule?: (fn: () => void, ms: number) => void
}
export const OLLAMA_PULL_SETTLE_DELAY_MS = 1200
export const EMPTY_PULL_PROGRESS: OllamaPullProgress = Object.freeze({
status: '',
percent: 0,
completed: 0,
total: 0,
}) as OllamaPullProgress
const INITIAL_SNAPSHOT: OllamaModelPullSnapshot = Object.freeze({
pullingModelId: null,
progress: EMPTY_PULL_PROGRESS,
error: null,
}) as OllamaModelPullSnapshot
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err)
}
/** IPC 호출을 IPCResult로 정규화 — reject도 실패 결과로 바꾼다 */
async function settle(call: () => Promise<IPCResult<void>>): Promise<{ ok: true } | { ok: false; message: string }> {
try {
const res = await call()
if (res.success) return { ok: true }
return { ok: false, message: res.error.message }
} catch (err) {
return { ok: false, message: errorMessage(err) }
}
}
function clampPercent(percent: number): number {
if (!Number.isFinite(percent)) return 0
return Math.max(0, Math.min(100, Math.floor(percent)))
}
export class OllamaModelPullController {
private snapshot: OllamaModelPullSnapshot = INITIAL_SNAPSHOT
private readonly listeners = new Set<() => void>()
/** 이 컨트롤러가 시작해 아직 IPC 결과를 기다리는 pull */
private inFlightModelId: string | null = null
private onPulled: ((modelId: string) => void) | null = null
private readonly autoSelect: boolean
private readonly settleDelayMs: number
private readonly schedule: (fn: () => void, ms: number) => void
constructor(
private readonly port: OllamaModelPullPort,
options: OllamaModelPullControllerOptions = {},
) {
this.autoSelect = options.autoSelect ?? false
this.settleDelayMs = options.settleDelayMs ?? OLLAMA_PULL_SETTLE_DELAY_MS
this.schedule = options.schedule ?? ((fn, ms) => { setTimeout(fn, ms) })
}
// ── store (useSyncExternalStore) ──
subscribe = (listener: () => void): (() => void) => {
this.listeners.add(listener)
return () => {
this.listeners.delete(listener)
}
}
getSnapshot = (): OllamaModelPullSnapshot => this.snapshot
private update(patch: Partial<OllamaModelPullSnapshot>): void {
this.snapshot = { ...this.snapshot, ...patch }
for (const listener of this.listeners) listener()
}
/** 받기 완료(성공) 후 상태 재조회 콜백 — 훅이 매 렌더 최신 콜백으로 갱신한다 */
setOnPulled(cb: ((modelId: string) => void) | null): void {
this.onPulled = cb
}
/** pull-progress 구독. 반환값으로 해제한다 (모달이 보일 때만 붙인다). */
attach = (): (() => void) => this.port.onPullProgress(this.handleProgress)
private handleProgress = (e: OllamaPullProgressEvent): void => {
// 다른 곳에서 시작했거나 이미 결과가 난 pull의 늦은 이벤트는 무시 (스피너 부활 방지)
if (this.inFlightModelId === null || e.modelId !== this.inFlightModelId) return
this.update({
progress: {
status: e.status,
percent: clampPercent(e.percent),
completed: e.completed,
total: e.total,
},
})
}
clearError = (): void => {
if (this.snapshot.error) this.update({ error: null })
}
pull = async (modelId: string): Promise<OllamaPullOutcome> => {
if (this.snapshot.pullingModelId !== null) return 'busy'
this.inFlightModelId = modelId
this.update({ pullingModelId: modelId, progress: EMPTY_PULL_PROGRESS, error: null })
const pulled = await settle(() => this.port.pullModel(modelId))
this.inFlightModelId = null
if (!pulled.ok) {
this.update({
pullingModelId: null,
progress: EMPTY_PULL_PROGRESS,
error: { modelId, stage: 'pull', message: pulled.message },
})
return 'failed'
}
let outcome: OllamaPullOutcome = 'pulled'
if (this.autoSelect) {
outcome = (await this.select(modelId)) ? 'selected' : 'failed'
}
this.update({
progress: { ...this.snapshot.progress, status: 'success', percent: 100 },
})
this.schedule(() => {
if (this.snapshot.pullingModelId === modelId) {
this.update({ pullingModelId: null, progress: EMPTY_PULL_PROGRESS })
}
this.onPulled?.(modelId)
}, this.settleDelayMs)
return outcome
}
/** 활성 모델 선택·저장. 두 단계 모두 성공해야 true. */
select = async (modelId: string): Promise<boolean> => {
const set = await settle(() => this.port.setModel(modelId))
const saved = set.ok ? await settle(() => this.port.persistModelId(modelId)) : set
if (!saved.ok) {
this.update({ error: { modelId, stage: 'select', message: saved.message } })
return false
}
if (this.snapshot.error?.modelId === modelId) this.update({ error: null })
return true
}
}