mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-05 21:54:50 +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:
193
src/platform/missingModel/components/MissingModelCard.test.ts
Normal file
193
src/platform/missingModel/components/MissingModelCard.test.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type {
|
||||
MissingModelGroup,
|
||||
MissingModelViewModel
|
||||
} from '@/platform/missingModel/types'
|
||||
|
||||
vi.mock('./MissingModelRow.vue', () => ({
|
||||
default: {
|
||||
name: 'MissingModelRow',
|
||||
template: '<div class="model-row" />',
|
||||
props: ['model', 'directory', 'showNodeIdBadge', 'isAssetSupported'],
|
||||
emits: ['locate-model']
|
||||
}
|
||||
}))
|
||||
|
||||
import MissingModelCard from './MissingModelCard.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
rightSidePanel: {
|
||||
missingModels: {
|
||||
importNotSupported: 'Import Not Supported',
|
||||
customNodeDownloadDisabled:
|
||||
'Cloud environment does not support model imports for custom nodes.',
|
||||
unknownCategory: 'Unknown Category'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
missingWarn: false,
|
||||
fallbackWarn: false
|
||||
})
|
||||
|
||||
function makeViewModel(
|
||||
name: string,
|
||||
nodeId: string = '1'
|
||||
): MissingModelViewModel {
|
||||
return {
|
||||
name,
|
||||
representative: {
|
||||
name,
|
||||
nodeId,
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
isAssetSupported: true,
|
||||
isMissing: true
|
||||
},
|
||||
referencingNodes: [{ nodeId, widgetName: 'ckpt_name' }]
|
||||
}
|
||||
}
|
||||
|
||||
function makeGroup(
|
||||
opts: {
|
||||
directory?: string | null
|
||||
isAssetSupported?: boolean
|
||||
modelNames?: string[]
|
||||
} = {}
|
||||
): MissingModelGroup {
|
||||
const names = opts.modelNames ?? ['model.safetensors']
|
||||
return {
|
||||
directory: 'directory' in opts ? (opts.directory ?? null) : 'checkpoints',
|
||||
isAssetSupported: opts.isAssetSupported ?? true,
|
||||
models: names.map((n, i) => makeViewModel(n, String(i + 1)))
|
||||
}
|
||||
}
|
||||
|
||||
function mountCard(
|
||||
props: Partial<{
|
||||
missingModelGroups: MissingModelGroup[]
|
||||
showNodeIdBadge: boolean
|
||||
}> = {}
|
||||
) {
|
||||
return mount(MissingModelCard, {
|
||||
props: {
|
||||
missingModelGroups: [makeGroup()],
|
||||
showNodeIdBadge: false,
|
||||
...props
|
||||
},
|
||||
global: {
|
||||
plugins: [PrimeVue, i18n]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('MissingModelCard', () => {
|
||||
describe('Rendering & Props', () => {
|
||||
it('renders directory name in category header', () => {
|
||||
const wrapper = mountCard({
|
||||
missingModelGroups: [makeGroup({ directory: 'loras' })]
|
||||
})
|
||||
expect(wrapper.text()).toContain('loras')
|
||||
})
|
||||
|
||||
it('renders translated unknown category when directory is null', () => {
|
||||
const wrapper = mountCard({
|
||||
missingModelGroups: [makeGroup({ directory: null })]
|
||||
})
|
||||
expect(wrapper.text()).toContain('Unknown Category')
|
||||
})
|
||||
|
||||
it('renders model count in category header', () => {
|
||||
const wrapper = mountCard({
|
||||
missingModelGroups: [
|
||||
makeGroup({ modelNames: ['a.safetensors', 'b.safetensors'] })
|
||||
]
|
||||
})
|
||||
expect(wrapper.text()).toContain('(2)')
|
||||
})
|
||||
|
||||
it('renders correct number of MissingModelRow components', () => {
|
||||
const wrapper = mountCard({
|
||||
missingModelGroups: [
|
||||
makeGroup({
|
||||
modelNames: ['a.safetensors', 'b.safetensors', 'c.safetensors']
|
||||
})
|
||||
]
|
||||
})
|
||||
expect(
|
||||
wrapper.findAllComponents({ name: 'MissingModelRow' })
|
||||
).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('renders multiple groups', () => {
|
||||
const wrapper = mountCard({
|
||||
missingModelGroups: [
|
||||
makeGroup({ directory: 'checkpoints' }),
|
||||
makeGroup({ directory: 'loras' })
|
||||
]
|
||||
})
|
||||
expect(wrapper.text()).toContain('checkpoints')
|
||||
expect(wrapper.text()).toContain('loras')
|
||||
})
|
||||
|
||||
it('renders zero rows when missingModelGroups is empty', () => {
|
||||
const wrapper = mountCard({ missingModelGroups: [] })
|
||||
expect(
|
||||
wrapper.findAllComponents({ name: 'MissingModelRow' })
|
||||
).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('passes props correctly to MissingModelRow children', () => {
|
||||
const wrapper = mountCard({ showNodeIdBadge: true })
|
||||
const row = wrapper.findComponent({ name: 'MissingModelRow' })
|
||||
expect(row.props('showNodeIdBadge')).toBe(true)
|
||||
expect(row.props('isAssetSupported')).toBe(true)
|
||||
expect(row.props('directory')).toBe('checkpoints')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Asset Unsupported Group', () => {
|
||||
it('shows "Import Not Supported" header for unsupported groups', () => {
|
||||
const wrapper = mountCard({
|
||||
missingModelGroups: [makeGroup({ isAssetSupported: false })]
|
||||
})
|
||||
expect(wrapper.text()).toContain('Import Not Supported')
|
||||
})
|
||||
|
||||
it('shows info notice for unsupported groups', () => {
|
||||
const wrapper = mountCard({
|
||||
missingModelGroups: [makeGroup({ isAssetSupported: false })]
|
||||
})
|
||||
expect(wrapper.text()).toContain(
|
||||
'Cloud environment does not support model imports'
|
||||
)
|
||||
})
|
||||
|
||||
it('hides info notice for supported groups', () => {
|
||||
const wrapper = mountCard({
|
||||
missingModelGroups: [makeGroup({ isAssetSupported: true })]
|
||||
})
|
||||
expect(wrapper.text()).not.toContain(
|
||||
'Cloud environment does not support model imports'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Event Handling', () => {
|
||||
it('emits locateModel when child emits locate-model', async () => {
|
||||
const wrapper = mountCard()
|
||||
const row = wrapper.findComponent({ name: 'MissingModelRow' })
|
||||
await row.vm.$emit('locate-model', '42')
|
||||
expect(wrapper.emitted('locateModel')).toBeTruthy()
|
||||
expect(wrapper.emitted('locateModel')?.[0]).toEqual(['42'])
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user