mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-14 11:17:35 +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:
189
src/platform/missingModel/missingModelStore.test.ts
Normal file
189
src/platform/missingModel/missingModelStore.test.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { MissingModelCandidate } from '@/platform/missingModel/types'
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
st: vi.fn((_key: string, fallback: string) => fallback)
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: false
|
||||
}))
|
||||
|
||||
import { useMissingModelStore } from './missingModelStore'
|
||||
|
||||
function makeModelCandidate(
|
||||
name: string,
|
||||
opts: {
|
||||
nodeId?: string | number
|
||||
nodeType?: string
|
||||
widgetName?: string
|
||||
isAssetSupported?: boolean
|
||||
} = {}
|
||||
): MissingModelCandidate {
|
||||
return {
|
||||
name,
|
||||
nodeId: opts.nodeId ?? '1',
|
||||
nodeType: opts.nodeType ?? 'CheckpointLoaderSimple',
|
||||
widgetName: opts.widgetName ?? 'ckpt_name',
|
||||
isAssetSupported: opts.isAssetSupported ?? false,
|
||||
isMissing: true
|
||||
}
|
||||
}
|
||||
|
||||
describe('missingModelStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
describe('setMissingModels', () => {
|
||||
it('sets missingModelCandidates with provided models', () => {
|
||||
const store = useMissingModelStore()
|
||||
store.setMissingModels([makeModelCandidate('model_a.safetensors')])
|
||||
|
||||
expect(store.missingModelCandidates).not.toBeNull()
|
||||
expect(store.missingModelCandidates).toHaveLength(1)
|
||||
expect(store.hasMissingModels).toBe(true)
|
||||
})
|
||||
|
||||
it('clears missingModelCandidates when given empty array', () => {
|
||||
const store = useMissingModelStore()
|
||||
store.setMissingModels([makeModelCandidate('model_a.safetensors')])
|
||||
expect(store.missingModelCandidates).not.toBeNull()
|
||||
|
||||
store.setMissingModels([])
|
||||
expect(store.missingModelCandidates).toBeNull()
|
||||
expect(store.hasMissingModels).toBe(false)
|
||||
})
|
||||
|
||||
it('includes model count in missingModelCount', () => {
|
||||
const store = useMissingModelStore()
|
||||
store.setMissingModels([
|
||||
makeModelCandidate('model_a.safetensors'),
|
||||
makeModelCandidate('model_b.safetensors', { nodeId: '2' })
|
||||
])
|
||||
|
||||
expect(store.missingModelCount).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasMissingModelOnNode', () => {
|
||||
it('returns true when node has missing model', () => {
|
||||
const store = useMissingModelStore()
|
||||
store.setMissingModels([
|
||||
makeModelCandidate('model_a.safetensors', { nodeId: '5' })
|
||||
])
|
||||
|
||||
expect(store.hasMissingModelOnNode('5')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when node has no missing model', () => {
|
||||
const store = useMissingModelStore()
|
||||
store.setMissingModels([
|
||||
makeModelCandidate('model_a.safetensors', { nodeId: '5' })
|
||||
])
|
||||
|
||||
expect(store.hasMissingModelOnNode('99')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when no models are missing', () => {
|
||||
const store = useMissingModelStore()
|
||||
expect(store.hasMissingModelOnNode('1')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeMissingModelByNameOnNodes', () => {
|
||||
it('removes only the named model from specified nodes', () => {
|
||||
const store = useMissingModelStore()
|
||||
store.setMissingModels([
|
||||
makeModelCandidate('model_a.safetensors', {
|
||||
nodeId: '1',
|
||||
widgetName: 'ckpt_name'
|
||||
}),
|
||||
makeModelCandidate('model_b.safetensors', {
|
||||
nodeId: '1',
|
||||
widgetName: 'vae_name'
|
||||
}),
|
||||
makeModelCandidate('model_a.safetensors', {
|
||||
nodeId: '2',
|
||||
widgetName: 'ckpt_name'
|
||||
})
|
||||
])
|
||||
|
||||
store.removeMissingModelByNameOnNodes(
|
||||
'model_a.safetensors',
|
||||
new Set(['1'])
|
||||
)
|
||||
|
||||
expect(store.missingModelCandidates).toHaveLength(2)
|
||||
expect(store.missingModelCandidates![0].name).toBe('model_b.safetensors')
|
||||
expect(store.missingModelCandidates![1].name).toBe('model_a.safetensors')
|
||||
expect(String(store.missingModelCandidates![1].nodeId)).toBe('2')
|
||||
})
|
||||
|
||||
it('sets missingModelCandidates to null when all removed', () => {
|
||||
const store = useMissingModelStore()
|
||||
store.setMissingModels([
|
||||
makeModelCandidate('model_a.safetensors', { nodeId: '1' })
|
||||
])
|
||||
|
||||
store.removeMissingModelByNameOnNodes(
|
||||
'model_a.safetensors',
|
||||
new Set(['1'])
|
||||
)
|
||||
|
||||
expect(store.missingModelCandidates).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearMissingModels', () => {
|
||||
it('clears missingModelCandidates and interaction state', () => {
|
||||
const store = useMissingModelStore()
|
||||
store.setMissingModels([
|
||||
makeModelCandidate('model_a.safetensors', { nodeId: '1' })
|
||||
])
|
||||
store.urlInputs['test-key'] = 'https://example.com'
|
||||
store.selectedLibraryModel['test-key'] = 'some-model'
|
||||
expect(store.missingModelCandidates).not.toBeNull()
|
||||
|
||||
store.clearMissingModels()
|
||||
|
||||
expect(store.missingModelCandidates).toBeNull()
|
||||
expect(store.hasMissingModels).toBe(false)
|
||||
expect(store.urlInputs).toEqual({})
|
||||
expect(store.selectedLibraryModel).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('isWidgetMissingModel', () => {
|
||||
it('returns true when specific widget has missing model', () => {
|
||||
const store = useMissingModelStore()
|
||||
store.setMissingModels([
|
||||
makeModelCandidate('model_a.safetensors', {
|
||||
nodeId: '5',
|
||||
widgetName: 'ckpt_name'
|
||||
})
|
||||
])
|
||||
|
||||
expect(store.isWidgetMissingModel('5', 'ckpt_name')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for different widget on same node', () => {
|
||||
const store = useMissingModelStore()
|
||||
store.setMissingModels([
|
||||
makeModelCandidate('model_a.safetensors', {
|
||||
nodeId: '5',
|
||||
widgetName: 'ckpt_name'
|
||||
})
|
||||
])
|
||||
|
||||
expect(store.isWidgetMissingModel('5', 'lora_name')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when no models are missing', () => {
|
||||
const store = useMissingModelStore()
|
||||
expect(store.isWidgetMissingModel('1', 'ckpt_name')).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user