feat: refresh missing models through pipeline (#11661)

## 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)
This commit is contained in:
jaeone94
2026-04-28 03:53:50 +09:00
committed by GitHub
parent 9a70676c61
commit b4d209b5f6
18 changed files with 1049 additions and 157 deletions

View File

@@ -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', () => {

View File

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

View File

@@ -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',