From b4d209b5f649f8fe2d883ddc4eee264b5efcaf13 Mon Sep 17 00:00:00 2001
From: jaeone94 <89377375+jaeone94@users.noreply.github.com>
Date: Tue, 28 Apr 2026 03:53:50 +0900
Subject: [PATCH] feat: refresh missing models through pipeline (#11661)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
Follow-up to the closed earlier attempt in #11646. This PR keeps the
same user-facing goal, but changes the implementation to reuse the
existing missing model pipeline for refresh instead of maintaining a
separate candidate-only recheck path.
Adds a missing model refresh action in the Errors tab by reusing the
existing missing model pipeline, so users can re-check models after
downloading or manually placing files without reloading the workflow.
## Changes
- **What**:
- Adds `app.refreshMissingModels()` as a reusable refresh entry point
for the current root graph.
- Splits node definition reloading into `app.reloadNodeDefs()` so
missing-model refresh can pull fresh `object_info` without showing the
generic combo refresh success flow.
- Reuses the existing missing model pipeline instead of adding a
separate candidate-only checker. The refresh path serializes the current
graph, reuses active workflow model metadata when available, falls back
to current missing-model metadata, and then reruns the same candidate
discovery/enrichment/surfacing flow used during workflow load.
- Adds missing model refresh state and error handling to
`missingModelStore`.
- Adds a Refresh button next to Download all in the missing model card
action bar.
- Moves Download all from the Errors tab header into the missing model
card, so the Download all and Refresh actions render or hide together.
- Changes Download all visibility from “more than one downloadable
model” to “at least one downloadable model.”
- Keeps the action bar hidden when there are no downloadable missing
models; Cloud still does not render this action area.
- Normalizes active workflow `pendingWarnings` updates so resolved
missing model warnings do not get revived by stale empty warning
objects.
- Adds test IDs and coverage for the new action bar, refresh state,
refresh delegation, pending warning sync, and E2E refresh behavior.
- **Breaking**: None.
- **Dependencies**: None.
## Review Focus
The main design choice is intentionally reusing the missing model
pipeline for refresh instead of implementing a smaller candidate-only
recheck.
The earlier candidate-only approach was cheaper, but it created a
separate source of truth for missing-model resolution and made edge
cases harder to reason about. In particular, it could diverge from the
behavior used when a workflow is loaded, and it did not naturally handle
the case where a model becomes missing after the workflow is already
open. This version pays the cost of refreshing node definitions and
rerunning the missing-model scan for the current graph, but keeps the
refresh behavior aligned with workflow load semantics.
Expected behavior by environment:
- OSS browser:
- The action bar appears when at least one missing model has a
downloadable URL and directory.
- Download all uses the existing browser download path.
- Refresh reloads `object_info`, refreshes node definitions/combo
values, reruns missing-model detection for the current graph, and clears
the error if the selected model is now available.
- OSS desktop:
- The same action bar appears under the same downloadable-model
condition.
- Download all uses the existing Electron DownloadManager path.
- Refresh uses the same missing-model pipeline as browser, so manually
placed files or desktop-downloaded files can be rechecked without
reloading the workflow.
- Cloud:
- The action bar remains hidden because model download/import is not
supported in this section for Cloud.
A few boundaries are intentional:
- This PR does not add automatic filesystem watching. Browser OSS cannot
reliably observe local model folder changes, so the user-triggered
Refresh button remains the cross-environment mechanism.
- This PR does not redesign the public `refreshComboInNodes` API beyond
extracting `reloadNodeDefs()` for reuse. Further cleanup of toast
behavior or a more explicit object-info reload API can be follow-up
work.
- This PR keeps refresh scoped to missing-model validation; missing
media and missing nodes continue to use their existing flows.
Linear: FE-417
## Screenshots (if applicable)
https://github.com/user-attachments/assets/2e02799f-1374-4377-b7b3-172241517772
## Validation
- `pnpm format`
- `pnpm lint` (passes; existing unrelated warning remains in
`src/platform/workspace/composables/useWorkspaceBilling.test.ts`)
- `pnpm typecheck`
- `pnpm test:unit`
- `pnpm test:browser:local -- --project=chromium
browser_tests/tests/propertiesPanel/errorsTabMissingModels.spec.ts`
- `pnpm build`
- `NX_SKIP_NX_CACHE=true DISTRIBUTION=desktop USE_PROD_CONFIG=true
NODE_OPTIONS='--max-old-space-size=8192' pnpm exec nx build`
- Manual desktop verification through `~/Projects/desktop` after copying
the desktop build into `assets/ComfyUI/web_custom_versions/desktop_app`:
- confirmed the FE bundle is built with `DISTRIBUTION = "desktop"`
- confirmed missing model Download uses the desktop download path
instead of browser download
- confirmed Refresh can clear the missing model error after the model is
available
- Push hook: `pnpm knip --cache`
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11661-feat-refresh-missing-models-through-pipeline-34f6d73d3650811488defee54a7a6667)
by [Unito](https://www.unito.io)
---
browser_tests/fixtures/selectors.ts | 3 +
.../errorsTabMissingModels.spec.ts | 53 +++++
.../rightSidePanel/errors/TabErrors.test.ts | 64 +++++-
.../rightSidePanel/errors/TabErrors.vue | 118 +++++-----
src/locales/en/main.json | 6 +-
.../components/MissingModelCard.test.ts | 93 +++++++-
.../components/MissingModelCard.vue | 80 +++++++
.../missingModel/missingModelDownload.ts | 2 +-
.../missingModel/missingModelStore.test.ts | 71 ++++++
.../missingModel/missingModelStore.ts | 29 +++
.../missingModelViewUtils.test.ts | 124 ++++++++++
.../missingModel/missingModelViewUtils.ts | 21 ++
.../workflow/core/services/workflowService.ts | 27 +--
.../core/utils/pendingWarnings.test.ts | 86 +++++++
.../workflow/core/utils/pendingWarnings.ts | 33 +++
src/scripts/app.test.ts | 211 +++++++++++++++++-
src/scripts/app.ts | 181 +++++++++------
src/scripts/ui.ts | 4 +-
18 files changed, 1049 insertions(+), 157 deletions(-)
create mode 100644 src/platform/missingModel/missingModelViewUtils.test.ts
create mode 100644 src/platform/missingModel/missingModelViewUtils.ts
create mode 100644 src/platform/workflow/core/utils/pendingWarnings.test.ts
create mode 100644 src/platform/workflow/core/utils/pendingWarnings.ts
diff --git a/browser_tests/fixtures/selectors.ts b/browser_tests/fixtures/selectors.ts
index 331e851482..8d1baf38e1 100644
--- a/browser_tests/fixtures/selectors.ts
+++ b/browser_tests/fixtures/selectors.ts
@@ -59,6 +59,9 @@ export const TestIds = {
missingModelCopyName: 'missing-model-copy-name',
missingModelCopyUrl: 'missing-model-copy-url',
missingModelDownload: 'missing-model-download',
+ missingModelActions: 'missing-model-actions',
+ missingModelDownloadAll: 'missing-model-download-all',
+ missingModelRefresh: 'missing-model-refresh',
missingModelImportUnsupported: 'missing-model-import-unsupported',
missingMediaGroup: 'error-group-missing-media',
missingMediaRow: 'missing-media-row',
diff --git a/browser_tests/tests/propertiesPanel/errorsTabMissingModels.spec.ts b/browser_tests/tests/propertiesPanel/errorsTabMissingModels.spec.ts
index eb2803efc6..1a043eed72 100644
--- a/browser_tests/tests/propertiesPanel/errorsTabMissingModels.spec.ts
+++ b/browser_tests/tests/propertiesPanel/errorsTabMissingModels.spec.ts
@@ -99,5 +99,58 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
)
await expect(downloadButton.first()).toBeVisible()
})
+
+ test('Should render Download all and Refresh actions for one downloadable model', async ({
+ comfyPage
+ }) => {
+ await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
+
+ await expect(
+ comfyPage.page.getByTestId(TestIds.dialogs.missingModelActions)
+ ).toBeVisible()
+ await expect(
+ comfyPage.page.getByTestId(TestIds.dialogs.missingModelDownloadAll)
+ ).toBeVisible()
+ await expect(
+ comfyPage.page.getByTestId(TestIds.dialogs.missingModelRefresh)
+ ).toBeVisible()
+ })
+
+ test('Should clear resolved missing model when Refresh is clicked', async ({
+ comfyPage
+ }) => {
+ await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
+ await comfyPage.page.route(/\/object_info$/, async (route) => {
+ const response = await route.fetch()
+ const objectInfo = await response.json()
+ const ckptName =
+ objectInfo.CheckpointLoaderSimple.input.required.ckpt_name
+ ckptName[0] = [...ckptName[0], 'fake_model.safetensors']
+ await route.fulfill({ response, json: objectInfo })
+ })
+
+ const objectInfoResponse = comfyPage.page.waitForResponse((response) => {
+ const url = new URL(response.url())
+ return url.pathname.endsWith('/object_info') && response.ok()
+ })
+ const modelFoldersResponse = comfyPage.page.waitForResponse(
+ (response) => {
+ const url = new URL(response.url())
+ return url.pathname.endsWith('/experiment/models') && response.ok()
+ }
+ )
+ const refreshButton = comfyPage.page.getByTestId(
+ TestIds.dialogs.missingModelRefresh
+ )
+
+ await Promise.all([
+ objectInfoResponse,
+ modelFoldersResponse,
+ refreshButton.click()
+ ])
+ await expect(
+ comfyPage.page.getByTestId(TestIds.dialogs.missingModelsGroup)
+ ).toBeHidden()
+ })
})
})
diff --git a/src/components/rightSidePanel/errors/TabErrors.test.ts b/src/components/rightSidePanel/errors/TabErrors.test.ts
index f7dc8502b3..11b8e06b43 100644
--- a/src/components/rightSidePanel/errors/TabErrors.test.ts
+++ b/src/components/rightSidePanel/errors/TabErrors.test.ts
@@ -5,6 +5,8 @@ import PrimeVue from 'primevue/config'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import TabErrors from './TabErrors.vue'
+import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
+import type { MissingModelCandidate } from '@/platform/missingModel/types'
vi.mock('@/scripts/app', () => ({
app: {
@@ -50,6 +52,12 @@ describe('TabErrors.vue', () => {
rightSidePanel: {
noErrors: 'No errors',
noneSearchDesc: 'No results found',
+ missingModels: {
+ missingModelsTitle: 'Missing Models',
+ downloadAll: 'Download all',
+ refresh: 'Refresh',
+ refreshing: 'Refreshing missing models.'
+ },
promptErrors: {
prompt_no_outputs: {
desc: 'Prompt has no outputs'
@@ -82,7 +90,7 @@ describe('TabErrors.vue', () => {
template: '
'
},
Button: {
- template: ' '
+ template: ' '
}
}
}
@@ -241,4 +249,58 @@ describe('TabErrors.vue', () => {
expect(screen.getByTestId('runtime-error-panel')).toBeInTheDocument()
expect(screen.getAllByText('RuntimeError: Out of memory')).toHaveLength(1)
})
+
+ it('shows missing model Refresh in the section header when no model is downloadable', async () => {
+ const missingModel = {
+ nodeId: '1',
+ nodeType: 'CheckpointLoaderSimple',
+ widgetName: 'ckpt_name',
+ name: 'local-only.safetensors',
+ directory: 'checkpoints',
+ isMissing: true,
+ isAssetSupported: true
+ } satisfies MissingModelCandidate
+
+ const { user } = renderComponent({
+ missingModel: {
+ missingModelCandidates: [missingModel]
+ }
+ })
+ const missingModelStore = useMissingModelStore()
+
+ expect(screen.getByText('Missing Models (1)')).toBeInTheDocument()
+ expect(
+ screen.queryByTestId('missing-model-actions')
+ ).not.toBeInTheDocument()
+
+ await user.click(screen.getByTestId('missing-model-header-refresh'))
+
+ expect(missingModelStore.refreshMissingModels).toHaveBeenCalled()
+ })
+
+ it('keeps missing model Refresh in the card actions when models are downloadable', () => {
+ const missingModel = {
+ nodeId: '1',
+ nodeType: 'CheckpointLoaderSimple',
+ widgetName: 'ckpt_name',
+ name: 'downloadable.safetensors',
+ url: 'https://huggingface.co/comfy/test/resolve/main/downloadable.safetensors',
+ directory: 'checkpoints',
+ isMissing: true,
+ isAssetSupported: true
+ } satisfies MissingModelCandidate
+
+ renderComponent({
+ missingModel: {
+ missingModelCandidates: [missingModel]
+ }
+ })
+
+ expect(
+ screen.queryByTestId('missing-model-header-refresh')
+ ).not.toBeInTheDocument()
+ expect(screen.getByTestId('missing-model-actions')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /Download all/ })).toBeVisible()
+ expect(screen.getByRole('button', { name: 'Refresh' })).toBeVisible()
+ })
})
diff --git a/src/components/rightSidePanel/errors/TabErrors.vue b/src/components/rightSidePanel/errors/TabErrors.vue
index bed0aa183a..6f0cc50716 100644
--- a/src/components/rightSidePanel/errors/TabErrors.vue
+++ b/src/components/rightSidePanel/errors/TabErrors.vue
@@ -101,18 +101,6 @@
: t('rightSidePanel.missingNodePacks.installAll')
}}
-
- {{ downloadAllLabel }}
-
+
+
+ {{ t('rightSidePanel.missingModels.refresh') }}
+
+
+ {{
+ missingModelStore.isRefreshingMissingModels
+ ? t('rightSidePanel.missingModels.refreshing')
+ : ''
+ }}
+
@@ -238,14 +267,10 @@ import SwapNodesCard from '@/platform/nodeReplacement/components/SwapNodesCard.v
import MissingModelCard from '@/platform/missingModel/components/MissingModelCard.vue'
import MissingMediaCard from '@/platform/missingMedia/components/MissingMediaCard.vue'
import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
-import {
- downloadModel,
- isModelDownloadable
-} from '@/platform/missingModel/missingModelDownload'
-import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
-import { formatSize } from '@/utils/formatUtil'
import Button from '@/components/ui/button/Button.vue'
import DotSpinner from '@/components/common/DotSpinner.vue'
+import { getDownloadableModels } from '@/platform/missingModel/missingModelViewUtils'
+import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { usePackInstall } from '@/workbench/extensions/manager/composables/nodePack/usePackInstall'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
import { useErrorActions } from './useErrorActions'
@@ -267,6 +292,7 @@ const { focusNode, enterSubgraph } = useFocusNode()
const { openGitHubIssues, contactSupport } = useErrorActions()
const settingStore = useSettingStore()
const rightSidePanelStore = useRightSidePanelStore()
+const missingModelStore = useMissingModelStore()
const { shouldShowManagerButtons, shouldShowInstallButton, openManager } =
useManagerState()
const { missingNodePacks } = useMissingNodes()
@@ -307,6 +333,23 @@ const {
swapNodeGroups
} = useErrorGroups(searchQuery, t)
+const missingModelDownloadableModels = computed(() => {
+ if (isCloud) return []
+
+ return getDownloadableModels(missingModelGroups.value)
+})
+
+const showMissingModelHeaderRefresh = computed(
+ () =>
+ !isCloud &&
+ missingModelGroups.value.length > 0 &&
+ missingModelDownloadableModels.value.length === 0
+)
+
+function handleMissingModelRefresh() {
+ void missingModelStore.refreshMissingModels()
+}
+
const singleRuntimeErrorGroup = computed(() => {
if (filteredGroups.value.length !== 1) return null
const group = filteredGroups.value[0]
@@ -321,45 +364,6 @@ const singleRuntimeErrorCard = computed(
() => singleRuntimeErrorGroup.value?.cards[0] ?? null
)
-const missingModelStore = useMissingModelStore()
-
-const downloadableModels = computed(() => {
- if (isCloud) return []
- return missingModelGroups.value.flatMap((group) =>
- group.models
- .filter(
- (m) =>
- m.representative.url &&
- m.representative.directory &&
- isModelDownloadable({
- name: m.representative.name,
- url: m.representative.url,
- directory: m.representative.directory
- })
- )
- .map((m) => ({
- name: m.representative.name,
- url: m.representative.url!,
- directory: m.representative.directory!
- }))
- )
-})
-
-const downloadAllLabel = computed(() => {
- const base = t('rightSidePanel.missingModels.downloadAll')
- const total = downloadableModels.value.reduce(
- (sum, m) => sum + (missingModelStore.fileSizes[m.url] ?? 0),
- 0
- )
- return total > 0 ? `${base} (${formatSize(total)})` : base
-})
-
-function downloadAllModels() {
- for (const model of downloadableModels.value) {
- downloadModel(model, missingModelStore.folderPaths)
- }
-}
-
const isAllCollapsed = computed({
get() {
return filteredGroups.value.every((g) => isSectionCollapsed(g.title))
diff --git a/src/locales/en/main.json b/src/locales/en/main.json
index b51aecf98e..c82dbda7e3 100644
--- a/src/locales/en/main.json
+++ b/src/locales/en/main.json
@@ -2080,6 +2080,7 @@
"failedToDownloadFile": "Failed to download file",
"updateRequested": "Update requested",
"nodeDefinitionsUpdated": "Node definitions updated",
+ "nodeDefinitionsUpdateFailed": "Failed to update node definitions",
"errorSaveSetting": "Error saving setting {id}: {err}",
"errorCopyImage": "Error copying image: {error}",
"errorOpenImage": "Error opening image: {error}",
@@ -3575,7 +3576,10 @@
"unknownCategory": "Unknown",
"missingModelsTitle": "Missing Models",
"assetLoadTimeout": "Model detection timed out. Try reloading the workflow.",
- "downloadAll": "Download all"
+ "downloadAll": "Download all",
+ "refresh": "Refresh",
+ "refreshing": "Refreshing missing models.",
+ "refreshFailed": "Failed to refresh missing models. Please try again."
},
"missingMedia": {
"missingMediaTitle": "Missing Inputs",
diff --git a/src/platform/missingModel/components/MissingModelCard.test.ts b/src/platform/missingModel/components/MissingModelCard.test.ts
index eec98899c0..f58a18d807 100644
--- a/src/platform/missingModel/components/MissingModelCard.test.ts
+++ b/src/platform/missingModel/components/MissingModelCard.test.ts
@@ -1,14 +1,17 @@
+import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import PrimeVue from 'primevue/config'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import type {
MissingModelGroup,
MissingModelViewModel
} from '@/platform/missingModel/types'
+import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
vi.mock('./MissingModelRow.vue', () => ({
default: {
@@ -39,7 +42,10 @@ const i18n = createI18n({
importNotSupported: 'Import Not Supported',
customNodeDownloadDisabled:
'Cloud environment does not support model imports for custom nodes.',
- unknownCategory: 'Unknown Category'
+ unknownCategory: 'Unknown Category',
+ downloadAll: 'Download all',
+ refresh: 'Refresh',
+ refreshing: 'Refreshing missing models.'
}
}
}
@@ -50,7 +56,11 @@ const i18n = createI18n({
function makeViewModel(
name: string,
- nodeId: string = '1'
+ nodeId: string = '1',
+ opts: {
+ url?: string
+ directory?: string
+ } = {}
): MissingModelViewModel {
return {
name,
@@ -60,7 +70,9 @@ function makeViewModel(
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
isAssetSupported: true,
- isMissing: true
+ isMissing: true,
+ url: opts.url,
+ directory: opts.directory
},
referencingNodes: [{ nodeId, widgetName: 'ckpt_name' }]
}
@@ -71,13 +83,23 @@ function makeGroup(
directory?: string | null
isAssetSupported?: boolean
modelNames?: string[]
+ withDownloadUrls?: boolean
} = {}
): MissingModelGroup {
const names = opts.modelNames ?? ['model.safetensors']
+ const directory =
+ 'directory' in opts ? (opts.directory ?? null) : 'checkpoints'
return {
- directory: 'directory' in opts ? (opts.directory ?? null) : 'checkpoints',
+ directory,
isAssetSupported: opts.isAssetSupported ?? true,
- models: names.map((n, i) => makeViewModel(n, String(i + 1)))
+ models: names.map((n, i) =>
+ makeViewModel(n, String(i + 1), {
+ url: opts.withDownloadUrls
+ ? `https://huggingface.co/comfy/test/resolve/main/${n}`
+ : undefined,
+ directory: directory ?? undefined
+ })
+ )
}
}
@@ -88,6 +110,7 @@ function mountCard(
}> = {},
onLocateModel?: (nodeId: string) => void
) {
+ const pinia = createTestingPinia({ createSpy: vi.fn })
return render(MissingModelCard, {
props: {
missingModelGroups: [makeGroup()],
@@ -96,7 +119,7 @@ function mountCard(
...(onLocateModel ? { onLocateModel } : {})
},
global: {
- plugins: [PrimeVue, i18n]
+ plugins: [pinia, PrimeVue, i18n]
}
})
}
@@ -159,6 +182,16 @@ describe('MissingModelCard', () => {
expect(container.querySelectorAll('.model-row')).toHaveLength(0)
})
+ it('hides bulk actions in cloud', () => {
+ mountCard({
+ missingModelGroups: [makeGroup({ withDownloadUrls: true })]
+ })
+
+ expect(
+ screen.queryByTestId('missing-model-actions')
+ ).not.toBeInTheDocument()
+ })
+
it('passes props correctly to MissingModelRow children', () => {
const { container } = mountCard({ showNodeIdBadge: true })
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
@@ -245,4 +278,52 @@ describe('MissingModelCard (OSS)', () => {
expect(container.textContent).toContain('Unknown Category')
expect(container.textContent).not.toContain('Import Not Supported')
})
+
+ it('shows bulk actions when one model is downloadable', () => {
+ mountCard({
+ missingModelGroups: [makeGroup({ withDownloadUrls: true })]
+ })
+
+ expect(screen.getByRole('button', { name: /Download all/ })).toBeVisible()
+ expect(screen.getByRole('button', { name: 'Refresh' })).toBeVisible()
+ })
+
+ it('hides bulk actions when no model is downloadable', () => {
+ mountCard()
+
+ expect(
+ screen.queryByRole('button', { name: /Download all/ })
+ ).not.toBeInTheDocument()
+ expect(
+ screen.queryByRole('button', { name: 'Refresh' })
+ ).not.toBeInTheDocument()
+ })
+
+ it('refreshes missing models from the action bar', async () => {
+ mountCard({
+ missingModelGroups: [makeGroup({ withDownloadUrls: true })]
+ })
+ const store = useMissingModelStore()
+
+ await userEvent.click(screen.getByRole('button', { name: 'Refresh' }))
+
+ expect(store.refreshMissingModels).toHaveBeenCalled()
+ })
+
+ it('keeps the Refresh button focusable and announces refresh progress', async () => {
+ mountCard({
+ missingModelGroups: [makeGroup({ withDownloadUrls: true })]
+ })
+ const store = useMissingModelStore()
+
+ store.isRefreshingMissingModels = true
+ await nextTick()
+
+ const refreshButton = screen.getByRole('button', { name: 'Refresh' })
+ expect(refreshButton).toHaveAttribute('aria-disabled', 'true')
+ expect(refreshButton).toHaveAttribute('aria-busy', 'true')
+ expect(screen.getByRole('status')).toHaveTextContent(
+ 'Refreshing missing models.'
+ )
+ })
})
diff --git a/src/platform/missingModel/components/MissingModelCard.vue b/src/platform/missingModel/components/MissingModelCard.vue
index 4f27090e83..f235a24805 100644
--- a/src/platform/missingModel/components/MissingModelCard.vue
+++ b/src/platform/missingModel/components/MissingModelCard.vue
@@ -1,5 +1,52 @@
+
+
+
+ {{ downloadAllLabel }}
+
+
+
+
+
+ {{ t('rightSidePanel.missingModels.refresh') }}
+
+
+ {{
+ missingModelStore.isRefreshingMissingModels
+ ? t('rightSidePanel.missingModels.refreshing')
+ : ''
+ }}
+
+
+
diff --git a/src/platform/missingModel/missingModelDownload.ts b/src/platform/missingModel/missingModelDownload.ts
index 7800fcb265..efbf0241f7 100644
--- a/src/platform/missingModel/missingModelDownload.ts
+++ b/src/platform/missingModel/missingModelDownload.ts
@@ -26,7 +26,7 @@ const WHITE_LISTED_URLS: ReadonlySet
= new Set([
'https://github.com/xinntao/Real-ESRGAN/releases/download/v0.1.0/RealESRGAN_x4plus.pth'
])
-interface ModelWithUrl {
+export interface ModelWithUrl {
name: string
url: string
directory: string
diff --git a/src/platform/missingModel/missingModelStore.test.ts b/src/platform/missingModel/missingModelStore.test.ts
index 4c20708164..86bb4acf6b 100644
--- a/src/platform/missingModel/missingModelStore.test.ts
+++ b/src/platform/missingModel/missingModelStore.test.ts
@@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { MissingModelCandidate } from '@/platform/missingModel/types'
vi.mock('@/i18n', () => ({
+ t: vi.fn((key: string) => `translated:${key}`),
st: vi.fn((_key: string, fallback: string) => fallback)
}))
@@ -12,6 +13,8 @@ vi.mock('@/platform/distribution/types', () => ({
}))
import { useMissingModelStore } from './missingModelStore'
+import { useToastStore } from '@/platform/updates/common/toastStore'
+import { app } from '@/scripts/app'
function makeModelCandidate(
name: string,
@@ -35,6 +38,7 @@ function makeModelCandidate(
describe('missingModelStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
+ vi.restoreAllMocks()
})
describe('setMissingModels', () => {
@@ -68,6 +72,73 @@ describe('missingModelStore', () => {
})
})
+ describe('refreshMissingModels', () => {
+ it('delegates to the app missing model refresh pipeline', async () => {
+ const store = useMissingModelStore()
+ const refreshSpy = vi
+ .spyOn(app, 'refreshMissingModels')
+ .mockResolvedValue({
+ missingModels: [],
+ confirmedCandidates: []
+ })
+
+ await store.refreshMissingModels()
+
+ expect(refreshSpy).toHaveBeenCalledWith({ silent: true })
+ expect(store.isRefreshingMissingModels).toBe(false)
+ })
+
+ it('ignores overlapping refresh requests', async () => {
+ const store = useMissingModelStore()
+ let resolveRefresh: () => void = () => {}
+ const refreshSpy = vi.spyOn(app, 'refreshMissingModels').mockReturnValue(
+ new Promise((resolve) => {
+ resolveRefresh = () =>
+ resolve({ missingModels: [], confirmedCandidates: [] })
+ })
+ )
+
+ const firstRefresh = store.refreshMissingModels()
+ const secondRefresh = store.refreshMissingModels()
+ resolveRefresh()
+ await Promise.all([firstRefresh, secondRefresh])
+
+ expect(refreshSpy).toHaveBeenCalledTimes(1)
+ expect(store.isRefreshingMissingModels).toBe(false)
+ })
+
+ it('shows a toast when the refresh pipeline fails', async () => {
+ const store = useMissingModelStore()
+ vi.spyOn(app, 'refreshMissingModels').mockRejectedValue(
+ new Error('object_info failed')
+ )
+ const toastStore = useToastStore()
+ const addSpy = vi.spyOn(toastStore, 'add')
+
+ await store.refreshMissingModels()
+
+ expect(addSpy).toHaveBeenCalledWith({
+ severity: 'error',
+ summary: 'translated:g.error',
+ detail: 'translated:rightSidePanel.missingModels.refreshFailed'
+ })
+ expect(store.isRefreshingMissingModels).toBe(false)
+ })
+
+ it('does not show a toast when the refresh is aborted', async () => {
+ const store = useMissingModelStore()
+ const abortError = new DOMException('Refresh aborted', 'AbortError')
+ vi.spyOn(app, 'refreshMissingModels').mockRejectedValue(abortError)
+ const toastStore = useToastStore()
+ const addSpy = vi.spyOn(toastStore, 'add')
+
+ await store.refreshMissingModels()
+
+ expect(addSpy).not.toHaveBeenCalled()
+ expect(store.isRefreshingMissingModels).toBe(false)
+ })
+ })
+
describe('hasMissingModelOnNode', () => {
it('returns true when node has missing model', () => {
const store = useMissingModelStore()
diff --git a/src/platform/missingModel/missingModelStore.ts b/src/platform/missingModel/missingModelStore.ts
index fbd8eefb60..3f37deb835 100644
--- a/src/platform/missingModel/missingModelStore.ts
+++ b/src/platform/missingModel/missingModelStore.ts
@@ -1,9 +1,11 @@
import { defineStore } from 'pinia'
import { computed, onScopeDispose, ref } from 'vue'
+import { t } from '@/i18n'
// eslint-disable-next-line import-x/no-restricted-paths
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
+import { useToastStore } from '@/platform/updates/common/toastStore'
import type { MissingModelCandidate } from '@/platform/missingModel/types'
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
@@ -20,6 +22,7 @@ export const useMissingModelStore = defineStore('missingModel', () => {
const canvasStore = useCanvasStore()
const missingModelCandidates = ref(null)
+ const isRefreshingMissingModels = ref(false)
const hasMissingModels = computed(
() => !!missingModelCandidates.value?.length
@@ -270,8 +273,33 @@ export const useMissingModelStore = defineStore('missingModel', () => {
fileSizes.value = {}
}
+ function isAbortError(error: unknown) {
+ return error instanceof Error && error.name === 'AbortError'
+ }
+
+ async function refreshMissingModels() {
+ if (isRefreshingMissingModels.value) return
+
+ isRefreshingMissingModels.value = true
+ try {
+ await app.refreshMissingModels({ silent: true })
+ } catch (error) {
+ if (isAbortError(error)) return
+
+ console.error('Failed to refresh missing models:', error)
+ useToastStore().add({
+ severity: 'error',
+ summary: t('g.error'),
+ detail: t('rightSidePanel.missingModels.refreshFailed')
+ })
+ } finally {
+ isRefreshingMissingModels.value = false
+ }
+ }
+
return {
missingModelCandidates,
+ isRefreshingMissingModels,
hasMissingModels,
missingModelCount,
missingModelNodeIds,
@@ -285,6 +313,7 @@ export const useMissingModelStore = defineStore('missingModel', () => {
removeMissingModelsByNodeId,
removeMissingModelsByPrefix,
clearMissingModels,
+ refreshMissingModels,
createVerificationAbortController,
hasMissingModelOnNode,
diff --git a/src/platform/missingModel/missingModelViewUtils.test.ts b/src/platform/missingModel/missingModelViewUtils.test.ts
new file mode 100644
index 0000000000..34ceb14496
--- /dev/null
+++ b/src/platform/missingModel/missingModelViewUtils.test.ts
@@ -0,0 +1,124 @@
+import { describe, expect, it } from 'vitest'
+
+import type {
+ MissingModelGroup,
+ MissingModelViewModel
+} from '@/platform/missingModel/types'
+import {
+ getDownloadableModels,
+ toDownloadableModel
+} from '@/platform/missingModel/missingModelViewUtils'
+
+function makeViewModel(
+ name: string,
+ opts: {
+ url?: string
+ directory?: string
+ } = {}
+): MissingModelViewModel {
+ return {
+ name,
+ representative: {
+ name,
+ nodeId: '1',
+ nodeType: 'CheckpointLoaderSimple',
+ widgetName: 'ckpt_name',
+ isAssetSupported: true,
+ isMissing: true,
+ url: opts.url,
+ directory: opts.directory
+ },
+ referencingNodes: [{ nodeId: '1', widgetName: 'ckpt_name' }]
+ }
+}
+
+function makeGroup(models: MissingModelViewModel[]): MissingModelGroup {
+ return {
+ directory: 'checkpoints',
+ isAssetSupported: true,
+ models
+ }
+}
+
+describe('missingModelViewUtils', () => {
+ describe('toDownloadableModel', () => {
+ it('returns a download model for supported URLs and file types', () => {
+ const model = makeViewModel('model.safetensors', {
+ url: 'https://huggingface.co/comfy/test/resolve/main/model.safetensors',
+ directory: 'checkpoints'
+ })
+
+ expect(toDownloadableModel(model)).toEqual({
+ name: 'model.safetensors',
+ url: 'https://huggingface.co/comfy/test/resolve/main/model.safetensors',
+ directory: 'checkpoints'
+ })
+ })
+
+ it('returns null when URL, directory, source, or suffix is not downloadable', () => {
+ expect(
+ toDownloadableModel(
+ makeViewModel('model.safetensors', { directory: 'checkpoints' })
+ )
+ ).toBeNull()
+ expect(
+ toDownloadableModel(
+ makeViewModel('model.safetensors', {
+ url: 'https://huggingface.co/comfy/test/resolve/main/model.safetensors'
+ })
+ )
+ ).toBeNull()
+ expect(
+ toDownloadableModel(
+ makeViewModel('model.safetensors', {
+ url: 'https://example.com/model.safetensors',
+ directory: 'checkpoints'
+ })
+ )
+ ).toBeNull()
+ expect(
+ toDownloadableModel(
+ makeViewModel('model.gguf', {
+ url: 'https://huggingface.co/comfy/test/resolve/main/model.gguf',
+ directory: 'checkpoints'
+ })
+ )
+ ).toBeNull()
+ })
+ })
+
+ describe('getDownloadableModels', () => {
+ it('flattens downloadable models across groups and drops non-downloadable entries', () => {
+ const groups = [
+ makeGroup([
+ makeViewModel('downloadable.safetensors', {
+ url: 'https://huggingface.co/comfy/test/resolve/main/downloadable.safetensors',
+ directory: 'checkpoints'
+ }),
+ makeViewModel('local-only.safetensors', {
+ directory: 'checkpoints'
+ })
+ ]),
+ makeGroup([
+ makeViewModel('other.ckpt', {
+ url: 'https://civitai.com/api/download/models/123',
+ directory: 'checkpoints'
+ })
+ ])
+ ]
+
+ expect(getDownloadableModels(groups)).toEqual([
+ {
+ name: 'downloadable.safetensors',
+ url: 'https://huggingface.co/comfy/test/resolve/main/downloadable.safetensors',
+ directory: 'checkpoints'
+ },
+ {
+ name: 'other.ckpt',
+ url: 'https://civitai.com/api/download/models/123',
+ directory: 'checkpoints'
+ }
+ ])
+ })
+ })
+})
diff --git a/src/platform/missingModel/missingModelViewUtils.ts b/src/platform/missingModel/missingModelViewUtils.ts
new file mode 100644
index 0000000000..b705916259
--- /dev/null
+++ b/src/platform/missingModel/missingModelViewUtils.ts
@@ -0,0 +1,21 @@
+import type { MissingModelGroup } from '@/platform/missingModel/types'
+import { isModelDownloadable } from '@/platform/missingModel/missingModelDownload'
+import type { ModelWithUrl } from '@/platform/missingModel/missingModelDownload'
+
+export function toDownloadableModel(
+ model: MissingModelGroup['models'][number]
+): ModelWithUrl | null {
+ const { name, url, directory } = model.representative
+ if (!url || !directory) return null
+
+ const downloadableModel = { name, url, directory }
+ return isModelDownloadable(downloadableModel) ? downloadableModel : null
+}
+
+export function getDownloadableModels(
+ groups: MissingModelGroup[]
+): ModelWithUrl[] {
+ return groups.flatMap((group) =>
+ group.models.flatMap((model) => toDownloadableModel(model) ?? [])
+ )
+}
diff --git a/src/platform/workflow/core/services/workflowService.ts b/src/platform/workflow/core/services/workflowService.ts
index 66b0b384e5..15dc01879b 100644
--- a/src/platform/workflow/core/services/workflowService.ts
+++ b/src/platform/workflow/core/services/workflowService.ts
@@ -6,6 +6,10 @@ import { LGraph, LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import type { Point, SerialisableGraph } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
+import {
+ normalizePendingWarnings,
+ updatePendingWarnings
+} from '@/platform/workflow/core/utils/pendingWarnings'
import { useWorkflowDraftStore } from '@/platform/workflow/persistence/stores/workflowDraftStore'
import {
ComfyWorkflow,
@@ -396,22 +400,11 @@ export const useWorkflowService = () => {
const modelCandidates = useMissingModelStore().missingModelCandidates
const mediaCandidates = useMissingMediaStore().missingMediaCandidates
const nodeTypes = missingNodesErrorStore.missingNodesError?.nodeTypes
- activeWorkflow.pendingWarnings = {
+ updatePendingWarnings(activeWorkflow, {
missingNodeTypes: nodeTypes?.length ? [...nodeTypes] : undefined,
- missingModelCandidates: modelCandidates?.length
- ? modelCandidates
- : undefined,
- missingMediaCandidates: mediaCandidates?.length
- ? mediaCandidates
- : undefined
- }
- if (
- !activeWorkflow.pendingWarnings.missingNodeTypes &&
- !activeWorkflow.pendingWarnings.missingModelCandidates &&
- !activeWorkflow.pendingWarnings.missingMediaCandidates
- ) {
- activeWorkflow.pendingWarnings = null
- }
+ missingModelCandidates: modelCandidates ?? undefined,
+ missingMediaCandidates: mediaCandidates ?? undefined
+ })
// Capture thumbnail before loading new graph
void workflowThumbnail.storeThumbnail(activeWorkflow)
@@ -604,11 +597,11 @@ export const useWorkflowService = () => {
missingModelCandidates?.length ||
missingMediaCandidates?.length
) {
- wf.pendingWarnings = {
+ wf.pendingWarnings = normalizePendingWarnings({
missingNodeTypes,
missingModelCandidates,
missingMediaCandidates
- }
+ })
} else {
wf.pendingWarnings = null
}
diff --git a/src/platform/workflow/core/utils/pendingWarnings.test.ts b/src/platform/workflow/core/utils/pendingWarnings.test.ts
new file mode 100644
index 0000000000..887edb1ffc
--- /dev/null
+++ b/src/platform/workflow/core/utils/pendingWarnings.test.ts
@@ -0,0 +1,86 @@
+import { describe, expect, it } from 'vitest'
+
+import type { PendingWarnings } from '@/platform/workflow/management/stores/comfyWorkflow'
+import {
+ normalizePendingWarnings,
+ updatePendingWarnings
+} from '@/platform/workflow/core/utils/pendingWarnings'
+
+describe('pendingWarnings utils', () => {
+ it('normalizes missing or empty warning collections to null', () => {
+ expect(normalizePendingWarnings(null)).toBeNull()
+ expect(normalizePendingWarnings(undefined)).toBeNull()
+ expect(
+ normalizePendingWarnings({
+ missingNodeTypes: [],
+ missingModelCandidates: [],
+ missingMediaCandidates: []
+ })
+ ).toBeNull()
+ })
+
+ it('drops empty warning fields while preserving populated fields', () => {
+ const warnings = {
+ missingNodeTypes: ['CustomNode'],
+ missingModelCandidates: [],
+ missingMediaCandidates: [
+ {
+ nodeId: '1',
+ nodeType: 'LoadImage',
+ widgetName: 'image',
+ mediaType: 'image' as const,
+ name: 'missing.png',
+ isMissing: true
+ }
+ ]
+ } satisfies PendingWarnings
+
+ expect(normalizePendingWarnings(warnings)).toStrictEqual({
+ missingNodeTypes: ['CustomNode'],
+ missingModelCandidates: undefined,
+ missingMediaCandidates: warnings.missingMediaCandidates
+ })
+ })
+
+ it('merges updates into existing warnings and removes stale empty state', () => {
+ const workflow = {
+ pendingWarnings: {
+ missingNodeTypes: ['CustomNode'],
+ missingModelCandidates: [
+ {
+ nodeId: '1',
+ nodeType: 'CheckpointLoaderSimple',
+ widgetName: 'ckpt_name',
+ name: 'missing.safetensors',
+ isMissing: true,
+ isAssetSupported: true
+ }
+ ]
+ } satisfies PendingWarnings
+ }
+
+ updatePendingWarnings(workflow, {
+ missingModelCandidates: []
+ })
+
+ expect(workflow.pendingWarnings).toStrictEqual({
+ missingNodeTypes: ['CustomNode'],
+ missingModelCandidates: undefined,
+ missingMediaCandidates: undefined
+ })
+
+ updatePendingWarnings(workflow, {
+ missingNodeTypes: []
+ })
+
+ expect(workflow.pendingWarnings).toBeNull()
+ })
+
+ it('does nothing when there is no workflow to update', () => {
+ expect(() =>
+ updatePendingWarnings(null, {
+ missingNodeTypes: ['CustomNode']
+ })
+ ).not.toThrow()
+ })
+})
diff --git a/src/platform/workflow/core/utils/pendingWarnings.ts b/src/platform/workflow/core/utils/pendingWarnings.ts
new file mode 100644
index 0000000000..0fbdf0d232
--- /dev/null
+++ b/src/platform/workflow/core/utils/pendingWarnings.ts
@@ -0,0 +1,33 @@
+import type {
+ ComfyWorkflow,
+ PendingWarnings
+} from '@/platform/workflow/management/stores/comfyWorkflow'
+
+const emptyToUndefined = (arr: T[] | undefined): T[] | undefined =>
+ arr?.length ? arr : undefined
+
+export function normalizePendingWarnings(
+ warnings: PendingWarnings | null | undefined
+): PendingWarnings | null {
+ if (!warnings) return null
+
+ const normalized: PendingWarnings = {
+ missingNodeTypes: emptyToUndefined(warnings.missingNodeTypes),
+ missingModelCandidates: emptyToUndefined(warnings.missingModelCandidates),
+ missingMediaCandidates: emptyToUndefined(warnings.missingMediaCandidates)
+ }
+
+ return Object.values(normalized).some(Boolean) ? normalized : null
+}
+
+export function updatePendingWarnings(
+ workflow: Pick | null | undefined,
+ updates: Partial
+) {
+ if (!workflow) return
+
+ workflow.pendingWarnings = normalizePendingWarnings({
+ ...workflow.pendingWarnings,
+ ...updates
+ })
+}
diff --git a/src/scripts/app.test.ts b/src/scripts/app.test.ts
index 8929407ecb..8f62552532 100644
--- a/src/scripts/app.test.ts
+++ b/src/scripts/app.test.ts
@@ -1,3 +1,4 @@
+import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type {
@@ -5,6 +6,10 @@ import type {
LGraphCanvas,
LGraphNode
} from '@/lib/litegraph/src/litegraph'
+import type {
+ ComfyWorkflowJSON,
+ ModelFile
+} from '@/platform/workflow/validation/schemas/workflowSchema'
import { ComfyApp } from './app'
import { createNode } from '@/utils/litegraphUtil'
import {
@@ -16,6 +21,32 @@ import {
pasteVideoNodes
} from '@/composables/usePaste'
import { getWorkflowDataFromFile } from '@/scripts/metadata/parser'
+import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
+import type { LoadedComfyWorkflow } from '@/platform/workflow/management/stores/comfyWorkflow'
+import type { MissingModelCandidate } from '@/platform/missingModel/types'
+
+const {
+ mockToastStore,
+ mockExtensionService,
+ mockNodeOutputStore,
+ mockWorkspaceWorkflow
+} = vi.hoisted(() => ({
+ mockToastStore: {
+ addAlert: vi.fn(),
+ add: vi.fn(),
+ remove: vi.fn()
+ },
+ mockExtensionService: {
+ invokeExtensions: vi.fn(),
+ invokeExtensionsAsync: vi.fn()
+ },
+ mockNodeOutputStore: {
+ refreshNodeOutputs: vi.fn()
+ },
+ mockWorkspaceWorkflow: {
+ activeWorkflow: null as unknown
+ }
+}))
vi.mock('@/utils/litegraphUtil', () => ({
createNode: vi.fn(),
@@ -40,10 +71,20 @@ vi.mock('@/scripts/metadata/parser', () => ({
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
- useToastStore: vi.fn(() => ({
- addAlert: vi.fn(),
- add: vi.fn(),
- remove: vi.fn()
+ useToastStore: vi.fn(() => mockToastStore)
+}))
+
+vi.mock('@/services/extensionService', () => ({
+ useExtensionService: vi.fn(() => mockExtensionService)
+}))
+
+vi.mock('@/stores/nodeOutputStore', () => ({
+ useNodeOutputStore: vi.fn(() => mockNodeOutputStore)
+}))
+
+vi.mock('@/stores/workspaceStore', () => ({
+ useWorkspaceStore: vi.fn(() => ({
+ workflow: mockWorkspaceWorkflow
}))
}))
@@ -74,15 +115,177 @@ function createTestFile(name: string, type: string): File {
return new File([''], name, { type })
}
+type ComfyAppMissingModelPipelineTarget = {
+ runMissingModelPipeline: (
+ graphData: ComfyWorkflowJSON,
+ options?: { silent?: boolean; missingNodeTypes?: string[] }
+ ) => Promise<{
+ missingModels: ModelFile[]
+ confirmedCandidates: MissingModelCandidate[]
+ }>
+}
+
+function createWorkflowGraphData(): ComfyWorkflowJSON {
+ return {
+ last_node_id: 0,
+ last_link_id: 0,
+ nodes: [],
+ links: [],
+ groups: [],
+ config: {},
+ extra: {},
+ version: 0.4
+ }
+}
+
describe('ComfyApp', () => {
let app: ComfyApp
let mockCanvas: LGraphCanvas
beforeEach(() => {
+ setActivePinia(createPinia())
vi.clearAllMocks()
app = new ComfyApp()
mockCanvas = createMockCanvas() as LGraphCanvas
app.canvas = mockCanvas as LGraphCanvas
+ mockExtensionService.invokeExtensions.mockReturnValue([])
+ mockExtensionService.invokeExtensionsAsync.mockResolvedValue(undefined)
+ })
+
+ describe('refreshComboInNodes', () => {
+ it('shows success toast and removes the pending toast after node defs reload', async () => {
+ app.vueAppReady = true
+ vi.spyOn(app, 'reloadNodeDefs').mockResolvedValue()
+
+ await app.refreshComboInNodes()
+
+ expect(mockToastStore.add).toHaveBeenCalledWith(
+ expect.objectContaining({ severity: 'info' })
+ )
+ expect(mockToastStore.add).toHaveBeenCalledWith(
+ expect.objectContaining({ severity: 'success' })
+ )
+ expect(mockToastStore.remove).toHaveBeenCalledWith(
+ mockToastStore.add.mock.calls[0][0]
+ )
+ })
+
+ it('shows failure toast, removes the pending toast, and rethrows reload failures', async () => {
+ app.vueAppReady = true
+ const error = new Error('object_info failed')
+ vi.spyOn(app, 'reloadNodeDefs').mockRejectedValue(error)
+
+ await expect(app.refreshComboInNodes()).rejects.toThrow(error)
+
+ expect(mockToastStore.add).toHaveBeenCalledWith(
+ expect.objectContaining({ severity: 'error' })
+ )
+ expect(mockToastStore.remove).toHaveBeenCalledWith(
+ mockToastStore.add.mock.calls[0][0]
+ )
+ })
+ })
+
+ describe('refreshMissingModels', () => {
+ function mockRefreshMissingModelsApp(
+ graphData: ComfyWorkflowJSON,
+ candidates: MissingModelCandidate[] = []
+ ) {
+ mockWorkspaceWorkflow.activeWorkflow = null
+ Reflect.set(app, 'rootGraphInternal', {
+ nodes: [],
+ serialize: vi.fn(() => graphData)
+ })
+ vi.spyOn(app, 'reloadNodeDefs').mockResolvedValue()
+ const appWithPrivate =
+ app as unknown as ComfyAppMissingModelPipelineTarget
+ const pipelineSpy = vi
+ .spyOn(appWithPrivate, 'runMissingModelPipeline')
+ .mockResolvedValue({
+ missingModels: [],
+ confirmedCandidates: []
+ })
+ useMissingModelStore().missingModelCandidates = candidates
+ return pipelineSpy
+ }
+
+ it('reuses active workflow model metadata when refreshing the current graph', async () => {
+ const graphData = createWorkflowGraphData()
+ const activeModels = [
+ {
+ name: 'embedded.safetensors',
+ url: 'https://example.com/embedded.safetensors',
+ directory: 'checkpoints'
+ }
+ ]
+ const pipelineSpy = mockRefreshMissingModelsApp(graphData, [
+ {
+ nodeId: '1',
+ nodeType: 'CheckpointLoaderSimple',
+ widgetName: 'ckpt_name',
+ name: 'candidate.safetensors',
+ url: 'https://example.com/candidate.safetensors',
+ directory: 'checkpoints',
+ isMissing: true,
+ isAssetSupported: true
+ }
+ ])
+ mockWorkspaceWorkflow.activeWorkflow = {
+ activeState: { models: activeModels }
+ } as LoadedComfyWorkflow
+
+ await app.refreshMissingModels({ silent: false })
+
+ expect(app.reloadNodeDefs).toHaveBeenCalled()
+ expect(pipelineSpy).toHaveBeenCalledWith(
+ expect.objectContaining({ models: activeModels }),
+ { silent: false }
+ )
+ })
+
+ it('falls back to current missing model metadata when workflow state has no models', async () => {
+ const graphData = createWorkflowGraphData()
+ const pipelineSpy = mockRefreshMissingModelsApp(graphData, [
+ {
+ nodeId: '1',
+ nodeType: 'CheckpointLoaderSimple',
+ widgetName: 'ckpt_name',
+ name: 'candidate.safetensors',
+ url: 'https://example.com/candidate.safetensors',
+ directory: 'checkpoints',
+ hash: 'abc123',
+ hashType: 'sha256',
+ isMissing: true,
+ isAssetSupported: true
+ },
+ {
+ nodeId: '2',
+ nodeType: 'CheckpointLoaderSimple',
+ widgetName: 'ckpt_name',
+ name: 'missing-url.safetensors',
+ directory: 'checkpoints',
+ isMissing: true,
+ isAssetSupported: true
+ }
+ ])
+
+ await app.refreshMissingModels()
+
+ expect(pipelineSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ models: [
+ {
+ name: 'candidate.safetensors',
+ url: 'https://example.com/candidate.safetensors',
+ directory: 'checkpoints',
+ hash: 'abc123',
+ hash_type: 'sha256'
+ }
+ ]
+ }),
+ { silent: true }
+ )
+ })
})
describe('handleFileList', () => {
diff --git a/src/scripts/app.ts b/src/scripts/app.ts
index f25ed4fcb1..54001c6b61 100644
--- a/src/scripts/app.ts
+++ b/src/scripts/app.ts
@@ -27,8 +27,8 @@ import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import type { WorkflowOpenSource } from '@/platform/telemetry/types'
import { useToastStore } from '@/platform/updates/common/toastStore'
+import { updatePendingWarnings } from '@/platform/workflow/core/utils/pendingWarnings'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
-import type { PendingWarnings } from '@/platform/workflow/management/stores/comfyWorkflow'
import { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowValidation } from '@/platform/workflow/validation/composables/useWorkflowValidation'
import type {
@@ -127,7 +127,6 @@ import {
findLegacyRerouteNodes,
noNativeReroutes
} from '@/utils/migration/migrateReroute'
-
import { deserialiseAndCreate } from '@/utils/vintageClipboard'
import { type ComfyApi, PromptExecutionError, api } from './api'
@@ -155,6 +154,11 @@ import {
pasteVideoNodes
} from '@/composables/usePaste'
+interface MissingModelPipelineOptions {
+ missingNodeTypes?: MissingNodeType[]
+ silent?: boolean
+}
+
export const ANIM_PREVIEW_WIDGET = '$$comfy_animation_preview'
export function sanitizeNodeName(string: string) {
@@ -1450,11 +1454,10 @@ export class ComfyApp {
)
if (!skipAssetScans) {
- await this.runMissingModelPipeline(
- graphData,
- activeMissingNodeTypes,
- silentAssetErrors
- )
+ await this.runMissingModelPipeline(graphData, {
+ missingNodeTypes: activeMissingNodeTypes,
+ silent: silentAssetErrors
+ })
await this.runMissingMediaPipeline(silentAssetErrors)
}
@@ -1476,10 +1479,13 @@ export class ComfyApp {
private async runMissingModelPipeline(
graphData: ComfyWorkflowJSON,
- missingNodeTypes: MissingNodeType[],
- silent: boolean = false
- ): Promise<{ missingModels: ModelFile[] }> {
+ { missingNodeTypes, silent = false }: MissingModelPipelineOptions = {}
+ ): Promise<{
+ missingModels: ModelFile[]
+ confirmedCandidates: MissingModelCandidate[]
+ }> {
const missingModelStore = useMissingModelStore()
+ const controller = missingModelStore.createVerificationAbortController()
const getDirectory = (nodeType: string) =>
useModelToNodeStore().getCategoryForNodeType(nodeType)
@@ -1539,22 +1545,13 @@ export class ComfyApp {
)
const activeWf = useWorkspaceStore().workflow.activeWorkflow
- if (activeWf) {
- activeWf.pendingWarnings = {
- ...activeWf.pendingWarnings,
- missingNodeTypes: missingNodeTypes.length
- ? missingNodeTypes
- : undefined,
- missingModelCandidates: confirmedCandidates.length
- ? confirmedCandidates
- : undefined
- }
- this.cleanupPendingWarnings(activeWf)
- }
+ updatePendingWarnings(activeWf, {
+ ...(missingNodeTypes ? { missingNodeTypes } : {}),
+ missingModelCandidates: confirmedCandidates
+ })
if (enrichedCandidates.length) {
if (isCloud) {
- const controller = missingModelStore.createVerificationAbortController()
void verifyAssetSupportedCandidates(
enrichedCandidates,
controller.signal
@@ -1566,11 +1563,7 @@ export class ComfyApp {
const confirmed = enrichedCandidates.filter((c) =>
isMissingCandidateActive(this.rootGraph, c)
)
- if (confirmed.length) {
- useExecutionErrorStore().surfaceMissingModels(confirmed, {
- silent
- })
- }
+ useExecutionErrorStore().surfaceMissingModels(confirmed, { silent })
this.cacheModelCandidates(activeWf, confirmed)
})
.catch((err) => {
@@ -1588,9 +1581,11 @@ export class ComfyApp {
})
})
} else {
- const controller = missingModelStore.createVerificationAbortController()
const confirmed = enrichedCandidates.filter((c) => c.isMissing === true)
- if (confirmed.length) {
+ if (!confirmed.length) {
+ useExecutionErrorStore().surfaceMissingModels([], { silent })
+ this.cacheModelCandidates(activeWf, [])
+ } else {
void api
.getFolderPaths()
.then((paths) => {
@@ -1625,21 +1620,49 @@ export class ComfyApp {
)
}
}
+ } else {
+ useExecutionErrorStore().surfaceMissingModels([], { silent })
+ this.cacheModelCandidates(activeWf, [])
}
- return { missingModels }
+ return { missingModels, confirmedCandidates }
}
- private cleanupPendingWarnings(wf: {
- pendingWarnings: PendingWarnings | null
- }) {
- if (
- !wf.pendingWarnings?.missingNodeTypes &&
- !wf.pendingWarnings?.missingModelCandidates &&
- !wf.pendingWarnings?.missingMediaCandidates
- ) {
- wf.pendingWarnings = null
- }
+ async refreshMissingModels(options: { silent?: boolean } = {}): Promise<{
+ missingModels: ModelFile[]
+ confirmedCandidates: MissingModelCandidate[]
+ }> {
+ await this.reloadNodeDefs()
+ const graphData = this.rootGraph.serialize() as unknown as ComfyWorkflowJSON
+ const activeWorkflowState =
+ useWorkspaceStore().workflow.activeWorkflow?.activeState
+ const currentModelMetadata =
+ useMissingModelStore()
+ .missingModelCandidates?.filter(
+ (
+ candidate
+ ): candidate is MissingModelCandidate & {
+ url: string
+ directory: string
+ } => !!candidate.url && !!candidate.directory
+ )
+ .map((candidate) => ({
+ name: candidate.name,
+ url: candidate.url,
+ directory: candidate.directory,
+ hash: candidate.hash,
+ hash_type: candidate.hashType
+ })) ?? []
+ const models = activeWorkflowState?.models?.length
+ ? activeWorkflowState.models
+ : currentModelMetadata
+
+ return this.runMissingModelPipeline(
+ models.length ? { ...graphData, models } : graphData,
+ {
+ silent: options.silent ?? true
+ }
+ )
}
private cacheModelCandidates(
@@ -1647,11 +1670,9 @@ export class ComfyApp {
confirmed: MissingModelCandidate[]
) {
if (!wf) return
- wf.pendingWarnings = {
- ...wf.pendingWarnings,
- missingModelCandidates: confirmed.length ? confirmed : undefined
- }
- this.cleanupPendingWarnings(wf)
+ updatePendingWarnings(wf, {
+ missingModelCandidates: confirmed
+ })
}
private cacheMediaCandidates(
@@ -1659,11 +1680,9 @@ export class ComfyApp {
confirmed: MissingMediaCandidate[]
) {
if (!wf) return
- wf.pendingWarnings = {
- ...wf.pendingWarnings,
- missingMediaCandidates: confirmed.length ? confirmed : undefined
- }
- this.cleanupPendingWarnings(wf)
+ updatePendingWarnings(wf, {
+ missingMediaCandidates: confirmed
+ })
}
private async runMissingMediaPipeline(
@@ -2224,18 +2243,9 @@ export class ComfyApp {
}
/**
- * Refresh combo list on whole nodes
+ * Reload node definitions and refresh combo lists on all nodes.
*/
- async refreshComboInNodes() {
- const requestToastMessage: ToastMessageOptions = {
- severity: 'info',
- summary: t('g.update'),
- detail: t('toastMessages.updateRequested')
- }
- if (this.vueAppReady) {
- useToastStore().add(requestToastMessage)
- }
-
+ async reloadNodeDefs() {
const defs = await this.getNodeDefs()
for (const nodeId in defs) {
this.registerNodeDef(nodeId, defs[nodeId])
@@ -2289,13 +2299,46 @@ export class ComfyApp {
if (this.vueAppReady) {
this.updateVueAppNodeDefs(defs)
- useToastStore().remove(requestToastMessage)
- useToastStore().add({
- severity: 'success',
- summary: t('g.updated'),
- detail: t('toastMessages.nodeDefinitionsUpdated'),
- life: 1000
- })
+ }
+ }
+
+ /**
+ * Refresh combo list on whole nodes
+ */
+ async refreshComboInNodes() {
+ const requestToastMessage: ToastMessageOptions = {
+ severity: 'info',
+ summary: t('g.update'),
+ detail: t('toastMessages.updateRequested')
+ }
+ if (this.vueAppReady) {
+ useToastStore().add(requestToastMessage)
+ }
+
+ try {
+ await this.reloadNodeDefs()
+
+ if (this.vueAppReady) {
+ useToastStore().add({
+ severity: 'success',
+ summary: t('g.updated'),
+ detail: t('toastMessages.nodeDefinitionsUpdated'),
+ life: 1000
+ })
+ }
+ } catch (error) {
+ if (this.vueAppReady) {
+ useToastStore().add({
+ severity: 'error',
+ summary: t('g.error'),
+ detail: t('toastMessages.nodeDefinitionsUpdateFailed')
+ })
+ }
+ throw error
+ } finally {
+ if (this.vueAppReady) {
+ useToastStore().remove(requestToastMessage)
+ }
}
}
diff --git a/src/scripts/ui.ts b/src/scripts/ui.ts
index 1d51661988..3f6f061e14 100644
--- a/src/scripts/ui.ts
+++ b/src/scripts/ui.ts
@@ -646,7 +646,9 @@ export class ComfyUI {
$el('button', {
id: 'comfy-refresh-button',
textContent: 'Refresh',
- onclick: () => app.refreshComboInNodes()
+ onclick: () => {
+ void app.refreshComboInNodes().catch(() => {})
+ }
}),
$el('button', {
id: 'comfy-clipspace-button',