mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-26 09:44:06 +00:00
[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:
@@ -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 |
@@ -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>
|
||||
@@ -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>
|
||||
173
src/components/dialog/content/MissingModelsContent.vue
Normal file
173
src/components/dialog/content/MissingModelsContent.vue
Normal 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>
|
||||
103
src/components/dialog/content/MissingModelsFooter.vue
Normal file
103
src/components/dialog/content/MissingModelsFooter.vue
Normal 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>
|
||||
10
src/components/dialog/content/MissingModelsHeader.vue
Normal file
10
src/components/dialog/content/MissingModelsHeader.vue
Normal 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>
|
||||
@@ -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>
|
||||
83
src/components/dialog/content/missingModelsUtils.ts
Normal file
83
src/components/dialog/content/missingModelsUtils.ts
Normal 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
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user