fix(ollama): stop pull spinner on failure and only activate pulled models
This commit is contained in:
parent
ed8b585804
commit
6533084a0d
5 changed files with 613 additions and 68 deletions
|
|
@ -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}%
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
73
apps/desktop/src/renderer/hooks/useOllamaModelPull.ts
Normal file
73
apps/desktop/src/renderer/hooks/useOllamaModelPull.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
214
apps/desktop/src/renderer/services/ollamaModelPullController.ts
Normal file
214
apps/desktop/src/renderer/services/ollamaModelPullController.ts
Normal 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
|
||||
}
|
||||
}
|
||||
278
apps/desktop/tests/unit/ollama-model-pull-redteam-r2-34.test.ts
Normal file
278
apps/desktop/tests/unit/ollama-model-pull-redteam-r2-34.test.ts
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
// tests/unit/ollama-model-pull-redteam-r2-34.test.ts
|
||||
// Ollama 모델 받기: 실패 시 영구 '다운로드 중' + 받지 못한 모델 활성화 회귀 테스트.
|
||||
// node 환경에서 가짜 포트(electronAPI 대역)로 성공·실패·진행 이벤트를 검증한다.
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { ipcError, ipcSuccess, ErrorCode, type IPCResult } from '@d3ro/core/errors'
|
||||
import {
|
||||
EMPTY_PULL_PROGRESS,
|
||||
OllamaModelPullController,
|
||||
type OllamaModelPullPort,
|
||||
type OllamaPullProgressEvent,
|
||||
} from '../../src/renderer/services/ollamaModelPullController'
|
||||
|
||||
interface Deferred<T> {
|
||||
promise: Promise<T>
|
||||
resolve: (value: T) => void
|
||||
reject: (err: Error) => void
|
||||
}
|
||||
|
||||
function deferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (err: Error) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
interface FakePort extends OllamaModelPullPort {
|
||||
emit: (e: Partial<OllamaPullProgressEvent> & { modelId: string }) => void
|
||||
pullResult: Deferred<IPCResult<void>>
|
||||
pullCalls: string[]
|
||||
setModelCalls: string[]
|
||||
persistCalls: string[]
|
||||
listenerCount: () => number
|
||||
}
|
||||
|
||||
function makePort(overrides: Partial<OllamaModelPullPort> = {}): FakePort {
|
||||
const listeners = new Set<(e: OllamaPullProgressEvent) => void>()
|
||||
const port: FakePort = {
|
||||
pullResult: deferred<IPCResult<void>>(),
|
||||
pullCalls: [],
|
||||
setModelCalls: [],
|
||||
persistCalls: [],
|
||||
pullModel: (modelId) => {
|
||||
port.pullCalls.push(modelId)
|
||||
return port.pullResult.promise
|
||||
},
|
||||
setModel: async (modelId) => {
|
||||
port.setModelCalls.push(modelId)
|
||||
return ipcSuccess(undefined)
|
||||
},
|
||||
persistModelId: async (modelId) => {
|
||||
port.persistCalls.push(modelId)
|
||||
return ipcSuccess(undefined)
|
||||
},
|
||||
onPullProgress: (cb) => {
|
||||
listeners.add(cb)
|
||||
return () => {
|
||||
listeners.delete(cb)
|
||||
}
|
||||
},
|
||||
emit: (e) => {
|
||||
const full: OllamaPullProgressEvent = {
|
||||
status: 'pulling',
|
||||
digest: 'sha256:x',
|
||||
total: 100,
|
||||
completed: 0,
|
||||
percent: 0,
|
||||
...e,
|
||||
}
|
||||
for (const cb of listeners) cb(full)
|
||||
},
|
||||
listenerCount: () => listeners.size,
|
||||
...overrides,
|
||||
}
|
||||
return port
|
||||
}
|
||||
|
||||
/** 즉시 실행 스케줄러 — 표시 지연을 건너뛴다 */
|
||||
const immediate = (fn: () => void): void => fn()
|
||||
|
||||
describe('OllamaModelPullController — pull 실패 처리', () => {
|
||||
it('main이 {success:false}로 resolve하면 진행 상태를 풀고 오류를 남긴다 (영구 스피너 방지)', async () => {
|
||||
const port = makePort()
|
||||
const c = new OllamaModelPullController(port, { schedule: immediate })
|
||||
const detach = c.attach()
|
||||
|
||||
const p = c.pull('gemma4:e4b')
|
||||
expect(c.getSnapshot().pullingModelId).toBe('gemma4:e4b')
|
||||
port.emit({ modelId: 'gemma4:e4b', percent: 42, completed: 42 })
|
||||
expect(c.getSnapshot().progress.percent).toBe(42)
|
||||
|
||||
port.pullResult.resolve(ipcError(ErrorCode.LLMServerUnreachable, 'Pull failed: HTTP 500'))
|
||||
await expect(p).resolves.toBe('failed')
|
||||
|
||||
const snap = c.getSnapshot()
|
||||
expect(snap.pullingModelId).toBeNull()
|
||||
expect(snap.progress).toEqual(EMPTY_PULL_PROGRESS)
|
||||
expect(snap.error).toEqual({ modelId: 'gemma4:e4b', stage: 'pull', message: 'Pull failed: HTTP 500' })
|
||||
detach()
|
||||
})
|
||||
|
||||
it('autoSelect여도 받기에 실패하면 setModel/llmModelId 저장을 하지 않는다', async () => {
|
||||
const port = makePort()
|
||||
const c = new OllamaModelPullController(port, { autoSelect: true, schedule: immediate })
|
||||
|
||||
const p = c.pull('gemma4:e4b')
|
||||
port.pullResult.resolve(ipcError(ErrorCode.LLMServerUnreachable, 'Pull failed: disk full'))
|
||||
await expect(p).resolves.toBe('failed')
|
||||
|
||||
expect(port.setModelCalls).toEqual([])
|
||||
expect(port.persistCalls).toEqual([])
|
||||
})
|
||||
|
||||
it('IPC 호출이 reject되어도 실패로 처리하고 진행 상태를 푼다', async () => {
|
||||
const port = makePort()
|
||||
const c = new OllamaModelPullController(port, { autoSelect: true, schedule: immediate })
|
||||
|
||||
const p = c.pull('llama3.2:3b')
|
||||
port.pullResult.reject(new Error('ipc gone'))
|
||||
await expect(p).resolves.toBe('failed')
|
||||
expect(c.getSnapshot().pullingModelId).toBeNull()
|
||||
expect(c.getSnapshot().error?.message).toBe('ipc gone')
|
||||
expect(port.setModelCalls).toEqual([])
|
||||
})
|
||||
|
||||
it('실패 후 다시 받기를 시작할 수 있고, 새 시도는 이전 오류를 지운다', async () => {
|
||||
const port = makePort()
|
||||
const c = new OllamaModelPullController(port, { schedule: immediate })
|
||||
const first = c.pull('phi4')
|
||||
port.pullResult.resolve(ipcError(ErrorCode.LLMServerUnreachable, 'boom'))
|
||||
await first
|
||||
|
||||
port.pullResult = deferred<IPCResult<void>>()
|
||||
const second = c.pull('phi4')
|
||||
expect(c.getSnapshot().error).toBeNull()
|
||||
expect(c.getSnapshot().pullingModelId).toBe('phi4')
|
||||
port.pullResult.resolve(ipcSuccess(undefined))
|
||||
await expect(second).resolves.toBe('pulled')
|
||||
expect(port.pullCalls).toEqual(['phi4', 'phi4'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('OllamaModelPullController — 성공/진행', () => {
|
||||
it('autoSelect면 받기 성공 후에만 setModel → llmModelId 저장 순으로 선택한다', async () => {
|
||||
const port = makePort()
|
||||
const onPulled = vi.fn()
|
||||
const c = new OllamaModelPullController(port, { autoSelect: true, schedule: immediate })
|
||||
c.setOnPulled(onPulled)
|
||||
|
||||
const p = c.pull('gemma4:e4b')
|
||||
expect(port.setModelCalls).toEqual([])
|
||||
port.pullResult.resolve(ipcSuccess(undefined))
|
||||
await expect(p).resolves.toBe('selected')
|
||||
|
||||
expect(port.setModelCalls).toEqual(['gemma4:e4b'])
|
||||
expect(port.persistCalls).toEqual(['gemma4:e4b'])
|
||||
expect(c.getSnapshot().pullingModelId).toBeNull()
|
||||
expect(onPulled).toHaveBeenCalledWith('gemma4:e4b')
|
||||
})
|
||||
|
||||
it('autoSelect=false면 받기만 하고 선택하지 않는다', async () => {
|
||||
const port = makePort()
|
||||
const c = new OllamaModelPullController(port, { schedule: immediate })
|
||||
const p = c.pull('qwen2.5:3b')
|
||||
port.pullResult.resolve(ipcSuccess(undefined))
|
||||
await expect(p).resolves.toBe('pulled')
|
||||
expect(port.setModelCalls).toEqual([])
|
||||
expect(port.persistCalls).toEqual([])
|
||||
})
|
||||
|
||||
it('레이어 하나가 percent 100에 도달해도 완료로 보지 않는다 (IPC 결과로만 판정)', async () => {
|
||||
const port = makePort()
|
||||
const schedule = vi.fn()
|
||||
const c = new OllamaModelPullController(port, { schedule })
|
||||
c.attach()
|
||||
|
||||
const p = c.pull('gemma4:e4b')
|
||||
port.emit({ modelId: 'gemma4:e4b', digest: 'sha256:small', percent: 100, completed: 100 })
|
||||
port.emit({ modelId: 'gemma4:e4b', status: 'success', percent: 100 })
|
||||
expect(c.getSnapshot().pullingModelId).toBe('gemma4:e4b')
|
||||
expect(schedule).not.toHaveBeenCalled()
|
||||
|
||||
port.emit({ modelId: 'gemma4:e4b', digest: 'sha256:big', percent: 3, completed: 3 })
|
||||
expect(c.getSnapshot().progress.percent).toBe(3)
|
||||
|
||||
port.pullResult.resolve(ipcSuccess(undefined))
|
||||
await p
|
||||
// 성공 후 100%를 잠시 보여 주고 지연 뒤 해제
|
||||
expect(c.getSnapshot().progress.percent).toBe(100)
|
||||
expect(c.getSnapshot().pullingModelId).toBe('gemma4:e4b')
|
||||
expect(schedule).toHaveBeenCalledTimes(1)
|
||||
const [fn, ms] = schedule.mock.calls[0] as [() => void, number]
|
||||
expect(ms).toBe(1200)
|
||||
fn()
|
||||
expect(c.getSnapshot().pullingModelId).toBeNull()
|
||||
})
|
||||
|
||||
it('다른 모델이나 결과가 난 뒤의 늦은 진행 이벤트는 무시한다', async () => {
|
||||
const port = makePort()
|
||||
const c = new OllamaModelPullController(port, { schedule: immediate })
|
||||
c.attach()
|
||||
|
||||
port.emit({ modelId: 'phi4', percent: 50 })
|
||||
expect(c.getSnapshot().pullingModelId).toBeNull()
|
||||
|
||||
const p = c.pull('gemma4:e4b')
|
||||
port.emit({ modelId: 'phi4', percent: 77 })
|
||||
expect(c.getSnapshot().progress.percent).toBe(0)
|
||||
|
||||
port.pullResult.resolve(ipcError(ErrorCode.LLMServerUnreachable, 'x'))
|
||||
await p
|
||||
port.emit({ modelId: 'gemma4:e4b', percent: 60 })
|
||||
expect(c.getSnapshot().pullingModelId).toBeNull()
|
||||
expect(c.getSnapshot().progress).toEqual(EMPTY_PULL_PROGRESS)
|
||||
})
|
||||
|
||||
it('진행 중에 또 받기를 요청하면 busy로 무시한다', async () => {
|
||||
const port = makePort()
|
||||
const c = new OllamaModelPullController(port, { schedule: immediate })
|
||||
const first = c.pull('gemma4:e4b')
|
||||
await expect(c.pull('phi4')).resolves.toBe('busy')
|
||||
expect(port.pullCalls).toEqual(['gemma4:e4b'])
|
||||
port.pullResult.resolve(ipcSuccess(undefined))
|
||||
await first
|
||||
})
|
||||
|
||||
it('attach 해제 후에는 구독이 남지 않는다', () => {
|
||||
const port = makePort()
|
||||
const c = new OllamaModelPullController(port)
|
||||
const detach = c.attach()
|
||||
expect(port.listenerCount()).toBe(1)
|
||||
detach()
|
||||
expect(port.listenerCount()).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('OllamaModelPullController — select', () => {
|
||||
it('setModel이 실패하면 llmModelId를 저장하지 않고 false + select 오류', async () => {
|
||||
const port = makePort({
|
||||
setModel: async () => ipcError(ErrorCode.LLMServerUnreachable, 'set failed'),
|
||||
})
|
||||
const c = new OllamaModelPullController(port)
|
||||
await expect(c.select('phi4')).resolves.toBe(false)
|
||||
expect(port.persistCalls).toEqual([])
|
||||
expect(c.getSnapshot().error).toEqual({ modelId: 'phi4', stage: 'select', message: 'set failed' })
|
||||
})
|
||||
|
||||
it('autoSelect 선택 단계가 실패하면 pull 결과는 failed이고 진행 상태는 풀린다', async () => {
|
||||
const port = makePort({
|
||||
persistModelId: async () => ipcError(ErrorCode.LLMServerUnreachable, 'config write failed'),
|
||||
})
|
||||
const c = new OllamaModelPullController(port, { autoSelect: true, schedule: immediate })
|
||||
const p = c.pull('gemma4:e4b')
|
||||
port.pullResult.resolve(ipcSuccess(undefined))
|
||||
await expect(p).resolves.toBe('failed')
|
||||
expect(c.getSnapshot().pullingModelId).toBeNull()
|
||||
expect(c.getSnapshot().error?.stage).toBe('select')
|
||||
})
|
||||
|
||||
it('구독자에게 상태 변경을 알린다', async () => {
|
||||
const port = makePort()
|
||||
const c = new OllamaModelPullController(port, { schedule: immediate })
|
||||
const listener = vi.fn()
|
||||
const unsub = c.subscribe(listener)
|
||||
const p = c.pull('gemma4:e4b')
|
||||
expect(listener).toHaveBeenCalled()
|
||||
port.pullResult.resolve(ipcSuccess(undefined))
|
||||
await p
|
||||
unsub()
|
||||
const calls = listener.mock.calls.length
|
||||
c.clearError()
|
||||
await c.select('gemma4:e4b')
|
||||
expect(listener.mock.calls.length).toBe(calls)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue