[feat] Surface missing models in Errors tab (Cloud) (#9743)

## Summary
When a workflow is loaded with missing models, users currently have no
way to identify or resolve them from within the UI. This PR adds a full
missing-model detection and resolution pipeline that surfaces missing
models in the Errors tab, allowing users to install or import them
without leaving the editor.

## Changes

### Missing Model Detection
- Scan all COMBO widgets across root graph and subgraphs for model-like
filenames during workflow load
- Enrich candidates with embedded workflow metadata (url, hash,
directory) when available
- Verify asset-supported candidates against the asset store
asynchronously to confirm installation status
- Propagate missing model state to `executionErrorStore` alongside
existing node/prompt errors

### Errors Tab UI — Model Resolution
- Group missing models by directory (e.g. `checkpoints`, `loras`, `vae`)
with collapsible category cards
- Each model row displays:
  - Model name with copy-to-clipboard button
  - Expandable list of referencing nodes with locate-on-canvas button
- **Library selector**: Pick an alternative from the user's existing
models to substitute the missing model with one click
- **URL import**: Paste a Civitai or HuggingFace URL to import a model
directly; debounced metadata fetch shows filename and file size before
confirming; type-mismatch warnings (e.g. importing a LoRA into
checkpoints directory) are surfaced with an "Import Anyway" option
- **Upgrade prompt**: In cloud environment, free-tier subscribers are
shown an upgrade modal when attempting URL import
- Separate "Import Not Supported" section for custom-node models that
cannot be auto-resolved
- Status card with live download progress, completion, failure, and
category-mismatch states

### Canvas Integration
- Highlight nodes and widgets that reference missing models with error
indicators
- Propagate missing-model badges through subgraph containers so issues
are visible at every graph level

### Code Cleanup
- Simplify `surfacePendingWarnings` in workflowService, remove stale
widget-detected model merging logic
- Add `flattenWorkflowNodes` utility to workflowSchema for traversing
nested subgraph structures
- Extract `MissingModelUrlInput`, `MissingModelLibrarySelect`,
`MissingModelStatusCard` as focused single-responsibility components

## Testing
- Unit tests for scan pipeline (`missingModelScan.test.ts`): enrichment,
skip-installed, subgraph flattening
- Unit tests for store (`missingModelStore.test.ts`): state management,
removal helpers
- Unit tests for interactions (`useMissingModelInteractions.test.ts`):
combo select, URL input, import flow, library confirm
- Component tests for `MissingModelCard` and error grouping
(`useErrorGroups.test.ts`)
- Updated `workflowService.test.ts` and `workflowSchema.test.ts` for new
logic

## Review Focus
- Missing model scan + enrichment pipeline in `missingModelScan.ts`
- Interaction composable `useMissingModelInteractions.ts` — URL metadata
fetch, library install, upload fallback
- Store integration and canvas-level error propagation

## Screenshots 


https://github.com/user-attachments/assets/339a6d5b-93a3-43cd-98dd-0fb00681b66f



┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9743-feat-Surface-missing-models-in-Errors-tab-Cloud-3206d73d365081678326d3a16c2165d8)
by [Unito](https://www.unito.io)
This commit is contained in:
jaeone94
2026-03-12 16:21:54 +09:00
committed by GitHub
parent 4c00d39ade
commit 2f7f3c4e56
30 changed files with 4219 additions and 129 deletions

View File

@@ -73,6 +73,7 @@ import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
import { useModelStore } from '@/stores/modelStore'
import { SYSTEM_NODE_DEFS, useNodeDefStore } from '@/stores/nodeDefStore'
import { useNodeReplacementStore } from '@/platform/nodeReplacement/nodeReplacementStore'
import { useSubgraphStore } from '@/stores/subgraphStore'
import { useWidgetStore } from '@/stores/widgetStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
@@ -82,6 +83,15 @@ import type { NodeExecutionId } from '@/types/nodeIdentification'
import { graphToPrompt } from '@/utils/executionUtil'
import { getCnrIdFromProperties } from '@/workbench/extensions/manager/utils/missingNodeErrorUtil'
import { rescanAndSurfaceMissingNodes } from '@/platform/nodeReplacement/missingNodeScan'
import {
scanAllModelCandidates,
enrichWithEmbeddedMetadata,
verifyAssetSupportedCandidates
} from '@/platform/missingModel/missingModelScan'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { assetService } from '@/platform/assets/services/assetService'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
import { anyItemOverlapsRect } from '@/utils/mathUtil'
import {
collectAllNodes,
@@ -104,7 +114,7 @@ import {
findLegacyRerouteNodes,
noNativeReroutes
} from '@/utils/migration/migrateReroute'
import { getSelectedModelsMetadata } from '@/workbench/utils/modelMetadataUtil'
import { deserialiseAndCreate } from '@/utils/vintageClipboard'
import { type ComfyApi, PromptExecutionError, api } from './api'
@@ -1124,6 +1134,8 @@ export class ComfyApp {
} = options
useWorkflowService().beforeLoadNewGraph()
useMissingModelStore().clearMissingModels()
if (clean !== false) {
this.clean()
}
@@ -1168,18 +1180,17 @@ export class ComfyApp {
useSubgraphService().loadSubgraphs(graphData)
const missingNodeTypes: MissingNodeType[] = []
const missingModels: ModelFile[] = []
await useExtensionService().invokeExtensionsAsync(
'beforeConfigureGraph',
graphData,
missingNodeTypes
)
const embeddedModels: ModelFile[] = []
const nodeReplacementStore = useNodeReplacementStore()
await nodeReplacementStore.load()
const collectMissingNodesAndModels = (
// Collect missing node types from all nodes (root + subgraphs)
const collectMissingNodes = (
nodes: ComfyWorkflowJSON['nodes'],
pathPrefix: string = '',
displayName: string = ''
@@ -1192,16 +1203,11 @@ export class ComfyApp {
return
}
for (let n of nodes) {
// Find missing node types
if (!(n.type in LiteGraph.registered_node_types)) {
const replacement = nodeReplacementStore.getReplacementFor(n.type)
// To access missing node information in the error tab
// we collect the cnr_id and execution_id here.
const cnrId = getCnrIdFromProperties(
n.properties as Record<string, unknown> | undefined
)
const executionId = pathPrefix
? `${pathPrefix}:${n.id}`
: String(n.id)
@@ -1219,65 +1225,25 @@ export class ComfyApp {
n.type = sanitizeNodeName(n.type)
}
// Collect models metadata from node
const selectedModels = getSelectedModelsMetadata(n)
if (selectedModels?.length) {
embeddedModels.push(...selectedModels)
}
}
}
// Process nodes at the top level
collectMissingNodesAndModels(graphData.nodes)
// Build map: subgraph definition UUID → full execution path prefix.
// Handles arbitrary nesting depth (e.g. root node 11 → "11", node 14 in sg 11 → "11:14").
collectMissingNodes(graphData.nodes)
const subgraphDefs = graphData.definitions?.subgraphs ?? []
const subgraphContainerIdMap = buildSubgraphExecutionPaths(
graphData.nodes,
graphData.definitions?.subgraphs ?? []
subgraphDefs
)
// Process nodes in subgraphs
if (graphData.definitions?.subgraphs) {
for (const subgraph of graphData.definitions.subgraphs) {
if (isSubgraphDefinition(subgraph)) {
const paths = subgraphContainerIdMap.get(subgraph.id) ?? []
for (const pathPrefix of paths) {
collectMissingNodesAndModels(
subgraph.nodes,
pathPrefix,
subgraph.name || subgraph.id
)
}
}
}
}
// Merge models from the workflow's root-level 'models' field
const workflowSchemaV1Models = graphData.models
if (workflowSchemaV1Models?.length)
embeddedModels.push(...workflowSchemaV1Models)
const getModelKey = (model: ModelFile) => model.url || model.hash
const validModels = embeddedModels.filter(getModelKey)
const uniqueModels = _.uniqBy(validModels, getModelKey)
if (
uniqueModels.length &&
useSettingStore().get('Comfy.Workflow.ShowMissingModelsWarning')
) {
const modelStore = useModelStore()
await modelStore.loadModelFolders()
for (const m of uniqueModels) {
const modelFolder = await modelStore.getLoadedModelFolder(m.directory)
const modelsAvailable = modelFolder?.models
const modelExists =
modelsAvailable &&
Object.values(modelsAvailable).some(
(model) => model.file_name === m.name
for (const subgraph of subgraphDefs) {
if (isSubgraphDefinition(subgraph)) {
const paths = subgraphContainerIdMap.get(subgraph.id) ?? []
for (const pathPrefix of paths) {
collectMissingNodes(
subgraph.nodes,
pathPrefix,
subgraph.name || subgraph.id
)
if (!modelExists) missingModels.push(m)
}
}
}
@@ -1423,21 +1389,12 @@ export class ComfyApp {
requestAnimationFrame(() => fitView())
}
// Store pending warnings on the workflow for deferred display
const activeWf = useWorkspaceStore().workflow.activeWorkflow
if (activeWf) {
const warnings: PendingWarnings = {}
if (missingNodeTypes.length && showMissingNodesDialog) {
warnings.missingNodeTypes = missingNodeTypes
}
if (missingModels.length && showMissingModelsDialog) {
const paths = await api.getFolderPaths()
warnings.missingModels = { missingModels: missingModels, paths }
}
if (warnings.missingNodeTypes || warnings.missingModels) {
activeWf.pendingWarnings = warnings
}
}
await this.runMissingModelPipeline(
graphData,
missingNodeTypes,
showMissingNodesDialog,
showMissingModelsDialog
)
if (!deferWarnings) {
useWorkflowService().showPendingWarnings()
@@ -1451,6 +1408,97 @@ export class ComfyApp {
}
}
private async runMissingModelPipeline(
graphData: ComfyWorkflowJSON,
missingNodeTypes: MissingNodeType[],
showMissingNodesDialog: boolean,
showMissingModelsDialog: boolean
): Promise<{ missingModels: ModelFile[] }> {
const missingModelStore = useMissingModelStore()
const candidates = isCloud
? scanAllModelCandidates(
this.rootGraph,
(nodeType, widgetName) =>
assetService.shouldUseAssetBrowser(nodeType, widgetName),
(nodeType) => useModelToNodeStore().getCategoryForNodeType(nodeType)
)
: []
const modelStore = useModelStore()
await modelStore.loadModelFolders()
const enrichedCandidates = await enrichWithEmbeddedMetadata(
candidates,
graphData,
async (name, directory) => {
const folder = await modelStore.getLoadedModelFolder(directory)
const models = folder?.models
return !!(
models && Object.values(models).some((m) => m.file_name === name)
)
},
isCloud
? (nodeType, widgetName) =>
assetService.shouldUseAssetBrowser(nodeType, widgetName)
: undefined
)
const missingModels: ModelFile[] = enrichedCandidates
.filter((c) => c.isMissing === true && c.url)
.map((c) => ({
name: c.name,
url: c.url ?? '',
directory: c.directory ?? '',
hash: c.hash,
hash_type: c.hashType
}))
const activeWf = useWorkspaceStore().workflow.activeWorkflow
if (activeWf) {
const warnings: PendingWarnings = {}
if (missingNodeTypes.length && showMissingNodesDialog) {
warnings.missingNodeTypes = missingNodeTypes
}
if (missingModels.length && showMissingModelsDialog) {
const paths = await api.getFolderPaths()
warnings.missingModels = { missingModels, paths }
}
if (warnings.missingNodeTypes || warnings.missingModels) {
activeWf.pendingWarnings = warnings
}
}
if (isCloud && enrichedCandidates.length) {
const controller = missingModelStore.createVerificationAbortController()
verifyAssetSupportedCandidates(enrichedCandidates, controller.signal)
.then(() => {
if (controller.signal.aborted) return
const confirmed = enrichedCandidates.filter(
(c) => c.isMissing === true
)
if (confirmed.length) {
useExecutionErrorStore().surfaceMissingModels(confirmed)
}
})
.catch((err) => {
console.warn(
'[Missing Model Pipeline] Asset verification failed:',
err
)
useToastStore().add({
severity: 'warn',
summary: st(
'toastMessages.missingModelVerificationFailed',
'Failed to verify missing models. Some models may not be shown in the Errors tab.'
),
life: 5000
})
})
}
return { missingModels }
}
async graphToPrompt(graph = this.rootGraph) {
return graphToPrompt(graph, {
sortNodes: useSettingStore().get('Comfy.Workflow.SortNodeIdOnSave')