mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-08 06:30:04 +00:00
[feat] Add model metadata fetching with loading skeleton and gated repo support (#9415)
## Summary - Fetch file sizes via HEAD requests (HuggingFace) and Civitai API with caching and request deduplication - Show skeleton loader while metadata is loading - Display "Accept terms" link for gated HuggingFace models instead of download button ## Changes - **`missingModelsUtils.ts`**: Add `fetchModelMetadata` with Civitai API support, HuggingFace gated repo detection, in-memory cache, and inflight request deduplication - **`MissingModelsContent.vue`**: Add Skeleton loading state, gated model "Accept terms" link, extract `showSkeleton` helper - **`missingModelsUtils.test.ts`**: Tests for HEAD/Civitai fetching, gated repo detection, caching, and deduplication - **`main.json`**: Add `acceptTerms` i18n key ## Related Issues https://github.com/Comfy-Org/ComfyUI_frontend/issues/9410 https://github.com/Comfy-Org/ComfyUI_frontend/issues/9412 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-9415-feat-Add-model-metadata-fetching-with-loading-skeleton-and-gated-repo-support-31a6d73d36508127859efa0b3847505e) by [Unito](https://www.unito.io)
This commit is contained in:
142
src/components/dialog/content/missingModelsUtils.test.ts
Normal file
142
src/components/dialog/content/missingModelsUtils.test.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
|
||||
import { fetchModelMetadata } from './missingModelsUtils'
|
||||
|
||||
const fetchMock = vi.fn()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({ isDesktop: false }))
|
||||
vi.mock('@/stores/electronDownloadStore', () => ({}))
|
||||
|
||||
let testId = 0
|
||||
|
||||
describe('fetchModelMetadata', () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset()
|
||||
testId++
|
||||
})
|
||||
|
||||
it('fetches file size via HEAD for non-Civitai URLs', async () => {
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
headers: new Headers({ 'content-length': '1048576' })
|
||||
})
|
||||
|
||||
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('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 }]
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user