Compare commits

...

2 Commits

Author SHA1 Message Date
Benjamin Lu
a7c70df301 Merge branch 'main' into bl/missing-model-download-ui-fix 2026-04-17 15:30:30 -07:00
Benjamin Lu
6584566595 Fix missing model Electron download UI state 2026-04-17 14:51:27 -07:00
9 changed files with 401 additions and 138 deletions

View File

@@ -347,7 +347,7 @@ const downloadAllLabel = computed(() => {
function downloadAllModels() {
for (const model of downloadableModels.value) {
downloadModel(model, missingModelStore.folderPaths)
void downloadModel(model, missingModelStore.folderPaths)
}
}

View File

@@ -230,9 +230,13 @@ const modelKey = computed(() =>
getModelStateKey(model.name, directory, isAssetSupported)
)
const downloadStatus = computed(() => getDownloadStatus(modelKey.value))
const downloadStatus = computed(() =>
getDownloadStatus(modelKey.value, model.representative.url)
)
const comboOptions = computed(() => getComboOptions(model.representative))
const canConfirm = computed(() => isSelectionConfirmable(modelKey.value))
const canConfirm = computed(() =>
isSelectionConfirmable(modelKey.value, model.representative.url)
)
const expanded = computed(() => isModelExpanded(modelKey.value))
const typeMismatch = computed(() => getTypeMismatch(modelKey.value, directory))
const isDownloadActive = computed(
@@ -284,13 +288,17 @@ 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 started = await downloadModel(
{ name: rep.name, url: rep.url, directory: rep.directory },
store.folderPaths
)
if (started) {
handleComboSelect(modelKey.value, rep.name)
}
} else {
console.warn('[MissingModelRow] Cannot download: missing url or directory')
}

View File

@@ -86,7 +86,7 @@
<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,
@@ -96,7 +96,7 @@ const {
} = defineProps<{
modelName: string
isDownloadActive: boolean
downloadStatus?: AssetDownload | null
downloadStatus?: MissingModelDownloadStatus | null
categoryMismatch?: string | null
}>()

View File

@@ -12,8 +12,14 @@ const mockGetAssetFilename = vi.fn((a: { name: string }) => a.name)
const mockGetAssets = vi.fn()
const mockUpdateModelsForNodeType = vi.fn()
const mockGetAllNodeProviders = 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', () => ({
@@ -68,6 +74,12 @@ vi.mock('@/stores/assetDownloadStore', () => ({
})
}))
vi.mock('@/stores/electronDownloadStore', () => ({
useElectronDownloadStore: () => ({
findByUrl: (...args: unknown[]) => mockFindElectronDownloadByUrl(...args)
})
}))
vi.mock('@/stores/modelToNodeStore', () => ({
useModelToNodeStore: () => ({
getAllNodeProviders: mockGetAllNodeProviders
@@ -135,8 +147,15 @@ describe('useMissingModelInteractions', () => {
mockGetAssetDisplayName.mockImplementation((a: { name: string }) => a.name)
mockGetAssetFilename.mockImplementation((a: { name: string }) => a.name)
mockDownloadList.mockImplementation(
(): Array<{ taskId: string; status: string }> => []
(): Array<{
taskId: string
status: string
progress?: number
error?: string
}> => []
)
mockFindElectronDownloadByUrl.mockReset()
mockFindElectronDownloadByUrl.mockReturnValue(null)
;(app as { rootGraph: unknown }).rootGraph = null
})
@@ -307,6 +326,40 @@ describe('useMissingModelInteractions', () => {
const { isSelectionConfirmable } = useMissingModelInteractions()
expect(isSelectionConfirmable('key1')).toBe(true)
})
it('returns false when a desktop download is still running', () => {
const store = useMissingModelStore()
store.selectedLibraryModel['key1'] = 'model.safetensors'
mockFindElectronDownloadByUrl.mockReturnValue({
status: 'in_progress',
progress: 0.42
})
const { isSelectionConfirmable } = useMissingModelInteractions()
expect(
isSelectionConfirmable(
'key1',
'https://huggingface.co/org/model/resolve/main/model.safetensors'
)
).toBe(false)
})
it('returns true when a desktop download has completed', () => {
const store = useMissingModelStore()
store.selectedLibraryModel['key1'] = 'model.safetensors'
mockFindElectronDownloadByUrl.mockReturnValue({
status: 'completed',
progress: 1
})
const { isSelectionConfirmable } = useMissingModelInteractions()
expect(
isSelectionConfirmable(
'key1',
'https://huggingface.co/org/model/resolve/main/model.safetensors'
)
).toBe(true)
})
})
describe('cancelLibrarySelect', () => {
@@ -513,4 +566,53 @@ describe('useMissingModelInteractions', () => {
expect(getTypeMismatch('key1', 'checkpoints')).toBeNull()
})
})
describe('getDownloadStatus', () => {
it('normalizes desktop download state for Missing Models UI', () => {
mockFindElectronDownloadByUrl.mockReturnValue({
status: 'pending',
progress: 0
})
const { getDownloadStatus } = useMissingModelInteractions()
expect(
getDownloadStatus(
'key1',
'https://huggingface.co/org/model/resolve/main/model.safetensors'
)
).toEqual({
progress: 0,
status: 'created'
})
})
it('prefers asset import status when an import task exists', () => {
const store = useMissingModelStore()
store.importTaskIds['key1'] = 'task-123'
mockDownloadList.mockReturnValue([
{
taskId: 'task-123',
status: 'running',
progress: 0.3,
error: undefined
}
])
mockFindElectronDownloadByUrl.mockReturnValue({
status: 'completed',
progress: 1
})
const { getDownloadStatus } = useMissingModelInteractions()
expect(
getDownloadStatus(
'key1',
'https://huggingface.co/org/model/resolve/main/model.safetensors'
)
).toEqual({
progress: 0.3,
status: 'running',
error: undefined
})
})
})
})

View File

@@ -13,11 +13,13 @@ 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'
@@ -90,6 +92,7 @@ export function useMissingModelInteractions() {
const store = useMissingModelStore()
const assetsStore = useAssetsStore()
const assetDownloadStore = useAssetDownloadStore()
const electronDownloadStore = useElectronDownloadStore()
const modelToNodeStore = useModelToNodeStore()
const _requestTokens: Record<string, symbol> = {}
@@ -126,18 +129,12 @@ export function useMissingModelInteractions() {
}
}
function isSelectionConfirmable(key: string): boolean {
function isSelectionConfirmable(key: string, downloadUrl?: string): boolean {
if (!store.selectedLibraryModel[key]) return false
if (store.importCategoryMismatch[key]) return false
const status = getDownloadStatus(key)
if (
status &&
(status.status === 'running' || status.status === 'created')
) {
return false
}
return true
const status = getDownloadStatus(key, downloadUrl)
return !status || status.status === 'completed'
}
function cancelLibrarySelect(key: string) {
@@ -280,12 +277,53 @@ export function useMissingModelInteractions() {
return null
}
function getDownloadStatus(key: string) {
function normalizeElectronDownloadStatus(
downloadUrl?: string
): MissingModelDownloadStatus | null {
if (!downloadUrl) return null
const download = electronDownloadStore.findByUrl(downloadUrl)
if (!download?.status) return null
const progress =
download.status === 'completed' ? 1 : (download.progress ?? 0)
switch (download.status) {
case 'pending':
return { progress, status: 'created' }
case 'in_progress':
case 'paused':
return { progress, status: 'running' }
case 'completed':
return { progress, status: 'completed' }
case 'cancelled':
case 'error':
return { progress, status: 'failed' }
default:
return null
}
}
function getDownloadStatus(
key: string,
downloadUrl?: string
): MissingModelDownloadStatus | null {
const taskId = store.importTaskIds[key]
if (!taskId) return null
return (
assetDownloadStore.downloadList.find((d) => d.taskId === taskId) ?? null
)
if (taskId) {
const assetDownload = assetDownloadStore.downloadList.find(
(download) => download.taskId === taskId
)
if (assetDownload) {
return {
progress: assetDownload.progress,
status: assetDownload.status,
error: assetDownload.error
}
}
}
return normalizeElectronDownloadStatus(downloadUrl)
}
function handleAsyncPending(

View File

@@ -1,143 +1,232 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { fetchModelMetadata, toBrowsableUrl } from './missingModelDownload'
const mockStartDownload = vi.fn()
const mockUseElectronDownloadStore = vi.fn(() => ({
start: mockStartDownload
}))
let mockIsDesktop = false
vi.mock('@/platform/distribution/types', () => ({
get isDesktop() {
return mockIsDesktop
}
}))
vi.mock('@/stores/electronDownloadStore', () => ({
useElectronDownloadStore: () => mockUseElectronDownloadStore()
}))
import {
downloadModel,
fetchModelMetadata,
toBrowsableUrl
} from './missingModelDownload'
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
vi.mock('@/platform/distribution/types', () => ({ isDesktop: false }))
vi.mock('@/stores/electronDownloadStore', () => ({}))
let testId = 0
describe('fetchModelMetadata', () => {
describe('missingModelDownload', () => {
beforeEach(() => {
fetchMock.mockReset()
mockStartDownload.mockReset()
mockUseElectronDownloadStore.mockClear()
mockIsDesktop = false
testId++
})
it('fetches file size via HEAD for non-Civitai URLs', async () => {
fetchMock.mockResolvedValueOnce({
ok: true,
headers: new Headers({ 'content-length': '1048576' })
describe('downloadModel', () => {
it('returns true immediately for browser downloads', async () => {
const click = vi.fn()
const createElement = vi
.spyOn(document, 'createElement')
.mockReturnValue({
click,
href: '',
download: '',
target: '',
rel: ''
} as unknown as HTMLAnchorElement)
await expect(
downloadModel(
{
name: 'model.safetensors',
url: 'https://huggingface.co/org/model/resolve/main/model.safetensors',
directory: 'checkpoints'
},
{ checkpoints: ['/models/checkpoints'] }
)
).resolves.toBe(true)
expect(click).toHaveBeenCalled()
createElement.mockRestore()
})
const url = `https://huggingface.co/org/model/resolve/main/head-${testId}.safetensors`
const metadata = await fetchModelMetadata(url)
expect(metadata.fileSize).toBe(1048576)
expect(metadata.gatedRepoUrl).toBeNull()
expect(fetchMock).toHaveBeenCalledWith(url, { method: 'HEAD' })
})
it('starts Electron downloads with the resolved save path', async () => {
mockIsDesktop = true
mockStartDownload.mockResolvedValue(true)
it('uses Civitai API for Civitai model URLs', async () => {
const url = `https://civitai.com/api/download/models/${testId}`
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => ({
files: [{ sizeKB: 1024, downloadUrl: url }]
await expect(
downloadModel(
{
name: 'model.safetensors',
url: 'https://huggingface.co/org/model/resolve/main/model.safetensors',
directory: 'checkpoints'
},
{ checkpoints: ['/models/checkpoints'] }
)
).resolves.toBe(true)
expect(mockStartDownload).toHaveBeenCalledWith({
url: 'https://huggingface.co/org/model/resolve/main/model.safetensors',
savePath: '/models/checkpoints',
filename: 'model.safetensors'
})
})
const metadata = await fetchModelMetadata(url)
expect(metadata.fileSize).toBe(1024 * 1024)
expect(metadata.gatedRepoUrl).toBeNull()
expect(fetchMock).toHaveBeenCalledWith(
`https://civitai.com/api/v1/model-versions/${testId}`
)
})
it('returns false when the destination directory is unavailable', async () => {
mockIsDesktop = true
it('returns null fileSize when Civitai API fails', async () => {
fetchMock.mockResolvedValueOnce({ ok: false })
await expect(
downloadModel(
{
name: 'model.safetensors',
url: 'https://huggingface.co/org/model/resolve/main/model.safetensors',
directory: 'checkpoints'
},
{}
)
).resolves.toBe(false)
const metadata = await fetchModelMetadata(
`https://civitai.com/api/download/models/${testId}`
)
expect(metadata.fileSize).toBeNull()
expect(metadata.gatedRepoUrl).toBeNull()
expect(fetchMock).toHaveBeenCalledTimes(1)
})
it('returns gatedRepoUrl for gated HuggingFace HEAD requests (403)', async () => {
fetchMock.mockResolvedValueOnce({ ok: false, status: 403 })
const metadata = await fetchModelMetadata(
`https://huggingface.co/bfl/FLUX.1/resolve/main/gated-${testId}.safetensors`
)
expect(metadata.gatedRepoUrl).toBe('https://huggingface.co/bfl/FLUX.1')
expect(metadata.fileSize).toBeNull()
})
it('does not treat HuggingFace 404/500 as gated', async () => {
fetchMock.mockResolvedValueOnce({ ok: false, status: 404 })
const metadata = await fetchModelMetadata(
`https://huggingface.co/org/model/resolve/main/notfound-${testId}.safetensors`
)
expect(metadata.gatedRepoUrl).toBeNull()
expect(metadata.fileSize).toBeNull()
})
it('returns null for unrecognized Civitai URL patterns', async () => {
const url = `https://civitai.com/api/v1/models/${testId}`
const metadata = await fetchModelMetadata(url)
expect(metadata.fileSize).toBeNull()
expect(metadata.gatedRepoUrl).toBeNull()
expect(fetchMock).not.toHaveBeenCalled()
})
it('returns cached metadata on second call', async () => {
const url = `https://huggingface.co/org/model/resolve/main/cached-${testId}.safetensors`
fetchMock.mockResolvedValueOnce({
ok: true,
headers: new Headers({ 'content-length': '500' })
expect(mockStartDownload).not.toHaveBeenCalled()
})
const first = await fetchModelMetadata(url)
const second = await fetchModelMetadata(url)
expect(first.fileSize).toBe(500)
expect(second.fileSize).toBe(500)
expect(fetchMock).toHaveBeenCalledTimes(1)
})
it('does not cache incomplete results so retries are possible', async () => {
const url = `https://example.com/retry-${testId}.safetensors`
fetchMock
.mockResolvedValueOnce({
describe('fetchModelMetadata', () => {
it('fetches file size via HEAD for non-Civitai URLs', async () => {
fetchMock.mockResolvedValueOnce({
ok: true,
headers: new Headers({})
})
.mockResolvedValueOnce({
ok: true,
headers: new Headers({ 'content-length': '1024' })
headers: new Headers({ 'content-length': '1048576' })
})
const first = await fetchModelMetadata(url)
const second = await fetchModelMetadata(url)
expect(first.fileSize).toBeNull()
expect(second.fileSize).toBe(1024)
expect(fetchMock).toHaveBeenCalledTimes(2)
})
it('deduplicates concurrent requests for the same URL', async () => {
const url = `https://huggingface.co/org/model/resolve/main/dedup-${testId}.safetensors`
fetchMock.mockResolvedValueOnce({
ok: true,
headers: new Headers({ 'content-length': '2048' })
const url = `https://huggingface.co/org/model/resolve/main/head-${testId}.safetensors`
const metadata = await fetchModelMetadata(url)
expect(metadata.fileSize).toBe(1048576)
expect(metadata.gatedRepoUrl).toBeNull()
expect(fetchMock).toHaveBeenCalledWith(url, { method: 'HEAD' })
})
const [first, second] = await Promise.all([
fetchModelMetadata(url),
fetchModelMetadata(url)
])
it('uses Civitai API for Civitai model URLs', async () => {
const url = `https://civitai.com/api/download/models/${testId}`
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => ({
files: [{ sizeKB: 1024, downloadUrl: url }]
})
})
expect(first.fileSize).toBe(2048)
expect(second.fileSize).toBe(2048)
expect(fetchMock).toHaveBeenCalledTimes(1)
const metadata = await fetchModelMetadata(url)
expect(metadata.fileSize).toBe(1024 * 1024)
expect(metadata.gatedRepoUrl).toBeNull()
expect(fetchMock).toHaveBeenCalledWith(
`https://civitai.com/api/v1/model-versions/${testId}`
)
})
it('returns null fileSize when Civitai API fails', async () => {
fetchMock.mockResolvedValueOnce({ ok: false })
const metadata = await fetchModelMetadata(
`https://civitai.com/api/download/models/${testId}`
)
expect(metadata.fileSize).toBeNull()
expect(metadata.gatedRepoUrl).toBeNull()
expect(fetchMock).toHaveBeenCalledTimes(1)
})
it('returns gatedRepoUrl for gated HuggingFace HEAD requests (403)', async () => {
fetchMock.mockResolvedValueOnce({ ok: false, status: 403 })
const metadata = await fetchModelMetadata(
`https://huggingface.co/bfl/FLUX.1/resolve/main/gated-${testId}.safetensors`
)
expect(metadata.gatedRepoUrl).toBe('https://huggingface.co/bfl/FLUX.1')
expect(metadata.fileSize).toBeNull()
})
it('does not treat HuggingFace 404/500 as gated', async () => {
fetchMock.mockResolvedValueOnce({ ok: false, status: 404 })
const metadata = await fetchModelMetadata(
`https://huggingface.co/org/model/resolve/main/notfound-${testId}.safetensors`
)
expect(metadata.gatedRepoUrl).toBeNull()
expect(metadata.fileSize).toBeNull()
})
it('returns null for unrecognized Civitai URL patterns', async () => {
const url = `https://civitai.com/api/v1/models/${testId}`
const metadata = await fetchModelMetadata(url)
expect(metadata.fileSize).toBeNull()
expect(metadata.gatedRepoUrl).toBeNull()
expect(fetchMock).not.toHaveBeenCalled()
})
it('returns cached metadata on second call', async () => {
const url = `https://huggingface.co/org/model/resolve/main/cached-${testId}.safetensors`
fetchMock.mockResolvedValueOnce({
ok: true,
headers: new Headers({ 'content-length': '500' })
})
const first = await fetchModelMetadata(url)
const second = await fetchModelMetadata(url)
expect(first.fileSize).toBe(500)
expect(second.fileSize).toBe(500)
expect(fetchMock).toHaveBeenCalledTimes(1)
})
it('does not cache incomplete results so retries are possible', async () => {
const url = `https://example.com/retry-${testId}.safetensors`
fetchMock
.mockResolvedValueOnce({
ok: true,
headers: new Headers({})
})
.mockResolvedValueOnce({
ok: true,
headers: new Headers({ 'content-length': '1024' })
})
const first = await fetchModelMetadata(url)
const second = await fetchModelMetadata(url)
expect(first.fileSize).toBeNull()
expect(second.fileSize).toBe(1024)
expect(fetchMock).toHaveBeenCalledTimes(2)
})
it('deduplicates concurrent requests for the same URL', async () => {
const url = `https://huggingface.co/org/model/resolve/main/dedup-${testId}.safetensors`
fetchMock.mockResolvedValueOnce({
ok: true,
headers: new Headers({ 'content-length': '2048' })
})
const [first, second] = await Promise.all([
fetchModelMetadata(url),
fetchModelMetadata(url)
])
expect(first.fileSize).toBe(2048)
expect(second.fileSize).toBe(2048)
expect(fetchMock).toHaveBeenCalledTimes(1)
})
})
})

View File

@@ -58,7 +58,7 @@ export function isModelDownloadable(model: ModelWithUrl): boolean {
export function downloadModel(
model: ModelWithUrl,
paths: Record<string, string[]>
): void {
): Promise<boolean> {
if (!isDesktop) {
const link = document.createElement('a')
link.href = model.url
@@ -66,17 +66,19 @@ export function downloadModel(
link.target = '_blank'
link.rel = 'noopener noreferrer'
link.click()
return
return Promise.resolve(true)
}
const modelPaths = paths[model.directory]
if (modelPaths?.[0]) {
void useElectronDownloadStore().start({
return useElectronDownloadStore().start({
url: model.url,
savePath: modelPaths[0],
filename: model.name
})
}
return Promise.resolve(false)
}
interface ModelMetadata {

View File

@@ -51,3 +51,9 @@ export interface MissingModelGroup {
models: MissingModelViewModel[]
isAssetSupported: boolean
}
export interface MissingModelDownloadStatus {
progress: number
status: 'created' | 'running' | 'completed' | 'failed'
error?: string
}

View File

@@ -50,7 +50,7 @@ export const useElectronDownloadStore = defineStore('downloads', () => {
void initialize()
const start = ({
const start = async ({
url,
savePath,
filename
@@ -58,7 +58,25 @@ export const useElectronDownloadStore = defineStore('downloads', () => {
url: string
savePath: string
filename: string
}) => DownloadManager!.startDownload(url, savePath, filename)
}) => {
const started = await DownloadManager!.startDownload(
url,
savePath,
filename
)
if (started && !findByUrl(url)) {
downloads.value.push({
url,
filename,
savePath,
progress: 0,
status: DownloadStatus.PENDING
})
}
return started
}
const pause = (url: string) => DownloadManager!.pauseDownload(url)
const resume = (url: string) => DownloadManager!.resumeDownload(url)
const cancel = (url: string) => DownloadManager!.cancelDownload(url)