Files
ComfyUI_frontend/src/platform/missingModel/missingModelDownload.ts
jaeone94 7caba4408d fix: open model library for desktop model downloads (#12478)
## Summary

Open the Model Library sidebar when a missing-model download starts on
desktop so users can immediately see Electron download progress. This is
a temporary UX patch until the dedicated desktop missing-model download
progress bar lands.

## Changes

- **What**: Activates the `model-library` sidebar tab before starting
desktop missing-model downloads.
- **Dependencies**: None.

## Review Focus

Confirm the minimal behavior is scoped to the desktop download path and
covers both individual downloads and Download all through the shared
`downloadModel()` helper. This should remain small and easy to remove
once the progress bar flow is available.

## Screenshots (if applicable)



https://github.com/user-attachments/assets/d5b01db7-46b5-4a52-bb11-45e75a422474



## Test Plan

- `pnpm format`
- `pnpm lint`
- `pnpm typecheck`
- `pnpm test:unit`
- `pnpm knip`
2026-05-27 15:25:26 +00:00

189 lines
5.4 KiB
TypeScript

import { downloadUrlToHfRepoUrl, isCivitaiModelUrl } from '@/utils/formatUtil'
import { isDesktop } from '@/platform/distribution/types'
import { useElectronDownloadStore } from '@/stores/electronDownloadStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
const ALLOWED_SOURCES = [
'https://civitai.com/',
'https://civitai.red/',
'https://huggingface.co/',
'http://localhost:'
] as const
// Intentionally restrictive subset of model extensions permitted for download.
// Does not include .bin, .onnx, .gguf — see MODEL_FILE_EXTENSIONS in
// missingModelScan.ts for the broader scanning set.
const ALLOWED_SUFFIXES = [
'.safetensors',
'.sft',
'.ckpt',
'.pth',
'.pt'
] as const
const WHITE_LISTED_URLS: ReadonlySet<string> = new Set([
'https://huggingface.co/stabilityai/stable-zero123/resolve/main/stable_zero123.ckpt',
'https://huggingface.co/TencentARC/T2I-Adapter/resolve/main/models/t2iadapter_depth_sd14v1.pth?download=true',
'https://github.com/xinntao/Real-ESRGAN/releases/download/v0.1.0/RealESRGAN_x4plus.pth'
])
const MODEL_LIBRARY_TAB_ID = 'model-library'
export interface ModelWithUrl {
name: string
url: string
directory: string
}
/**
* Converts a model download URL to a browsable page URL.
* - HuggingFace: `/resolve/` → `/blob/` (file page with model info)
* - Civitai: strips `/api/download` or `/api/v1` prefix (model page)
*/
export function toBrowsableUrl(url: string): string {
if (isCivitaiModelUrl(url)) {
return url.replace('/api/download/', '/').replace('/api/v1/', '/')
}
if (url.includes('huggingface.co')) {
return url.replace('/resolve/', '/blob/')
}
return url
}
export function isModelDownloadable(model: ModelWithUrl): boolean {
if (WHITE_LISTED_URLS.has(model.url)) return true
if (!ALLOWED_SOURCES.some((source) => model.url.startsWith(source)))
return false
if (!ALLOWED_SUFFIXES.some((suffix) => model.name.endsWith(suffix)))
return false
return true
}
export function downloadModel(
model: ModelWithUrl,
paths: Record<string, string[]>
): void {
if (!isDesktop) {
const link = document.createElement('a')
link.href = model.url
link.download = model.name
link.target = '_blank'
link.rel = 'noopener noreferrer'
link.click()
return
}
const modelPaths = paths[model.directory]
if (modelPaths?.[0]) {
useSidebarTabStore().activeSidebarTabId = MODEL_LIBRARY_TAB_ID
void useElectronDownloadStore().start({
url: model.url,
savePath: modelPaths[0],
filename: model.name
})
}
}
interface ModelMetadata {
fileSize: number | null
gatedRepoUrl: string | null
}
interface CivitaiModelFile {
sizeKB: number
downloadUrl: string
}
interface CivitaiModelVersionResponse {
files: CivitaiModelFile[]
}
const metadataCache = new Map<string, ModelMetadata>()
const inflight = new Map<string, Promise<ModelMetadata>>()
async function fetchCivitaiMetadata(url: string): Promise<ModelMetadata> {
try {
const pathname = new URL(url).pathname
const versionIdMatch =
pathname.match(/^\/api\/download\/models\/(\d+)$/) ??
pathname.match(/^\/api\/v1\/models-versions\/(\d+)$/)
if (!versionIdMatch) return { fileSize: null, gatedRepoUrl: null }
const [, modelVersionId] = versionIdMatch
const apiUrl = `https://civitai.com/api/v1/model-versions/${modelVersionId}`
const res = await fetch(apiUrl)
if (!res.ok) return { fileSize: null, gatedRepoUrl: null }
const data: CivitaiModelVersionResponse = await res.json()
const matchingFile = data.files?.find((file) => {
const downloadUrl = file.downloadUrl
return (
typeof downloadUrl === 'string' &&
downloadUrl.length > 0 &&
downloadUrl.startsWith(url)
)
})
const fileSize = matchingFile?.sizeKB ? matchingFile.sizeKB * 1024 : null
return { fileSize, gatedRepoUrl: null }
} catch {
return { fileSize: null, gatedRepoUrl: null }
}
}
const GATED_STATUS_CODES = new Set([401, 403, 451])
async function fetchHeadMetadata(url: string): Promise<ModelMetadata> {
try {
const response = await fetch(url, { method: 'HEAD' })
if (!response.ok) {
if (
url.includes('huggingface.co') &&
GATED_STATUS_CODES.has(response.status)
) {
return { fileSize: null, gatedRepoUrl: downloadUrlToHfRepoUrl(url) }
}
return { fileSize: null, gatedRepoUrl: null }
}
const size = response.headers.get('content-length')
const parsedSize = size ? parseInt(size, 10) : null
return {
fileSize:
parsedSize !== null && !Number.isNaN(parsedSize) ? parsedSize : null,
gatedRepoUrl: null
}
} catch {
return { fileSize: null, gatedRepoUrl: null }
}
}
function isComplete(metadata: ModelMetadata): boolean {
return metadata.fileSize !== null || metadata.gatedRepoUrl !== null
}
export async function fetchModelMetadata(url: string): Promise<ModelMetadata> {
const cached = metadataCache.get(url)
if (cached !== undefined) return cached
const existing = inflight.get(url)
if (existing) return existing
const promise = (async () => {
const metadata = isCivitaiModelUrl(url)
? await fetchCivitaiMetadata(url)
: await fetchHeadMetadata(url)
if (isComplete(metadata)) {
metadataCache.set(url, metadata)
}
return metadata
})()
inflight.set(url, promise)
try {
return await promise
} finally {
inflight.delete(url)
}
}