mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-05 21:54:50 +00:00
Compare commits
2 Commits
batch-disp
...
bl/missing
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7c70df301 | ||
|
|
6584566595 |
@@ -347,7 +347,7 @@ const downloadAllLabel = computed(() => {
|
||||
|
||||
function downloadAllModels() {
|
||||
for (const model of downloadableModels.value) {
|
||||
downloadModel(model, missingModelStore.folderPaths)
|
||||
void downloadModel(model, missingModelStore.folderPaths)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}>()
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -51,3 +51,9 @@ export interface MissingModelGroup {
|
||||
models: MissingModelViewModel[]
|
||||
isAssetSupported: boolean
|
||||
}
|
||||
|
||||
export interface MissingModelDownloadStatus {
|
||||
progress: number
|
||||
status: 'created' | 'running' | 'completed' | 'failed'
|
||||
error?: string
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user