mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-13 09:00:16 +00:00
[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:
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user