Compare commits

...

5 Commits

Author SHA1 Message Date
Benjamin Lu
c9f4c8e0ea Fix missing model download id tracking 2026-05-05 02:13:35 -07:00
Benjamin Lu
41c03034a1 fix: correlate desktop missing model downloads by id 2026-05-04 08:21:01 -07:00
Benjamin Lu
7e16c0a815 fix: handle missing model desktop download states 2026-05-03 20:13:54 -07:00
Benjamin Lu
6a60a26cb3 fix: limit missing model download refs to desktop 2026-05-03 04:30:08 -07:00
Benjamin Lu
eaa09fa92c Fix missing model direct download state 2026-05-03 03:41:15 -07:00
19 changed files with 1394 additions and 108 deletions

View File

@@ -3,7 +3,7 @@
<div>
{{ getDownloadLabel(download.savePath ?? '') }}
</div>
<div v-if="['cancelled', 'error'].includes(download.status ?? '')">
<div v-if="['cancelled', 'failed'].includes(download.status)">
<Chip
class="mt-2 h-6 bg-red-700 text-sm font-light"
removable
@@ -14,7 +14,7 @@
</div>
<div
v-if="
['in_progress', 'paused', 'completed'].includes(download.status ?? '')
['created', 'running', 'paused', 'completed'].includes(download.status)
"
class="mt-2 flex flex-row items-center gap-2"
>
@@ -28,7 +28,7 @@
/>
<Button
v-if="download.status === 'in_progress'"
v-if="download.status === 'running'"
v-tooltip.top="t('electronFileDownload.pause')"
class="size-[22px] rounded-full"
variant="secondary"
@@ -52,7 +52,7 @@
</Button>
<Button
v-if="['in_progress', 'paused'].includes(download.status ?? '')"
v-if="['running', 'paused'].includes(download.status)"
v-tooltip.top="t('electronFileDownload.cancel')"
class="size-[22px] rounded-full"
variant="destructive"
@@ -91,17 +91,21 @@ const getDownloadLabel = (savePath: string) => {
return `${dir}/${name}`
}
const getDownloadIdentifier = () =>
props.download.downloadId ?? props.download.url
const triggerCancelDownload = () =>
electronDownloadStore.cancel(props.download.url)
electronDownloadStore.cancel(getDownloadIdentifier())
const triggerPauseDownload = () =>
electronDownloadStore.pause(props.download.url)
electronDownloadStore.pause(getDownloadIdentifier())
const triggerResumeDownload = () =>
electronDownloadStore.resume(props.download.url)
electronDownloadStore.resume(getDownloadIdentifier())
const handleRemoveDownload = () => {
electronDownloadStore.$patch((state) => {
state.downloads = state.downloads.filter(
({ url }) => url !== props.download.url
(download) =>
(download.downloadId ?? download.url) !== getDownloadIdentifier()
)
})
}

View File

@@ -4,7 +4,10 @@
{{ $t('electronFileDownload.inProgress') }}
</div>
<template v-for="download in inProgressDownloads" :key="download.url">
<template
v-for="download in inProgressDownloads"
:key="download.downloadId ?? download.url"
>
<DownloadItem :download="download" />
</template>
</div>

View File

@@ -0,0 +1,13 @@
export type DownloadLifecycleStatus =
| 'created'
| 'running'
| 'paused'
| 'completed'
| 'failed'
| 'cancelled'
export interface DownloadLifecycleState {
progress: number
status: DownloadLifecycleStatus
error?: string
}

View File

@@ -1,5 +1,5 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import { render, screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import PrimeVue from 'primevue/config'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -23,13 +23,23 @@ vi.mock('./MissingModelRow.vue', () => ({
}
}))
const mockDownloadModel = vi.hoisted(() => vi.fn())
const mockIsCloud = vi.hoisted(() => ({ value: true }))
const mockIsDesktop = vi.hoisted(() => ({ value: false }))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return mockIsCloud.value
},
get isDesktop() {
return mockIsDesktop.value
}
}))
vi.mock('@/platform/missingModel/missingModelDownload', () => ({
downloadModel: (...args: unknown[]) => mockDownloadModel(...args),
isModelDownloadable: () => true
}))
import MissingModelCard from './MissingModelCard.vue'
const i18n = createI18n({
@@ -127,6 +137,9 @@ function mountCard(
describe('MissingModelCard', () => {
beforeEach(() => {
mockIsCloud.value = true
mockIsDesktop.value = false
mockDownloadModel.mockReset()
mockDownloadModel.mockResolvedValue({ started: true })
})
describe('Rendering & Props', () => {
@@ -244,6 +257,9 @@ describe('MissingModelCard', () => {
describe('MissingModelCard (OSS)', () => {
beforeEach(() => {
mockIsCloud.value = false
mockIsDesktop.value = false
mockDownloadModel.mockReset()
mockDownloadModel.mockResolvedValue({ started: true })
})
afterEach(() => {
@@ -326,4 +342,83 @@ describe('MissingModelCard (OSS)', () => {
'Refreshing missing models.'
)
})
it('tracks each successfully started desktop download from Download all', async () => {
mockIsDesktop.value = true
mockDownloadModel
.mockResolvedValueOnce({
started: true,
downloadId: '/models/checkpoints/a.safetensors'
})
.mockResolvedValueOnce({ started: false })
.mockResolvedValueOnce({
started: true,
downloadId: '/models/checkpoints/c.safetensors'
})
mountCard({
missingModelGroups: [
makeGroup({
isAssetSupported: false,
withDownloadUrls: true,
modelNames: ['a.safetensors', 'b.safetensors', 'c.safetensors']
})
]
})
const store = useMissingModelStore()
store.folderPaths = { checkpoints: ['/models/checkpoints'] }
await userEvent.click(screen.getByRole('button', { name: /Download all/ }))
await waitFor(() => expect(mockDownloadModel).toHaveBeenCalledTimes(3))
expect(
store.downloadRefs['unsupported::checkpoints::a.safetensors']
).toEqual({
kind: 'electron-download',
downloadId: '/models/checkpoints/a.safetensors',
url: 'https://huggingface.co/comfy/test/resolve/main/a.safetensors'
})
expect(
store.selectedLibraryModel['unsupported::checkpoints::a.safetensors']
).toBe('a.safetensors')
expect(
store.downloadRefs['unsupported::checkpoints::b.safetensors']
).toBeUndefined()
expect(
store.selectedLibraryModel['unsupported::checkpoints::b.safetensors']
).toBeUndefined()
expect(
store.downloadRefs['unsupported::checkpoints::c.safetensors']
).toEqual({
kind: 'electron-download',
downloadId: '/models/checkpoints/c.safetensors',
url: 'https://huggingface.co/comfy/test/resolve/main/c.safetensors'
})
expect(
store.selectedLibraryModel['unsupported::checkpoints::c.safetensors']
).toBe('c.safetensors')
})
it('does not create desktop tracking state for browser Download all starts', async () => {
mountCard({
missingModelGroups: [
makeGroup({
isAssetSupported: false,
withDownloadUrls: true,
modelNames: ['a.safetensors']
})
]
})
const store = useMissingModelStore()
await userEvent.click(screen.getByRole('button', { name: /Download all/ }))
await waitFor(() => expect(mockDownloadModel).toHaveBeenCalledTimes(1))
expect(
store.downloadRefs['unsupported::checkpoints::a.safetensors']
).toBeUndefined()
expect(
store.selectedLibraryModel['unsupported::checkpoints::a.safetensors']
).toBeUndefined()
})
})

View File

@@ -1,7 +1,7 @@
<template>
<div class="px-4 pb-2">
<div
v-if="downloadableModels.length > 0"
v-if="downloadableModelEntries.length > 0"
data-testid="missing-model-actions"
class="flex items-center gap-2 border-b border-interface-stroke py-2"
>
@@ -117,12 +117,13 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { MissingModelGroup } from '@/platform/missingModel/types'
import { isCloud } from '@/platform/distribution/types'
import { isCloud, isDesktop } from '@/platform/distribution/types'
import MissingModelRow from '@/platform/missingModel/components/MissingModelRow.vue'
import Button from '@/components/ui/button/Button.vue'
import DotSpinner from '@/components/common/DotSpinner.vue'
import { downloadModel } from '@/platform/missingModel/missingModelDownload'
import { getDownloadableModels } from '@/platform/missingModel/missingModelViewUtils'
import type { ModelWithUrl } from '@/platform/missingModel/missingModelDownload'
import { getDownloadableModelEntries } from '@/platform/missingModel/missingModelViewUtils'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { formatSize } from '@/utils/formatUtil'
@@ -138,27 +139,47 @@ const emit = defineEmits<{
const { t } = useI18n()
const missingModelStore = useMissingModelStore()
const downloadableModels = computed(() => {
const downloadableModelEntries = computed(() => {
if (isCloud) return []
return getDownloadableModels(missingModelGroups)
return getDownloadableModelEntries(missingModelGroups)
})
const downloadAllLabel = computed(() => {
const base = t('rightSidePanel.missingModels.downloadAll')
const total = downloadableModels.value.reduce(
(sum, model) => sum + (missingModelStore.fileSizes[model.url] ?? 0),
const total = downloadableModelEntries.value.reduce(
(sum, { model }) => sum + (missingModelStore.fileSizes[model.url] ?? 0),
0
)
return total > 0 ? `${base} (${formatSize(total)})` : base
})
function downloadAllModels() {
for (const model of downloadableModels.value) {
downloadModel(model, missingModelStore.folderPaths)
async function downloadAndTrackModel({
key,
model
}: {
key: string
model: ModelWithUrl
}) {
try {
const result = await downloadModel(model, missingModelStore.folderPaths)
if (!result.started || !isDesktop) return
missingModelStore.downloadRefs[key] = {
kind: 'electron-download',
...(result.downloadId ? { downloadId: result.downloadId } : {}),
url: model.url
}
missingModelStore.selectedLibraryModel[key] = model.name
} catch (error: unknown) {
console.warn('[MissingModelCard] Failed to start model download:', error)
}
}
async function downloadAllModels() {
await Promise.all(downloadableModelEntries.value.map(downloadAndTrackModel))
}
function handleRefreshClick() {
void missingModelStore.refreshMissingModels()
}

View File

@@ -0,0 +1,313 @@
import { render, screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import type {
MissingModelDownloadStatus,
MissingModelViewModel
} from '@/platform/missingModel/types'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
const mockDownloadModel = vi.hoisted(() => vi.fn())
const mockFetchModelMetadata = vi.hoisted(() => vi.fn())
const mockCopyToClipboard = vi.hoisted(() => vi.fn())
const mockIsDesktop = vi.hoisted(() => ({ value: false }))
const mockDownloadStatuses = vi.hoisted(
() => new Map<string, MissingModelDownloadStatus | null>()
)
vi.mock('@/platform/distribution/types', () => ({
isCloud: false,
get isDesktop() {
return mockIsDesktop.value
}
}))
vi.mock('@/scripts/app', () => ({
app: {
rootGraph: null
}
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({})
}))
vi.mock('@/composables/useCopyToClipboard', () => ({
useCopyToClipboard: () => ({
copyToClipboard: mockCopyToClipboard
})
}))
vi.mock('@/components/rightSidePanel/layout/TransitionCollapse.vue', () => ({
default: {
name: 'TransitionCollapse',
template: '<div><slot /></div>'
}
}))
vi.mock(
'@/platform/missingModel/components/MissingModelStatusCard.vue',
() => ({
default: {
name: 'MissingModelStatusCard',
props: ['downloadStatus', 'canCancelSelection'],
template:
'<div data-testid="missing-model-status-card" :data-can-cancel-selection="canCancelSelection ? \'true\' : \'false\'">{{ downloadStatus?.status ?? "none" }}</div>'
}
})
)
vi.mock('@/platform/missingModel/components/MissingModelUrlInput.vue', () => ({
default: {
name: 'MissingModelUrlInput',
template: '<div data-testid="missing-model-url-input" />'
}
}))
vi.mock(
'@/platform/missingModel/components/MissingModelLibrarySelect.vue',
() => ({
default: {
name: 'MissingModelLibrarySelect',
emits: ['select'],
template:
'<button data-testid="missing-model-library-select" @click="$emit(\'select\', \'library-model.safetensors\')">Select</button>'
}
})
)
vi.mock(
'@/platform/missingModel/composables/useMissingModelInteractions',
() => ({
getModelStateKey: (
modelName: string,
directory: string | null,
isAssetSupported: boolean
) =>
`${isAssetSupported ? 'supported' : 'unsupported'}::${directory ?? ''}::${modelName}`,
getNodeDisplayLabel: (nodeId: string | number) => `Node ${nodeId}`,
getComboValue: () => undefined,
useMissingModelInteractions: () => {
const store = useMissingModelStore()
return {
toggleModelExpand: vi.fn(),
isModelExpanded: () => false,
getComboOptions: () => [],
handleComboSelect: (key: string, value: string | undefined) => {
if (value) {
store.selectedLibraryModel[key] = value
}
},
isSelectionConfirmable: () => false,
cancelLibrarySelect: (key: string) => {
delete store.selectedLibraryModel[key]
delete store.importCategoryMismatch[key]
delete store.downloadRefs[key]
},
confirmLibrarySelect: vi.fn(),
getTypeMismatch: () => null,
getDownloadStatus: (key: string) => {
if (mockDownloadStatuses.has(key)) {
return mockDownloadStatuses.get(key) ?? null
}
return store.downloadRefs[key]?.kind === 'electron-download'
? { progress: 0, status: 'created' as const }
: null
}
}
}
})
)
vi.mock('@/platform/missingModel/missingModelDownload', () => ({
downloadModel: (...args: unknown[]) => mockDownloadModel(...args),
fetchModelMetadata: (...args: unknown[]) => mockFetchModelMetadata(...args),
isModelDownloadable: () => true,
toBrowsableUrl: (url: string) => url
}))
import MissingModelRow from './MissingModelRow.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
download: 'Download'
},
rightSidePanel: {
missingModels: {
copyModelName: 'Copy model name',
copyUrl: 'Copy URL',
confirmSelection: 'Confirm selection',
collapseNodes: 'Collapse nodes',
expandNodes: 'Expand nodes'
}
}
}
},
missingWarn: false,
fallbackWarn: false
})
const model: MissingModelViewModel = {
name: 'z_image_turbo_bf16.safetensors',
representative: {
name: 'z_image_turbo_bf16.safetensors',
url: 'https://example.com/z_image_turbo_bf16.safetensors',
directory: 'checkpoints',
nodeId: '1',
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
isAssetSupported: false,
isMissing: true
},
referencingNodes: [{ nodeId: '1', widgetName: 'ckpt_name' }]
}
const modelKey = 'unsupported::checkpoints::z_image_turbo_bf16.safetensors'
function renderComponent() {
return render(MissingModelRow, {
props: {
model,
directory: 'checkpoints',
showNodeIdBadge: false,
isAssetSupported: false
},
global: {
plugins: [i18n]
}
})
}
describe('MissingModelRow', () => {
beforeEach(() => {
setActivePinia(createPinia())
mockDownloadModel.mockReset()
mockDownloadModel.mockResolvedValue({
started: true,
downloadId: '/models/checkpoints/z_image_turbo_bf16.safetensors'
})
mockFetchModelMetadata.mockReset()
mockFetchModelMetadata.mockResolvedValue({
fileSize: null,
gatedRepoUrl: null
})
mockCopyToClipboard.mockReset()
mockIsDesktop.value = false
mockDownloadStatuses.clear()
const store = useMissingModelStore()
store.folderPaths = {
checkpoints: ['/models/checkpoints']
}
})
it('tracks and surfaces direct Electron downloads immediately after the button is clicked', async () => {
mockIsDesktop.value = true
const user = userEvent.setup()
const store = useMissingModelStore()
renderComponent()
await user.click(screen.getByTestId('missing-model-download'))
expect(mockDownloadModel).toHaveBeenCalledWith(
{
name: model.representative.name,
url: model.representative.url,
directory: model.representative.directory
},
store.folderPaths
)
expect(store.downloadRefs[modelKey]).toEqual({
kind: 'electron-download',
downloadId: '/models/checkpoints/z_image_turbo_bf16.safetensors',
url: model.representative.url
})
expect(store.selectedLibraryModel[modelKey]).toBe(model.representative.name)
expect(screen.getByTestId('missing-model-status-card')).toHaveTextContent(
'created'
)
expect(screen.getByTestId('missing-model-status-card')).toHaveAttribute(
'data-can-cancel-selection',
'false'
)
})
it('does not track browser downloads as installed library selections outside desktop', async () => {
const user = userEvent.setup()
const store = useMissingModelStore()
renderComponent()
await user.click(screen.getByTestId('missing-model-download'))
expect(mockDownloadModel).toHaveBeenCalled()
expect(store.downloadRefs[modelKey]).toBeUndefined()
expect(store.selectedLibraryModel[modelKey]).toBeUndefined()
expect(
screen.queryByTestId('missing-model-status-card')
).not.toBeInTheDocument()
})
it('does not create UI state when the Electron download does not start', async () => {
mockIsDesktop.value = true
mockDownloadModel.mockResolvedValue({ started: false })
const user = userEvent.setup()
const store = useMissingModelStore()
renderComponent()
await user.click(screen.getByTestId('missing-model-download'))
expect(store.downloadRefs[modelKey]).toBeUndefined()
expect(store.selectedLibraryModel[modelKey]).toBeUndefined()
expect(
screen.queryByTestId('missing-model-status-card')
).not.toBeInTheDocument()
})
it('clears stale download refs when the user picks a library alternative', async () => {
const user = userEvent.setup()
const store = useMissingModelStore()
store.downloadRefs[modelKey] = {
kind: 'electron-download',
url: model.representative.url!
}
renderComponent()
await user.click(screen.getByTestId('missing-model-library-select'))
expect(store.downloadRefs[modelKey]).toBeUndefined()
expect(store.selectedLibraryModel[modelKey]).toBe(
'library-model.safetensors'
)
})
it('returns to download controls when a tracked Electron download disappears', async () => {
const store = useMissingModelStore()
store.selectedLibraryModel[modelKey] = model.representative.name
store.downloadRefs[modelKey] = {
kind: 'electron-download',
downloadId: '/models/checkpoints/z_image_turbo_bf16.safetensors',
url: model.representative.url!
}
mockDownloadStatuses.set(modelKey, null)
renderComponent()
await waitFor(() => {
expect(store.selectedLibraryModel[modelKey]).toBeUndefined()
})
expect(store.downloadRefs[modelKey]).toBeUndefined()
expect(screen.getByTestId('missing-model-download')).toBeInTheDocument()
expect(
screen.queryByTestId('missing-model-status-card')
).not.toBeInTheDocument()
})
})

View File

@@ -130,6 +130,7 @@
:is-download-active="isDownloadActive"
:download-status="downloadStatus"
:category-mismatch="importCategoryMismatch[modelKey]"
:can-cancel-selection="!isActiveElectronDownload"
@cancel="cancelLibrarySelect(modelKey)"
/>
</TransitionCollapse>
@@ -175,7 +176,7 @@
:model-value="getComboValue(model.representative)"
:options="comboOptions"
:show-divider="isAssetSupported || downloadable"
@select="handleComboSelect(modelKey, $event)"
@select="handleLibraryModelSelect"
/>
</TransitionCollapse>
</div>
@@ -184,7 +185,7 @@
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
@@ -203,7 +204,7 @@ import {
} from '@/platform/missingModel/composables/useMissingModelInteractions'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { isCloud } from '@/platform/distribution/types'
import { isCloud, isDesktop } from '@/platform/distribution/types'
import {
downloadModel,
fetchModelMetadata,
@@ -225,6 +226,9 @@ const emit = defineEmits<{
const { t } = useI18n()
const { copyToClipboard } = useCopyToClipboard()
const store = useMissingModelStore()
const { selectedLibraryModel, importCategoryMismatch, urlInputs } =
storeToRefs(store)
const modelKey = computed(() =>
getModelStateKey(model.name, directory, isAssetSupported)
@@ -235,15 +239,19 @@ const comboOptions = computed(() => getComboOptions(model.representative))
const canConfirm = computed(() => isSelectionConfirmable(modelKey.value))
const expanded = computed(() => isModelExpanded(modelKey.value))
const typeMismatch = computed(() => getTypeMismatch(modelKey.value, directory))
const hasSeenElectronDownloadStatus = ref(false)
const isDownloadActive = computed(
() =>
downloadStatus.value?.status === 'running' ||
downloadStatus.value?.status === 'created'
)
const isActiveElectronDownload = computed(() => {
const downloadRef = store.downloadRefs[modelKey.value]
if (downloadRef?.kind !== 'electron-download') return false
const store = useMissingModelStore()
const { selectedLibraryModel, importCategoryMismatch, urlInputs } =
storeToRefs(store)
const status = downloadStatus.value?.status
return status === 'created' || status === 'running' || status === 'paused'
})
onMounted(() => {
const url = model.representative.url
@@ -284,18 +292,32 @@ const downloadLabel = computed(() => {
return size ? `${base} (${formatSize(size)})` : base
})
function handleDownload() {
async function handleDownload() {
const rep = model.representative
if (rep.url && rep.directory) {
downloadModel(
const result = await downloadModel(
{ name: rep.name, url: rep.url, directory: rep.directory },
store.folderPaths
)
if (result.started && isDesktop) {
store.downloadRefs[modelKey.value] = {
kind: 'electron-download',
...(result.downloadId ? { downloadId: result.downloadId } : {}),
url: rep.url
}
handleComboSelect(modelKey.value, rep.name)
}
} else {
console.warn('[MissingModelRow] Cannot download: missing url or directory')
}
}
function handleLibraryModelSelect(value: string | undefined) {
delete store.downloadRefs[modelKey.value]
handleComboSelect(modelKey.value, value)
}
const {
toggleModelExpand,
isModelExpanded,
@@ -308,6 +330,27 @@ const {
getDownloadStatus
} = useMissingModelInteractions()
watch(
() => ({
downloadRef: store.downloadRefs[modelKey.value],
downloadStatus: downloadStatus.value
}),
({ downloadRef, downloadStatus }) => {
if (downloadRef?.kind !== 'electron-download') {
hasSeenElectronDownloadStatus.value = false
return
}
if (downloadStatus) {
hasSeenElectronDownloadStatus.value = true
return
}
if (downloadRef.downloadId || hasSeenElectronDownloadStatus.value) {
cancelLibrarySelect(modelKey.value)
}
},
{ immediate: true }
)
function handleLibrarySelect() {
confirmLibrarySelect(
modelKey.value,

View File

@@ -0,0 +1,65 @@
import { render, screen } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import MissingModelStatusCard from './MissingModelStatusCard.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
electronFileDownload: {
cancelled: 'Cancelled',
paused: 'Paused'
},
rightSidePanel: {
missingModels: {
alreadyExistsInCategory: 'Already exists in {category}',
cancelSelection: 'Cancel selection',
imported: 'Imported',
importing: 'Importing',
importFailed: 'Import failed',
usingFromLibrary: 'Using from library'
}
}
}
},
missingWarn: false,
fallbackWarn: false
})
function renderComponent(canCancelSelection = true) {
return render(MissingModelStatusCard, {
props: {
modelName: 'model.safetensors',
isDownloadActive: true,
downloadStatus: {
progress: 0.4,
status: 'running'
},
canCancelSelection
},
global: {
plugins: [i18n]
}
})
}
describe('MissingModelStatusCard', () => {
it('shows the cancel selection control by default', () => {
renderComponent()
expect(
screen.getByRole('button', { name: 'Cancel selection' })
).toBeVisible()
})
it('hides the cancel selection control when cancellation is disabled', () => {
renderComponent(false)
expect(
screen.queryByRole('button', { name: 'Cancel selection' })
).not.toBeInTheDocument()
})
})

View File

@@ -22,11 +22,21 @@
aria-hidden="true"
class="icon-[lucide--circle-alert] size-5 text-destructive-background"
/>
<i
v-else-if="downloadStatus?.status === 'cancelled'"
aria-hidden="true"
class="icon-[lucide--circle-x] size-5 text-destructive-background"
/>
<i
v-else-if="downloadStatus?.status === 'completed'"
aria-hidden="true"
class="icon-[lucide--check-circle] size-5 text-success-background"
/>
<i
v-else-if="downloadStatus?.status === 'paused'"
aria-hidden="true"
class="icon-[lucide--pause-circle] size-5 text-muted-foreground"
/>
<i
v-else-if="isDownloadActive"
aria-hidden="true"
@@ -58,6 +68,13 @@
<template v-else-if="downloadStatus?.status === 'completed'">
{{ t('rightSidePanel.missingModels.imported') }}
</template>
<template v-else-if="downloadStatus?.status === 'paused'">
{{ t('electronFileDownload.paused') }}
{{ Math.round((downloadStatus?.progress ?? 0) * 100) }}%
</template>
<template v-else-if="downloadStatus?.status === 'cancelled'">
{{ t('electronFileDownload.cancelled') }}
</template>
<template v-else-if="downloadStatus?.status === 'failed'">
{{
downloadStatus?.error ||
@@ -71,6 +88,7 @@
</div>
<Button
v-if="canCancelSelection"
variant="textonly"
size="icon-sm"
:aria-label="t('rightSidePanel.missingModels.cancelSelection')"
@@ -86,18 +104,20 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import type { AssetDownload } from '@/stores/assetDownloadStore'
import type { MissingModelDownloadStatus } from '@/platform/missingModel/types'
const {
modelName,
isDownloadActive,
downloadStatus = null,
categoryMismatch = null
categoryMismatch = null,
canCancelSelection = true
} = defineProps<{
modelName: string
isDownloadActive: boolean
downloadStatus?: AssetDownload | null
downloadStatus?: MissingModelDownloadStatus | null
categoryMismatch?: string | null
canCancelSelection?: boolean
}>()
const emit = defineEmits<{

View File

@@ -15,8 +15,15 @@ const mockGetAssetFilename = vi.fn((a: { name: string }) => a.name)
const mockGetAssets = vi.fn()
const mockUpdateModelsForNodeType = vi.fn()
const mockGetAllNodeProviders = vi.fn()
const mockFindElectronDownloadById = vi.fn()
const mockFindElectronDownloadByUrl = vi.fn()
const mockDownloadList = vi.fn(
(): Array<{ taskId: string; status: string }> => []
(): Array<{
taskId: string
status: string
progress?: number
error?: string
}> => []
)
vi.mock('@/i18n', () => ({
@@ -24,7 +31,8 @@ vi.mock('@/i18n', () => ({
}))
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
isCloud: false,
isDesktop: false
}))
vi.mock('vue-i18n', () => ({
@@ -71,6 +79,14 @@ vi.mock('@/stores/assetDownloadStore', () => ({
})
}))
vi.mock('@/stores/electronDownloadStore', () => ({
useElectronDownloadStore: () => ({
findByDownloadId: (...args: unknown[]) =>
mockFindElectronDownloadById(...args),
findByUrl: (...args: unknown[]) => mockFindElectronDownloadByUrl(...args)
})
}))
vi.mock('@/stores/modelToNodeStore', () => ({
useModelToNodeStore: () => ({
getAllNodeProviders: mockGetAllNodeProviders
@@ -141,6 +157,10 @@ describe('useMissingModelInteractions', () => {
mockDownloadList.mockImplementation(
(): Array<{ taskId: string; status: string }> => []
)
mockFindElectronDownloadById.mockReset()
mockFindElectronDownloadById.mockReturnValue(null)
mockFindElectronDownloadByUrl.mockReset()
mockFindElectronDownloadByUrl.mockReturnValue(null)
;(app as { rootGraph: unknown }).rootGraph = null
})
@@ -285,7 +305,7 @@ describe('useMissingModelInteractions', () => {
it('returns false when download is running', () => {
const store = useMissingModelStore()
store.selectedLibraryModel['key1'] = 'model.safetensors'
store.importTaskIds['key1'] = 'task-123'
store.downloadRefs['key1'] = { kind: 'asset-import', taskId: 'task-123' }
mockDownloadList.mockReturnValue([
{ taskId: 'task-123', status: 'running' }
])
@@ -311,19 +331,180 @@ describe('useMissingModelInteractions', () => {
const { isSelectionConfirmable } = useMissingModelInteractions()
expect(isSelectionConfirmable('key1')).toBe(true)
})
it('returns false when an Electron download ref no longer has a status', () => {
const store = useMissingModelStore()
store.selectedLibraryModel['key1'] = 'model.safetensors'
store.downloadRefs['key1'] = {
kind: 'electron-download',
url: 'https://example.com/model.safetensors'
}
mockFindElectronDownloadByUrl.mockReturnValue(null)
const { isSelectionConfirmable } = useMissingModelInteractions()
expect(isSelectionConfirmable('key1')).toBe(false)
})
it('returns true when a tracked download is completed', () => {
const store = useMissingModelStore()
store.selectedLibraryModel['key1'] = 'model.safetensors'
store.downloadRefs['key1'] = {
kind: 'electron-download',
url: 'https://example.com/model.safetensors'
}
mockFindElectronDownloadByUrl.mockReturnValue({
progress: 1,
status: 'completed'
})
const { isSelectionConfirmable } = useMissingModelInteractions()
expect(isSelectionConfirmable('key1')).toBe(true)
})
it('returns false when a tracked download failed', () => {
const store = useMissingModelStore()
store.selectedLibraryModel['key1'] = 'model.safetensors'
store.downloadRefs['key1'] = {
kind: 'electron-download',
url: 'https://example.com/model.safetensors'
}
mockFindElectronDownloadByUrl.mockReturnValue({
progress: 0.3,
status: 'failed'
})
const { isSelectionConfirmable } = useMissingModelInteractions()
expect(isSelectionConfirmable('key1')).toBe(false)
})
it('does not use URL fallback to confirm an Electron ref with a missing download id', () => {
const store = useMissingModelStore()
store.selectedLibraryModel['key1'] = 'model.safetensors'
store.downloadRefs['key1'] = {
kind: 'electron-download',
downloadId: '/models/checkpoints/model.safetensors',
url: 'https://example.com/model.safetensors'
}
mockFindElectronDownloadById.mockReturnValue(null)
mockFindElectronDownloadByUrl.mockReturnValue({
progress: 1,
status: 'completed'
})
const { isSelectionConfirmable } = useMissingModelInteractions()
expect(isSelectionConfirmable('key1')).toBe(false)
expect(mockFindElectronDownloadByUrl).not.toHaveBeenCalled()
})
})
describe('getDownloadStatus', () => {
it('returns the tracked asset import status for asset-import refs', () => {
const store = useMissingModelStore()
store.downloadRefs['key1'] = { kind: 'asset-import', taskId: 'task-123' }
mockDownloadList.mockReturnValue([
{
taskId: 'task-123',
status: 'running',
progress: 0.5,
error: undefined
}
])
const { getDownloadStatus } = useMissingModelInteractions()
expect(getDownloadStatus('key1')).toEqual({
progress: 0.5,
status: 'running',
error: undefined
})
})
it('returns the tracked Electron download status for electron refs', () => {
const store = useMissingModelStore()
store.downloadRefs['key1'] = {
kind: 'electron-download',
url: 'https://example.com/model.safetensors'
}
mockFindElectronDownloadByUrl.mockReturnValue({
progress: 0.4,
status: 'paused',
error: 'network stalled'
})
const { getDownloadStatus } = useMissingModelInteractions()
expect(getDownloadStatus('key1')).toEqual({
progress: 0.4,
status: 'paused',
error: 'network stalled'
})
})
it('prefers download id over URL for Electron download status', () => {
const store = useMissingModelStore()
store.downloadRefs['key1'] = {
kind: 'electron-download',
downloadId: '/models/checkpoints/model.safetensors',
url: 'https://example.com/model.safetensors'
}
mockFindElectronDownloadById.mockReturnValue({
progress: 0.8,
status: 'running'
})
mockFindElectronDownloadByUrl.mockReturnValue({
progress: 0.1,
status: 'paused'
})
const { getDownloadStatus } = useMissingModelInteractions()
expect(getDownloadStatus('key1')).toEqual({
progress: 0.8,
status: 'running',
error: undefined
})
expect(mockFindElectronDownloadById).toHaveBeenCalledWith(
'/models/checkpoints/model.safetensors'
)
})
it('returns null instead of falling back to URL when a download id is present but missing', () => {
const store = useMissingModelStore()
store.downloadRefs['key1'] = {
kind: 'electron-download',
downloadId: '/models/checkpoints/model.safetensors',
url: 'https://example.com/model.safetensors'
}
mockFindElectronDownloadById.mockReturnValue(null)
mockFindElectronDownloadByUrl.mockReturnValue({
progress: 1,
status: 'completed'
})
const { getDownloadStatus } = useMissingModelInteractions()
expect(getDownloadStatus('key1')).toBeNull()
expect(mockFindElectronDownloadByUrl).not.toHaveBeenCalled()
})
it('returns null when no tracked download ref exists', () => {
const { getDownloadStatus } = useMissingModelInteractions()
expect(getDownloadStatus('key1')).toBeNull()
})
})
describe('cancelLibrarySelect', () => {
it('clears selectedLibraryModel and importCategoryMismatch', () => {
it('clears selectedLibraryModel, importCategoryMismatch, and download refs', () => {
const store = useMissingModelStore()
store.selectedLibraryModel['key1'] = 'model.safetensors'
store.importCategoryMismatch['key1'] = 'loras'
store.downloadRefs['key1'] = {
kind: 'electron-download',
url: 'https://example.com/model.safetensors'
}
const { cancelLibrarySelect } = useMissingModelInteractions()
cancelLibrarySelect('key1')
expect(store.selectedLibraryModel['key1']).toBeUndefined()
expect(store.importCategoryMismatch['key1']).toBeUndefined()
expect(store.downloadRefs['key1']).toBeUndefined()
})
})
@@ -347,6 +528,10 @@ describe('useMissingModelInteractions', () => {
const store = useMissingModelStore()
store.selectedLibraryModel['key1'] = 'new_model.safetensors'
store.downloadRefs['key1'] = {
kind: 'electron-download',
url: 'https://example.com/old_model.safetensors'
}
store.setMissingModels([
makeCandidate({ name: 'old_model.safetensors', nodeId: '10' }),
makeCandidate({ name: 'old_model.safetensors', nodeId: '20' })
@@ -371,6 +556,7 @@ describe('useMissingModelInteractions', () => {
'old_model.safetensors',
new Set(['10', '20'])
)
expect(store.downloadRefs['key1']).toBeUndefined()
expect(store.selectedLibraryModel['key1']).toBeUndefined()
})
@@ -568,14 +754,14 @@ describe('useMissingModelInteractions', () => {
})
describe('getDownloadStatus', () => {
it('returns null when no taskId is tracked for the key', () => {
it('returns null when no download ref is tracked for the key', () => {
const { getDownloadStatus } = useMissingModelInteractions()
expect(getDownloadStatus('key1')).toBeNull()
})
it('returns the matching download record when a taskId is tracked', () => {
it('returns the matching download record when an asset import ref is tracked', () => {
const store = useMissingModelStore()
store.importTaskIds['key1'] = 'task-42'
store.downloadRefs['key1'] = { kind: 'asset-import', taskId: 'task-42' }
mockDownloadList.mockReturnValue([
{ taskId: 'task-other', status: 'running' },
{ taskId: 'task-42', status: 'created' }
@@ -583,7 +769,6 @@ describe('useMissingModelInteractions', () => {
const { getDownloadStatus } = useMissingModelInteractions()
expect(getDownloadStatus('key1')).toEqual({
taskId: 'task-42',
status: 'created'
})
})
@@ -601,7 +786,7 @@ describe('useMissingModelInteractions', () => {
return store
}
it('tracks an async-pending result via importTaskIds and trackDownload', async () => {
it('tracks an async-pending result via download refs and trackDownload', async () => {
const store = setupImportableState('key1')
mockUploadAssetAsync.mockResolvedValueOnce({
type: 'async',
@@ -611,7 +796,10 @@ describe('useMissingModelInteractions', () => {
const { handleImport } = useMissingModelInteractions()
await handleImport('key1', 'checkpoints')
expect(store.importTaskIds['key1']).toBe('task-99')
expect(store.downloadRefs['key1']).toEqual({
kind: 'asset-import',
taskId: 'task-99'
})
expect(mockTrackDownload).toHaveBeenCalledWith(
'task-99',
'checkpoints',

View File

@@ -13,15 +13,18 @@ import { validateSourceUrl } from '@/platform/assets/utils/importSourceUtil'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useAssetsStore } from '@/stores/assetsStore'
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
import { useElectronDownloadStore } from '@/stores/electronDownloadStore'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
import { app } from '@/scripts/app'
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
import type {
MissingModelCandidate,
MissingModelDownloadStatus,
MissingModelViewModel
} from '@/platform/missingModel/types'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
export { getModelStateKey } from '@/platform/missingModel/missingModelViewUtils'
const importSources = [civitaiImportSource, huggingfaceImportSource]
@@ -35,15 +38,6 @@ const MODEL_TYPE_TAGS = [
const URL_DEBOUNCE_MS = 800
export function getModelStateKey(
modelName: string,
directory: string | null,
isAssetSupported: boolean
): string {
const prefix = isAssetSupported ? 'supported' : 'unsupported'
return `${prefix}::${directory ?? ''}::${modelName}`
}
export function getNodeDisplayLabel(
nodeId: string | number,
fallback: string
@@ -90,6 +84,7 @@ export function useMissingModelInteractions() {
const store = useMissingModelStore()
const assetsStore = useAssetsStore()
const assetDownloadStore = useAssetDownloadStore()
const electronDownloadStore = useElectronDownloadStore()
const modelToNodeStore = useModelToNodeStore()
const _requestTokens: Record<string, symbol> = {}
@@ -130,19 +125,17 @@ export function useMissingModelInteractions() {
if (!store.selectedLibraryModel[key]) return false
if (store.importCategoryMismatch[key]) return false
const downloadRef = store.downloadRefs[key]
const status = getDownloadStatus(key)
if (
status &&
(status.status === 'running' || status.status === 'created')
) {
return false
}
return true
if (downloadRef?.kind === 'electron-download' && !status) return false
return !status || status.status === 'completed'
}
function cancelLibrarySelect(key: string) {
delete store.selectedLibraryModel[key]
delete store.importCategoryMismatch[key]
delete store.downloadRefs[key]
}
/** Apply selected model to referencing nodes, removing only that model from the error list. */
@@ -189,6 +182,7 @@ export function useMissingModelInteractions() {
}
delete store.selectedLibraryModel[key]
delete store.downloadRefs[key]
const nodeIdSet = new Set(referencingNodes.map((ref) => String(ref.nodeId)))
store.removeMissingModelByNameOnNodes(modelName, nodeIdSet)
}
@@ -280,12 +274,34 @@ export function useMissingModelInteractions() {
return null
}
function getDownloadStatus(key: string) {
const taskId = store.importTaskIds[key]
if (!taskId) return null
return (
assetDownloadStore.downloadList.find((d) => d.taskId === taskId) ?? null
)
function getDownloadStatus(key: string): MissingModelDownloadStatus | null {
const downloadRef = store.downloadRefs[key]
if (!downloadRef) return null
if (downloadRef.kind === 'asset-import') {
const assetDownload = assetDownloadStore.downloadList.find(
(download) => download.taskId === downloadRef.taskId
)
return assetDownload
? {
progress: assetDownload.progress,
status: assetDownload.status,
error: assetDownload.error
}
: null
}
const download = downloadRef.downloadId
? electronDownloadStore.findByDownloadId(downloadRef.downloadId)
: electronDownloadStore.findByUrl(downloadRef.url)
return download
? {
progress: download.progress,
status: download.status,
error: download.error
}
: null
}
function handleAsyncPending(
@@ -294,7 +310,7 @@ export function useMissingModelInteractions() {
modelType: string | undefined,
filename: string
) {
store.importTaskIds[key] = taskId
store.downloadRefs[key] = { kind: 'asset-import', taskId }
if (modelType) {
assetDownloadStore.trackDownload(taskId, modelType, filename)
}

View File

@@ -1,6 +1,10 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mockIsDesktop = vi.hoisted(() => ({ value: false }))
const mockStartElectronDownload = vi.hoisted(() => vi.fn())
import {
downloadModel,
fetchModelMetadata,
isModelDownloadable,
toBrowsableUrl
@@ -9,14 +13,24 @@ import {
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
vi.mock('@/platform/distribution/types', () => ({ isDesktop: false }))
vi.mock('@/stores/electronDownloadStore', () => ({}))
vi.mock('@/platform/distribution/types', () => ({
get isDesktop() {
return mockIsDesktop.value
}
}))
vi.mock('@/stores/electronDownloadStore', () => ({
useElectronDownloadStore: () => ({
start: (...args: unknown[]) => mockStartElectronDownload(...args)
})
}))
let testId = 0
describe('fetchModelMetadata', () => {
beforeEach(() => {
fetchMock.mockReset()
mockIsDesktop.value = false
mockStartElectronDownload.mockReset()
testId++
})
@@ -213,3 +227,83 @@ describe('isModelDownloadable', () => {
).toBe(false)
})
})
describe('downloadModel', () => {
beforeEach(() => {
mockIsDesktop.value = false
mockStartElectronDownload.mockReset()
})
it('opens the source URL directly outside desktop builds', async () => {
const click = vi.fn()
const createElementSpy = vi
.spyOn(document, 'createElement')
.mockReturnValue({
click,
download: '',
href: '',
rel: '',
target: ''
} as unknown as HTMLAnchorElement)
const result = await downloadModel(
{
name: 'model.safetensors',
url: 'https://example.com/model.safetensors',
directory: 'checkpoints'
},
{ checkpoints: ['/models/checkpoints'] }
)
expect(result).toEqual({ started: true })
expect(click).toHaveBeenCalledOnce()
createElementSpy.mockRestore()
})
it('starts an Electron download when a desktop save path exists', async () => {
mockIsDesktop.value = true
mockStartElectronDownload.mockResolvedValue({
started: true,
download: {
downloadId: '/models/checkpoints/model.safetensors'
}
})
const result = await downloadModel(
{
name: 'model.safetensors',
url: 'https://example.com/model.safetensors',
directory: 'checkpoints'
},
{ checkpoints: ['/models/checkpoints'] }
)
expect(result).toEqual({
started: true,
downloadId: '/models/checkpoints/model.safetensors'
})
expect(mockStartElectronDownload).toHaveBeenCalledWith({
url: 'https://example.com/model.safetensors',
savePath: '/models/checkpoints',
filename: 'model.safetensors'
})
})
it('returns not started on desktop when no save path exists for the model directory', async () => {
mockIsDesktop.value = true
await expect(
downloadModel(
{
name: 'model.safetensors',
url: 'https://example.com/model.safetensors',
directory: 'checkpoints'
},
{}
)
).resolves.toEqual({ started: false })
expect(mockStartElectronDownload).not.toHaveBeenCalled()
})
})

View File

@@ -32,6 +32,12 @@ export interface ModelWithUrl {
directory: string
}
export interface DownloadModelResult {
started: boolean
downloadId?: string
error?: string
}
/**
* Converts a model download URL to a browsable page URL.
* - HuggingFace: `/resolve/` → `/blob/` (file page with model info)
@@ -56,10 +62,10 @@ export function isModelDownloadable(model: ModelWithUrl): boolean {
return true
}
export function downloadModel(
export async function downloadModel(
model: ModelWithUrl,
paths: Record<string, string[]>
): void {
): Promise<DownloadModelResult> {
if (!isDesktop) {
const link = document.createElement('a')
link.href = model.url
@@ -67,17 +73,26 @@ export function downloadModel(
link.target = '_blank'
link.rel = 'noopener noreferrer'
link.click()
return
return { started: true }
}
const modelPaths = paths[model.directory]
if (modelPaths?.[0]) {
void useElectronDownloadStore().start({
const result = await useElectronDownloadStore().start({
url: model.url,
savePath: modelPaths[0],
filename: model.name
})
return {
started: result.started,
...(result.download?.downloadId
? { downloadId: result.download.downloadId }
: {}),
...(result.error ? { error: result.error } : {})
}
}
return { started: false }
}
interface ModelMetadata {

View File

@@ -6,7 +6,10 @@ import { t } from '@/i18n'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
import { useToastStore } from '@/platform/updates/common/toastStore'
import type { MissingModelCandidate } from '@/platform/missingModel/types'
import type {
MissingModelCandidate,
MissingModelDownloadRef
} from '@/platform/missingModel/types'
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { getAncestorExecutionIds } from '@/types/nodeIdentification'
@@ -82,7 +85,7 @@ export const useMissingModelStore = defineStore('missingModel', () => {
const modelExpandState = ref<Record<string, boolean>>({})
const selectedLibraryModel = ref<Record<string, string>>({})
const importCategoryMismatch = ref<Record<string, string>>({})
const importTaskIds = ref<Record<string, string>>({})
const downloadRefs = ref<Record<string, MissingModelDownloadRef>>({})
const urlInputs = ref<Record<string, string>>({})
const urlMetadata = ref<Record<string, AssetMetadata | null>>({})
const urlFetching = ref<Record<string, boolean>>({})
@@ -135,7 +138,7 @@ export const useMissingModelStore = defineStore('missingModel', () => {
delete modelExpandState.value[name]
delete selectedLibraryModel.value[name]
delete importCategoryMismatch.value[name]
delete importTaskIds.value[name]
delete downloadRefs.value[name]
delete urlInputs.value[name]
delete urlMetadata.value[name]
delete urlFetching.value[name]
@@ -263,7 +266,7 @@ export const useMissingModelStore = defineStore('missingModel', () => {
modelExpandState.value = {}
selectedLibraryModel.value = {}
importCategoryMismatch.value = {}
importTaskIds.value = {}
downloadRefs.value = {}
urlInputs.value = {}
urlMetadata.value = {}
urlFetching.value = {}
@@ -322,7 +325,7 @@ export const useMissingModelStore = defineStore('missingModel', () => {
modelExpandState,
selectedLibraryModel,
importTaskIds,
downloadRefs,
importCategoryMismatch,
urlInputs,
urlMetadata,

View File

@@ -2,6 +2,15 @@ import type { MissingModelGroup } from '@/platform/missingModel/types'
import { isModelDownloadable } from '@/platform/missingModel/missingModelDownload'
import type { ModelWithUrl } from '@/platform/missingModel/missingModelDownload'
export function getModelStateKey(
modelName: string,
directory: string | null,
isAssetSupported: boolean
): string {
const prefix = isAssetSupported ? 'supported' : 'unsupported'
return `${prefix}::${directory ?? ''}::${modelName}`
}
export function toDownloadableModel(
model: MissingModelGroup['models'][number]
): ModelWithUrl | null {
@@ -12,10 +21,30 @@ export function toDownloadableModel(
return isModelDownloadable(downloadableModel) ? downloadableModel : null
}
export function getDownloadableModelEntries(
groups: MissingModelGroup[]
): Array<{ key: string; model: ModelWithUrl }> {
return groups.flatMap((group) =>
group.models.flatMap((model) => {
const downloadableModel = toDownloadableModel(model)
if (!downloadableModel) return []
return [
{
key: getModelStateKey(
model.name,
group.directory,
group.isAssetSupported
),
model: downloadableModel
}
]
})
)
}
export function getDownloadableModels(
groups: MissingModelGroup[]
): ModelWithUrl[] {
return groups.flatMap((group) =>
group.models.flatMap((model) => toDownloadableModel(model) ?? [])
)
return getDownloadableModelEntries(groups).map(({ model }) => model)
}

View File

@@ -2,6 +2,7 @@ import type {
ModelFile,
NodeId
} from '@/platform/workflow/validation/schemas/workflowSchema'
import type { DownloadLifecycleState } from '@/platform/downloads/types'
/**
* A single (node, widget, model) binding detected by the missing model pipeline.
@@ -51,3 +52,16 @@ export interface MissingModelGroup {
models: MissingModelViewModel[]
isAssetSupported: boolean
}
export type MissingModelDownloadRef =
| {
kind: 'asset-import'
taskId: string
}
| {
kind: 'electron-download'
downloadId?: string
url: string
}
export type MissingModelDownloadStatus = DownloadLifecycleState

View File

@@ -0,0 +1,226 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DownloadStatus } from '@comfyorg/comfyui-electron-types'
const mockGetAllDownloads = vi.fn()
const mockStartDownload = vi.fn()
const mockPauseDownload = vi.fn()
const mockResumeDownload = vi.fn()
const mockCancelDownload = vi.fn()
let downloadProgressHandler:
| ((download: {
downloadId?: string
url: string
filename: string
savePath?: string
progress?: number
status?: DownloadStatus
state?: DownloadStatus
receivedBytes?: number
totalBytes?: number
isPaused?: boolean
message?: string
}) => void)
| undefined
vi.mock('@/platform/distribution/types', () => ({
isDesktop: true
}))
vi.mock('@/utils/envUtil', () => ({
electronAPI: () => ({
DownloadManager: {
getAllDownloads: mockGetAllDownloads,
onDownloadProgress: (callback: typeof downloadProgressHandler) => {
downloadProgressHandler = callback
},
startDownload: mockStartDownload,
pauseDownload: mockPauseDownload,
resumeDownload: mockResumeDownload,
cancelDownload: mockCancelDownload
}
})
}))
import { useElectronDownloadStore } from './electronDownloadStore'
async function flushStoreSetup() {
await Promise.resolve()
await Promise.resolve()
}
describe('electronDownloadStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
downloadProgressHandler = undefined
mockGetAllDownloads.mockResolvedValue([])
})
it('normalizes canonical desktop snapshots during initialization', async () => {
mockGetAllDownloads.mockResolvedValue([
{
downloadId: '/models/checkpoints/model.safetensors',
url: 'https://example.com/model.safetensors',
filename: 'model.safetensors',
savePath: '/models/checkpoints/model.safetensors',
progress: 0.25,
status: DownloadStatus.IN_PROGRESS,
state: DownloadStatus.IN_PROGRESS,
receivedBytes: 25,
totalBytes: 100,
isPaused: false
}
])
const store = useElectronDownloadStore()
await flushStoreSetup()
expect(store.downloads).toEqual([
{
downloadId: '/models/checkpoints/model.safetensors',
url: 'https://example.com/model.safetensors',
filename: 'model.safetensors',
savePath: '/models/checkpoints/model.safetensors',
progress: 0.25,
status: 'running',
error: undefined
}
])
})
it('normalizes legacy desktop snapshots without canonical fields', async () => {
mockGetAllDownloads.mockResolvedValue([
{
url: 'https://example.com/model.safetensors',
filename: 'model.safetensors',
state: DownloadStatus.PAUSED,
receivedBytes: 5,
totalBytes: 10,
isPaused: true
}
])
const store = useElectronDownloadStore()
await flushStoreSetup()
expect(store.downloads).toEqual([
{
url: 'https://example.com/model.safetensors',
filename: 'model.safetensors',
savePath: '',
progress: 0.5,
status: 'paused',
error: undefined
}
])
})
it('upserts progress updates into the normalized store shape', async () => {
const store = useElectronDownloadStore()
await flushStoreSetup()
downloadProgressHandler?.({
downloadId: '/models/checkpoints/model.safetensors',
url: 'https://example.com/model.safetensors',
filename: 'model.safetensors',
savePath: '/models/checkpoints/model.safetensors',
progress: 0,
status: DownloadStatus.PENDING
})
downloadProgressHandler?.({
downloadId: '/models/checkpoints/model.safetensors',
url: 'https://example.com/model.safetensors',
filename: 'model.safetensors',
savePath: '/models/checkpoints/model.safetensors',
progress: 0.6,
status: DownloadStatus.IN_PROGRESS
})
expect(store.downloads).toEqual([
{
downloadId: '/models/checkpoints/model.safetensors',
url: 'https://example.com/model.safetensors',
filename: 'model.safetensors',
savePath: '/models/checkpoints/model.safetensors',
progress: 0.6,
status: 'running',
error: undefined
}
])
})
it('keeps same-url downloads separate when desktop supplies download ids', async () => {
const store = useElectronDownloadStore()
await flushStoreSetup()
downloadProgressHandler?.({
downloadId: '/models/checkpoints/model.safetensors',
url: 'https://example.com/model.safetensors',
filename: 'model.safetensors',
savePath: '/models/checkpoints/model.safetensors',
progress: 0.2,
status: DownloadStatus.IN_PROGRESS
})
downloadProgressHandler?.({
downloadId: '/models/loras/model.safetensors',
url: 'https://example.com/model.safetensors',
filename: 'model.safetensors',
savePath: '/models/loras/model.safetensors',
progress: 0.7,
status: DownloadStatus.IN_PROGRESS
})
expect(store.downloads).toEqual([
expect.objectContaining({
downloadId: '/models/checkpoints/model.safetensors',
savePath: '/models/checkpoints/model.safetensors',
progress: 0.2
}),
expect.objectContaining({
downloadId: '/models/loras/model.safetensors',
savePath: '/models/loras/model.safetensors',
progress: 0.7
})
])
})
it('normalizes the desktop start result and stores its download id', async () => {
mockStartDownload.mockResolvedValue({
ok: true,
download: {
downloadId: '/models/checkpoints/model.safetensors',
url: 'https://example.com/model.safetensors',
filename: 'model.safetensors',
savePath: '/models/checkpoints/model.safetensors',
progress: 0,
status: DownloadStatus.PENDING
}
})
const store = useElectronDownloadStore()
await flushStoreSetup()
const result = await store.start({
url: 'https://example.com/model.safetensors',
savePath: '/models/checkpoints',
filename: 'model.safetensors'
})
expect(result).toEqual({
started: true,
download: expect.objectContaining({
downloadId: '/models/checkpoints/model.safetensors',
status: 'created'
})
})
expect(
store.findByDownloadId('/models/checkpoints/model.safetensors')
).toEqual(
expect.objectContaining({
url: 'https://example.com/model.safetensors'
})
)
})
})

View File

@@ -1,20 +1,57 @@
import { DownloadStatus } from '@comfyorg/comfyui-electron-types'
import type { DownloadState } from '@comfyorg/comfyui-electron-types'
import type {
DownloadProgressUpdate,
DownloadState
} from '@comfyorg/comfyui-electron-types'
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import type {
DownloadLifecycleState,
DownloadLifecycleStatus
} from '@/platform/downloads/types'
import { isDesktop } from '@/platform/distribution/types'
import { electronAPI } from '@/utils/envUtil'
export interface ElectronDownload extends Pick<
DownloadState,
type DesktopDownloadSnapshot = Pick<
DownloadProgressUpdate,
'url' | 'filename'
> {
progress?: number
savePath?: string
status?: DownloadStatus
> &
Partial<DownloadState> &
Partial<DownloadProgressUpdate> & {
downloadId?: string
}
type DesktopStartDownloadResult =
| boolean
| {
ok: boolean
download?: DesktopDownloadSnapshot
error?: string
}
interface ElectronDownloadStartResult {
started: boolean
download?: ElectronDownload
error?: string
}
export interface ElectronDownload extends DownloadLifecycleState {
downloadId?: string
url: string
filename: string
savePath: string
}
const desktopStatusToLifecycleStatus = {
[DownloadStatus.PENDING]: 'created',
[DownloadStatus.IN_PROGRESS]: 'running',
[DownloadStatus.PAUSED]: 'paused',
[DownloadStatus.COMPLETED]: 'completed',
[DownloadStatus.CANCELLED]: 'cancelled',
[DownloadStatus.ERROR]: 'failed'
} satisfies Record<DownloadStatus, DownloadLifecycleStatus>
/** Electron downloads store handler */
export const useElectronDownloadStore = defineStore('downloads', () => {
const downloads = ref<ElectronDownload[]>([])
@@ -22,6 +59,74 @@ export const useElectronDownloadStore = defineStore('downloads', () => {
const findByUrl = (url: string) =>
downloads.value.find((download) => url === download.url)
const findByDownloadId = (downloadId: string) =>
downloads.value.find((download) => download.downloadId === downloadId)
function normalizeStatus(
status?: DownloadStatus,
isPaused?: boolean
): ElectronDownload['status'] {
if (isPaused || status === DownloadStatus.PAUSED) {
return 'paused'
}
if (status == null) return 'created'
return desktopStatusToLifecycleStatus[status]
}
function normalizeProgress(
download: DesktopDownloadSnapshot,
status: ElectronDownload['status']
): number {
if (typeof download.progress === 'number') {
return download.progress
}
if (
typeof download.receivedBytes === 'number' &&
typeof download.totalBytes === 'number' &&
download.totalBytes > 0
) {
return download.receivedBytes / download.totalBytes
}
return status === 'completed' ? 1 : 0
}
function normalizeDownload(
download: DesktopDownloadSnapshot
): ElectronDownload {
const status = normalizeStatus(
download.status ?? download.state,
download.isPaused
)
return {
...(download.downloadId ? { downloadId: download.downloadId } : {}),
url: download.url,
filename: download.filename,
savePath: download.savePath ?? '',
progress: normalizeProgress(download, status),
status,
error: download.message
}
}
function upsertDownload(download: DesktopDownloadSnapshot) {
const normalizedDownload = normalizeDownload(download)
const existingDownload = normalizedDownload.downloadId
? findByDownloadId(normalizedDownload.downloadId)
: findByUrl(normalizedDownload.url)
if (existingDownload) {
Object.assign(existingDownload, normalizedDownload)
return existingDownload
}
downloads.value.push(normalizedDownload)
return normalizedDownload
}
const initialize = async () => {
if (!isDesktop || !DownloadManager) return
@@ -29,28 +134,17 @@ export const useElectronDownloadStore = defineStore('downloads', () => {
const allDownloads = await DownloadManager.getAllDownloads()
for (const download of allDownloads) {
downloads.value.push(download)
upsertDownload(download)
}
DownloadManager.onDownloadProgress((data) => {
if (!findByUrl(data.url)) {
downloads.value.push(data)
}
const download = findByUrl(data.url)
if (download) {
download.progress = data.progress
download.status = data.status
download.filename = data.filename
download.savePath = data.savePath
}
upsertDownload(data)
})
}
void initialize()
const start = ({
const start = async ({
url,
savePath,
filename
@@ -58,10 +152,41 @@ export const useElectronDownloadStore = defineStore('downloads', () => {
url: string
savePath: string
filename: string
}) => DownloadManager!.startDownload(url, savePath, filename)
const pause = (url: string) => DownloadManager!.pauseDownload(url)
const resume = (url: string) => DownloadManager!.resumeDownload(url)
const cancel = (url: string) => DownloadManager!.cancelDownload(url)
}): Promise<ElectronDownloadStartResult> => {
const result = (await DownloadManager!.startDownload(
url,
savePath,
filename
)) as DesktopStartDownloadResult
if (typeof result === 'boolean') {
return { started: result }
}
if (!result.ok) {
if (result.download) {
upsertDownload(result.download)
}
return {
started: false,
...(result.error ? { error: result.error } : {})
}
}
const download = result.download
? upsertDownload(result.download)
: undefined
return {
started: true,
...(download ? { download } : {})
}
}
const pause = (downloadIdOrUrl: string) =>
DownloadManager!.pauseDownload(downloadIdOrUrl)
const resume = (downloadIdOrUrl: string) =>
DownloadManager!.resumeDownload(downloadIdOrUrl)
const cancel = (downloadIdOrUrl: string) =>
DownloadManager!.cancelDownload(downloadIdOrUrl)
return {
downloads,
@@ -69,12 +194,11 @@ export const useElectronDownloadStore = defineStore('downloads', () => {
pause,
resume,
cancel,
findByDownloadId,
findByUrl,
initialize,
inProgressDownloads: computed(() =>
downloads.value.filter(
({ status }) => status !== DownloadStatus.COMPLETED
)
downloads.value.filter(({ status }) => status !== 'completed')
)
}
})

View File

@@ -54,7 +54,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
/** Clear all error state. Called at execution start and workflow changes.
* Missing model state is intentionally preserved here to avoid wiping
* in-progress model repairs (importTaskIds, URL inputs, etc.).
* in-progress model repairs (download refs, URL inputs, etc.).
* Missing models are cleared separately during workflow load/clean paths. */
function clearAllErrors() {
lastExecutionError.value = null