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') }} -