From 2f7f3c4e56e7ca5b95b041ed2a387950c9718901 Mon Sep 17 00:00:00 2001
From: jaeone94 <89377375+jaeone94@users.noreply.github.com>
Date: Thu, 12 Mar 2026 16:21:54 +0900
Subject: [PATCH] [feat] Surface missing models in Errors tab (Cloud) (#9743)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## 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)
---
.../rightSidePanel/RightSidePanel.vue | 21 +-
.../rightSidePanel/errors/TabErrors.vue | 32 +-
src/components/rightSidePanel/errors/types.ts | 1 +
.../errors/useErrorGroups.test.ts | 118 ++
.../rightSidePanel/errors/useErrorGroups.ts | 77 +-
src/components/ui/select/SelectContent.vue | 1 +
src/locales/en/main.json | 28 +
.../components/MissingModelCard.test.ts | 193 ++++
.../components/MissingModelCard.vue | 78 ++
.../components/MissingModelLibrarySelect.vue | 113 ++
.../components/MissingModelRow.vue | 224 ++++
.../components/MissingModelStatusCard.vue | 108 ++
.../components/MissingModelUrlInput.vue | 156 +++
.../useMissingModelInteractions.test.ts | 516 +++++++++
.../useMissingModelInteractions.ts | 393 +++++++
.../missingModel/missingModelScan.test.ts | 1019 +++++++++++++++++
src/platform/missingModel/missingModelScan.ts | 386 +++++++
.../missingModel/missingModelStore.test.ts | 189 +++
.../missingModel/missingModelStore.ts | 201 ++++
src/platform/missingModel/types.ts | 53 +
.../workflow/core/services/workflowService.ts | 13 +-
.../validation/schemas/workflowSchema.test.ts | 51 +-
.../validation/schemas/workflowSchema.ts | 53 +
.../vueNodes/components/LGraphNode.vue | 6 +-
.../vueNodes/components/NodeWidgets.vue | 9 +-
src/scripts/app.ts | 202 ++--
src/stores/assetsStore.ts | 13 +-
src/stores/executionErrorStore.test.ts | 7 +
src/stores/executionErrorStore.ts | 63 +-
src/utils/graphTraversalUtil.ts | 24 +
30 files changed, 4219 insertions(+), 129 deletions(-)
create mode 100644 src/platform/missingModel/components/MissingModelCard.test.ts
create mode 100644 src/platform/missingModel/components/MissingModelCard.vue
create mode 100644 src/platform/missingModel/components/MissingModelLibrarySelect.vue
create mode 100644 src/platform/missingModel/components/MissingModelRow.vue
create mode 100644 src/platform/missingModel/components/MissingModelStatusCard.vue
create mode 100644 src/platform/missingModel/components/MissingModelUrlInput.vue
create mode 100644 src/platform/missingModel/composables/useMissingModelInteractions.test.ts
create mode 100644 src/platform/missingModel/composables/useMissingModelInteractions.ts
create mode 100644 src/platform/missingModel/missingModelScan.test.ts
create mode 100644 src/platform/missingModel/missingModelScan.ts
create mode 100644 src/platform/missingModel/missingModelStore.test.ts
create mode 100644 src/platform/missingModel/missingModelStore.ts
create mode 100644 src/platform/missingModel/types.ts
diff --git a/src/components/rightSidePanel/RightSidePanel.vue b/src/components/rightSidePanel/RightSidePanel.vue
index 6f0697932c..38a9c663e9 100644
--- a/src/components/rightSidePanel/RightSidePanel.vue
+++ b/src/components/rightSidePanel/RightSidePanel.vue
@@ -13,6 +13,7 @@ import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
+import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import type { RightSidePanelTab } from '@/stores/workspace/rightSidePanelStore'
@@ -36,6 +37,7 @@ import TabErrors from './errors/TabErrors.vue'
const canvasStore = useCanvasStore()
const executionErrorStore = useExecutionErrorStore()
+const missingModelStore = useMissingModelStore()
const rightSidePanelStore = useRightSidePanelStore()
const settingStore = useSettingStore()
const { t } = useI18n()
@@ -43,6 +45,8 @@ const { t } = useI18n()
const { hasAnyError, allErrorExecutionIds, activeMissingNodeGraphIds } =
storeToRefs(executionErrorStore)
+const { activeMissingModelGraphIds } = storeToRefs(missingModelStore)
+
const { findParentGroup } = useGraphHierarchy()
const { selectedItems: directlySelectedItems } = storeToRefs(canvasStore)
@@ -118,12 +122,21 @@ const hasMissingNodeSelected = computed(
)
)
+const hasMissingModelSelected = computed(
+ () =>
+ hasSelection.value &&
+ selectedNodes.value.some((node) =>
+ activeMissingModelGraphIds.value.has(String(node.id))
+ )
+)
+
const hasRelevantErrors = computed(() => {
if (!hasSelection.value) return hasAnyError.value
return (
hasDirectNodeError.value ||
hasContainerInternalError.value ||
- hasMissingNodeSelected.value
+ hasMissingNodeSelected.value ||
+ hasMissingModelSelected.value
)
})
@@ -314,7 +327,11 @@ function handleTitleCancel() {
:value="tab.value"
>
{{ tab.label() }}
-
+
diff --git a/src/components/rightSidePanel/errors/TabErrors.vue b/src/components/rightSidePanel/errors/TabErrors.vue
index bc5b0de55b..6dce313994 100644
--- a/src/components/rightSidePanel/errors/TabErrors.vue
+++ b/src/components/rightSidePanel/errors/TabErrors.vue
@@ -12,7 +12,7 @@
-
+
@@ -130,6 +126,14 @@
@copy-to-clipboard="copyToClipboard"
/>
+
+
+
@@ -187,12 +191,14 @@ import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/f
import ErrorNodeCard from './ErrorNodeCard.vue'
import MissingNodeCard from './MissingNodeCard.vue'
import SwapNodesCard from '@/platform/nodeReplacement/components/SwapNodesCard.vue'
+import MissingModelCard from '@/platform/missingModel/components/MissingModelCard.vue'
import Button from '@/components/ui/button/Button.vue'
import DotSpinner from '@/components/common/DotSpinner.vue'
import { usePackInstall } from '@/workbench/extensions/manager/composables/nodePack/usePackInstall'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
import { useErrorGroups } from './useErrorGroups'
import type { SwapNodeGroup } from './useErrorGroups'
+import type { ErrorGroup } from './types'
import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacement'
const { t } = useI18n()
@@ -211,6 +217,15 @@ const { replaceGroup, replaceAllGroups } = useNodeReplacement()
const searchQuery = ref('')
const isSearching = computed(() => searchQuery.value.trim() !== '')
+const fullSizeGroupTypes = new Set([
+ 'missing_node',
+ 'swap_nodes',
+ 'missing_model'
+])
+function getGroupSize(group: ErrorGroup) {
+ return fullSizeGroupTypes.has(group.type) ? 'lg' : 'default'
+}
+
const showNodeIdBadge = computed(
() =>
(settingStore.get('Comfy.NodeBadge.NodeIdBadgeMode') as NodeBadgeMode) !==
@@ -226,6 +241,7 @@ const {
errorNodeCache,
missingNodeCache,
missingPackGroups,
+ missingModelGroups,
swapNodeGroups
} = useErrorGroups(searchQuery, t)
@@ -283,6 +299,10 @@ function handleLocateMissingNode(nodeId: string) {
focusNode(nodeId, missingNodeCache.value)
}
+function handleLocateModel(nodeId: string) {
+ focusNode(nodeId)
+}
+
function handleOpenManagerInfo(packId: string) {
const isKnownToRegistry = missingNodePacks.value.some((p) => p.id === packId)
if (isKnownToRegistry) {
diff --git a/src/components/rightSidePanel/errors/types.ts b/src/components/rightSidePanel/errors/types.ts
index 223fe4650d..f2242f2de1 100644
--- a/src/components/rightSidePanel/errors/types.ts
+++ b/src/components/rightSidePanel/errors/types.ts
@@ -23,3 +23,4 @@ export type ErrorGroup =
}
| { type: 'missing_node'; title: string; priority: number }
| { type: 'swap_nodes'; title: string; priority: number }
+ | { type: 'missing_model'; title: string; priority: number }
diff --git a/src/components/rightSidePanel/errors/useErrorGroups.test.ts b/src/components/rightSidePanel/errors/useErrorGroups.test.ts
index eca0453761..548cfc2b27 100644
--- a/src/components/rightSidePanel/errors/useErrorGroups.test.ts
+++ b/src/components/rightSidePanel/errors/useErrorGroups.test.ts
@@ -47,6 +47,13 @@ vi.mock('@/utils/executableGroupNodeDto', () => ({
isGroupNode: vi.fn(() => false)
}))
+vi.mock(
+ '@/platform/missingModel/composables/useMissingModelInteractions',
+ () => ({
+ clearMissingModelState: vi.fn()
+ })
+)
+
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useErrorGroups } from './useErrorGroups'
@@ -520,4 +527,115 @@ describe('useErrorGroups', () => {
expect(typeof groups.collapseState).toBe('object')
})
})
+
+ describe('missingModelGroups', () => {
+ function makeModel(
+ name: string,
+ opts: {
+ nodeId?: string | number
+ widgetName?: string
+ directory?: string
+ isAssetSupported?: boolean
+ } = {}
+ ) {
+ return {
+ name,
+ nodeId: opts.nodeId ?? '1',
+ nodeType: 'CheckpointLoaderSimple',
+ widgetName: opts.widgetName ?? 'ckpt_name',
+ isAssetSupported: opts.isAssetSupported ?? false,
+ isMissing: true as const,
+ directory: opts.directory
+ }
+ }
+
+ it('returns empty array when no missing models', () => {
+ const { groups } = createErrorGroups()
+ expect(groups.missingModelGroups.value).toEqual([])
+ })
+
+ it('groups asset-supported models by directory', async () => {
+ const { store, groups } = createErrorGroups()
+ store.surfaceMissingModels([
+ makeModel('model_a.safetensors', {
+ directory: 'checkpoints',
+ isAssetSupported: true
+ }),
+ makeModel('model_b.safetensors', {
+ nodeId: '2',
+ directory: 'checkpoints',
+ isAssetSupported: true
+ }),
+ makeModel('lora_a.safetensors', {
+ nodeId: '3',
+ directory: 'loras',
+ isAssetSupported: true
+ })
+ ])
+ await nextTick()
+
+ expect(groups.missingModelGroups.value).toHaveLength(2)
+ const ckptGroup = groups.missingModelGroups.value.find(
+ (g) => g.directory === 'checkpoints'
+ )
+ expect(ckptGroup?.models).toHaveLength(2)
+ expect(ckptGroup?.isAssetSupported).toBe(true)
+ })
+
+ it('puts unsupported models in a separate group', async () => {
+ const { store, groups } = createErrorGroups()
+ store.surfaceMissingModels([
+ makeModel('model_a.safetensors', {
+ directory: 'checkpoints',
+ isAssetSupported: true
+ }),
+ makeModel('custom_model.safetensors', {
+ nodeId: '2',
+ isAssetSupported: false
+ })
+ ])
+ await nextTick()
+
+ expect(groups.missingModelGroups.value).toHaveLength(2)
+ const unsupported = groups.missingModelGroups.value.find(
+ (g) => !g.isAssetSupported
+ )
+ expect(unsupported?.models).toHaveLength(1)
+ })
+
+ it('merges same-named models into one view model with multiple referencingNodes', async () => {
+ const { store, groups } = createErrorGroups()
+ store.surfaceMissingModels([
+ makeModel('shared_model.safetensors', {
+ nodeId: '1',
+ widgetName: 'ckpt_name',
+ directory: 'checkpoints',
+ isAssetSupported: true
+ }),
+ makeModel('shared_model.safetensors', {
+ nodeId: '2',
+ widgetName: 'ckpt_name',
+ directory: 'checkpoints',
+ isAssetSupported: true
+ })
+ ])
+ await nextTick()
+
+ expect(groups.missingModelGroups.value).toHaveLength(1)
+ const model = groups.missingModelGroups.value[0].models[0]
+ expect(model.name).toBe('shared_model.safetensors')
+ expect(model.referencingNodes).toHaveLength(2)
+ })
+
+ it('includes missing_model group in allErrorGroups', async () => {
+ const { store, groups } = createErrorGroups()
+ store.surfaceMissingModels([makeModel('model_a.safetensors')])
+ await nextTick()
+
+ const modelGroup = groups.allErrorGroups.value.find(
+ (g) => g.type === 'missing_model'
+ )
+ expect(modelGroup).toBeDefined()
+ })
+ })
})
diff --git a/src/components/rightSidePanel/errors/useErrorGroups.ts b/src/components/rightSidePanel/errors/useErrorGroups.ts
index 71781886fb..4d8818adaf 100644
--- a/src/components/rightSidePanel/errors/useErrorGroups.ts
+++ b/src/components/rightSidePanel/errors/useErrorGroups.ts
@@ -3,6 +3,7 @@ import type { MaybeRefOrGetter } from 'vue'
import Fuse from 'fuse.js'
import type { IFuseOptions } from 'fuse.js'
+import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
@@ -23,6 +24,11 @@ import { st } from '@/i18n'
import type { MissingNodeType } from '@/types/comfy'
import type { ErrorCardData, ErrorGroup, ErrorItem } from './types'
import type { NodeExecutionId } from '@/types/nodeIdentification'
+import type {
+ MissingModelCandidate,
+ MissingModelGroup
+} from '@/platform/missingModel/types'
+import { groupCandidatesByName } from '@/platform/missingModel/missingModelScan'
import {
isNodeExecutionId,
compareExecutionId
@@ -39,6 +45,9 @@ const KNOWN_PROMPT_ERROR_TYPES = new Set([
/** Sentinel: distinguishes "fetch in-flight" from "fetch done, pack not found (null)". */
const RESOLVING = '__RESOLVING__'
+/** Sentinel key for grouping non-asset-supported missing models. */
+const UNSUPPORTED = Symbol('unsupported')
+
export interface MissingPackGroup {
packId: string | null
nodeTypes: MissingNodeType[]
@@ -231,6 +240,7 @@ export function useErrorGroups(
t: (key: string) => string
) {
const executionErrorStore = useExecutionErrorStore()
+ const missingModelStore = useMissingModelStore()
const canvasStore = useCanvasStore()
const { inferPackFromNodeName } = useComfyRegistryStore()
const collapseState = reactive
>({})
@@ -559,6 +569,60 @@ export function useErrorGroups(
return groups.sort((a, b) => a.priority - b.priority)
}
+ /** Groups missing models. Asset-supported models group by directory; others go into a separate group.
+ * Within each group, candidates with the same model name are merged into a single view model. */
+ const missingModelGroups = computed(() => {
+ const candidates = missingModelStore.missingModelCandidates
+ if (!candidates?.length) return []
+
+ type GroupKey = string | null | typeof UNSUPPORTED
+ const map = new Map<
+ GroupKey,
+ { candidates: MissingModelCandidate[]; isAssetSupported: boolean }
+ >()
+
+ for (const c of candidates) {
+ const groupKey: GroupKey = c.isAssetSupported
+ ? c.directory || null
+ : UNSUPPORTED
+
+ const existing = map.get(groupKey)
+ if (existing) {
+ existing.candidates.push(c)
+ } else {
+ map.set(groupKey, {
+ candidates: [c],
+ isAssetSupported: c.isAssetSupported
+ })
+ }
+ }
+
+ return Array.from(map.entries())
+ .sort(([dirA], [dirB]) => {
+ if (dirA === UNSUPPORTED) return 1
+ if (dirB === UNSUPPORTED) return -1
+ if (dirA === null) return 1
+ if (dirB === null) return -1
+ return dirA.localeCompare(dirB)
+ })
+ .map(([key, { candidates: groupCandidates, isAssetSupported }]) => ({
+ directory: typeof key === 'string' ? key : null,
+ models: groupCandidatesByName(groupCandidates),
+ isAssetSupported
+ }))
+ })
+
+ function buildMissingModelGroups(): ErrorGroup[] {
+ if (!missingModelGroups.value.length) return []
+ return [
+ {
+ type: 'missing_model' as const,
+ title: `${t('rightSidePanel.missingModels.missingModelsTitle')} (${missingModelGroups.value.reduce((count, group) => count + group.models.length, 0)})`,
+ priority: 2
+ }
+ ]
+ }
+
const allErrorGroups = computed(() => {
const groupsMap = new Map()
@@ -566,7 +630,11 @@ export function useErrorGroups(
processNodeErrors(groupsMap)
processExecutionError(groupsMap)
- return [...buildMissingNodeGroups(), ...toSortedGroups(groupsMap)]
+ return [
+ ...buildMissingNodeGroups(),
+ ...buildMissingModelGroups(),
+ ...toSortedGroups(groupsMap)
+ ]
})
const tabErrorGroups = computed(() => {
@@ -580,7 +648,11 @@ export function useErrorGroups(
? toSortedGroups(regroupByErrorMessage(groupsMap))
: toSortedGroups(groupsMap)
- return [...buildMissingNodeGroups(), ...executionGroups]
+ return [
+ ...buildMissingNodeGroups(),
+ ...buildMissingModelGroups(),
+ ...executionGroups
+ ]
})
const filteredGroups = computed(() => {
@@ -615,6 +687,7 @@ export function useErrorGroups(
missingNodeCache,
groupedErrorMessages,
missingPackGroups,
+ missingModelGroups,
swapNodeGroups
}
}
diff --git a/src/components/ui/select/SelectContent.vue b/src/components/ui/select/SelectContent.vue
index 8e0595eddf..7576f01929 100644
--- a/src/components/ui/select/SelectContent.vue
+++ b/src/components/ui/select/SelectContent.vue
@@ -64,6 +64,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
)
"
>
+
({
+ default: {
+ name: 'MissingModelRow',
+ template: '',
+ 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'])
+ })
+ })
+})
diff --git a/src/platform/missingModel/components/MissingModelCard.vue b/src/platform/missingModel/components/MissingModelCard.vue
new file mode 100644
index 0000000000..01e67761d3
--- /dev/null
+++ b/src/platform/missingModel/components/MissingModelCard.vue
@@ -0,0 +1,78 @@
+
+
+
+
+
+
+
+
+ {{ t('rightSidePanel.missingModels.importNotSupported') }}
+ ({{ group.models.length }})
+
+
+ {{
+ group.directory ??
+ t('rightSidePanel.missingModels.unknownCategory')
+ }}
+ ({{ group.models.length }})
+
+
+
+
+
+
+
+
+ {{ t('rightSidePanel.missingModels.customNodeDownloadDisabled') }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/platform/missingModel/components/MissingModelLibrarySelect.vue b/src/platform/missingModel/components/MissingModelLibrarySelect.vue
new file mode 100644
index 0000000000..daddd739bb
--- /dev/null
+++ b/src/platform/missingModel/components/MissingModelLibrarySelect.vue
@@ -0,0 +1,113 @@
+
+
+
+
+ {{ t('rightSidePanel.missingModels.or') }}
+
+
+
+
+
+
+
+
diff --git a/src/platform/missingModel/components/MissingModelRow.vue b/src/platform/missingModel/components/MissingModelRow.vue
new file mode 100644
index 0000000000..d2b16ebb41
--- /dev/null
+++ b/src/platform/missingModel/components/MissingModelRow.vue
@@ -0,0 +1,224 @@
+
+
+
+
+
+
+
+
+ {{ model.name }} ({{ model.referencingNodes.length }})
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ #{{ ref.nodeId }}
+
+
+ {{ getNodeDisplayLabel(ref.nodeId, model.representative.nodeType) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/platform/missingModel/components/MissingModelStatusCard.vue b/src/platform/missingModel/components/MissingModelStatusCard.vue
new file mode 100644
index 0000000000..7c41445675
--- /dev/null
+++ b/src/platform/missingModel/components/MissingModelStatusCard.vue
@@ -0,0 +1,108 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ modelName }}
+
+
+
+ {{
+ t('rightSidePanel.missingModels.alreadyExistsInCategory', {
+ category: categoryMismatch
+ })
+ }}
+
+
+ {{ t('rightSidePanel.missingModels.importing') }}
+ {{ Math.round((downloadStatus?.progress ?? 0) * 100) }}%
+
+
+ {{ t('rightSidePanel.missingModels.imported') }}
+
+
+ {{
+ downloadStatus?.error ||
+ t('rightSidePanel.missingModels.importFailed')
+ }}
+
+
+ {{ t('rightSidePanel.missingModels.usingFromLibrary') }}
+
+
+
+
+
+
+
+
+
+
diff --git a/src/platform/missingModel/components/MissingModelUrlInput.vue b/src/platform/missingModel/components/MissingModelUrlInput.vue
new file mode 100644
index 0000000000..01a25f1c78
--- /dev/null
+++ b/src/platform/missingModel/components/MissingModelUrlInput.vue
@@ -0,0 +1,156 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ urlMetadata[modelKey]?.filename }}
+
+
+ {{ formatSize(urlMetadata[modelKey]?.content_length ?? 0) }}
+
+
+
+
+
+
+ {{
+ t('rightSidePanel.missingModels.typeMismatch', {
+ detectedType: typeMismatch
+ })
+ }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('g.loading') }}
+
+
+
+
+
+
+ {{ urlErrors[modelKey] }}
+
+
+
+
+
+
diff --git a/src/platform/missingModel/composables/useMissingModelInteractions.test.ts b/src/platform/missingModel/composables/useMissingModelInteractions.test.ts
new file mode 100644
index 0000000000..b4afd36ef6
--- /dev/null
+++ b/src/platform/missingModel/composables/useMissingModelInteractions.test.ts
@@ -0,0 +1,516 @@
+import { createPinia, setActivePinia } from 'pinia'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import type { MissingModelCandidate } from '@/platform/missingModel/types'
+
+const mockGetNodeByExecutionId = vi.fn()
+const mockResolveNodeDisplayName = vi.fn()
+const mockValidateSourceUrl = vi.fn()
+const mockGetAssetMetadata = vi.fn()
+const mockGetAssetDisplayName = vi.fn((a: { name: string }) => a.name)
+const mockGetAssetFilename = vi.fn((a: { name: string }) => a.name)
+const mockGetAssets = vi.fn()
+const mockUpdateModelsForNodeType = vi.fn()
+const mockGetAllNodeProviders = vi.fn()
+const mockDownloadList = vi.fn(
+ (): Array<{ taskId: string; status: string }> => []
+)
+
+vi.mock('@/i18n', () => ({
+ st: vi.fn((_key: string, fallback: string) => fallback)
+}))
+
+vi.mock('@/platform/distribution/types', () => ({
+ isCloud: false
+}))
+
+vi.mock('vue-i18n', () => ({
+ useI18n: () => ({
+ t: (key: string) => key
+ })
+}))
+
+vi.mock('@/scripts/app', () => ({
+ app: {
+ rootGraph: null
+ }
+}))
+
+vi.mock('@/utils/graphTraversalUtil', () => ({
+ getNodeByExecutionId: (...args: unknown[]) =>
+ mockGetNodeByExecutionId(...args)
+}))
+
+vi.mock('@/utils/nodeTitleUtil', () => ({
+ resolveNodeDisplayName: (...args: unknown[]) =>
+ mockResolveNodeDisplayName(...args)
+}))
+
+vi.mock('@/renderer/core/canvas/canvasStore', () => ({
+ useCanvasStore: () => ({})
+}))
+
+vi.mock('@/stores/assetsStore', () => ({
+ useAssetsStore: () => ({
+ getAssets: mockGetAssets,
+ updateModelsForNodeType: mockUpdateModelsForNodeType,
+ invalidateModelsForCategory: vi.fn(),
+ updateModelsForTag: vi.fn()
+ })
+}))
+
+vi.mock('@/stores/assetDownloadStore', () => ({
+ useAssetDownloadStore: () => ({
+ get downloadList() {
+ return mockDownloadList()
+ },
+ trackDownload: vi.fn()
+ })
+}))
+
+vi.mock('@/stores/modelToNodeStore', () => ({
+ useModelToNodeStore: () => ({
+ getAllNodeProviders: mockGetAllNodeProviders
+ })
+}))
+
+vi.mock('@/platform/assets/services/assetService', () => ({
+ assetService: {
+ getAssetMetadata: (...args: unknown[]) => mockGetAssetMetadata(...args)
+ }
+}))
+
+vi.mock('@/platform/assets/utils/assetMetadataUtils', () => ({
+ getAssetDisplayName: (a: { name: string }) => mockGetAssetDisplayName(a),
+ getAssetFilename: (a: { name: string }) => mockGetAssetFilename(a)
+}))
+
+vi.mock('@/platform/assets/importSources/civitaiImportSource', () => ({
+ civitaiImportSource: {
+ type: 'civitai',
+ name: 'Civitai',
+ hostnames: ['civitai.com']
+ }
+}))
+
+vi.mock('@/platform/assets/importSources/huggingfaceImportSource', () => ({
+ huggingfaceImportSource: {
+ type: 'huggingface',
+ name: 'Hugging Face',
+ hostnames: ['huggingface.co']
+ }
+}))
+
+vi.mock('@/platform/assets/utils/importSourceUtil', () => ({
+ validateSourceUrl: (...args: unknown[]) => mockValidateSourceUrl(...args)
+}))
+
+import { app } from '@/scripts/app'
+import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
+import {
+ getComboValue,
+ getModelStateKey,
+ getNodeDisplayLabel,
+ useMissingModelInteractions
+} from './useMissingModelInteractions'
+
+function makeCandidate(
+ overrides: Partial = {}
+): MissingModelCandidate {
+ return {
+ name: 'model.safetensors',
+ nodeId: '1',
+ nodeType: 'CheckpointLoaderSimple',
+ widgetName: 'ckpt_name',
+ isAssetSupported: false,
+ isMissing: true,
+ ...overrides
+ }
+}
+
+describe('useMissingModelInteractions', () => {
+ beforeEach(() => {
+ setActivePinia(createPinia())
+ vi.resetAllMocks()
+ mockGetAssetDisplayName.mockImplementation((a: { name: string }) => a.name)
+ mockGetAssetFilename.mockImplementation((a: { name: string }) => a.name)
+ mockDownloadList.mockImplementation(
+ (): Array<{ taskId: string; status: string }> => []
+ )
+ ;(app as { rootGraph: unknown }).rootGraph = null
+ })
+
+ describe('getModelStateKey', () => {
+ it('returns key with supported prefix when asset is supported', () => {
+ expect(getModelStateKey('model.safetensors', 'checkpoints', true)).toBe(
+ 'supported::checkpoints::model.safetensors'
+ )
+ })
+
+ it('returns key with unsupported prefix when asset is not supported', () => {
+ expect(getModelStateKey('model.safetensors', 'loras', false)).toBe(
+ 'unsupported::loras::model.safetensors'
+ )
+ })
+
+ it('handles null directory', () => {
+ expect(getModelStateKey('model.safetensors', null, true)).toBe(
+ 'supported::::model.safetensors'
+ )
+ })
+ })
+
+ describe('getNodeDisplayLabel', () => {
+ it('returns fallback when graph is null', () => {
+ ;(app as { rootGraph: unknown }).rootGraph = null
+ expect(getNodeDisplayLabel('1', 'Node #1')).toBe('Node #1')
+ })
+
+ it('calls resolveNodeDisplayName when graph is available', () => {
+ const mockGraph = {}
+ const mockNode = { id: 1 }
+ ;(app as { rootGraph: unknown }).rootGraph = mockGraph
+ mockGetNodeByExecutionId.mockReturnValue(mockNode)
+ mockResolveNodeDisplayName.mockReturnValue('My Checkpoint')
+
+ const result = getNodeDisplayLabel('1', 'Node #1')
+
+ expect(mockGetNodeByExecutionId).toHaveBeenCalledWith(mockGraph, '1')
+ expect(result).toBe('My Checkpoint')
+ })
+ })
+
+ describe('getComboValue', () => {
+ it('returns undefined when node is not found', () => {
+ ;(app as { rootGraph: unknown }).rootGraph = {}
+ mockGetNodeByExecutionId.mockReturnValue(null)
+
+ const result = getComboValue(makeCandidate())
+ expect(result).toBeUndefined()
+ })
+
+ it('returns undefined when widget is not found', () => {
+ ;(app as { rootGraph: unknown }).rootGraph = {}
+ mockGetNodeByExecutionId.mockReturnValue({
+ widgets: [{ name: 'other_widget', value: 'test' }]
+ })
+
+ const result = getComboValue(makeCandidate())
+ expect(result).toBeUndefined()
+ })
+
+ it('returns string value directly', () => {
+ ;(app as { rootGraph: unknown }).rootGraph = {}
+ mockGetNodeByExecutionId.mockReturnValue({
+ widgets: [{ name: 'ckpt_name', value: 'v1-5.safetensors' }]
+ })
+
+ expect(getComboValue(makeCandidate())).toBe('v1-5.safetensors')
+ })
+
+ it('returns stringified number value', () => {
+ ;(app as { rootGraph: unknown }).rootGraph = {}
+ mockGetNodeByExecutionId.mockReturnValue({
+ widgets: [{ name: 'ckpt_name', value: 42 }]
+ })
+
+ expect(getComboValue(makeCandidate())).toBe('42')
+ })
+
+ it('returns undefined for unexpected types', () => {
+ ;(app as { rootGraph: unknown }).rootGraph = {}
+ mockGetNodeByExecutionId.mockReturnValue({
+ widgets: [{ name: 'ckpt_name', value: { complex: true } }]
+ })
+
+ expect(getComboValue(makeCandidate())).toBeUndefined()
+ })
+
+ it('returns undefined when nodeId is null', () => {
+ const result = getComboValue(makeCandidate({ nodeId: undefined }))
+ expect(result).toBeUndefined()
+ })
+ })
+
+ describe('toggleModelExpand / isModelExpanded', () => {
+ it('starts collapsed by default', () => {
+ const { isModelExpanded } = useMissingModelInteractions()
+ expect(isModelExpanded('key1')).toBe(false)
+ })
+
+ it('toggles to expanded', () => {
+ const { toggleModelExpand, isModelExpanded } =
+ useMissingModelInteractions()
+ toggleModelExpand('key1')
+ expect(isModelExpanded('key1')).toBe(true)
+ })
+
+ it('toggles back to collapsed', () => {
+ const { toggleModelExpand, isModelExpanded } =
+ useMissingModelInteractions()
+ toggleModelExpand('key1')
+ toggleModelExpand('key1')
+ expect(isModelExpanded('key1')).toBe(false)
+ })
+ })
+
+ describe('handleComboSelect', () => {
+ it('sets selectedLibraryModel in store', () => {
+ const store = useMissingModelStore()
+ const { handleComboSelect } = useMissingModelInteractions()
+
+ handleComboSelect('key1', 'model_v2.safetensors')
+ expect(store.selectedLibraryModel['key1']).toBe('model_v2.safetensors')
+ })
+
+ it('does not set value when undefined', () => {
+ const store = useMissingModelStore()
+ const { handleComboSelect } = useMissingModelInteractions()
+
+ handleComboSelect('key1', undefined)
+ expect(store.selectedLibraryModel['key1']).toBeUndefined()
+ })
+ })
+
+ describe('isSelectionConfirmable', () => {
+ it('returns false when no selection exists', () => {
+ const { isSelectionConfirmable } = useMissingModelInteractions()
+ expect(isSelectionConfirmable('key1')).toBe(false)
+ })
+
+ it('returns false when download is running', () => {
+ const store = useMissingModelStore()
+ store.selectedLibraryModel['key1'] = 'model.safetensors'
+ store.importTaskIds['key1'] = 'task-123'
+ mockDownloadList.mockReturnValue([
+ { taskId: 'task-123', status: 'running' }
+ ])
+
+ const { isSelectionConfirmable } = useMissingModelInteractions()
+ expect(isSelectionConfirmable('key1')).toBe(false)
+ })
+
+ it('returns false when importCategoryMismatch exists', () => {
+ const store = useMissingModelStore()
+ store.selectedLibraryModel['key1'] = 'model.safetensors'
+ store.importCategoryMismatch['key1'] = 'loras'
+
+ const { isSelectionConfirmable } = useMissingModelInteractions()
+ expect(isSelectionConfirmable('key1')).toBe(false)
+ })
+
+ it('returns true when selection is ready with no active download', () => {
+ const store = useMissingModelStore()
+ store.selectedLibraryModel['key1'] = 'model.safetensors'
+ mockDownloadList.mockReturnValue([])
+
+ const { isSelectionConfirmable } = useMissingModelInteractions()
+ expect(isSelectionConfirmable('key1')).toBe(true)
+ })
+ })
+
+ describe('cancelLibrarySelect', () => {
+ it('clears selectedLibraryModel and importCategoryMismatch', () => {
+ const store = useMissingModelStore()
+ store.selectedLibraryModel['key1'] = 'model.safetensors'
+ store.importCategoryMismatch['key1'] = 'loras'
+
+ const { cancelLibrarySelect } = useMissingModelInteractions()
+ cancelLibrarySelect('key1')
+
+ expect(store.selectedLibraryModel['key1']).toBeUndefined()
+ expect(store.importCategoryMismatch['key1']).toBeUndefined()
+ })
+ })
+
+ describe('confirmLibrarySelect', () => {
+ it('updates widget values on referencing nodes and removes missing model', () => {
+ const mockGraph = {}
+ ;(app as { rootGraph: unknown }).rootGraph = mockGraph
+
+ const widget1 = { name: 'ckpt_name', value: 'old_model.safetensors' }
+ const widget2 = { name: 'ckpt_name', value: 'old_model.safetensors' }
+ const node1 = { widgets: [widget1] }
+ const node2 = { widgets: [widget2] }
+
+ mockGetNodeByExecutionId.mockImplementation(
+ (_graph: unknown, id: string) => {
+ if (id === '10') return node1
+ if (id === '20') return node2
+ return null
+ }
+ )
+
+ const store = useMissingModelStore()
+ store.selectedLibraryModel['key1'] = 'new_model.safetensors'
+ store.setMissingModels([
+ makeCandidate({ name: 'old_model.safetensors', nodeId: '10' }),
+ makeCandidate({ name: 'old_model.safetensors', nodeId: '20' })
+ ])
+
+ const removeSpy = vi.spyOn(store, 'removeMissingModelByNameOnNodes')
+
+ const { confirmLibrarySelect } = useMissingModelInteractions()
+ confirmLibrarySelect(
+ 'key1',
+ 'old_model.safetensors',
+ [
+ { nodeId: '10', widgetName: 'ckpt_name' },
+ { nodeId: '20', widgetName: 'ckpt_name' }
+ ],
+ null
+ )
+
+ expect(widget1.value).toBe('new_model.safetensors')
+ expect(widget2.value).toBe('new_model.safetensors')
+ expect(removeSpy).toHaveBeenCalledWith(
+ 'old_model.safetensors',
+ new Set(['10', '20'])
+ )
+ expect(store.selectedLibraryModel['key1']).toBeUndefined()
+ })
+
+ it('does nothing when no selection exists', () => {
+ ;(app as { rootGraph: unknown }).rootGraph = {}
+ const store = useMissingModelStore()
+ const removeSpy = vi.spyOn(store, 'removeMissingModelByNameOnNodes')
+
+ const { confirmLibrarySelect } = useMissingModelInteractions()
+ confirmLibrarySelect('key1', 'model.safetensors', [], null)
+
+ expect(removeSpy).not.toHaveBeenCalled()
+ })
+
+ it('does nothing when graph is null', () => {
+ ;(app as { rootGraph: unknown }).rootGraph = null
+ const store = useMissingModelStore()
+ store.selectedLibraryModel['key1'] = 'new.safetensors'
+ const removeSpy = vi.spyOn(store, 'removeMissingModelByNameOnNodes')
+
+ const { confirmLibrarySelect } = useMissingModelInteractions()
+ confirmLibrarySelect('key1', 'model.safetensors', [], null)
+
+ expect(removeSpy).not.toHaveBeenCalled()
+ })
+
+ it('refreshes model cache when directory is provided', () => {
+ ;(app as { rootGraph: unknown }).rootGraph = {}
+ mockGetNodeByExecutionId.mockReturnValue(null)
+ mockGetAllNodeProviders.mockReturnValue([
+ { nodeDef: { name: 'CheckpointLoaderSimple' } }
+ ])
+
+ const store = useMissingModelStore()
+ store.selectedLibraryModel['key1'] = 'new.safetensors'
+
+ const { confirmLibrarySelect } = useMissingModelInteractions()
+ confirmLibrarySelect('key1', 'model.safetensors', [], 'checkpoints')
+
+ expect(mockGetAllNodeProviders).toHaveBeenCalledWith('checkpoints')
+ })
+ })
+
+ describe('handleUrlInput', () => {
+ it('clears previous state on new input', () => {
+ const store = useMissingModelStore()
+ store.urlMetadata['key1'] = { name: 'old' } as never
+ store.urlErrors['key1'] = 'old error'
+ store.urlFetching['key1'] = true
+
+ const { handleUrlInput } = useMissingModelInteractions()
+ handleUrlInput('key1', 'https://civitai.com/models/123')
+
+ expect(store.urlInputs['key1']).toBe('https://civitai.com/models/123')
+ expect(store.urlMetadata['key1']).toBeUndefined()
+ expect(store.urlErrors['key1']).toBeUndefined()
+ expect(store.urlFetching['key1']).toBe(false)
+ })
+
+ it('does not set debounce timer for empty input', () => {
+ const store = useMissingModelStore()
+ const setTimerSpy = vi.spyOn(store, 'setDebounceTimer')
+
+ const { handleUrlInput } = useMissingModelInteractions()
+ handleUrlInput('key1', ' ')
+
+ expect(setTimerSpy).not.toHaveBeenCalled()
+ })
+
+ it('sets debounce timer for non-empty input', () => {
+ const store = useMissingModelStore()
+ const setTimerSpy = vi.spyOn(store, 'setDebounceTimer')
+
+ const { handleUrlInput } = useMissingModelInteractions()
+ handleUrlInput('key1', 'https://civitai.com/models/123')
+
+ expect(setTimerSpy).toHaveBeenCalledWith(
+ 'key1',
+ expect.any(Function),
+ 800
+ )
+ })
+
+ it('clears previous debounce timer', () => {
+ const store = useMissingModelStore()
+ const clearTimerSpy = vi.spyOn(store, 'clearDebounceTimer')
+
+ const { handleUrlInput } = useMissingModelInteractions()
+ handleUrlInput('key1', 'https://civitai.com/models/123')
+
+ expect(clearTimerSpy).toHaveBeenCalledWith('key1')
+ })
+ })
+
+ describe('getTypeMismatch', () => {
+ it('returns null when groupDirectory is null', () => {
+ const { getTypeMismatch } = useMissingModelInteractions()
+ expect(getTypeMismatch('key1', null)).toBeNull()
+ })
+
+ it('returns null when no metadata exists', () => {
+ const { getTypeMismatch } = useMissingModelInteractions()
+ expect(getTypeMismatch('key1', 'checkpoints')).toBeNull()
+ })
+
+ it('returns null when metadata has no tags', () => {
+ const store = useMissingModelStore()
+ store.urlMetadata['key1'] = { name: 'model', tags: [] } as never
+
+ const { getTypeMismatch } = useMissingModelInteractions()
+ expect(getTypeMismatch('key1', 'checkpoints')).toBeNull()
+ })
+
+ it('returns null when detected type matches directory', () => {
+ const store = useMissingModelStore()
+ store.urlMetadata['key1'] = {
+ name: 'model',
+ tags: ['checkpoints']
+ } as never
+
+ const { getTypeMismatch } = useMissingModelInteractions()
+ expect(getTypeMismatch('key1', 'checkpoints')).toBeNull()
+ })
+
+ it('returns detected type when it differs from directory', () => {
+ const store = useMissingModelStore()
+ store.urlMetadata['key1'] = {
+ name: 'model',
+ tags: ['loras']
+ } as never
+
+ const { getTypeMismatch } = useMissingModelInteractions()
+ expect(getTypeMismatch('key1', 'checkpoints')).toBe('loras')
+ })
+
+ it('returns null when tags contain no recognized model type', () => {
+ const store = useMissingModelStore()
+ store.urlMetadata['key1'] = {
+ name: 'model',
+ tags: ['other', 'random']
+ } as never
+
+ const { getTypeMismatch } = useMissingModelInteractions()
+ expect(getTypeMismatch('key1', 'checkpoints')).toBeNull()
+ })
+ })
+})
diff --git a/src/platform/missingModel/composables/useMissingModelInteractions.ts b/src/platform/missingModel/composables/useMissingModelInteractions.ts
new file mode 100644
index 0000000000..237c68aecd
--- /dev/null
+++ b/src/platform/missingModel/composables/useMissingModelInteractions.ts
@@ -0,0 +1,393 @@
+import { useI18n } from 'vue-i18n'
+
+import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
+import { st } from '@/i18n'
+import { assetService } from '@/platform/assets/services/assetService'
+import {
+ getAssetDisplayName,
+ getAssetFilename
+} from '@/platform/assets/utils/assetMetadataUtils'
+import { civitaiImportSource } from '@/platform/assets/importSources/civitaiImportSource'
+import { huggingfaceImportSource } from '@/platform/assets/importSources/huggingfaceImportSource'
+import { validateSourceUrl } from '@/platform/assets/utils/importSourceUtil'
+import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
+import { useAssetsStore } from '@/stores/assetsStore'
+import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
+import { useModelToNodeStore } from '@/stores/modelToNodeStore'
+import { app } from '@/scripts/app'
+import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
+import type {
+ MissingModelCandidate,
+ MissingModelViewModel
+} from '@/platform/missingModel/types'
+import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
+import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
+
+const importSources = [civitaiImportSource, huggingfaceImportSource]
+
+const MODEL_TYPE_TAGS = [
+ 'checkpoints',
+ 'loras',
+ 'vae',
+ 'text_encoders',
+ 'diffusion_models'
+] as const
+
+const URL_DEBOUNCE_MS = 800
+
+export function getModelStateKey(
+ modelName: string,
+ directory: string | null,
+ isAssetSupported: boolean
+): string {
+ const prefix = isAssetSupported ? 'supported' : 'unsupported'
+ return `${prefix}::${directory ?? ''}::${modelName}`
+}
+
+export function getNodeDisplayLabel(
+ nodeId: string | number,
+ fallback: string
+): string {
+ const graph = app.rootGraph
+ if (!graph) return fallback
+ const node = getNodeByExecutionId(graph, String(nodeId))
+ return resolveNodeDisplayName(node, {
+ emptyLabel: fallback,
+ untitledLabel: fallback,
+ st
+ })
+}
+
+function getModelComboWidget(
+ model: MissingModelCandidate
+): { node: LGraphNode; widget: IBaseWidget } | null {
+ if (model.nodeId == null) return null
+
+ const graph = app.rootGraph
+ if (!graph) return null
+ const node = getNodeByExecutionId(graph, String(model.nodeId))
+ if (!node) return null
+
+ const widget = node.widgets?.find((w) => w.name === model.widgetName)
+ if (!widget) return null
+
+ return { node, widget }
+}
+
+export function getComboValue(
+ model: MissingModelCandidate
+): string | undefined {
+ const result = getModelComboWidget(model)
+ if (!result) return undefined
+ const val = result.widget.value
+ if (typeof val === 'string') return val
+ if (typeof val === 'number') return String(val)
+ return undefined
+}
+
+export function useMissingModelInteractions() {
+ const { t } = useI18n()
+ const store = useMissingModelStore()
+ const assetsStore = useAssetsStore()
+ const assetDownloadStore = useAssetDownloadStore()
+ const modelToNodeStore = useModelToNodeStore()
+
+ const _requestTokens: Record = {}
+
+ function toggleModelExpand(key: string) {
+ store.modelExpandState[key] = !isModelExpanded(key)
+ }
+
+ function isModelExpanded(key: string): boolean {
+ return store.modelExpandState[key] ?? false
+ }
+
+ function getComboOptions(
+ model: MissingModelCandidate
+ ): { name: string; value: string }[] {
+ if (model.isAssetSupported && model.nodeType) {
+ const assets = assetsStore.getAssets(model.nodeType) ?? []
+ return assets.map((asset) => ({
+ name: getAssetDisplayName(asset),
+ value: getAssetFilename(asset)
+ }))
+ }
+
+ const result = getModelComboWidget(model)
+ if (!result) return []
+ const values = result.widget.options?.values
+ if (!Array.isArray(values)) return []
+ return values.map((v) => ({ name: String(v), value: String(v) }))
+ }
+
+ function handleComboSelect(key: string, value: string | undefined) {
+ if (value) {
+ store.selectedLibraryModel[key] = value
+ }
+ }
+
+ function isSelectionConfirmable(key: string): boolean {
+ if (!store.selectedLibraryModel[key]) return false
+ if (store.importCategoryMismatch[key]) return false
+
+ const status = getDownloadStatus(key)
+ if (
+ status &&
+ (status.status === 'running' || status.status === 'created')
+ ) {
+ return false
+ }
+ return true
+ }
+
+ function cancelLibrarySelect(key: string) {
+ delete store.selectedLibraryModel[key]
+ delete store.importCategoryMismatch[key]
+ }
+
+ /** Apply selected model to referencing nodes, removing only that model from the error list. */
+ function confirmLibrarySelect(
+ key: string,
+ modelName: string,
+ referencingNodes: MissingModelViewModel['referencingNodes'],
+ directory: string | null
+ ) {
+ const value = store.selectedLibraryModel[key]
+ if (!value) return
+
+ const graph = app.rootGraph
+ if (!graph) return
+
+ if (directory) {
+ const providers = modelToNodeStore.getAllNodeProviders(directory)
+ void Promise.allSettled(
+ providers.map((provider) =>
+ assetsStore.updateModelsForNodeType(provider.nodeDef.name)
+ )
+ ).then((results) => {
+ for (const r of results) {
+ if (r.status === 'rejected') {
+ console.warn(
+ '[Missing Model] Failed to refresh model cache:',
+ r.reason
+ )
+ }
+ }
+ })
+ }
+
+ for (const ref of referencingNodes) {
+ const node = getNodeByExecutionId(graph, String(ref.nodeId))
+ if (node) {
+ const widget = node.widgets?.find((w) => w.name === ref.widgetName)
+ if (widget) {
+ widget.value = value
+ widget.callback?.(value)
+ }
+ node.graph?.setDirtyCanvas(true, true)
+ }
+ }
+
+ delete store.selectedLibraryModel[key]
+ const nodeIdSet = new Set(referencingNodes.map((ref) => String(ref.nodeId)))
+ store.removeMissingModelByNameOnNodes(modelName, nodeIdSet)
+ }
+
+ function handleUrlInput(key: string, value: string) {
+ store.urlInputs[key] = value
+
+ delete store.urlMetadata[key]
+ delete store.urlErrors[key]
+ delete store.importCategoryMismatch[key]
+ store.urlFetching[key] = false
+
+ store.clearDebounceTimer(key)
+
+ const trimmed = value.trim()
+ if (!trimmed) return
+
+ store.setDebounceTimer(
+ key,
+ () => {
+ void fetchUrlMetadata(key, trimmed)
+ },
+ URL_DEBOUNCE_MS
+ )
+ }
+
+ async function fetchUrlMetadata(key: string, url: string) {
+ const source = importSources.find((s) => validateSourceUrl(url, s))
+ if (!source) {
+ store.urlErrors[key] = t('rightSidePanel.missingModels.unsupportedUrl')
+ return
+ }
+
+ const token = Symbol()
+ _requestTokens[key] = token
+
+ store.urlFetching[key] = true
+ delete store.urlErrors[key]
+
+ try {
+ const metadata = await assetService.getAssetMetadata(url)
+
+ if (_requestTokens[key] !== token) return
+
+ if (metadata.filename) {
+ try {
+ const decoded = decodeURIComponent(metadata.filename)
+ const basename = decoded.split(/[/\\]/).pop() ?? decoded
+ if (!basename.includes('..')) {
+ metadata.filename = basename
+ }
+ } catch {
+ /* keep original */
+ }
+ }
+
+ store.urlMetadata[key] = metadata
+ } catch (error) {
+ if (_requestTokens[key] !== token) return
+
+ store.urlErrors[key] =
+ error instanceof Error
+ ? error.message
+ : t('rightSidePanel.missingModels.metadataFetchFailed')
+ } finally {
+ if (_requestTokens[key] === token) {
+ store.urlFetching[key] = false
+ }
+ }
+ }
+
+ function getTypeMismatch(
+ key: string,
+ groupDirectory: string | null
+ ): string | null {
+ if (!groupDirectory) return null
+
+ const metadata = store.urlMetadata[key]
+ if (!metadata?.tags?.length) return null
+
+ const detectedType = metadata.tags.find((tag) =>
+ MODEL_TYPE_TAGS.includes(tag as (typeof MODEL_TYPE_TAGS)[number])
+ )
+ if (!detectedType) return null
+
+ if (detectedType !== groupDirectory) {
+ return detectedType
+ }
+ return null
+ }
+
+ function getDownloadStatus(key: string) {
+ const taskId = store.importTaskIds[key]
+ if (!taskId) return null
+ return (
+ assetDownloadStore.downloadList.find((d) => d.taskId === taskId) ?? null
+ )
+ }
+
+ function handleAsyncPending(
+ key: string,
+ taskId: string,
+ modelType: string | undefined,
+ filename: string
+ ) {
+ store.importTaskIds[key] = taskId
+ if (modelType) {
+ assetDownloadStore.trackDownload(taskId, modelType, filename)
+ }
+ }
+
+ function handleAsyncCompleted(modelType: string | undefined) {
+ if (modelType) {
+ assetsStore.invalidateModelsForCategory(modelType)
+ void assetsStore.updateModelsForTag(modelType)
+ }
+ }
+
+ function handleSyncResult(
+ key: string,
+ tags: string[],
+ modelType: string | undefined
+ ) {
+ const existingCategory = tags.find((tag) =>
+ MODEL_TYPE_TAGS.includes(tag as (typeof MODEL_TYPE_TAGS)[number])
+ )
+ if (existingCategory && modelType && existingCategory !== modelType) {
+ store.importCategoryMismatch[key] = existingCategory
+ }
+ }
+
+ async function handleImport(key: string, groupDirectory: string | null) {
+ const metadata = store.urlMetadata[key]
+ if (!metadata) return
+
+ const url = store.urlInputs[key]?.trim()
+ if (!url) return
+
+ const source = importSources.find((s) => validateSourceUrl(url, s))
+ if (!source) return
+
+ const token = Symbol()
+ _requestTokens[key] = token
+
+ store.urlImporting[key] = true
+ delete store.urlErrors[key]
+ delete store.importCategoryMismatch[key]
+
+ try {
+ const modelType = groupDirectory || undefined
+ const tags = modelType ? ['models', modelType] : ['models']
+ const filename = metadata.filename || metadata.name || 'model'
+
+ const result = await assetService.uploadAssetAsync({
+ source_url: url,
+ tags,
+ user_metadata: {
+ source: source.type,
+ source_url: url,
+ model_type: modelType
+ }
+ })
+
+ if (_requestTokens[key] !== token) return
+
+ if (result.type === 'async' && result.task.status !== 'completed') {
+ handleAsyncPending(key, result.task.task_id, modelType, filename)
+ } else if (result.type === 'async') {
+ handleAsyncCompleted(modelType)
+ } else if (result.type === 'sync') {
+ handleSyncResult(key, result.asset.tags ?? [], modelType)
+ }
+
+ store.selectedLibraryModel[key] = filename
+ } catch (error) {
+ if (_requestTokens[key] !== token) return
+
+ store.urlErrors[key] =
+ error instanceof Error
+ ? error.message
+ : t('rightSidePanel.missingModels.importFailed')
+ } finally {
+ if (_requestTokens[key] === token) {
+ store.urlImporting[key] = false
+ }
+ }
+ }
+
+ return {
+ toggleModelExpand,
+ isModelExpanded,
+ getComboOptions,
+ handleComboSelect,
+ isSelectionConfirmable,
+ cancelLibrarySelect,
+ confirmLibrarySelect,
+ handleUrlInput,
+ getTypeMismatch,
+ getDownloadStatus,
+ handleImport
+ }
+}
diff --git a/src/platform/missingModel/missingModelScan.test.ts b/src/platform/missingModel/missingModelScan.test.ts
new file mode 100644
index 0000000000..e29659e62d
--- /dev/null
+++ b/src/platform/missingModel/missingModelScan.test.ts
@@ -0,0 +1,1019 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import {
+ scanAllModelCandidates,
+ isModelFileName,
+ enrichWithEmbeddedMetadata,
+ verifyAssetSupportedCandidates,
+ MODEL_FILE_EXTENSIONS
+} from '@/platform/missingModel/missingModelScan'
+import type { MissingModelCandidate } from '@/platform/missingModel/types'
+import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
+import type { LGraph } from '@/lib/litegraph/src/LGraph'
+import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
+import type {
+ IBaseWidget,
+ IComboWidget
+} from '@/lib/litegraph/src/types/widgets'
+
+vi.mock('@/utils/graphTraversalUtil', () => ({
+ collectAllNodes: (graph: { _testNodes: LGraphNode[] }) => graph._testNodes,
+ getExecutionIdByNode: (
+ _graph: unknown,
+ node: { _testExecutionId?: string; id: number }
+ ) => node._testExecutionId ?? String(node.id)
+}))
+
+/** Helper: create a combo widget mock */
+function makeComboWidget(
+ name: string,
+ value: string | number,
+ options: string[] = []
+): IComboWidget {
+ return {
+ type: 'combo',
+ name,
+ value,
+ options: { values: options }
+ } as unknown as IComboWidget
+}
+
+/** Helper: create an asset widget mock (Cloud combo replacement) */
+function makeAssetWidget(name: string, value: string): IBaseWidget {
+ return {
+ type: 'asset',
+ name,
+ value,
+ options: {}
+ } as unknown as IBaseWidget
+}
+
+/** Helper: create a non-combo widget mock */
+function makeOtherWidget(name: string, value: unknown): IBaseWidget {
+ return {
+ type: 'number',
+ name,
+ value,
+ options: {}
+ } as unknown as IBaseWidget
+}
+
+/** Helper: create a mock LGraphNode with configured widgets */
+function makeNode(
+ id: number,
+ type: string,
+ widgets: IBaseWidget[] = [],
+ executionId?: string
+): LGraphNode {
+ return {
+ id,
+ type,
+ widgets,
+ _testExecutionId: executionId
+ } as unknown as LGraphNode
+}
+
+/** Helper: create a mock LGraph containing given nodes */
+function makeGraph(nodes: LGraphNode[]): LGraph {
+ return { _testNodes: nodes } as unknown as LGraph
+}
+
+const noAssetSupport = () => false
+
+describe('isModelFileName', () => {
+ it('should return true for common model extensions', () => {
+ expect(isModelFileName('model.safetensors')).toBe(true)
+ expect(isModelFileName('model.ckpt')).toBe(true)
+ expect(isModelFileName('model.pt')).toBe(true)
+ expect(isModelFileName('model.pth')).toBe(true)
+ expect(isModelFileName('model.bin')).toBe(true)
+ expect(isModelFileName('model.gguf')).toBe(true)
+ })
+
+ it('should return false for non-model extensions', () => {
+ expect(isModelFileName('image.png')).toBe(false)
+ expect(isModelFileName('video.mp4')).toBe(false)
+ expect(isModelFileName('config.json')).toBe(false)
+ expect(isModelFileName('no_extension')).toBe(false)
+ })
+
+ it('should be case-insensitive', () => {
+ expect(isModelFileName('MODEL.SAFETENSORS')).toBe(true)
+ expect(isModelFileName('Model.Ckpt')).toBe(true)
+ })
+})
+
+describe('MODEL_FILE_EXTENSIONS', () => {
+ it('should contain standard extensions', () => {
+ expect(MODEL_FILE_EXTENSIONS.has('.safetensors')).toBe(true)
+ expect(MODEL_FILE_EXTENSIONS.has('.ckpt')).toBe(true)
+ })
+})
+
+describe('scanAllModelCandidates', () => {
+ it('should detect a missing model from a combo widget', () => {
+ const graph = makeGraph([
+ makeNode(1, 'CheckpointLoaderSimple', [
+ makeComboWidget('ckpt_name', 'missing_model.safetensors', [
+ 'existing_model.safetensors'
+ ])
+ ])
+ ])
+
+ const result = scanAllModelCandidates(graph, noAssetSupport)
+
+ expect(result).toEqual([
+ {
+ nodeId: '1',
+ nodeType: 'CheckpointLoaderSimple',
+ widgetName: 'ckpt_name',
+ isAssetSupported: false,
+ name: 'missing_model.safetensors',
+ isMissing: true
+ }
+ ])
+ })
+
+ it('should not report models that exist in combo options', () => {
+ const graph = makeGraph([
+ makeNode(1, 'CheckpointLoaderSimple', [
+ makeComboWidget('ckpt_name', 'sd_xl_base_1.0.safetensors', [
+ 'sd_xl_base_1.0.safetensors'
+ ])
+ ])
+ ])
+
+ const result = scanAllModelCandidates(graph, noAssetSupport)
+
+ expect(result).toEqual([
+ {
+ nodeId: '1',
+ nodeType: 'CheckpointLoaderSimple',
+ widgetName: 'ckpt_name',
+ isAssetSupported: false,
+ name: 'sd_xl_base_1.0.safetensors',
+ isMissing: false
+ }
+ ])
+ })
+
+ it('should skip non-model values (no model extension)', () => {
+ const graph = makeGraph([
+ makeNode(1, 'SomeNode', [
+ makeComboWidget('mode', 'custom_mode', ['fast', 'slow'])
+ ])
+ ])
+
+ const result = scanAllModelCandidates(graph, noAssetSupport)
+
+ expect(result).toEqual([])
+ })
+
+ it('should skip non-combo widgets', () => {
+ const graph = makeGraph([
+ makeNode(1, 'SomeNode', [
+ makeOtherWidget('steps', 20),
+ makeOtherWidget('cfg', 7.5)
+ ])
+ ])
+
+ const result = scanAllModelCandidates(graph, noAssetSupport)
+
+ expect(result).toEqual([])
+ })
+
+ it('should produce separate entries for same model in different nodes', () => {
+ const graph = makeGraph([
+ makeNode(1, 'CheckpointLoaderSimple', [
+ makeComboWidget('ckpt_name', 'missing.safetensors', [])
+ ]),
+ makeNode(2, 'CheckpointLoaderSimple', [
+ makeComboWidget('ckpt_name', 'missing.safetensors', [])
+ ])
+ ])
+
+ const result = scanAllModelCandidates(graph, noAssetSupport)
+
+ expect(result).toHaveLength(2)
+ expect(result[0].nodeId).toBe('1')
+ expect(result[1].nodeId).toBe('2')
+ })
+
+ it('should use correct widget name for each combo widget', () => {
+ const graph = makeGraph([
+ makeNode(1, 'LoraLoader', [
+ makeComboWidget('lora_name', 'custom_lora.safetensors', [
+ 'existing.safetensors'
+ ]),
+ makeOtherWidget('strength', 0.8)
+ ])
+ ])
+
+ const result = scanAllModelCandidates(graph, noAssetSupport)
+
+ expect(result).toEqual([
+ {
+ nodeId: '1',
+ nodeType: 'LoraLoader',
+ widgetName: 'lora_name',
+ isAssetSupported: false,
+ name: 'custom_lora.safetensors',
+ isMissing: true
+ }
+ ])
+ })
+
+ it('should skip nodes with no widgets', () => {
+ const graph = makeGraph([makeNode(1, 'EmptyNode', [])])
+
+ const result = scanAllModelCandidates(graph, noAssetSupport)
+
+ expect(result).toEqual([])
+ })
+
+ it('should detect missing models from custom nodes', () => {
+ const graph = makeGraph([
+ makeNode(1, 'WanVideoModelLoader', [
+ makeComboWidget('model', 'Wan2_1-I2V-14B-480P_fp8_e4m3fn.safetensors', [
+ 'Wan2_1-I2V-14B.safetensors'
+ ])
+ ]),
+ makeNode(2, 'WanVideoLoraSelect', [
+ makeComboWidget('lora', 'SquishSquish_18.safetensors', [
+ 'default_lora.safetensors'
+ ])
+ ])
+ ])
+
+ const result = scanAllModelCandidates(graph, noAssetSupport)
+
+ expect(result).toHaveLength(2)
+ expect(result.map((r) => r.name)).toEqual([
+ 'Wan2_1-I2V-14B-480P_fp8_e4m3fn.safetensors',
+ 'SquishSquish_18.safetensors'
+ ])
+ })
+
+ it('should detect multiple missing models from different nodes', () => {
+ const graph = makeGraph([
+ makeNode(1, 'CheckpointLoaderSimple', [
+ makeComboWidget('ckpt_name', 'model_a.safetensors', [])
+ ]),
+ makeNode(2, 'LoraLoader', [
+ makeComboWidget('lora_name', 'lora_b.safetensors', []),
+ makeOtherWidget('strength', 0.8)
+ ]),
+ makeNode(3, 'VAELoader', [
+ makeComboWidget('vae_name', 'vae_c.safetensors', [])
+ ])
+ ])
+
+ const result = scanAllModelCandidates(graph, noAssetSupport)
+
+ expect(result).toHaveLength(3)
+ })
+
+ it('should handle whitespace-only widget values', () => {
+ const graph = makeGraph([
+ makeNode(1, 'CheckpointLoaderSimple', [
+ makeComboWidget('ckpt_name', ' ', []),
+ makeComboWidget('other', '', [])
+ ])
+ ])
+
+ const result = scanAllModelCandidates(graph, noAssetSupport)
+
+ expect(result).toEqual([])
+ })
+
+ it('should set isMissing=undefined for asset-supported nodes', () => {
+ const graph = makeGraph([
+ makeNode(1, 'CheckpointLoaderSimple', [
+ makeComboWidget('ckpt_name', 'missing.safetensors', [])
+ ])
+ ])
+
+ const result = scanAllModelCandidates(graph, () => true)
+
+ expect(result).toHaveLength(1)
+ expect(result[0].isAssetSupported).toBe(true)
+ expect(result[0].isMissing).toBeUndefined()
+ })
+
+ it('should set isMissing=true for non-asset nodes with missing model', () => {
+ const graph = makeGraph([
+ makeNode(1, 'CustomLoader', [
+ makeComboWidget('model', 'custom.safetensors', [])
+ ])
+ ])
+
+ const result = scanAllModelCandidates(graph, noAssetSupport)
+
+ expect(result).toHaveLength(1)
+ expect(result[0].isAssetSupported).toBe(false)
+ expect(result[0].isMissing).toBe(true)
+ })
+
+ it('should pass directory from getDirectory callback', () => {
+ const graph = makeGraph([
+ makeNode(1, 'CheckpointLoaderSimple', [
+ makeComboWidget('ckpt_name', 'model.safetensors', [])
+ ])
+ ])
+
+ const result = scanAllModelCandidates(
+ graph,
+ noAssetSupport,
+ () => 'checkpoints'
+ )
+
+ expect(result[0].directory).toBe('checkpoints')
+ })
+
+ it('should use execution ID from graph traversal for subgraph nodes', () => {
+ const graph = makeGraph([
+ makeNode(
+ 99,
+ 'CheckpointLoaderSimple',
+ [makeComboWidget('ckpt_name', 'subgraph_model.safetensors', [])],
+ '10:99'
+ )
+ ])
+
+ const result = scanAllModelCandidates(graph, noAssetSupport)
+
+ expect(result).toHaveLength(1)
+ expect(result[0].nodeId).toBe('10:99')
+ expect(result[0].name).toBe('subgraph_model.safetensors')
+ })
+
+ it('should detect missing models from asset widgets (Cloud combo replacement)', () => {
+ const graph = makeGraph([
+ makeNode(1, 'CheckpointLoaderSimple', [
+ makeAssetWidget('ckpt_name', 'missing_model.safetensors')
+ ])
+ ])
+
+ const result = scanAllModelCandidates(graph, noAssetSupport)
+
+ expect(result).toHaveLength(1)
+ expect(result[0].isAssetSupported).toBe(true)
+ expect(result[0].isMissing).toBeUndefined()
+ expect(result[0].name).toBe('missing_model.safetensors')
+ expect(result[0].widgetName).toBe('ckpt_name')
+ })
+
+ it('should skip asset widgets with non-model values', () => {
+ const graph = makeGraph([
+ makeNode(1, 'SomeNode', [makeAssetWidget('mode', 'not_a_model')])
+ ])
+
+ const result = scanAllModelCandidates(graph, noAssetSupport)
+
+ expect(result).toEqual([])
+ })
+
+ it('should scan both combo and asset widgets on the same node', () => {
+ const graph = makeGraph([
+ makeNode(1, 'DualLoaderNode', [
+ makeAssetWidget('ckpt_name', 'cloud_model.safetensors'),
+ makeComboWidget('vae_name', 'local_vae.safetensors', [])
+ ])
+ ])
+
+ const result = scanAllModelCandidates(graph, noAssetSupport)
+
+ expect(result).toHaveLength(2)
+ expect(result[0].widgetName).toBe('ckpt_name')
+ expect(result[0].isAssetSupported).toBe(true)
+ expect(result[1].widgetName).toBe('vae_name')
+ })
+})
+
+function makeCandidate(
+ name: string,
+ opts: Partial = {}
+): MissingModelCandidate {
+ return {
+ nodeId: opts.nodeId ?? 1,
+ nodeType: opts.nodeType ?? 'CheckpointLoaderSimple',
+ widgetName: opts.widgetName ?? 'ckpt_name',
+ isAssetSupported: opts.isAssetSupported ?? false,
+ name,
+ isMissing: opts.isMissing ?? true,
+ ...opts
+ }
+}
+
+const alwaysMissing = async () => false
+const alwaysInstalled = async () => true
+
+describe('enrichWithEmbeddedMetadata', () => {
+ it('enriches existing candidate with url and directory from embedded metadata', async () => {
+ const candidates = [makeCandidate('model_a.safetensors')]
+ const graphData = {
+ last_node_id: 1,
+ last_link_id: 0,
+ nodes: [
+ {
+ id: 1,
+ type: 'CheckpointLoaderSimple',
+ pos: [0, 0],
+ size: [100, 100],
+ flags: {},
+ order: 0,
+ mode: 0,
+ properties: {},
+ widgets_values: { ckpt_name: 'model_a.safetensors' }
+ }
+ ],
+ links: [],
+ groups: [],
+ config: {},
+ extra: {},
+ version: 0.4,
+ models: [
+ {
+ name: 'model_a.safetensors',
+ url: 'https://example.com/model_a',
+ directory: 'checkpoints',
+ hash: 'abc123',
+ hash_type: 'sha256'
+ }
+ ]
+ } as unknown as ComfyWorkflowJSON
+
+ const result = await enrichWithEmbeddedMetadata(
+ candidates,
+ graphData,
+ alwaysMissing
+ )
+
+ expect(result[0].url).toBe('https://example.com/model_a')
+ expect(result[0].directory).toBe('checkpoints')
+ expect(result[0].hash).toBe('abc123')
+ })
+
+ it('does not overwrite existing fields on candidate', async () => {
+ const candidates = [
+ makeCandidate('model_a.safetensors', {
+ directory: 'existing_dir',
+ url: 'https://existing.com'
+ })
+ ]
+ const graphData = {
+ last_node_id: 1,
+ last_link_id: 0,
+ nodes: [
+ {
+ id: 1,
+ type: 'CheckpointLoaderSimple',
+ pos: [0, 0],
+ size: [100, 100],
+ flags: {},
+ order: 0,
+ mode: 0,
+ properties: {},
+ widgets_values: { ckpt_name: 'model_a.safetensors' }
+ }
+ ],
+ links: [],
+ groups: [],
+ config: {},
+ extra: {},
+ version: 0.4,
+ models: [
+ {
+ name: 'model_a.safetensors',
+ url: 'https://new.com',
+ directory: 'new_dir'
+ }
+ ]
+ } as unknown as ComfyWorkflowJSON
+
+ const result = await enrichWithEmbeddedMetadata(
+ candidates,
+ graphData,
+ alwaysMissing
+ )
+
+ // ??= should not overwrite existing values
+ expect(result[0].url).toBe('https://existing.com')
+ expect(result[0].directory).toBe('existing_dir')
+ })
+
+ it('does not mutate the original candidates array', async () => {
+ const candidates = [makeCandidate('model_a.safetensors')]
+ const graphData = {
+ last_node_id: 1,
+ last_link_id: 0,
+ nodes: [
+ {
+ id: 1,
+ type: 'CheckpointLoaderSimple',
+ pos: [0, 0],
+ size: [100, 100],
+ flags: {},
+ order: 0,
+ mode: 0,
+ properties: {},
+ widgets_values: { ckpt_name: 'model_a.safetensors' }
+ }
+ ],
+ links: [],
+ groups: [],
+ config: {},
+ extra: {},
+ version: 0.4,
+ models: [
+ {
+ name: 'model_a.safetensors',
+ url: 'https://example.com/model_a',
+ directory: 'checkpoints'
+ }
+ ]
+ } as unknown as ComfyWorkflowJSON
+
+ const originalUrl = candidates[0].url
+ await enrichWithEmbeddedMetadata(candidates, graphData, alwaysMissing)
+
+ expect(candidates[0].url).toBe(originalUrl)
+ })
+
+ it('adds new candidate for embedded model not found by COMBO scan', async () => {
+ const candidates: MissingModelCandidate[] = []
+ const graphData = {
+ last_node_id: 1,
+ last_link_id: 0,
+ nodes: [
+ {
+ id: 1,
+ type: 'CheckpointLoaderSimple',
+ pos: [0, 0],
+ size: [100, 100],
+ flags: {},
+ order: 0,
+ mode: 0,
+ properties: {},
+ widgets_values: { ckpt_name: 'model_a.safetensors' }
+ }
+ ],
+ links: [],
+ groups: [],
+ config: {},
+ extra: {},
+ version: 0.4,
+ models: [
+ {
+ name: 'model_a.safetensors',
+ url: 'https://example.com/model_a',
+ directory: 'checkpoints'
+ }
+ ]
+ } as unknown as ComfyWorkflowJSON
+
+ const result = await enrichWithEmbeddedMetadata(
+ candidates,
+ graphData,
+ alwaysMissing
+ )
+
+ expect(result).toHaveLength(1)
+ expect(result[0].name).toBe('model_a.safetensors')
+ expect(result[0].isMissing).toBe(true)
+ })
+
+ it('does not add candidate when model is already installed', async () => {
+ const candidates: MissingModelCandidate[] = []
+ const graphData = {
+ last_node_id: 0,
+ last_link_id: 0,
+ nodes: [],
+ links: [],
+ groups: [],
+ config: {},
+ extra: {},
+ version: 0.4,
+ models: [
+ {
+ name: 'installed_model.safetensors',
+ url: 'https://example.com',
+ directory: 'checkpoints'
+ }
+ ]
+ } as unknown as ComfyWorkflowJSON
+
+ const result = await enrichWithEmbeddedMetadata(
+ candidates,
+ graphData,
+ alwaysInstalled
+ )
+
+ expect(result).toHaveLength(0)
+ })
+})
+
+describe('OSS missing model detection (non-Cloud path)', () => {
+ it('scanAllModelCandidates returns empty array when not called (simulating isCloud === false guard)', () => {
+ // In the app, when isCloud is false, scanAllModelCandidates is not called
+ // and an empty array is used instead. This test verifies the OSS path
+ // starts with an empty candidates list.
+ const isCloud = false
+ const graph = makeGraph([
+ makeNode(1, 'CheckpointLoaderSimple', [
+ makeComboWidget('ckpt_name', 'missing_model.safetensors', [])
+ ])
+ ])
+
+ const modelCandidates = isCloud
+ ? scanAllModelCandidates(graph, noAssetSupport)
+ : []
+
+ expect(modelCandidates).toEqual([])
+ })
+
+ it('enrichWithEmbeddedMetadata detects missing embedded models without prior COMBO scan (OSS dialog path)', async () => {
+ // OSS path: candidates start empty, enrichWithEmbeddedMetadata adds
+ // missing embedded models so the dialog can show them.
+ const candidates: MissingModelCandidate[] = []
+ const graphData = {
+ last_node_id: 2,
+ last_link_id: 0,
+ nodes: [
+ {
+ id: 1,
+ type: 'CheckpointLoaderSimple',
+ pos: [0, 0],
+ size: [100, 100],
+ flags: {},
+ order: 0,
+ mode: 0,
+ properties: {},
+ widgets_values: { ckpt_name: 'sd_xl_base_1.0.safetensors' }
+ },
+ {
+ id: 2,
+ type: 'LoraLoader',
+ pos: [200, 0],
+ size: [100, 100],
+ flags: {},
+ order: 1,
+ mode: 0,
+ properties: {},
+ widgets_values: { lora_name: 'detail_enhancer.safetensors' }
+ }
+ ],
+ links: [],
+ groups: [],
+ config: {},
+ extra: {},
+ version: 0.4,
+ models: [
+ {
+ name: 'sd_xl_base_1.0.safetensors',
+ url: 'https://example.com/sdxl',
+ directory: 'checkpoints'
+ },
+ {
+ name: 'detail_enhancer.safetensors',
+ url: 'https://example.com/lora',
+ directory: 'loras'
+ }
+ ]
+ } as unknown as ComfyWorkflowJSON
+
+ const result = await enrichWithEmbeddedMetadata(
+ candidates,
+ graphData,
+ alwaysMissing
+ )
+
+ expect(result).toHaveLength(2)
+ expect(result.every((c) => c.isMissing === true)).toBe(true)
+ expect(result.map((c) => c.name)).toEqual([
+ 'sd_xl_base_1.0.safetensors',
+ 'detail_enhancer.safetensors'
+ ])
+ })
+
+ it('enrichWithEmbeddedMetadata sets isMissing=true when isAssetSupported is not provided (OSS)', async () => {
+ // When isAssetSupported is omitted (OSS), unmatched embedded models
+ // should have isMissing=true (not undefined), enabling the dialog.
+ const candidates: MissingModelCandidate[] = []
+ const graphData = {
+ last_node_id: 1,
+ last_link_id: 0,
+ nodes: [
+ {
+ id: 1,
+ type: 'CheckpointLoaderSimple',
+ pos: [0, 0],
+ size: [100, 100],
+ flags: {},
+ order: 0,
+ mode: 0,
+ properties: {},
+ widgets_values: { ckpt_name: 'missing_model.safetensors' }
+ }
+ ],
+ links: [],
+ groups: [],
+ config: {},
+ extra: {},
+ version: 0.4,
+ models: [
+ {
+ name: 'missing_model.safetensors',
+ url: 'https://example.com/model',
+ directory: 'checkpoints'
+ }
+ ]
+ } as unknown as ComfyWorkflowJSON
+
+ const result = await enrichWithEmbeddedMetadata(
+ candidates,
+ graphData,
+ alwaysMissing
+ )
+
+ expect(result).toHaveLength(1)
+ expect(result[0].isMissing).toBe(true)
+ expect(result[0].isAssetSupported).toBe(false)
+ })
+
+ it('enrichWithEmbeddedMetadata correctly filters for dialog: only isMissing=true with url', async () => {
+ const candidates: MissingModelCandidate[] = []
+ const graphData = {
+ last_node_id: 1,
+ last_link_id: 0,
+ nodes: [
+ {
+ id: 1,
+ type: 'CheckpointLoaderSimple',
+ pos: [0, 0],
+ size: [100, 100],
+ flags: {},
+ order: 0,
+ mode: 0,
+ properties: {},
+ widgets_values: { ckpt_name: 'missing_model.safetensors' }
+ }
+ ],
+ links: [],
+ groups: [],
+ config: {},
+ extra: {},
+ version: 0.4,
+ models: [
+ {
+ name: 'missing_model.safetensors',
+ url: 'https://example.com/model',
+ directory: 'checkpoints'
+ },
+ {
+ name: 'installed_model.safetensors',
+ url: 'https://example.com/installed',
+ directory: 'checkpoints'
+ }
+ ]
+ } as unknown as ComfyWorkflowJSON
+
+ const selectiveInstallCheck = async (name: string) =>
+ name === 'installed_model.safetensors'
+
+ const result = await enrichWithEmbeddedMetadata(
+ candidates,
+ graphData,
+ selectiveInstallCheck
+ )
+
+ const dialogModels = result.filter((c) => c.isMissing === true && c.url)
+ expect(dialogModels).toHaveLength(1)
+ expect(dialogModels[0].name).toBe('missing_model.safetensors')
+ expect(dialogModels[0].url).toBe('https://example.com/model')
+ })
+
+ it('enrichWithEmbeddedMetadata with isAssetSupported leaves isMissing undefined for asset-supported models (Cloud path)', async () => {
+ const candidates: MissingModelCandidate[] = []
+ const graphData = {
+ last_node_id: 1,
+ last_link_id: 0,
+ nodes: [
+ {
+ id: 1,
+ type: 'CheckpointLoaderSimple',
+ pos: [0, 0],
+ size: [100, 100],
+ flags: {},
+ order: 0,
+ mode: 0,
+ properties: {},
+ widgets_values: { ckpt_name: 'model.safetensors' }
+ }
+ ],
+ links: [],
+ groups: [],
+ config: {},
+ extra: {},
+ version: 0.4,
+ models: [
+ {
+ name: 'model.safetensors',
+ url: 'https://example.com/model',
+ directory: 'checkpoints'
+ }
+ ]
+ } as unknown as ComfyWorkflowJSON
+
+ const result = await enrichWithEmbeddedMetadata(
+ candidates,
+ graphData,
+ alwaysMissing,
+ () => true
+ )
+
+ expect(result).toHaveLength(1)
+ expect(result[0].isMissing).toBeUndefined()
+ expect(result[0].isAssetSupported).toBe(true)
+ })
+})
+
+const {
+ mockUpdateModelsForNodeType,
+ mockIsModelLoading,
+ mockHasMore,
+ mockGetAssets
+} = vi.hoisted(() => ({
+ mockUpdateModelsForNodeType: vi.fn().mockResolvedValue(undefined),
+ mockIsModelLoading: vi.fn().mockReturnValue(false),
+ mockHasMore: vi.fn().mockReturnValue(false),
+ mockGetAssets: vi.fn().mockReturnValue([])
+}))
+
+vi.mock('@/stores/assetsStore', () => ({
+ useAssetsStore: () => ({
+ updateModelsForNodeType: mockUpdateModelsForNodeType,
+ isModelLoading: mockIsModelLoading,
+ hasMore: mockHasMore,
+ getAssets: mockGetAssets
+ })
+}))
+
+vi.mock('@/platform/updates/common/toastStore', () => ({
+ useToastStore: () => ({
+ add: vi.fn()
+ })
+}))
+
+vi.mock('@/i18n', () => ({
+ st: (_key: string, fallback: string) => fallback
+}))
+
+function makeAssetCandidate(
+ name: string,
+ opts: Partial = {}
+): MissingModelCandidate {
+ return {
+ nodeId: opts.nodeId ?? 1,
+ nodeType: opts.nodeType ?? 'CheckpointLoaderSimple',
+ widgetName: opts.widgetName ?? 'ckpt_name',
+ isAssetSupported: opts.isAssetSupported ?? true,
+ name,
+ isMissing: opts.isMissing,
+ ...opts
+ }
+}
+
+describe('verifyAssetSupportedCandidates', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockIsModelLoading.mockReturnValue(false)
+ mockHasMore.mockReturnValue(false)
+ mockGetAssets.mockReturnValue([])
+ })
+
+ it('should resolve isMissing=true for candidates not found in asset store', async () => {
+ const candidates = [makeAssetCandidate('missing_model.safetensors')]
+ mockGetAssets.mockReturnValue([])
+
+ await verifyAssetSupportedCandidates(candidates)
+
+ expect(candidates[0].isMissing).toBe(true)
+ expect(mockUpdateModelsForNodeType).toHaveBeenCalledWith(
+ 'CheckpointLoaderSimple'
+ )
+ })
+
+ it('should resolve isMissing=false when asset with matching hash exists', async () => {
+ const candidates = [
+ makeAssetCandidate('model.safetensors', {
+ hash: 'abc123',
+ hashType: 'sha256'
+ })
+ ]
+ mockGetAssets.mockReturnValue([
+ { id: '1', name: 'model.safetensors', asset_hash: 'sha256:abc123' }
+ ])
+
+ await verifyAssetSupportedCandidates(candidates)
+
+ expect(candidates[0].isMissing).toBe(false)
+ })
+
+ it('should resolve isMissing=false when asset with matching filename exists', async () => {
+ const candidates = [makeAssetCandidate('my_model.safetensors')]
+ mockGetAssets.mockReturnValue([
+ {
+ id: '1',
+ name: 'my_model.safetensors',
+ asset_hash: null,
+ metadata: { filename: 'my_model.safetensors' }
+ }
+ ])
+
+ await verifyAssetSupportedCandidates(candidates)
+
+ expect(candidates[0].isMissing).toBe(false)
+ })
+
+ it('should return immediately when signal is already aborted', async () => {
+ const candidates = [makeAssetCandidate('model.safetensors')]
+ const controller = new AbortController()
+ controller.abort()
+
+ await verifyAssetSupportedCandidates(candidates, controller.signal)
+
+ // isMissing should remain undefined since we aborted before resolving
+ expect(candidates[0].isMissing).toBeUndefined()
+ })
+
+ it('should return immediately when no asset-supported candidates exist', async () => {
+ const candidates = [
+ makeAssetCandidate('model.safetensors', {
+ isAssetSupported: false,
+ isMissing: true
+ })
+ ]
+
+ await verifyAssetSupportedCandidates(candidates)
+
+ expect(mockUpdateModelsForNodeType).not.toHaveBeenCalled()
+ expect(candidates[0].isMissing).toBe(true)
+ })
+
+ it('should skip candidates with isMissing already resolved', async () => {
+ const candidates = [
+ makeAssetCandidate('found.safetensors', { isMissing: false }),
+ makeAssetCandidate('missing.safetensors', { isMissing: true })
+ ]
+
+ await verifyAssetSupportedCandidates(candidates)
+
+ expect(mockUpdateModelsForNodeType).not.toHaveBeenCalled()
+ expect(candidates[0].isMissing).toBe(false)
+ expect(candidates[1].isMissing).toBe(true)
+ })
+
+ it('should deduplicate nodeType calls to updateModelsForNodeType', async () => {
+ const candidates = [
+ makeAssetCandidate('model_a.safetensors'),
+ makeAssetCandidate('model_b.safetensors')
+ ]
+
+ await verifyAssetSupportedCandidates(candidates)
+
+ expect(mockUpdateModelsForNodeType).toHaveBeenCalledTimes(1)
+ })
+
+ it('should call updateModelsForNodeType for each unique nodeType', async () => {
+ const candidates = [
+ makeAssetCandidate('model_a.safetensors', {
+ nodeType: 'CheckpointLoaderSimple'
+ }),
+ makeAssetCandidate('model_b.safetensors', { nodeType: 'LoraLoader' })
+ ]
+
+ await verifyAssetSupportedCandidates(candidates)
+
+ expect(mockUpdateModelsForNodeType).toHaveBeenCalledWith(
+ 'CheckpointLoaderSimple'
+ )
+ expect(mockUpdateModelsForNodeType).toHaveBeenCalledWith('LoraLoader')
+ })
+
+ it('should match filename with path prefix normalization', async () => {
+ const candidates = [makeAssetCandidate('subfolder/my_model.safetensors')]
+ mockGetAssets.mockReturnValue([
+ {
+ id: '1',
+ name: 'my_model.safetensors',
+ asset_hash: null,
+ metadata: { filename: 'subfolder/my_model.safetensors' }
+ }
+ ])
+
+ await verifyAssetSupportedCandidates(candidates)
+
+ expect(candidates[0].isMissing).toBe(false)
+ })
+})
diff --git a/src/platform/missingModel/missingModelScan.ts b/src/platform/missingModel/missingModelScan.ts
new file mode 100644
index 0000000000..20f215905d
--- /dev/null
+++ b/src/platform/missingModel/missingModelScan.ts
@@ -0,0 +1,386 @@
+import type {
+ ComfyWorkflowJSON,
+ NodeId
+} from '@/platform/workflow/validation/schemas/workflowSchema'
+import { flattenWorkflowNodes } from '@/platform/workflow/validation/schemas/workflowSchema'
+import type {
+ MissingModelCandidate,
+ MissingModelViewModel,
+ EmbeddedModelWithSource
+} from './types'
+import { getAssetFilename } from '@/platform/assets/utils/assetMetadataUtils'
+import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
+import { getSelectedModelsMetadata } from '@/workbench/utils/modelMetadataUtil'
+import type { LGraph } from '@/lib/litegraph/src/LGraph'
+import type {
+ IAssetWidget,
+ IBaseWidget,
+ IComboWidget
+} from '@/lib/litegraph/src/types/widgets'
+import {
+ collectAllNodes,
+ getExecutionIdByNode
+} from '@/utils/graphTraversalUtil'
+
+function isComboWidget(widget: IBaseWidget): widget is IComboWidget {
+ return widget.type === 'combo'
+}
+
+function isAssetWidget(widget: IBaseWidget): widget is IAssetWidget {
+ return widget.type === 'asset'
+}
+
+export const MODEL_FILE_EXTENSIONS = new Set([
+ '.safetensors',
+ '.ckpt',
+ '.pt',
+ '.pth',
+ '.bin',
+ '.sft',
+ '.onnx',
+ '.gguf'
+])
+
+export function isModelFileName(name: string): boolean {
+ const lower = name.toLowerCase()
+ for (const ext of MODEL_FILE_EXTENSIONS) {
+ if (lower.endsWith(ext)) return true
+ }
+ return false
+}
+
+function resolveComboOptions(widget: IComboWidget): string[] {
+ const values = widget.options.values
+ if (!values) return []
+ if (typeof values === 'function') return values(widget)
+ if (Array.isArray(values)) return values
+ return Object.keys(values)
+}
+
+/**
+ * Scan COMBO and asset widgets on configured graph nodes for model-like values.
+ * Must be called after `graph.configure()` so widget name/value mappings are accurate.
+ *
+ * Non-asset-supported nodes: `isMissing` resolved immediately via widget options.
+ * Asset-supported nodes: `isMissing` left `undefined` for async verification.
+ */
+export function scanAllModelCandidates(
+ rootGraph: LGraph,
+ isAssetSupported: (nodeType: string, widgetName: string) => boolean,
+ getDirectory?: (nodeType: string) => string | undefined
+): MissingModelCandidate[] {
+ if (!rootGraph) return []
+
+ const allNodes = collectAllNodes(rootGraph)
+ const candidates: MissingModelCandidate[] = []
+
+ for (const node of allNodes) {
+ if (!node.widgets?.length) continue
+
+ const executionId = getExecutionIdByNode(rootGraph, node)
+ if (!executionId) continue
+
+ for (const widget of node.widgets) {
+ let candidate: MissingModelCandidate | null = null
+
+ if (isAssetWidget(widget)) {
+ candidate = scanAssetWidget(node, widget, executionId, getDirectory)
+ } else if (isComboWidget(widget)) {
+ candidate = scanComboWidget(
+ node,
+ widget,
+ executionId,
+ isAssetSupported,
+ getDirectory
+ )
+ }
+
+ if (candidate) candidates.push(candidate)
+ }
+ }
+
+ return candidates
+}
+
+function scanAssetWidget(
+ node: { type: string },
+ widget: IAssetWidget,
+ executionId: string,
+ getDirectory: ((nodeType: string) => string | undefined) | undefined
+): MissingModelCandidate | null {
+ const value = widget.value
+ if (!value.trim()) return null
+ if (!isModelFileName(value)) return null
+
+ return {
+ nodeId: executionId as NodeId,
+ nodeType: node.type,
+ widgetName: widget.name,
+ isAssetSupported: true,
+ name: value,
+ directory: getDirectory?.(node.type),
+ isMissing: undefined
+ }
+}
+
+function scanComboWidget(
+ node: { type: string },
+ widget: IComboWidget,
+ executionId: string,
+ isAssetSupported: (nodeType: string, widgetName: string) => boolean,
+ getDirectory: ((nodeType: string) => string | undefined) | undefined
+): MissingModelCandidate | null {
+ const value = widget.value
+ if (typeof value !== 'string' || !value.trim()) return null
+ if (!isModelFileName(value)) return null
+
+ const nodeIsAssetSupported = isAssetSupported(node.type, widget.name)
+ const options = resolveComboOptions(widget)
+ const inOptions = options.includes(value)
+
+ return {
+ nodeId: executionId as NodeId,
+ nodeType: node.type,
+ widgetName: widget.name,
+ isAssetSupported: nodeIsAssetSupported,
+ name: value,
+ directory: getDirectory?.(node.type),
+ isMissing: nodeIsAssetSupported ? undefined : !inOptions
+ }
+}
+
+export async function enrichWithEmbeddedMetadata(
+ candidates: readonly MissingModelCandidate[],
+ graphData: ComfyWorkflowJSON,
+ checkModelInstalled: (name: string, directory: string) => Promise,
+ isAssetSupported?: (nodeType: string, widgetName: string) => boolean
+): Promise {
+ const allNodes = flattenWorkflowNodes(graphData)
+ const embeddedModels = collectEmbeddedModelsWithSource(allNodes, graphData)
+
+ const enriched = candidates.map((c) => ({ ...c }))
+ const candidatesByKey = new Map()
+ for (const c of enriched) {
+ const dirKey = `${c.name}::${c.directory ?? ''}`
+ const dirList = candidatesByKey.get(dirKey)
+ if (dirList) dirList.push(c)
+ else candidatesByKey.set(dirKey, [c])
+
+ const nameKey = c.name
+ const nameList = candidatesByKey.get(nameKey)
+ if (nameList) nameList.push(c)
+ else candidatesByKey.set(nameKey, [c])
+ }
+
+ const deduped: EmbeddedModelWithSource[] = []
+ const enrichedKeys = new Set()
+ for (const model of embeddedModels) {
+ const dedupeKey = `${model.name}::${model.directory}`
+ if (enrichedKeys.has(dedupeKey)) continue
+ enrichedKeys.add(dedupeKey)
+ deduped.push(model)
+ }
+
+ const unmatched: EmbeddedModelWithSource[] = []
+ for (const model of deduped) {
+ const dirKey = `${model.name}::${model.directory}`
+ const exact = candidatesByKey.get(dirKey)
+ const fallback = candidatesByKey.get(model.name)
+ const existing = exact?.length ? exact : fallback
+ if (existing) {
+ for (const c of existing) {
+ if (c.directory && c.directory !== model.directory) continue
+ c.directory ??= model.directory
+ c.url ??= model.url
+ c.hash ??= model.hash
+ c.hashType ??= model.hash_type
+ }
+ } else {
+ unmatched.push(model)
+ }
+ }
+
+ const settled = await Promise.allSettled(
+ unmatched.map(async (model) => {
+ const installed = await checkModelInstalled(model.name, model.directory)
+ if (installed) return null
+
+ const nodeIsAssetSupported = isAssetSupported
+ ? isAssetSupported(model.sourceNodeType, model.sourceWidgetName)
+ : false
+
+ return {
+ nodeId: model.sourceNodeId,
+ nodeType: model.sourceNodeType,
+ widgetName: model.sourceWidgetName,
+ isAssetSupported: nodeIsAssetSupported,
+ name: model.name,
+ directory: model.directory,
+ url: model.url,
+ hash: model.hash,
+ hashType: model.hash_type,
+ isMissing: nodeIsAssetSupported ? undefined : true
+ } satisfies MissingModelCandidate
+ })
+ )
+
+ for (const r of settled) {
+ if (r.status === 'rejected') {
+ console.warn(
+ '[Missing Model Pipeline] checkModelInstalled failed:',
+ r.reason
+ )
+ continue
+ }
+ if (r.value) enriched.push(r.value)
+ }
+
+ return enriched
+}
+
+function collectEmbeddedModelsWithSource(
+ allNodes: ReturnType,
+ graphData: ComfyWorkflowJSON
+): EmbeddedModelWithSource[] {
+ const result: EmbeddedModelWithSource[] = []
+
+ for (const node of allNodes) {
+ const selected = getSelectedModelsMetadata(
+ node as Parameters[0]
+ )
+ if (!selected?.length) continue
+
+ for (const model of selected) {
+ result.push({
+ ...model,
+ sourceNodeId: node.id,
+ sourceNodeType: node.type,
+ sourceWidgetName: findWidgetNameForModel(node, model.name)
+ })
+ }
+ }
+
+ // Workflow-level model entries have no originating node; sourceNodeId
+ // remains undefined and empty-string node type/widget are handled by
+ // groupCandidatesByName (no nodeId → no referencing node entry).
+ if (graphData.models?.length) {
+ for (const model of graphData.models) {
+ result.push({
+ ...model,
+ sourceNodeType: '',
+ sourceWidgetName: ''
+ })
+ }
+ }
+
+ return result
+}
+
+function findWidgetNameForModel(
+ node: ReturnType[number],
+ modelName: string
+): string {
+ if (Array.isArray(node.widgets_values) || !node.widgets_values) return ''
+ const wv = node.widgets_values as Record
+ for (const [key, val] of Object.entries(wv)) {
+ if (val === modelName) return key
+ }
+ return ''
+}
+
+interface AssetVerifier {
+ updateModelsForNodeType: (nodeType: string) => Promise
+ getAssets: (nodeType: string) => AssetItem[] | undefined
+}
+
+export async function verifyAssetSupportedCandidates(
+ candidates: MissingModelCandidate[],
+ signal?: AbortSignal,
+ assetsStore?: AssetVerifier
+): Promise {
+ if (signal?.aborted) return
+
+ const pendingNodeTypes = new Set()
+ for (const c of candidates) {
+ if (c.isAssetSupported && c.isMissing === undefined) {
+ pendingNodeTypes.add(c.nodeType)
+ }
+ }
+
+ if (pendingNodeTypes.size === 0) return
+
+ const store =
+ assetsStore ?? (await import('@/stores/assetsStore')).useAssetsStore()
+
+ const failedNodeTypes = new Set()
+ await Promise.allSettled(
+ [...pendingNodeTypes].map(async (nodeType) => {
+ if (signal?.aborted) return
+ try {
+ await store.updateModelsForNodeType(nodeType)
+ } catch (err) {
+ failedNodeTypes.add(nodeType)
+ console.warn(
+ `[Missing Model Pipeline] Failed to load assets for ${nodeType}:`,
+ err
+ )
+ }
+ })
+ )
+
+ if (signal?.aborted) return
+
+ for (const c of candidates) {
+ if (!c.isAssetSupported || c.isMissing !== undefined) continue
+ if (failedNodeTypes.has(c.nodeType)) continue
+
+ const assets = store.getAssets(c.nodeType) ?? []
+ c.isMissing = !isAssetInstalled(c, assets)
+ }
+}
+
+function normalizePath(path: string): string {
+ return path.replace(/\\/g, '/')
+}
+
+function isAssetInstalled(
+ candidate: MissingModelCandidate,
+ assets: AssetItem[]
+): boolean {
+ if (candidate.hash && candidate.hashType) {
+ const candidateHash = `${candidate.hashType}:${candidate.hash}`
+ if (assets.some((a) => a.asset_hash === candidateHash)) return true
+ }
+
+ const normalizedName = normalizePath(candidate.name)
+ return assets.some((a) => {
+ const f = normalizePath(getAssetFilename(a))
+ return f === normalizedName || f.endsWith('/' + normalizedName)
+ })
+}
+
+export function groupCandidatesByName(
+ candidates: MissingModelCandidate[]
+): MissingModelViewModel[] {
+ const map = new Map()
+ for (const c of candidates) {
+ const existing = map.get(c.name)
+ if (existing) {
+ if (c.nodeId) {
+ existing.referencingNodes.push({
+ nodeId: c.nodeId,
+ widgetName: c.widgetName
+ })
+ }
+ } else {
+ map.set(c.name, {
+ name: c.name,
+ representative: c,
+ referencingNodes: c.nodeId
+ ? [{ nodeId: c.nodeId, widgetName: c.widgetName }]
+ : []
+ })
+ }
+ }
+ return Array.from(map.values())
+}
diff --git a/src/platform/missingModel/missingModelStore.test.ts b/src/platform/missingModel/missingModelStore.test.ts
new file mode 100644
index 0000000000..5e53394617
--- /dev/null
+++ b/src/platform/missingModel/missingModelStore.test.ts
@@ -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)
+ })
+ })
+})
diff --git a/src/platform/missingModel/missingModelStore.ts b/src/platform/missingModel/missingModelStore.ts
new file mode 100644
index 0000000000..be4fd5c1d1
--- /dev/null
+++ b/src/platform/missingModel/missingModelStore.ts
@@ -0,0 +1,201 @@
+import { defineStore } from 'pinia'
+import { computed, onScopeDispose, ref } from 'vue'
+
+import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
+import { app } from '@/scripts/app'
+import type { MissingModelCandidate } from '@/platform/missingModel/types'
+import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
+import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
+import { getAncestorExecutionIds } from '@/types/nodeIdentification'
+import type { NodeExecutionId } from '@/types/nodeIdentification'
+import { getActiveGraphNodeIds } from '@/utils/graphTraversalUtil'
+
+/**
+ * Missing model error state and interaction state.
+ * Separated from executionErrorStore to keep domain boundaries clean.
+ * The executionErrorStore composes from this store for aggregate error flags.
+ */
+export const useMissingModelStore = defineStore('missingModel', () => {
+ const canvasStore = useCanvasStore()
+
+ const missingModelCandidates = ref(null)
+
+ const hasMissingModels = computed(
+ () => !!missingModelCandidates.value?.length
+ )
+
+ const missingModelCount = computed(
+ () => missingModelCandidates.value?.length ?? 0
+ )
+
+ const missingModelNodeIds = computed>(() => {
+ const ids = new Set()
+ if (!missingModelCandidates.value) return ids
+ for (const m of missingModelCandidates.value) {
+ if (m.nodeId != null) ids.add(String(m.nodeId))
+ }
+ return ids
+ })
+
+ const missingModelWidgetKeys = computed>(() => {
+ const keys = new Set()
+ if (!missingModelCandidates.value) return keys
+ for (const m of missingModelCandidates.value) {
+ keys.add(`${String(m.nodeId)}::${m.widgetName}`)
+ }
+ return keys
+ })
+
+ /**
+ * Set of all execution ID prefixes derived from missing model node IDs,
+ * including the missing model nodes themselves.
+ *
+ * Example: missing model on node "65:70:63" → Set { "65", "65:70", "65:70:63" }
+ */
+ const missingModelAncestorExecutionIds = computed>(
+ () => {
+ const ids = new Set()
+ for (const nodeId of missingModelNodeIds.value) {
+ for (const id of getAncestorExecutionIds(nodeId)) {
+ ids.add(id)
+ }
+ }
+ return ids
+ }
+ )
+
+ const activeMissingModelGraphIds = computed>(() => {
+ if (!app.rootGraph) return new Set()
+ return getActiveGraphNodeIds(
+ app.rootGraph,
+ canvasStore.currentGraph ?? app.rootGraph,
+ missingModelAncestorExecutionIds.value
+ )
+ })
+
+ // Persists across component re-mounts so that download progress,
+ // URL inputs, etc. survive tab switches within the right-side panel.
+ const modelExpandState = ref>({})
+ const selectedLibraryModel = ref>({})
+ const importCategoryMismatch = ref>({})
+ const importTaskIds = ref>({})
+ const urlInputs = ref>({})
+ const urlMetadata = ref>({})
+ const urlFetching = ref>({})
+ const urlErrors = ref>({})
+ const urlImporting = ref>({})
+
+ const _urlDebounceTimers: Record> = {}
+
+ let _verificationAbortController: AbortController | null = null
+
+ onScopeDispose(cancelDebounceTimers)
+
+ function createVerificationAbortController(): AbortController {
+ _verificationAbortController?.abort()
+ _verificationAbortController = new AbortController()
+ return _verificationAbortController
+ }
+
+ function setMissingModels(models: MissingModelCandidate[]) {
+ missingModelCandidates.value = models.length ? models : null
+ }
+
+ function removeMissingModelByNameOnNodes(
+ modelName: string,
+ nodeIds: Set
+ ) {
+ if (!missingModelCandidates.value) return
+ missingModelCandidates.value = missingModelCandidates.value.filter(
+ (m) =>
+ m.name !== modelName ||
+ m.nodeId == null ||
+ !nodeIds.has(String(m.nodeId))
+ )
+ if (!missingModelCandidates.value.length)
+ missingModelCandidates.value = null
+ }
+
+ function hasMissingModelOnNode(nodeLocatorId: string): boolean {
+ return missingModelNodeIds.value.has(nodeLocatorId)
+ }
+
+ function isWidgetMissingModel(nodeId: string, widgetName: string): boolean {
+ return missingModelWidgetKeys.value.has(`${nodeId}::${widgetName}`)
+ }
+
+ function isContainerWithMissingModel(node: LGraphNode): boolean {
+ return activeMissingModelGraphIds.value.has(String(node.id))
+ }
+
+ function cancelDebounceTimers() {
+ for (const key of Object.keys(_urlDebounceTimers)) {
+ clearTimeout(_urlDebounceTimers[key])
+ delete _urlDebounceTimers[key]
+ }
+ }
+
+ function setDebounceTimer(
+ key: string,
+ callback: () => void,
+ delayMs: number
+ ) {
+ if (_urlDebounceTimers[key]) {
+ clearTimeout(_urlDebounceTimers[key])
+ }
+ _urlDebounceTimers[key] = setTimeout(callback, delayMs)
+ }
+
+ function clearDebounceTimer(key: string) {
+ if (_urlDebounceTimers[key]) {
+ clearTimeout(_urlDebounceTimers[key])
+ delete _urlDebounceTimers[key]
+ }
+ }
+
+ function clearMissingModels() {
+ _verificationAbortController?.abort()
+ _verificationAbortController = null
+ missingModelCandidates.value = null
+ cancelDebounceTimers()
+ modelExpandState.value = {}
+ selectedLibraryModel.value = {}
+ importCategoryMismatch.value = {}
+ importTaskIds.value = {}
+ urlInputs.value = {}
+ urlMetadata.value = {}
+ urlFetching.value = {}
+ urlErrors.value = {}
+ urlImporting.value = {}
+ }
+
+ return {
+ missingModelCandidates,
+ hasMissingModels,
+ missingModelCount,
+ missingModelNodeIds,
+ activeMissingModelGraphIds,
+
+ setMissingModels,
+ removeMissingModelByNameOnNodes,
+ clearMissingModels,
+ createVerificationAbortController,
+
+ hasMissingModelOnNode,
+ isWidgetMissingModel,
+ isContainerWithMissingModel,
+
+ modelExpandState,
+ selectedLibraryModel,
+ importTaskIds,
+ importCategoryMismatch,
+ urlInputs,
+ urlMetadata,
+ urlFetching,
+ urlErrors,
+ urlImporting,
+
+ setDebounceTimer,
+ clearDebounceTimer
+ }
+})
diff --git a/src/platform/missingModel/types.ts b/src/platform/missingModel/types.ts
new file mode 100644
index 0000000000..dd0b863127
--- /dev/null
+++ b/src/platform/missingModel/types.ts
@@ -0,0 +1,53 @@
+import type {
+ ModelFile,
+ NodeId
+} from '@/platform/workflow/validation/schemas/workflowSchema'
+
+/**
+ * A single (node, widget, model) binding detected by the missing model pipeline.
+ * The same model name may appear multiple times across different nodes.
+ */
+export interface MissingModelCandidate {
+ /** Undefined for workflow-level models not tied to a specific node. */
+ nodeId?: NodeId
+ nodeType: string
+ widgetName: string
+ isAssetSupported: boolean
+
+ name: string
+ directory?: string
+ url?: string
+ hash?: string
+ hashType?: string
+
+ /**
+ * - `true` — confirmed missing
+ * - `false` — confirmed installed
+ * - `undefined` — pending async verification (asset-supported nodes only)
+ */
+ isMissing: boolean | undefined
+}
+
+export interface EmbeddedModelWithSource extends ModelFile {
+ /** Undefined for workflow-level models not tied to a specific node. */
+ sourceNodeId?: NodeId
+ sourceNodeType: string
+ sourceWidgetName: string
+}
+
+/** View model grouping multiple candidate references under a single model name. */
+export interface MissingModelViewModel {
+ name: string
+ representative: MissingModelCandidate
+ referencingNodes: Array<{
+ nodeId: NodeId
+ widgetName: string
+ }>
+}
+
+/** A category group of missing models sharing the same directory. */
+export interface MissingModelGroup {
+ directory: string | null
+ models: MissingModelViewModel[]
+ isAssetSupported: boolean
+}
diff --git a/src/platform/workflow/core/services/workflowService.ts b/src/platform/workflow/core/services/workflowService.ts
index a0cab36085..f7b79011e1 100644
--- a/src/platform/workflow/core/services/workflowService.ts
+++ b/src/platform/workflow/core/services/workflowService.ts
@@ -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)
+ }
}
}
diff --git a/src/platform/workflow/validation/schemas/workflowSchema.test.ts b/src/platform/workflow/validation/schemas/workflowSchema.test.ts
index 1d687455c5..cdb003a982 100644
--- a/src/platform/workflow/validation/schemas/workflowSchema.test.ts
+++ b/src/platform/workflow/validation/schemas/workflowSchema.test.ts
@@ -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'])
+ })
+})
diff --git a/src/platform/workflow/validation/schemas/workflowSchema.ts b/src/platform/workflow/validation/schemas/workflowSchema.ts
index 20b32bb00b..c44c4566ba 100644
--- a/src/platform/workflow/validation/schemas/workflowSchema.ts
+++ b/src/platform/workflow/validation/schemas/workflowSchema.ts
@@ -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()
+
+ 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[] {
+ 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
+}
diff --git a/src/renderer/extensions/vueNodes/components/LGraphNode.vue b/src/renderer/extensions/vueNodes/components/LGraphNode.vue
index e70c907158..ceab3eb46b 100644
--- a/src/renderer/extensions/vueNodes/components/LGraphNode.vue
+++ b/src/renderer/extensions/vueNodes/components/LGraphNode.vue
@@ -289,6 +289,7 @@ import { useNodePreviewState } from '@/renderer/extensions/vueNodes/preview/useN
import { nonWidgetedInputs } from '@/renderer/extensions/vueNodes/utils/nodeDataUtils'
import { applyLightThemeColor } from '@/renderer/extensions/vueNodes/utils/nodeStyleUtils'
import { app } from '@/scripts/app'
+import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
@@ -339,6 +340,7 @@ const isSelected = computed(() => {
const nodeLocatorId = computed(() => getLocatorIdFromNodeData(nodeData))
const { executing, progress } = useNodeExecutionState(nodeLocatorId)
const executionErrorStore = useExecutionErrorStore()
+const missingModelStore = useMissingModelStore()
const hasExecutionError = computed(
() => executionErrorStore.lastExecutionErrorNodeId === nodeData.id
)
@@ -349,9 +351,11 @@ const hasAnyError = computed((): boolean => {
nodeData.hasErrors ||
error ||
executionErrorStore.getNodeErrors(nodeLocatorId.value) ||
+ missingModelStore.hasMissingModelOnNode(nodeLocatorId.value) ||
(lgraphNode.value &&
(executionErrorStore.isContainerWithInternalError(lgraphNode.value) ||
- executionErrorStore.isContainerWithMissingNode(lgraphNode.value)))
+ executionErrorStore.isContainerWithMissingNode(lgraphNode.value) ||
+ missingModelStore.isContainerWithMissingModel(lgraphNode.value)))
)
})
diff --git a/src/renderer/extensions/vueNodes/components/NodeWidgets.vue b/src/renderer/extensions/vueNodes/components/NodeWidgets.vue
index f1fa7509dc..3883e117f4 100644
--- a/src/renderer/extensions/vueNodes/components/NodeWidgets.vue
+++ b/src/renderer/extensions/vueNodes/components/NodeWidgets.vue
@@ -115,6 +115,7 @@ import {
useWidgetValueStore
} from '@/stores/widgetValueStore'
import { usePromotionStore } from '@/stores/promotionStore'
+import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import type { SimplifiedWidget, WidgetValue } from '@/types/simplifiedWidget'
import { cn } from '@/utils/tailwindUtil'
@@ -134,6 +135,7 @@ const canvasStore = useCanvasStore()
const { bringNodeToFront } = useNodeZIndex()
const promotionStore = usePromotionStore()
const executionErrorStore = useExecutionErrorStore()
+const missingModelStore = useMissingModelStore()
function handleWidgetPointerEvent(event: PointerEvent) {
if (shouldHandleNodePointerEvents.value) return
@@ -202,6 +204,7 @@ const processedWidgets = computed((): ProcessedWidget[] => {
const graphId = canvasStore.canvas?.graph?.rootGraph.id
const nodeId = nodeData.id
+ const nodeIdStr = String(nodeId)
const { widgets } = nodeData
const result: ProcessedWidget[] = []
@@ -284,9 +287,11 @@ const processedWidgets = computed((): ProcessedWidget[] => {
handleContextMenu,
hasLayoutSize: widget.hasLayoutSize ?? false,
hasError:
- nodeErrors?.errors?.some(
+ (nodeErrors?.errors?.some(
(error) => error.extra_info?.input_name === widget.name
- ) ?? false,
+ ) ??
+ false) ||
+ missingModelStore.isWidgetMissingModel(nodeIdStr, widget.name),
hidden: widget.options?.hidden ?? false,
id: String(bareWidgetId),
name: widget.name,
diff --git a/src/scripts/app.ts b/src/scripts/app.ts
index b3119fc388..6479268155 100644
--- a/src/scripts/app.ts
+++ b/src/scripts/app.ts
@@ -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 | 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')
diff --git a/src/stores/assetsStore.ts b/src/stores/assetsStore.ts
index 785cada3ae..df655a3b3b 100644
--- a/src/stores/assetsStore.ts
+++ b/src/stores/assetsStore.ts
@@ -295,6 +295,7 @@ export const useAssetsStore = defineStore('assets', () => {
>()
const pendingRequestByCategory = new Map()
+ const pendingPromiseByCategory = new Map>()
function createState(
existingAssets?: Map
@@ -400,9 +401,8 @@ export const useAssetsStore = defineStore('assets', () => {
category: string,
fetcher: (options: PaginationOptions) => Promise
): Promise {
- // Short-circuit if a request for this category is already in progress
- if (pendingRequestByCategory.has(category)) {
- return
+ if (pendingPromiseByCategory.has(category)) {
+ return pendingPromiseByCategory.get(category)!
}
const existingState = modelStateByCategory.value.get(category)
@@ -478,7 +478,11 @@ export const useAssetsStore = defineStore('assets', () => {
pendingRequestByCategory.delete(category)
}
- await loadBatches()
+ const promise = loadBatches().finally(() => {
+ pendingPromiseByCategory.delete(category)
+ })
+ pendingPromiseByCategory.set(category, promise)
+ await promise
}
/**
@@ -517,6 +521,7 @@ export const useAssetsStore = defineStore('assets', () => {
modelStateByCategory.value.delete(category)
assetsArrayCache.delete(category)
pendingRequestByCategory.delete(category)
+ pendingPromiseByCategory.delete(category)
}
/**
diff --git a/src/stores/executionErrorStore.test.ts b/src/stores/executionErrorStore.test.ts
index f6c166e74a..7a1f729a9c 100644
--- a/src/stores/executionErrorStore.test.ts
+++ b/src/stores/executionErrorStore.test.ts
@@ -18,6 +18,13 @@ vi.mock('@/stores/settingStore', () => ({
}))
}))
+vi.mock(
+ '@/platform/missingModel/composables/useMissingModelInteractions',
+ () => ({
+ clearMissingModelState: vi.fn()
+ })
+)
+
import { useExecutionErrorStore } from './executionErrorStore'
describe('executionErrorStore — missing node operations', () => {
diff --git a/src/stores/executionErrorStore.ts b/src/stores/executionErrorStore.ts
index baff3c427f..e82ece7526 100644
--- a/src/stores/executionErrorStore.ts
+++ b/src/stores/executionErrorStore.ts
@@ -13,6 +13,8 @@ import type {
PromptError
} from '@/schemas/apiSchema'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
+import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
+import type { MissingModelCandidate } from '@/platform/missingModel/types'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
getAncestorExecutionIds,
@@ -24,7 +26,8 @@ import {
executionIdToNodeLocatorId,
forEachNode,
getNodeByExecutionId,
- getExecutionIdByNode
+ getExecutionIdByNode,
+ getActiveGraphNodeIds
} from '@/utils/graphTraversalUtil'
interface MissingNodesError {
@@ -70,11 +73,13 @@ function applyNodeError(
}
}
-/** Execution error state: node errors, runtime errors, prompt errors, and missing nodes. */
+/** Execution error state: node errors, runtime errors, prompt errors, and missing assets. */
export const useExecutionErrorStore = defineStore('executionError', () => {
const workflowStore = useWorkflowStore()
const canvasStore = useCanvasStore()
+ const missingModelStore = useMissingModelStore()
+
const lastNodeErrors = ref | null>(null)
const lastExecutionError = ref(null)
const lastPromptError = ref(null)
@@ -90,7 +95,10 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
isErrorOverlayOpen.value = false
}
- /** Clear all error state. Called at execution start. */
+ /** Clear all error state. Called at execution start and workflow changes.
+ * Missing model state is intentionally preserved here to avoid wiping
+ * in-progress model repairs (importTaskIds, URL inputs, etc.).
+ * Missing models are cleared separately during workflow load/clean paths. */
function clearAllErrors() {
lastExecutionError.value = null
lastPromptError.value = null
@@ -112,6 +120,17 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
}
}
+ /** Set missing models and open the error overlay if the Errors tab is enabled. */
+ function surfaceMissingModels(models: MissingModelCandidate[]) {
+ missingModelStore.setMissingModels(models)
+ if (
+ models.length &&
+ useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab')
+ ) {
+ showErrorOverlay()
+ }
+ }
+
/** Remove specific node types from the missing nodes list (e.g. after replacement). */
function removeMissingNodesByType(typesToRemove: string[]) {
if (!missingNodesError.value) return
@@ -170,27 +189,23 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
return localId != null ? String(localId) : null
})
- /** Whether a runtime execution error is present */
const hasExecutionError = computed(() => !!lastExecutionError.value)
- /** Whether a prompt-level error is present (e.g. invalid_prompt, prompt_no_outputs) */
const hasPromptError = computed(() => !!lastPromptError.value)
- /** Whether any node validation errors are present */
const hasNodeError = computed(
() => !!lastNodeErrors.value && Object.keys(lastNodeErrors.value).length > 0
)
- /** Whether any missing node types are present in the current workflow */
const hasMissingNodes = computed(() => !!missingNodesError.value)
- /** Whether any error (node validation, runtime execution, prompt-level, or missing nodes) is present */
const hasAnyError = computed(
() =>
hasExecutionError.value ||
hasPromptError.value ||
hasNodeError.value ||
- hasMissingNodes.value
+ hasMissingNodes.value ||
+ missingModelStore.hasMissingModels
)
const allErrorExecutionIds = computed(() => {
@@ -207,10 +222,8 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
return ids
})
- /** Count of prompt-level errors (0 or 1) */
const promptErrorCount = computed(() => (lastPromptError.value ? 1 : 0))
- /** Count of all individual node validation errors */
const nodeErrorCount = computed(() => {
if (!lastNodeErrors.value) return 0
let count = 0
@@ -220,19 +233,17 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
return count
})
- /** Count of runtime execution errors (0 or 1) */
const executionErrorCount = computed(() => (lastExecutionError.value ? 1 : 0))
- /** Count of missing node errors (0 or 1) */
const missingNodeCount = computed(() => (missingNodesError.value ? 1 : 0))
- /** Total count of all individual errors */
const totalErrorCount = computed(
() =>
promptErrorCount.value +
nodeErrorCount.value +
executionErrorCount.value +
- missingNodeCount.value
+ missingNodeCount.value +
+ missingModelStore.missingModelCount
)
/** Graph node IDs (as strings) that have errors in the current graph scope. */
@@ -286,19 +297,12 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
})
const activeMissingNodeGraphIds = computed>(() => {
- const ids = new Set()
- if (!app.isGraphReady) return ids
-
- const activeGraph = canvasStore.currentGraph ?? app.rootGraph
-
- for (const executionId of missingAncestorExecutionIds.value) {
- const graphNode = getNodeByExecutionId(app.rootGraph, executionId)
- if (graphNode?.graph === activeGraph) {
- ids.add(String(graphNode.id))
- }
- }
-
- return ids
+ if (!app.isGraphReady) return new Set()
+ return getActiveGraphNodeIds(
+ app.rootGraph,
+ canvasStore.currentGraph ?? app.rootGraph,
+ missingAncestorExecutionIds.value
+ )
})
/** Map of node errors indexed by locator ID. */
@@ -419,6 +423,9 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
surfaceMissingNodes,
removeMissingNodesByType,
+ // Missing model coordination (delegates to missingModelStore)
+ surfaceMissingModels,
+
// Lookup helpers
getNodeErrors,
slotHasError,
diff --git a/src/utils/graphTraversalUtil.ts b/src/utils/graphTraversalUtil.ts
index adc4112573..f4dec3d7aa 100644
--- a/src/utils/graphTraversalUtil.ts
+++ b/src/utils/graphTraversalUtil.ts
@@ -649,6 +649,30 @@ export function getExecutionIdsForSelectedNodes(
})
}
+/**
+ * Returns the set of local graph node IDs (as strings) for nodes that live in
+ * `activeGraph` and whose execution ID appears in `executionIds`.
+ *
+ * @param rootGraph - The root graph used to resolve execution IDs
+ * @param activeGraph - The currently-visible graph scope
+ * @param executionIds - Set of execution IDs to look up
+ * @returns Set of stringified local node IDs belonging to activeGraph
+ */
+export function getActiveGraphNodeIds(
+ rootGraph: LGraph,
+ activeGraph: LGraph | Subgraph,
+ executionIds: Set
+): Set {
+ const ids = new Set()
+ for (const executionId of executionIds) {
+ const graphNode = getNodeByExecutionId(rootGraph, executionId)
+ if (graphNode?.graph === activeGraph) {
+ ids.add(String(graphNode.id))
+ }
+ }
+ return ids
+}
+
function findPartialExecutionPathToGraph(
target: LGraph,
root: LGraph