mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-22 13:32:11 +00:00
Compare commits
5 Commits
glary/add-
...
bl/missing
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9f4c8e0ea | ||
|
|
41c03034a1 | ||
|
|
7e16c0a815 | ||
|
|
6a60a26cb3 | ||
|
|
eaa09fa92c |
@@ -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()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
13
src/platform/downloads/types.ts
Normal file
13
src/platform/downloads/types.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export type DownloadLifecycleStatus =
|
||||
| 'created'
|
||||
| 'running'
|
||||
| 'paused'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'cancelled'
|
||||
|
||||
export interface DownloadLifecycleState {
|
||||
progress: number
|
||||
status: DownloadLifecycleStatus
|
||||
error?: string
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
313
src/platform/missingModel/components/MissingModelRow.test.ts
Normal file
313
src/platform/missingModel/components/MissingModelRow.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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<{
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
226
src/stores/electronDownloadStore.test.ts
Normal file
226
src/stores/electronDownloadStore.test.ts
Normal 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'
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user