[refactor] Redesign missing models dialog (#9014)

## Summary
Redesign the missing models warning dialog to match the MissingNodes
dialog pattern with header/content/footer separation, type badges, file
sizes, and context-sensitive actions.

## Changes
- **What**: Split `MissingModelsWarning.vue` into `MissingModelsHeader`,
`MissingModelsContent`, `MissingModelsFooter` components following the
established MissingNodes pattern. Added model type badges (VAE,
DIFFUSION, LORA, etc.), inline file sizes, total download size, custom
model warnings, and context-sensitive footer buttons (Download all /
Download available / Ok, got it). Extracted security validation into
shared `missingModelsUtils.ts`. Removed orphaned `FileDownload`,
`ElectronFileDownload`, `useDownload`, and `useCivitaiModel` files.
- **Breaking**: None

## Review Focus
- Badge styling and icon button variants for theme compatibility
- Security validation logic preserved correctly in extracted utility
- E2e test locator updates for the new dialog structure

<img width="641" height="478" alt="스크린샷 2026-02-20 오후 7 35 23"
src="https://github.com/user-attachments/assets/ded27dc7-04e6-431d-9b2e-a96ba61043a4"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9014-refactor-Redesign-missing-models-dialog-30d6d73d365081809cb0c555c2c28034)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
Jin Yi
2026-02-25 10:51:18 +09:00
committed by GitHub
parent 9108b7535a
commit 164379bf4b
14 changed files with 417 additions and 589 deletions

View File

@@ -104,15 +104,13 @@ test.describe('Missing models warning', () => {
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_models')
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
await expect(missingModelsWarning).toBeVisible()
const dialogTitle = comfyPage.page.getByText(
'This workflow is missing models'
)
await expect(dialogTitle).toBeVisible()
const downloadButton = missingModelsWarning.getByText('Download')
await expect(downloadButton).toBeVisible()
// Check that the copy URL button is also visible for Desktop environment
const copyUrlButton = missingModelsWarning.getByText('Copy URL')
await expect(copyUrlButton).toBeVisible()
const downloadAllButton = comfyPage.page.getByText('Download all')
await expect(downloadAllButton).toBeVisible()
})
test('Should display a warning when missing models are found in node properties', async ({
@@ -123,15 +121,13 @@ test.describe('Missing models warning', () => {
'missing/missing_models_from_node_properties'
)
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
await expect(missingModelsWarning).toBeVisible()
const dialogTitle = comfyPage.page.getByText(
'This workflow is missing models'
)
await expect(dialogTitle).toBeVisible()
const downloadButton = missingModelsWarning.getByText('Download')
await expect(downloadButton).toBeVisible()
// Check that the copy URL button is also visible for Desktop environment
const copyUrlButton = missingModelsWarning.getByText('Copy URL')
await expect(copyUrlButton).toBeVisible()
const downloadAllButton = comfyPage.page.getByText('Download all')
await expect(downloadAllButton).toBeVisible()
})
test('Should not display a warning when no missing models are found', async ({
@@ -172,8 +168,10 @@ test.describe('Missing models warning', () => {
await comfyPage.workflow.loadWorkflow('missing/missing_models')
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
await expect(missingModelsWarning).not.toBeVisible()
const dialogTitle = comfyPage.page.getByText(
'This workflow is missing models'
)
await expect(dialogTitle).not.toBeVisible()
})
test('Should not display warning when model metadata exists but widget values have changed', async ({
@@ -186,8 +184,10 @@ test.describe('Missing models warning', () => {
)
// The missing models warning should NOT appear
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
await expect(missingModelsWarning).not.toBeVisible()
const dialogTitle = comfyPage.page.getByText(
'This workflow is missing models'
)
await expect(dialogTitle).not.toBeVisible()
})
// Flaky test after parallelization
@@ -199,13 +199,15 @@ test.describe('Missing models warning', () => {
// https://github.com/Comfy-Org/ComfyUI_devtools/blob/main/__init__.py
await comfyPage.workflow.loadWorkflow('missing/missing_models')
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
await expect(missingModelsWarning).toBeVisible()
const dialogTitle = comfyPage.page.getByText(
'This workflow is missing models'
)
await expect(dialogTitle).toBeVisible()
const downloadButton = comfyPage.page.getByText('Download')
await expect(downloadButton).toBeVisible()
const downloadAllButton = comfyPage.page.getByText('Download all')
await expect(downloadAllButton).toBeVisible()
const downloadPromise = comfyPage.page.waitForEvent('download')
await downloadButton.click()
await downloadAllButton.click()
const download = await downloadPromise
expect(download.suggestedFilename()).toBe('fake_model.safetensors')
@@ -229,13 +231,14 @@ test.describe('Missing models warning', () => {
test('Should disable warning dialog when checkbox is checked', async ({
comfyPage
}) => {
await checkbox.click()
const changeSettingPromise = comfyPage.page.waitForRequest(
'**/api/settings/Comfy.Workflow.ShowMissingModelsWarning'
)
await closeButton.click()
await checkbox.click()
await changeSettingPromise
await closeButton.click()
const settingValue = await comfyPage.settings.getSetting(
'Comfy.Workflow.ShowMissingModelsWarning'
)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 88 KiB

View File

@@ -1,148 +0,0 @@
<!-- A Electron-backed download button with a label, size hint and progress bar -->
<template>
<div class="flex flex-col">
<div class="flex flex-row items-center gap-2">
<i v-if="status === 'completed'" class="pi pi-check text-green-500" />
<div class="file-info">
<div class="file-details">
<span class="file-type" :title="hint">{{ label }}</span>
</div>
<div v-if="props.error" class="file-error">
{{ props.error }}
</div>
</div>
<div class="file-action flex flex-row items-center gap-2">
<Button
v-if="status === null || status === 'error'"
class="file-action-button"
variant="secondary"
size="sm"
:disabled="!!props.error"
@click="triggerDownload"
>
<i class="pi pi-download" />
{{ $t('g.downloadWithSize', { size: fileSize }) }}
</Button>
<Button
v-if="(status === null || status === 'error') && !!props.url"
variant="secondary"
size="sm"
@click="copyURL"
>
{{ $t('g.copyURL') }}
</Button>
</div>
</div>
<div
v-if="status === 'in_progress' || status === 'paused'"
class="flex flex-row items-center gap-2"
>
<!-- Temporary fix for issue when % only comes into view only if the progress bar is large enough
https://comfy-organization.slack.com/archives/C07H3GLKDPF/p1731551013385499
-->
<ProgressBar
class="flex-1"
:value="downloadProgress"
:show-value="downloadProgress > 10"
/>
<Button
v-if="status === 'in_progress'"
v-tooltip.top="t('electronFileDownload.pause')"
class="file-action-button"
variant="secondary"
size="sm"
:disabled="!!props.error"
@click="triggerPauseDownload"
>
<i class="pi pi-pause-circle" />
</Button>
<Button
v-if="status === 'paused'"
v-tooltip.top="t('electronFileDownload.resume')"
class="file-action-button"
variant="secondary"
size="sm"
:aria-label="t('electronFileDownload.resume')"
:disabled="!!props.error"
@click="triggerResumeDownload"
>
<i class="pi pi-play-circle" />
</Button>
<Button
v-tooltip.top="t('electronFileDownload.cancel')"
class="file-action-button"
variant="destructive"
size="sm"
:aria-label="t('electronFileDownload.cancel')"
:disabled="!!props.error"
@click="triggerCancelDownload"
>
<i class="pi pi-times-circle" />
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import ProgressBar from 'primevue/progressbar'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { useDownload } from '@/composables/useDownload'
import { useElectronDownloadStore } from '@/stores/electronDownloadStore'
import { formatSize } from '@/utils/formatUtil'
const props = defineProps<{
url: string
hint?: string
label?: string
error?: string
}>()
const { t } = useI18n()
const label = computed(() => props.label || props.url.split('/').pop())
const hint = computed(() => props.hint || props.url)
const download = useDownload(props.url)
const downloadProgress = ref<number>(0)
const status = ref<string | null>(null)
const fileSize = computed(() =>
download.fileSize.value ? formatSize(download.fileSize.value) : '?'
)
const { copyToClipboard } = useCopyToClipboard()
const electronDownloadStore = useElectronDownloadStore()
// @ts-expect-error fixme ts strict error
const [savePath, filename] = props.label.split('/')
electronDownloadStore.$subscribe((_, { downloads }) => {
const download = downloads.find((download) => props.url === download.url)
if (download) {
// @ts-expect-error fixme ts strict error
downloadProgress.value = Number((download.progress * 100).toFixed(1))
// @ts-expect-error fixme ts strict error
status.value = download.status
}
})
const triggerDownload = async () => {
await electronDownloadStore.start({
url: props.url,
savePath: savePath.trim(),
filename: filename.trim()
})
}
const triggerCancelDownload = () => electronDownloadStore.cancel(props.url)
const triggerPauseDownload = () => electronDownloadStore.pause(props.url)
const triggerResumeDownload = () => electronDownloadStore.resume(props.url)
const copyURL = async () => {
await copyToClipboard(props.url)
}
</script>

View File

@@ -1,69 +0,0 @@
<!-- A file download button with a label and a size hint -->
<template>
<div class="flex flex-row items-center gap-2">
<div>
<div>
<span :title="hint">{{ label }}</span>
</div>
<Message
v-if="props.error"
severity="error"
icon="pi pi-exclamation-triangle"
size="small"
variant="outlined"
class="my-2 h-min max-w-xs px-1"
:title="props.error"
:pt="{
text: { class: 'overflow-hidden text-ellipsis' }
}"
>
{{ props.error }}
</Message>
</div>
<div>
<Button
variant="secondary"
:disabled="!!props.error"
:title="props.url"
@click="download.triggerBrowserDownload"
>
{{ $t('g.downloadWithSize', { size: fileSize }) }}
</Button>
</div>
<div>
<Button variant="secondary" :disabled="!!props.error" @click="copyURL">
{{ $t('g.copyURL') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import Message from 'primevue/message'
import { computed } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { useDownload } from '@/composables/useDownload'
import { formatSize } from '@/utils/formatUtil'
const props = defineProps<{
url: string
hint?: string
label?: string
error?: string
}>()
const label = computed(() => props.label || props.url.split('/').pop())
const hint = computed(() => props.hint || props.url)
const download = useDownload(props.url)
const fileSize = computed(() =>
download.fileSize.value ? formatSize(download.fileSize.value) : '?'
)
const copyURL = async () => {
await copyToClipboard(props.url)
}
const { copyToClipboard } = useCopyToClipboard()
</script>

View File

@@ -0,0 +1,173 @@
<template>
<div
class="flex w-full max-w-[490px] flex-col border-t border-border-default"
>
<div class="flex h-full w-full flex-col gap-4 p-4">
<p class="m-0 text-sm leading-5 text-muted-foreground">
{{ $t('missingModelsDialog.description') }}
</p>
<div
class="flex max-h-[300px] flex-col overflow-y-auto rounded-lg bg-secondary-background scrollbar-custom"
>
<div
v-for="model in processedModels"
:key="model.name"
class="flex items-center justify-between px-3 py-2"
>
<div class="flex items-center gap-2 overflow-hidden">
<span
class="min-w-0 truncate text-sm text-foreground"
:title="model.name"
>
{{ model.name }}
</span>
<span
class="inline-flex h-4 shrink-0 items-center rounded-full bg-muted-foreground/20 px-1.5 text-xxxs font-semibold uppercase text-muted-foreground"
>
{{ model.badgeLabel }}
</span>
</div>
<div class="flex shrink-0 items-center gap-2">
<span
v-if="model.isDownloadable && fileSizes.get(model.url)"
class="text-xs text-muted-foreground"
>
{{ formatSize(fileSizes.get(model.url)) }}
</span>
<Button
v-if="model.isDownloadable"
variant="textonly"
size="icon"
:title="model.url"
:aria-label="$t('g.download')"
@click="downloadModel(model, paths)"
>
<i class="icon-[lucide--download] size-4" />
</Button>
<Button
v-else
variant="textonly"
size="icon"
:title="model.url"
:aria-label="$t('g.copyURL')"
@click="void copyToClipboard(model.url)"
>
<i class="icon-[lucide--copy] size-4" />
</Button>
</div>
</div>
<div
v-if="totalDownloadSize > 0"
class="sticky bottom-0 flex items-center justify-between border-t border-border-default bg-secondary-background px-3 py-2"
>
<span class="text-xs font-medium text-muted-foreground">
{{ $t('missingModelsDialog.totalSize') }}
</span>
<span class="text-xs text-muted-foreground">
{{ formatSize(totalDownloadSize) }}
</span>
</div>
</div>
<p
class="m-0 text-xs leading-5 text-muted-foreground whitespace-pre-line"
>
{{ $t('missingModelsDialog.footerDescription') }}
</p>
<div
v-if="hasCustomModels"
class="flex gap-3 rounded-lg border border-warning-background bg-warning-background/10 p-3"
>
<i
class="icon-[lucide--triangle-alert] mt-0.5 h-4 w-4 shrink-0 text-warning-background"
/>
<div class="flex flex-col gap-1">
<p
class="m-0 text-xs font-semibold leading-5 text-warning-background"
>
{{ $t('missingModelsDialog.customModelsWarning') }}
</p>
<p class="m-0 text-xs leading-5 text-warning-background">
{{ $t('missingModelsDialog.customModelsInstruction') }}
</p>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { formatSize } from '@/utils/formatUtil'
import type { ModelWithUrl } from './missingModelsUtils'
import {
downloadModel,
getBadgeLabel,
hasValidDirectory,
isModelDownloadable
} from './missingModelsUtils'
const { missingModels, paths } = defineProps<{
missingModels: ModelWithUrl[]
paths: Record<string, string[]>
}>()
interface ProcessedModel {
name: string
url: string
directory: string
badgeLabel: string
isDownloadable: boolean
}
const processedModels = computed<ProcessedModel[]>(() =>
missingModels.map((model) => ({
name: model.name,
url: model.url,
directory: model.directory,
badgeLabel: getBadgeLabel(model.directory),
isDownloadable:
hasValidDirectory(model, paths) && isModelDownloadable(model)
}))
)
const hasCustomModels = computed(() =>
processedModels.value.some((m) => !m.isDownloadable)
)
const fileSizes = reactive(new Map<string, number>())
const totalDownloadSize = computed(() =>
processedModels.value
.filter((model) => model.isDownloadable)
.reduce((total, model) => total + (fileSizes.get(model.url) ?? 0), 0)
)
onMounted(async () => {
const downloadableUrls = processedModels.value
.filter((m) => m.isDownloadable)
.map((m) => m.url)
await Promise.allSettled(
downloadableUrls.map(async (url) => {
try {
const response = await fetch(url, { method: 'HEAD' })
if (!response.ok) return
const size = response.headers.get('content-length')
if (size) fileSizes.set(url, parseInt(size, 10))
} catch {
// Silently skip size fetch failures
}
})
)
})
const { copyToClipboard } = useCopyToClipboard()
</script>

View File

@@ -0,0 +1,103 @@
<template>
<div class="flex w-full flex-col gap-2 px-4 py-2">
<div class="flex flex-col gap-1 text-sm text-muted-foreground">
<div class="flex items-center gap-1">
<input
id="doNotAskAgainModels"
v-model="doNotAskAgain"
type="checkbox"
class="h-4 w-4 cursor-pointer"
/>
<label for="doNotAskAgainModels">{{
$t('missingModelsDialog.doNotAskAgain')
}}</label>
</div>
<i18n-t
v-if="doNotAskAgain"
keypath="missingModelsDialog.reEnableInSettings"
tag="span"
class="ml-6 text-sm text-muted-foreground"
>
<template #link>
<Button
variant="textonly"
class="cursor-pointer p-0 text-sm text-muted-foreground underline hover:bg-transparent"
@click="openShowMissingModelsSetting"
>
{{ $t('missingModelsDialog.reEnableInSettingsLink') }}
</Button>
</template>
</i18n-t>
</div>
<div class="flex justify-end gap-1">
<Button variant="secondary" size="md" @click="handleAction">
{{ buttonLabel }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { useDialogStore } from '@/stores/dialogStore'
import type { ModelWithUrl } from './missingModelsUtils'
import {
downloadModel,
hasValidDirectory,
isModelDownloadable
} from './missingModelsUtils'
const { missingModels, paths } = defineProps<{
missingModels: ModelWithUrl[]
paths: Record<string, string[]>
}>()
const DIALOG_KEY = 'global-missing-models-warning'
const { t } = useI18n()
const dialogStore = useDialogStore()
const doNotAskAgain = ref(false)
watch(doNotAskAgain, (value) => {
void useSettingStore().set('Comfy.Workflow.ShowMissingModelsWarning', !value)
})
function openShowMissingModelsSetting() {
dialogStore.closeDialog({ key: DIALOG_KEY })
useSettingsDialog().show(undefined, 'Comfy.Workflow.ShowMissingModelsWarning')
}
const downloadableModels = computed(() =>
missingModels.filter(
(model) => hasValidDirectory(model, paths) && isModelDownloadable(model)
)
)
const hasDownloadable = computed(() => downloadableModels.value.length > 0)
const hasCustom = computed(
() => downloadableModels.value.length < missingModels.length
)
const buttonLabel = computed(() => {
if (hasDownloadable.value && hasCustom.value)
return t('missingModelsDialog.downloadAvailable')
if (hasDownloadable.value) return t('missingModelsDialog.downloadAll')
return t('missingModelsDialog.gotIt')
})
function handleAction() {
if (hasDownloadable.value) {
for (const model of downloadableModels.value) {
downloadModel(model, paths)
}
}
dialogStore.closeDialog({ key: DIALOG_KEY })
}
</script>

View File

@@ -0,0 +1,10 @@
<template>
<div class="flex w-full items-center justify-between p-4">
<div class="flex items-center gap-2">
<i class="icon-[lucide--triangle-alert] text-warning-background"></i>
<p class="m-0 text-sm">
{{ $t('missingModelsDialog.title') }}
</p>
</div>
</div>
</template>

View File

@@ -1,177 +0,0 @@
<template>
<NoResultsPlaceholder
class="pb-0"
icon="pi pi-exclamation-circle"
:title="t('missingModelsDialog.missingModels')"
:message="t('missingModelsDialog.missingModelsMessage')"
/>
<div class="mb-4 flex flex-col gap-1">
<div class="flex gap-1">
<input
id="doNotAskAgain"
v-model="doNotAskAgain"
type="checkbox"
class="h-4 w-4 cursor-pointer"
/>
<label for="doNotAskAgain">{{
t('missingModelsDialog.doNotAskAgain')
}}</label>
</div>
<i18n-t
v-if="doNotAskAgain"
keypath="missingModelsDialog.reEnableInSettings"
tag="span"
class="text-sm text-muted-foreground ml-6"
>
<template #link>
<Button
variant="textonly"
class="underline cursor-pointer p-0 text-sm text-muted-foreground hover:bg-transparent"
@click="openShowMissingModelsSetting"
>
{{ t('missingModelsDialog.reEnableInSettingsLink') }}
</Button>
</template>
</i18n-t>
</div>
<ListBox :options="missingModels" class="comfy-missing-models">
<template #option="{ option }">
<Suspense v-if="isDesktop">
<ElectronFileDownload
:url="option.url"
:label="option.label"
:error="option.error"
/>
</Suspense>
<FileDownload
v-else
:url="option.url"
:label="option.label"
:error="option.error"
/>
</template>
</ListBox>
</template>
<script setup lang="ts">
import ListBox from 'primevue/listbox'
import { computed, onBeforeUnmount, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import ElectronFileDownload from '@/components/common/ElectronFileDownload.vue'
import FileDownload from '@/components/common/FileDownload.vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import { isDesktop } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { useDialogStore } from '@/stores/dialogStore'
// TODO: Read this from server internal API rather than hardcoding here
// as some installations may wish to use custom sources
const allowedSources = [
'https://civitai.com/',
'https://huggingface.co/',
'http://localhost:' // Included for testing usage only
]
const allowedSuffixes = ['.safetensors', '.sft']
// Models that fail above conditions but are still allowed
const whiteListedUrls = 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'
])
interface ModelInfo {
name: string
directory: string
url: string
downloading?: boolean
completed?: boolean
progress?: number
error?: string
folder_path?: string
}
const props = defineProps<{
missingModels: ModelInfo[]
paths: Record<string, string[]>
}>()
const { t } = useI18n()
const doNotAskAgain = ref(false)
function openShowMissingModelsSetting() {
useDialogStore().closeDialog({ key: 'global-missing-models-warning' })
useSettingsDialog().show(undefined, 'Comfy.Workflow.ShowMissingModelsWarning')
}
const modelDownloads = ref<Record<string, ModelInfo>>({})
const missingModels = computed(() => {
return props.missingModels.map((model) => {
const paths = props.paths[model.directory]
if (!paths) {
return {
label: `${model.directory} / ${model.name}`,
url: model.url,
error: 'Invalid directory specified (does this require custom nodes?)'
}
}
const downloadInfo: ModelInfo = modelDownloads.value[model.name] ?? {
downloading: false,
completed: false,
progress: 0,
error: null,
name: model.name,
directory: model.directory,
url: model.url,
folder_path: paths[0]
}
modelDownloads.value[model.name] = downloadInfo
if (!whiteListedUrls.has(model.url)) {
if (!allowedSources.some((source) => model.url.startsWith(source))) {
return {
label: `${model.directory} / ${model.name}`,
url: model.url,
error: `Download not allowed from source '${model.url}', only allowed from '${allowedSources.join("', '")}'`
}
}
if (!allowedSuffixes.some((suffix) => model.name.endsWith(suffix))) {
return {
label: `${model.directory} / ${model.name}`,
url: model.url,
error: `Only allowed suffixes are: '${allowedSuffixes.join("', '")}'`
}
}
}
return {
url: model.url,
label: `${model.directory} / ${model.name}`,
downloading: downloadInfo.downloading,
completed: downloadInfo.completed,
progress: downloadInfo.progress,
error: downloadInfo.error,
name: model.name,
paths: paths,
folderPath: downloadInfo.folder_path
}
})
})
onBeforeUnmount(async () => {
if (doNotAskAgain.value) {
await useSettingStore().set(
'Comfy.Workflow.ShowMissingModelsWarning',
false
)
}
})
</script>
<style scoped>
.comfy-missing-models {
max-height: 300px;
overflow-y: auto;
}
</style>

View File

@@ -0,0 +1,83 @@
import { isDesktop } from '@/platform/distribution/types'
import { useElectronDownloadStore } from '@/stores/electronDownloadStore'
const ALLOWED_SOURCES = [
'https://civitai.com/',
'https://huggingface.co/',
'http://localhost:'
] as const
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 DIRECTORY_BADGE_MAP = {
vae: 'VAE',
diffusion_models: 'DIFFUSION',
text_encoders: 'TEXT ENCODER',
loras: 'LORA',
checkpoints: 'CHECKPOINT'
} as const
export interface ModelWithUrl {
name: string
url: string
directory: string
}
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 hasValidDirectory(
model: ModelWithUrl,
paths: Record<string, string[]>
): boolean {
return !!paths[model.directory]
}
export function getBadgeLabel(directory: string): string {
if (directory in DIRECTORY_BADGE_MAP) {
return DIRECTORY_BADGE_MAP[directory as keyof typeof DIRECTORY_BADGE_MAP]
}
return directory.toUpperCase()
}
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]) {
void useElectronDownloadStore().start({
url: model.url,
savePath: modelPaths[0],
filename: model.name
})
}
}

View File

@@ -1,95 +0,0 @@
import { useAsyncState } from '@vueuse/core'
import { computed } from 'vue'
type ModelType =
| 'Checkpoint'
| 'TextualInversion'
| 'Hypernetwork'
| 'AestheticGradient'
| 'LORA'
| 'Controlnet'
| 'Poses'
interface CivitaiFileMetadata {
fp?: 'fp16' | 'fp32'
size?: 'full' | 'pruned'
format?: 'SafeTensor' | 'PickleTensor' | 'Other'
}
interface CivitaiModelFile {
name: string
id: number
sizeKB: number
type: string
downloadUrl: string
metadata: CivitaiFileMetadata
}
interface CivitaiModel {
name: string
type: ModelType
}
interface CivitaiModelVersionResponse {
id: number
name: string
model: CivitaiModel
modelId: number
files: CivitaiModelFile[]
[key: string]: unknown
}
/**
* Composable to manage Civitai model
* @param url - The URL of the Civitai model, where the model ID is the last part of the URL's pathname
* @see https://developer.civitai.com/docs/api/public-rest
* @example
* const { fileSize, isLoading, error, modelData } =
* useCivitaiModel('https://civitai.com/api/download/models/16576?type=Model&format=SafeTensor&size=full&fp=fp16')
*/
export function useCivitaiModel(url: string) {
const createModelVersionUrl = (modelId: string): string =>
`https://civitai.com/api/v1/model-versions/${modelId}`
const extractModelIdFromUrl = (): string | null => {
const urlObj = new URL(url)
return urlObj.pathname.split('/').pop() || null
}
const fetchModelData =
async (): Promise<CivitaiModelVersionResponse | null> => {
const modelId = extractModelIdFromUrl()
if (!modelId) return null
const apiUrl = createModelVersionUrl(modelId)
const res = await fetch(apiUrl)
return res.json()
}
const findMatchingFileSize = (): number | null => {
const matchingFile = modelData.value?.files?.find(
(file) => file.downloadUrl && url.startsWith(file.downloadUrl)
)
return matchingFile?.sizeKB ? matchingFile.sizeKB << 10 : null
}
const {
state: modelData,
isLoading,
error
} = useAsyncState(fetchModelData, null, {
immediate: true
})
const fileSize = computed(() =>
!isLoading.value ? findMatchingFileSize() : null
)
return {
fileSize,
isLoading,
error,
modelData
}
}

View File

@@ -1,67 +0,0 @@
import { whenever } from '@vueuse/core'
import { onMounted, ref } from 'vue'
import { useCivitaiModel } from '@/composables/useCivitaiModel'
import { downloadUrlToHfRepoUrl, isCivitaiModelUrl } from '@/utils/formatUtil'
export function useDownload(url: string, fileName?: string) {
const fileSize = ref<number | null>(null)
const error = ref<Error | null>(null)
const setFileSize = (size: number) => {
fileSize.value = size
}
const fetchFileSize = async () => {
try {
const response = await fetch(url, { method: 'HEAD' })
if (!response.ok) throw new Error('Failed to fetch file size')
const size = response.headers.get('content-length')
if (size) {
setFileSize(parseInt(size))
} else {
console.error('"content-length" header not found')
return null
}
} catch (e) {
console.error('Error fetching file size:', e)
error.value = e instanceof Error ? e : new Error(String(e))
return null
}
}
/**
* Trigger browser download
*/
const triggerBrowserDownload = () => {
const link = document.createElement('a')
if (url.includes('huggingface.co') && error.value) {
// If model is a gated HF model, send user to the repo page so they can sign in first
link.href = downloadUrlToHfRepoUrl(url)
} else {
link.href = url
link.download = fileName || url.split('/').pop() || 'download'
}
link.target = '_blank' // Opens in new tab if download attribute is not supported
link.rel = 'noopener noreferrer' // Security best practice for _blank links
link.click()
}
onMounted(() => {
if (isCivitaiModelUrl(url)) {
const { fileSize: civitaiSize, error: civitaiErr } = useCivitaiModel(url)
whenever(civitaiSize, setFileSize)
// Try falling back to normal fetch if using Civitai API fails
whenever(civitaiErr, fetchFileSize, { once: true })
} else {
// Fetch file size in the background
void fetchFileSize()
}
})
return {
triggerBrowserDownload,
fileSize
}
}

View File

@@ -1,6 +1,8 @@
import type { ComponentAttrs } from 'vue-component-type-helpers'
import MissingModelsWarning from '@/components/dialog/content/MissingModelsWarning.vue'
import MissingModelsContent from '@/components/dialog/content/MissingModelsContent.vue'
import MissingModelsFooter from '@/components/dialog/content/MissingModelsFooter.vue'
import MissingModelsHeader from '@/components/dialog/content/MissingModelsHeader.vue'
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
@@ -14,11 +16,14 @@ export function useMissingModelsDialog() {
dialogStore.closeDialog({ key: DIALOG_KEY })
}
function show(props: ComponentAttrs<typeof MissingModelsWarning>) {
function show(props: ComponentAttrs<typeof MissingModelsContent>) {
showSmallLayoutDialog({
key: DIALOG_KEY,
component: MissingModelsWarning,
props
headerComponent: MissingModelsHeader,
footerComponent: MissingModelsFooter,
component: MissingModelsContent,
props,
footerProps: props
})
}

View File

@@ -1749,8 +1749,15 @@
"doNotAskAgain": "Don't show this again",
"reEnableInSettings": "Re-enable in {link}",
"reEnableInSettingsLink": "Settings",
"missingModels": "Missing Models",
"missingModelsMessage": "When loading the graph, the following models were not found"
"title": "This workflow is missing models",
"description": "This workflow requires models you haven't downloaded yet.",
"totalSize": "Total download size:",
"downloadAll": "Download all",
"downloadAvailable": "Download available",
"gotIt": "Ok, got it",
"footerDescription": "Download and place these models in the correct folder.\nNodes with missing models are highlighted red on the canvas.",
"customModelsWarning": "Some of these are custom models that we don't recognize.",
"customModelsInstruction": "You'll need to find and download them manually. Search for them online (try Civitai or HuggingFace) or contact the original workflow provider."
},
"versionMismatchWarning": {
"title": "Version Compatibility Warning",