[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

@@ -547,15 +547,16 @@ export const useWorkflowService = () => {
if (settingStore.get('Comfy.Workflow.ShowMissingNodesWarning')) {
missingNodesDialog.show({ missingNodeTypes })
}
executionErrorStore.surfaceMissingNodes(missingNodeTypes)
}
if (
missingModels &&
settingStore.get('Comfy.Workflow.ShowMissingModelsWarning')
) {
missingModelsDialog.show(missingModels)
// Missing models are NOT surfaced to the Errors tab here.
// On Cloud, the dedicated pipeline in app.ts handles detection and
// surfacing via surfaceMissingModels(). OSS uses only this dialog.
if (missingModels) {
if (settingStore.get('Comfy.Workflow.ShowMissingModelsWarning')) {
missingModelsDialog.show(missingModels)
}
}
}

View File

@@ -3,9 +3,13 @@ import { describe, expect, it } from 'vitest'
import {
buildSubgraphExecutionPaths,
flattenWorkflowNodes,
validateComfyWorkflow
} from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ComfyNode } from '@/platform/workflow/validation/schemas/workflowSchema'
import type {
ComfyNode,
ComfyWorkflowJSON
} from '@/platform/workflow/validation/schemas/workflowSchema'
import { defaultGraph } from '@/scripts/defaultGraph'
const WORKFLOW_DIR = 'src/platform/workflow/validation/schemas/__fixtures__'
@@ -274,3 +278,48 @@ describe('buildSubgraphExecutionPaths', () => {
).not.toThrow()
})
})
describe('flattenWorkflowNodes', () => {
it('returns root nodes when no subgraphs exist', () => {
const result = flattenWorkflowNodes({
nodes: [node(1, 'KSampler'), node(2, 'CLIPLoader')]
} as ComfyWorkflowJSON)
expect(result).toHaveLength(2)
expect(result.map((n) => n.id)).toEqual([1, 2])
})
it('returns empty array when nodes is undefined', () => {
const result = flattenWorkflowNodes({} as ComfyWorkflowJSON)
expect(result).toEqual([])
})
it('includes subgraph nodes with prefixed IDs', () => {
const result = flattenWorkflowNodes({
nodes: [node(5, 'def-A')],
definitions: {
subgraphs: [
subgraphDef('def-A', [node(10, 'Inner'), node(20, 'Inner2')])
]
}
} as unknown as ComfyWorkflowJSON)
expect(result).toHaveLength(3) // 1 root + 2 subgraph
expect(result.map((n) => n.id)).toEqual([5, '5:10', '5:20'])
})
it('prefixes nested subgraph nodes with full execution path', () => {
const result = flattenWorkflowNodes({
nodes: [node(5, 'def-A')],
definitions: {
subgraphs: [
subgraphDef('def-A', [node(10, 'def-B')]),
subgraphDef('def-B', [node(3, 'Leaf')])
]
}
} as unknown as ComfyWorkflowJSON)
// root:5, def-A inner: 5:10, def-B inner: 5:10:3
expect(result.map((n) => n.id)).toEqual([5, '5:10', '5:10:3'])
})
})

View File

@@ -592,3 +592,56 @@ export function buildSubgraphExecutionPaths(
build(rootNodes, '')
return pathMap
}
/**
* Recursively collect all subgraph definitions from root and nested levels.
*/
function collectAllSubgraphDefs(rootDefs: unknown[]): SubgraphDefinition[] {
const result: SubgraphDefinition[] = []
const seen = new Set<string>()
function collect(defs: unknown[]) {
for (const def of defs) {
if (!isSubgraphDefinition(def)) continue
if (seen.has(def.id)) continue
seen.add(def.id)
result.push(def)
if (def.definitions?.subgraphs?.length) {
collect(def.definitions.subgraphs)
}
}
}
collect(rootDefs)
return result
}
/**
* Flatten all workflow nodes (root + subgraphs) into a single array.
* Each node's `id` is prefixed with its execution path (e.g. node "3" inside container "11" → "11:3").
*/
export function flattenWorkflowNodes(
graphData: ComfyWorkflowJSON
): Readonly<ComfyNode>[] {
const rootNodes = graphData.nodes ?? []
const allDefs = collectAllSubgraphDefs(graphData.definitions?.subgraphs ?? [])
const pathMap = buildSubgraphExecutionPaths(rootNodes, allDefs)
const allNodes: ComfyNode[] = [...rootNodes]
const subgraphDefMap = new Map(allDefs.map((s) => [s.id, s]))
for (const [defId, paths] of pathMap.entries()) {
const def = subgraphDefMap.get(defId)
if (!def?.nodes) continue
for (const prefix of paths) {
for (const node of def.nodes) {
allNodes.push({
...node,
id: `${prefix}:${node.id}`
})
}
}
}
return allNodes
}