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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue