[feat] Surface missing models in Errors tab (Cloud) (#9743)

## Summary
When a workflow is loaded with missing models, users currently have no
way to identify or resolve them from within the UI. This PR adds a full
missing-model detection and resolution pipeline that surfaces missing
models in the Errors tab, allowing users to install or import them
without leaving the editor.

## Changes

### Missing Model Detection
- Scan all COMBO widgets across root graph and subgraphs for model-like
filenames during workflow load
- Enrich candidates with embedded workflow metadata (url, hash,
directory) when available
- Verify asset-supported candidates against the asset store
asynchronously to confirm installation status
- Propagate missing model state to `executionErrorStore` alongside
existing node/prompt errors

### Errors Tab UI — Model Resolution
- Group missing models by directory (e.g. `checkpoints`, `loras`, `vae`)
with collapsible category cards
- Each model row displays:
  - Model name with copy-to-clipboard button
  - Expandable list of referencing nodes with locate-on-canvas button
- **Library selector**: Pick an alternative from the user's existing
models to substitute the missing model with one click
- **URL import**: Paste a Civitai or HuggingFace URL to import a model
directly; debounced metadata fetch shows filename and file size before
confirming; type-mismatch warnings (e.g. importing a LoRA into
checkpoints directory) are surfaced with an "Import Anyway" option
- **Upgrade prompt**: In cloud environment, free-tier subscribers are
shown an upgrade modal when attempting URL import
- Separate "Import Not Supported" section for custom-node models that
cannot be auto-resolved
- Status card with live download progress, completion, failure, and
category-mismatch states

### Canvas Integration
- Highlight nodes and widgets that reference missing models with error
indicators
- Propagate missing-model badges through subgraph containers so issues
are visible at every graph level

### Code Cleanup
- Simplify `surfacePendingWarnings` in workflowService, remove stale
widget-detected model merging logic
- Add `flattenWorkflowNodes` utility to workflowSchema for traversing
nested subgraph structures
- Extract `MissingModelUrlInput`, `MissingModelLibrarySelect`,
`MissingModelStatusCard` as focused single-responsibility components

## Testing
- Unit tests for scan pipeline (`missingModelScan.test.ts`): enrichment,
skip-installed, subgraph flattening
- Unit tests for store (`missingModelStore.test.ts`): state management,
removal helpers
- Unit tests for interactions (`useMissingModelInteractions.test.ts`):
combo select, URL input, import flow, library confirm
- Component tests for `MissingModelCard` and error grouping
(`useErrorGroups.test.ts`)
- Updated `workflowService.test.ts` and `workflowSchema.test.ts` for new
logic

## Review Focus
- Missing model scan + enrichment pipeline in `missingModelScan.ts`
- Interaction composable `useMissingModelInteractions.ts` — URL metadata
fetch, library install, upload fallback
- Store integration and canvas-level error propagation

## Screenshots 


https://github.com/user-attachments/assets/339a6d5b-93a3-43cd-98dd-0fb00681b66f



┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9743-feat-Surface-missing-models-in-Errors-tab-Cloud-3206d73d365081678326d3a16c2165d8)
by [Unito](https://www.unito.io)
This commit is contained in:
jaeone94
2026-03-12 16:21:54 +09:00
committed by GitHub
parent 4c00d39ade
commit 2f7f3c4e56
30 changed files with 4219 additions and 129 deletions

View File

@@ -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() }}
<i v-if="tab.icon" :class="cn(tab.icon, 'size-4')" />
<i
v-if="tab.icon"
aria-hidden="true"
:class="cn(tab.icon, 'size-4')"
/>
</Tab>
</TabList>
</nav>

View File

@@ -12,7 +12,7 @@
</div>
<!-- Scrollable content -->
<div class="min-w-0 flex-1 overflow-y-auto">
<div class="min-w-0 flex-1 overflow-y-auto" aria-live="polite">
<TransitionGroup tag="div" name="list-scale" class="relative">
<div
v-if="filteredGroups.length === 0"
@@ -32,11 +32,7 @@
:key="group.title"
:collapse="isSectionCollapsed(group.title) && !isSearching"
class="border-b border-interface-stroke"
:size="
group.type === 'missing_node' || group.type === 'swap_nodes'
? 'lg'
: 'default'
"
:size="getGroupSize(group)"
@update:collapse="setSectionCollapsed(group.title, $event)"
>
<template #label>
@@ -130,6 +126,14 @@
@copy-to-clipboard="copyToClipboard"
/>
</div>
<!-- Missing Models -->
<MissingModelCard
v-else-if="group.type === 'missing_model'"
:missing-model-groups="missingModelGroups"
:show-node-id-badge="showNodeIdBadge"
@locate-model="handleLocateModel"
/>
</PropertiesAccordionItem>
</TransitionGroup>
</div>
@@ -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) {

View File

@@ -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 }

View File

@@ -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()
})
})
})

View File

@@ -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<Record<string, boolean>>({})
@@ -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<MissingModelGroup[]>(() => {
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<ErrorGroup[]>(() => {
const groupsMap = new Map<string, GroupEntry>()
@@ -566,7 +630,11 @@ export function useErrorGroups(
processNodeErrors(groupsMap)
processExecutionError(groupsMap)
return [...buildMissingNodeGroups(), ...toSortedGroups(groupsMap)]
return [
...buildMissingNodeGroups(),
...buildMissingModelGroups(),
...toSortedGroups(groupsMap)
]
})
const tabErrorGroups = computed<ErrorGroup[]>(() => {
@@ -580,7 +648,11 @@ export function useErrorGroups(
? toSortedGroups(regroupByErrorMessage(groupsMap))
: toSortedGroups(groupsMap)
return [...buildMissingNodeGroups(), ...executionGroups]
return [
...buildMissingNodeGroups(),
...buildMissingModelGroups(),
...executionGroups
]
})
const filteredGroups = computed<ErrorGroup[]>(() => {
@@ -615,6 +687,7 @@ export function useErrorGroups(
missingNodeCache,
groupedErrorMessages,
missingPackGroups,
missingModelGroups,
swapNodeGroups
}
}

View File

@@ -64,6 +64,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
)
"
>
<slot name="prepend" />
<SelectScrollUpButton />
<SelectViewport
:class="

View File

@@ -1957,6 +1957,7 @@
"exportSuccess": "Successfully exported model as {format}",
"fileLoadError": "Unable to find workflow in {fileName}",
"dropFileError": "Unable to process dropped item: {error}",
"missingModelVerificationFailed": "Failed to verify missing models. Some models may not be shown in the Errors tab.",
"interrupted": "Execution has been interrupted",
"pendingTasksDeleted": "Pending tasks deleted",
"nothingToGroup": "Nothing to group",
@@ -3388,6 +3389,33 @@
"viewInManager": "View in Manager",
"collapse": "Collapse",
"expand": "Expand"
},
"missingModels": {
"urlPlaceholder": "Paste Model URL (Civitai or Hugging Face)",
"or": "OR",
"useFromLibrary": "Use from Library",
"usingFromLibrary": "Using from Library",
"unsupportedUrl": "Only Civitai and Hugging Face URLs are supported.",
"metadataFetchFailed": "Failed to retrieve metadata. Please check the link and try again.",
"import": "Import",
"importing": "Importing...",
"imported": "Imported",
"importFailed": "Import failed",
"typeMismatch": "This model seems to be a \"{detectedType}\". Are you sure?",
"importAnyway": "Import Anyway",
"alreadyExistsInCategory": "This model already exists in \"{category}\"",
"customNodeDownloadDisabled": "Cloud environment does not support model imports for custom nodes in this section. Please use standard loader nodes or substitute with a model from the library below.",
"importNotSupported": "Import Not Supported",
"copyModelName": "Copy model name",
"confirmSelection": "Confirm selection",
"locateNode": "Locate node on canvas",
"cancelSelection": "Cancel selection",
"clearUrl": "Clear URL",
"expandNodes": "Show referencing nodes",
"collapseNodes": "Hide referencing nodes",
"unknownCategory": "Unknown",
"missingModelsTitle": "Missing Models",
"assetLoadTimeout": "Model detection timed out. Try reloading the workflow."
}
},
"errorOverlay": {

View File

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

View File

@@ -0,0 +1,78 @@
<template>
<div class="px-4 pb-2">
<!-- Category groups (by directory) -->
<div
v-for="group in missingModelGroups"
:key="`${group.isAssetSupported ? 'supported' : 'unsupported'}::${group.directory ?? '__unknown__'}`"
class="flex w-full flex-col border-t border-interface-stroke py-2 first:border-t-0 first:pt-0"
>
<!-- Category header -->
<div class="flex h-8 w-full items-center">
<p
class="min-w-0 flex-1 truncate text-sm font-medium"
:class="
!group.isAssetSupported || group.directory === null
? 'text-warning-background'
: 'text-destructive-background-hover'
"
>
<span v-if="!group.isAssetSupported" class="text-warning-background">
{{ t('rightSidePanel.missingModels.importNotSupported') }}
({{ group.models.length }})
</span>
<span v-else>
{{
group.directory ??
t('rightSidePanel.missingModels.unknownCategory')
}}
({{ group.models.length }})
</span>
</p>
</div>
<!-- Asset unsupported group notice -->
<div
v-if="!group.isAssetSupported"
class="flex items-start gap-1.5 px-0.5 py-1 pl-2"
>
<i
aria-hidden="true"
class="mt-0.5 icon-[lucide--info] size-3.5 shrink-0 text-muted-foreground"
/>
<span class="text-xs/tight text-muted-foreground">
{{ t('rightSidePanel.missingModels.customNodeDownloadDisabled') }}
</span>
</div>
<!-- Model rows -->
<div class="flex flex-col gap-1 overflow-hidden pl-2">
<MissingModelRow
v-for="model in group.models"
:key="model.name"
:model="model"
:directory="group.directory"
:show-node-id-badge="showNodeIdBadge"
:is-asset-supported="group.isAssetSupported"
@locate-model="emit('locateModel', $event)"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import type { MissingModelGroup } from '@/platform/missingModel/types'
import MissingModelRow from '@/platform/missingModel/components/MissingModelRow.vue'
const { missingModelGroups, showNodeIdBadge } = defineProps<{
missingModelGroups: MissingModelGroup[]
showNodeIdBadge: boolean
}>()
const emit = defineEmits<{
locateModel: [nodeId: string]
}>()
const { t } = useI18n()
</script>

View File

@@ -0,0 +1,113 @@
<template>
<div class="flex flex-col gap-2">
<div v-if="showDivider" class="flex items-center justify-center py-0.5">
<span class="text-xs font-bold text-muted-foreground">
{{ t('rightSidePanel.missingModels.or') }}
</span>
</div>
<Select
:model-value="modelValue"
:disabled="options.length === 0"
@update:model-value="handleSelect"
>
<SelectTrigger
size="md"
class="border-transparent bg-secondary-background text-xs hover:border-interface-stroke"
>
<SelectValue
:placeholder="t('rightSidePanel.missingModels.useFromLibrary')"
/>
</SelectTrigger>
<SelectContent>
<template v-if="options.length > 4" #prepend>
<div class="px-1 pb-1.5">
<div
class="flex items-center gap-1.5 rounded-md border border-border-default px-2"
>
<i
aria-hidden="true"
class="icon-[lucide--search] size-3.5 shrink-0 text-muted-foreground"
/>
<input
v-model="filterQuery"
type="text"
:aria-label="t('g.searchPlaceholder', { subject: '' })"
class="h-7 w-full border-none bg-transparent text-xs outline-none placeholder:text-muted-foreground"
:placeholder="t('g.searchPlaceholder', { subject: '' })"
@keydown.stop
/>
</div>
</div>
</template>
<SelectItem
v-for="option in filteredOptions"
:key="option.value"
:value="option.value"
class="text-xs"
>
{{ option.name }}
</SelectItem>
<div
v-if="filteredOptions.length === 0"
role="status"
class="px-3 py-2 text-xs text-muted-foreground"
>
{{ t('g.noResultsFound') }}
</div>
</SelectContent>
</Select>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useFuse } from '@vueuse/integrations/useFuse'
import Select from '@/components/ui/select/Select.vue'
import SelectContent from '@/components/ui/select/SelectContent.vue'
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
import SelectValue from '@/components/ui/select/SelectValue.vue'
import SelectItem from '@/components/ui/select/SelectItem.vue'
const { options, showDivider = false } = defineProps<{
modelValue: string | undefined
options: { name: string; value: string }[]
showDivider?: boolean
}>()
const emit = defineEmits<{
select: [value: string]
}>()
const { t } = useI18n()
const filterQuery = ref('')
watch(
() => options.length,
(len) => {
if (len <= 4) filterQuery.value = ''
}
)
const { results: fuseResults } = useFuse(filterQuery, () => options, {
fuseOptions: {
keys: ['name'],
threshold: 0.4,
ignoreLocation: true
},
matchAllWhenSearchEmpty: true
})
const filteredOptions = computed(() => fuseResults.value.map((r) => r.item))
function handleSelect(value: unknown) {
if (typeof value === 'string') {
filterQuery.value = ''
emit('select', value)
}
}
</script>

View File

@@ -0,0 +1,224 @@
<template>
<div class="flex w-full flex-col pb-3">
<!-- Model header -->
<div class="flex h-8 w-full items-center gap-2">
<i
aria-hidden="true"
class="text-foreground icon-[lucide--file-check] size-4 shrink-0"
/>
<div class="flex min-w-0 flex-1 items-center">
<p
class="text-foreground min-w-0 truncate text-sm font-medium"
:title="model.name"
>
{{ model.name }} ({{ model.referencingNodes.length }})
</p>
<Button
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 hover:bg-transparent"
:aria-label="t('rightSidePanel.missingModels.copyModelName')"
:title="t('rightSidePanel.missingModels.copyModelName')"
@click="copyToClipboard(model.name)"
>
<i
aria-hidden="true"
class="icon-[lucide--copy] size-3.5 text-muted-foreground"
/>
</Button>
</div>
<Button
variant="textonly"
size="icon-sm"
:aria-label="t('rightSidePanel.missingModels.confirmSelection')"
:disabled="!canConfirm"
:class="
cn(
'size-8 shrink-0 rounded-lg transition-colors',
canConfirm ? 'bg-primary/10 hover:bg-primary/15' : 'opacity-20'
)
"
@click="handleLibrarySelect"
>
<i
aria-hidden="true"
class="icon-[lucide--check] size-4"
:class="canConfirm ? 'text-primary' : 'text-foreground'"
/>
</Button>
<Button
v-if="model.referencingNodes.length > 0"
variant="textonly"
size="icon-sm"
:aria-label="
expanded
? t('rightSidePanel.missingModels.collapseNodes')
: t('rightSidePanel.missingModels.expandNodes')
"
:class="
cn(
'size-8 shrink-0 transition-transform duration-200 hover:bg-transparent',
expanded && 'rotate-180'
)
"
@click="toggleModelExpand(modelKey)"
>
<i
aria-hidden="true"
class="icon-[lucide--chevron-down] size-4 text-muted-foreground group-hover:text-base-foreground"
/>
</Button>
</div>
<!-- Referencing nodes -->
<TransitionCollapse>
<div
v-if="expanded"
class="mb-1 flex flex-col gap-0.5 overflow-hidden pl-6"
>
<div
v-for="ref in model.referencingNodes"
:key="`${String(ref.nodeId)}::${ref.widgetName}`"
class="flex h-7 items-center"
>
<span
v-if="showNodeIdBadge"
class="mr-1 shrink-0 rounded-md bg-secondary-background-selected px-2 py-0.5 font-mono text-xs font-bold text-muted-foreground"
>
#{{ ref.nodeId }}
</span>
<p class="min-w-0 flex-1 truncate text-xs text-muted-foreground">
{{ getNodeDisplayLabel(ref.nodeId, model.representative.nodeType) }}
</p>
<Button
variant="textonly"
size="icon-sm"
:aria-label="t('rightSidePanel.missingModels.locateNode')"
class="mr-1 size-6 shrink-0 text-muted-foreground hover:text-base-foreground"
@click="emit('locateModel', String(ref.nodeId))"
>
<i aria-hidden="true" class="icon-[lucide--locate] size-3" />
</Button>
</div>
</div>
</TransitionCollapse>
<!-- Status card -->
<TransitionCollapse>
<MissingModelStatusCard
v-if="selectedLibraryModel[modelKey]"
:model-name="selectedLibraryModel[modelKey]"
:is-download-active="isDownloadActive"
:download-status="downloadStatus"
:category-mismatch="importCategoryMismatch[modelKey]"
@cancel="cancelLibrarySelect(modelKey)"
/>
</TransitionCollapse>
<!-- Input area -->
<TransitionCollapse>
<div
v-if="!selectedLibraryModel[modelKey]"
class="mt-1 flex flex-col gap-2"
>
<template v-if="isAssetSupported">
<MissingModelUrlInput
:model-key="modelKey"
:directory="directory"
:type-mismatch="typeMismatch"
/>
</template>
<TransitionCollapse>
<MissingModelLibrarySelect
v-if="!urlInputs[modelKey]"
:model-value="getComboValue(model.representative)"
:options="comboOptions"
:show-divider="model.representative.isAssetSupported"
@select="handleComboSelect(modelKey, $event)"
/>
</TransitionCollapse>
</div>
</TransitionCollapse>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import { cn } from '@/utils/tailwindUtil'
import Button from '@/components/ui/button/Button.vue'
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
import MissingModelStatusCard from '@/platform/missingModel/components/MissingModelStatusCard.vue'
import MissingModelUrlInput from '@/platform/missingModel/components/MissingModelUrlInput.vue'
import MissingModelLibrarySelect from '@/platform/missingModel/components/MissingModelLibrarySelect.vue'
import type { MissingModelViewModel } from '@/platform/missingModel/types'
import {
useMissingModelInteractions,
getModelStateKey,
getNodeDisplayLabel,
getComboValue
} from '@/platform/missingModel/composables/useMissingModelInteractions'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
const { model, directory, isAssetSupported } = defineProps<{
model: MissingModelViewModel
directory: string | null
showNodeIdBadge: boolean
isAssetSupported: boolean
}>()
const emit = defineEmits<{
locateModel: [nodeId: string]
}>()
const { t } = useI18n()
const { copyToClipboard } = useCopyToClipboard()
const modelKey = computed(() =>
getModelStateKey(model.name, directory, isAssetSupported)
)
const downloadStatus = computed(() => getDownloadStatus(modelKey.value))
const comboOptions = computed(() => getComboOptions(model.representative))
const canConfirm = computed(() => isSelectionConfirmable(modelKey.value))
const expanded = computed(() => isModelExpanded(modelKey.value))
const typeMismatch = computed(() => getTypeMismatch(modelKey.value, directory))
const isDownloadActive = computed(
() =>
downloadStatus.value?.status === 'running' ||
downloadStatus.value?.status === 'created'
)
const store = useMissingModelStore()
const { selectedLibraryModel, importCategoryMismatch, urlInputs } =
storeToRefs(store)
const {
toggleModelExpand,
isModelExpanded,
getComboOptions,
handleComboSelect,
isSelectionConfirmable,
cancelLibrarySelect,
confirmLibrarySelect,
getTypeMismatch,
getDownloadStatus
} = useMissingModelInteractions()
function handleLibrarySelect() {
confirmLibrarySelect(
modelKey.value,
model.name,
model.referencingNodes,
directory
)
}
</script>

View File

@@ -0,0 +1,108 @@
<template>
<div
aria-live="polite"
class="bg-foreground/5 relative mt-1 overflow-hidden rounded-lg border border-interface-stroke p-2"
>
<!-- Progress bar fill -->
<div
v-if="isDownloadActive"
class="absolute inset-y-0 left-0 bg-primary/10 transition-all duration-200 ease-linear"
:style="{ width: (downloadStatus?.progress ?? 0) * 100 + '%' }"
/>
<div class="relative z-10 flex items-center gap-2">
<div class="flex size-8 shrink-0 items-center justify-center">
<i
v-if="categoryMismatch"
aria-hidden="true"
class="mt-0.5 icon-[lucide--triangle-alert] size-5 text-warning-background"
/>
<i
v-else-if="downloadStatus?.status === 'failed'"
aria-hidden="true"
class="icon-[lucide--circle-alert] size-5 text-destructive-background"
/>
<i
v-else-if="downloadStatus?.status === 'completed'"
aria-hidden="true"
class="icon-[lucide--check-circle] size-5 text-success-background"
/>
<i
v-else-if="isDownloadActive"
aria-hidden="true"
class="icon-[lucide--loader-circle] size-5 animate-spin text-muted-foreground"
/>
<i
v-else
aria-hidden="true"
class="icon-[lucide--file-check] size-5 text-muted-foreground"
/>
</div>
<div class="flex min-w-0 flex-1 flex-col justify-center">
<span class="text-foreground truncate text-xs/tight font-medium">
{{ modelName }}
</span>
<span class="mt-0.5 text-xs/tight text-muted-foreground">
<template v-if="categoryMismatch">
{{
t('rightSidePanel.missingModels.alreadyExistsInCategory', {
category: categoryMismatch
})
}}
</template>
<template v-else-if="isDownloadActive">
{{ t('rightSidePanel.missingModels.importing') }}
{{ Math.round((downloadStatus?.progress ?? 0) * 100) }}%
</template>
<template v-else-if="downloadStatus?.status === 'completed'">
{{ t('rightSidePanel.missingModels.imported') }}
</template>
<template v-else-if="downloadStatus?.status === 'failed'">
{{
downloadStatus?.error ||
t('rightSidePanel.missingModels.importFailed')
}}
</template>
<template v-else>
{{ t('rightSidePanel.missingModels.usingFromLibrary') }}
</template>
</span>
</div>
<Button
variant="textonly"
size="icon-sm"
:aria-label="t('rightSidePanel.missingModels.cancelSelection')"
class="relative z-10 size-6 shrink-0 text-muted-foreground hover:text-base-foreground"
@click="emit('cancel')"
>
<i aria-hidden="true" class="icon-[lucide--circle-x] size-4" />
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import type { AssetDownload } from '@/stores/assetDownloadStore'
const {
modelName,
isDownloadActive,
downloadStatus = null,
categoryMismatch = null
} = defineProps<{
modelName: string
isDownloadActive: boolean
downloadStatus?: AssetDownload | null
categoryMismatch?: string | null
}>()
const emit = defineEmits<{
cancel: []
}>()
const { t } = useI18n()
</script>

View File

@@ -0,0 +1,156 @@
<template>
<div
:class="
cn(
'flex h-8 items-center rounded-lg border border-transparent bg-secondary-background px-3 transition-colors focus-within:border-interface-stroke',
!canImportModels && 'cursor-pointer'
)
"
v-bind="upgradePromptAttrs"
@click="!canImportModels && showUploadDialog()"
>
<label :for="`url-input-${modelKey}`" class="sr-only">
{{ t('rightSidePanel.missingModels.urlPlaceholder') }}
</label>
<input
:id="`url-input-${modelKey}`"
type="text"
:value="urlInputs[modelKey] ?? ''"
:readonly="!canImportModels"
:placeholder="t('rightSidePanel.missingModels.urlPlaceholder')"
:class="
cn(
'text-foreground w-full border-none bg-transparent text-xs outline-none placeholder:text-muted-foreground',
!canImportModels && 'pointer-events-none opacity-60'
)
"
@input="
handleUrlInput(modelKey, ($event.target as HTMLInputElement).value)
"
/>
<Button
v-if="urlInputs[modelKey]"
variant="textonly"
size="icon-sm"
:aria-label="t('rightSidePanel.missingModels.clearUrl')"
class="ml-1 shrink-0"
@click.stop="handleUrlInput(modelKey, '')"
>
<i aria-hidden="true" class="icon-[lucide--x] size-3.5" />
</Button>
</div>
<TransitionCollapse>
<div v-if="urlMetadata[modelKey]" class="flex flex-col gap-2">
<div class="flex items-center gap-2 px-0.5 pt-0.5">
<span class="text-foreground min-w-0 truncate text-xs font-bold">
{{ urlMetadata[modelKey]?.filename }}
</span>
<span
v-if="(urlMetadata[modelKey]?.content_length ?? 0) > 0"
class="shrink-0 rounded-sm bg-secondary-background-selected px-1.5 py-0.5 text-xs font-medium text-muted-foreground"
>
{{ formatSize(urlMetadata[modelKey]?.content_length ?? 0) }}
</span>
</div>
<div v-if="typeMismatch" class="flex items-start gap-1.5 px-0.5">
<i
aria-hidden="true"
class="mt-0.5 icon-[lucide--triangle-alert] size-3 shrink-0 text-warning-background"
/>
<span class="text-xs/tight text-warning-background">
{{
t('rightSidePanel.missingModels.typeMismatch', {
detectedType: typeMismatch
})
}}
</span>
</div>
<div class="pt-0.5">
<Button
variant="primary"
class="h-9 w-full justify-center gap-2 text-sm font-semibold"
:loading="urlImporting[modelKey]"
@click="handleImport(modelKey, directory)"
>
<i aria-hidden="true" class="icon-[lucide--download] size-4" />
{{
typeMismatch
? t('rightSidePanel.missingModels.importAnyway')
: t('rightSidePanel.missingModels.import')
}}
</Button>
</div>
</div>
</TransitionCollapse>
<TransitionCollapse>
<div
v-if="urlFetching[modelKey]"
aria-live="polite"
class="flex items-center justify-center py-2"
>
<i
aria-hidden="true"
class="icon-[lucide--loader-circle] size-4 animate-spin text-muted-foreground"
/>
<span class="sr-only">{{ t('g.loading') }}</span>
</div>
</TransitionCollapse>
<TransitionCollapse>
<div v-if="urlErrors[modelKey]" class="px-0.5" role="alert">
<span class="text-xs text-destructive-background-hover">
{{ urlErrors[modelKey] }}
</span>
</div>
</TransitionCollapse>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import { cn } from '@/utils/tailwindUtil'
import Button from '@/components/ui/button/Button.vue'
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
import { formatSize } from '@/utils/formatUtil'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useMissingModelInteractions } from '@/platform/missingModel/composables/useMissingModelInteractions'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useModelUpload } from '@/platform/assets/composables/useModelUpload'
const { modelKey, directory, typeMismatch } = defineProps<{
modelKey: string
directory: string | null
typeMismatch: string | null
}>()
const { t } = useI18n()
const { flags } = useFeatureFlags()
const canImportModels = computed(() => flags.privateModelsEnabled)
const { showUploadDialog } = useModelUpload()
const store = useMissingModelStore()
const { urlInputs, urlMetadata, urlFetching, urlErrors, urlImporting } =
storeToRefs(store)
const { handleUrlInput, handleImport } = useMissingModelInteractions()
const upgradePromptAttrs = computed(() =>
canImportModels.value
? {}
: {
role: 'button',
tabindex: 0,
onKeydown: (e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
showUploadDialog()
}
}
}
)
</script>

View File

@@ -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> = {}
): 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()
})
})
})

View File

@@ -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<string, symbol> = {}
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
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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<boolean>,
isAssetSupported?: (nodeType: string, widgetName: string) => boolean
): Promise<MissingModelCandidate[]> {
const allNodes = flattenWorkflowNodes(graphData)
const embeddedModels = collectEmbeddedModelsWithSource(allNodes, graphData)
const enriched = candidates.map((c) => ({ ...c }))
const candidatesByKey = new Map<string, MissingModelCandidate[]>()
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<string>()
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<typeof flattenWorkflowNodes>,
graphData: ComfyWorkflowJSON
): EmbeddedModelWithSource[] {
const result: EmbeddedModelWithSource[] = []
for (const node of allNodes) {
const selected = getSelectedModelsMetadata(
node as Parameters<typeof getSelectedModelsMetadata>[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<typeof flattenWorkflowNodes>[number],
modelName: string
): string {
if (Array.isArray(node.widgets_values) || !node.widgets_values) return ''
const wv = node.widgets_values as Record<string, unknown>
for (const [key, val] of Object.entries(wv)) {
if (val === modelName) return key
}
return ''
}
interface AssetVerifier {
updateModelsForNodeType: (nodeType: string) => Promise<void>
getAssets: (nodeType: string) => AssetItem[] | undefined
}
export async function verifyAssetSupportedCandidates(
candidates: MissingModelCandidate[],
signal?: AbortSignal,
assetsStore?: AssetVerifier
): Promise<void> {
if (signal?.aborted) return
const pendingNodeTypes = new Set<string>()
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<string>()
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<string, MissingModelViewModel>()
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())
}

View File

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

View File

@@ -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<MissingModelCandidate[] | null>(null)
const hasMissingModels = computed(
() => !!missingModelCandidates.value?.length
)
const missingModelCount = computed(
() => missingModelCandidates.value?.length ?? 0
)
const missingModelNodeIds = computed<Set<string>>(() => {
const ids = new Set<string>()
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<Set<string>>(() => {
const keys = new Set<string>()
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<Set<NodeExecutionId>>(
() => {
const ids = new Set<NodeExecutionId>()
for (const nodeId of missingModelNodeIds.value) {
for (const id of getAncestorExecutionIds(nodeId)) {
ids.add(id)
}
}
return ids
}
)
const activeMissingModelGraphIds = computed<Set<string>>(() => {
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<Record<string, boolean>>({})
const selectedLibraryModel = ref<Record<string, string>>({})
const importCategoryMismatch = ref<Record<string, string>>({})
const importTaskIds = ref<Record<string, string>>({})
const urlInputs = ref<Record<string, string>>({})
const urlMetadata = ref<Record<string, AssetMetadata | null>>({})
const urlFetching = ref<Record<string, boolean>>({})
const urlErrors = ref<Record<string, string>>({})
const urlImporting = ref<Record<string, boolean>>({})
const _urlDebounceTimers: Record<string, ReturnType<typeof setTimeout>> = {}
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<string>
) {
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
}
})

View File

@@ -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
}

View File

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

View File

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

View File

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

View File

@@ -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)))
)
})

View File

@@ -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,

View File

@@ -73,6 +73,7 @@ import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
import { useModelStore } from '@/stores/modelStore'
import { SYSTEM_NODE_DEFS, useNodeDefStore } from '@/stores/nodeDefStore'
import { useNodeReplacementStore } from '@/platform/nodeReplacement/nodeReplacementStore'
import { useSubgraphStore } from '@/stores/subgraphStore'
import { useWidgetStore } from '@/stores/widgetStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
@@ -82,6 +83,15 @@ import type { NodeExecutionId } from '@/types/nodeIdentification'
import { graphToPrompt } from '@/utils/executionUtil'
import { getCnrIdFromProperties } from '@/workbench/extensions/manager/utils/missingNodeErrorUtil'
import { rescanAndSurfaceMissingNodes } from '@/platform/nodeReplacement/missingNodeScan'
import {
scanAllModelCandidates,
enrichWithEmbeddedMetadata,
verifyAssetSupportedCandidates
} from '@/platform/missingModel/missingModelScan'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { assetService } from '@/platform/assets/services/assetService'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
import { anyItemOverlapsRect } from '@/utils/mathUtil'
import {
collectAllNodes,
@@ -104,7 +114,7 @@ import {
findLegacyRerouteNodes,
noNativeReroutes
} from '@/utils/migration/migrateReroute'
import { getSelectedModelsMetadata } from '@/workbench/utils/modelMetadataUtil'
import { deserialiseAndCreate } from '@/utils/vintageClipboard'
import { type ComfyApi, PromptExecutionError, api } from './api'
@@ -1124,6 +1134,8 @@ export class ComfyApp {
} = options
useWorkflowService().beforeLoadNewGraph()
useMissingModelStore().clearMissingModels()
if (clean !== false) {
this.clean()
}
@@ -1168,18 +1180,17 @@ export class ComfyApp {
useSubgraphService().loadSubgraphs(graphData)
const missingNodeTypes: MissingNodeType[] = []
const missingModels: ModelFile[] = []
await useExtensionService().invokeExtensionsAsync(
'beforeConfigureGraph',
graphData,
missingNodeTypes
)
const embeddedModels: ModelFile[] = []
const nodeReplacementStore = useNodeReplacementStore()
await nodeReplacementStore.load()
const collectMissingNodesAndModels = (
// Collect missing node types from all nodes (root + subgraphs)
const collectMissingNodes = (
nodes: ComfyWorkflowJSON['nodes'],
pathPrefix: string = '',
displayName: string = ''
@@ -1192,16 +1203,11 @@ export class ComfyApp {
return
}
for (let n of nodes) {
// Find missing node types
if (!(n.type in LiteGraph.registered_node_types)) {
const replacement = nodeReplacementStore.getReplacementFor(n.type)
// To access missing node information in the error tab
// we collect the cnr_id and execution_id here.
const cnrId = getCnrIdFromProperties(
n.properties as Record<string, unknown> | undefined
)
const executionId = pathPrefix
? `${pathPrefix}:${n.id}`
: String(n.id)
@@ -1219,65 +1225,25 @@ export class ComfyApp {
n.type = sanitizeNodeName(n.type)
}
// Collect models metadata from node
const selectedModels = getSelectedModelsMetadata(n)
if (selectedModels?.length) {
embeddedModels.push(...selectedModels)
}
}
}
// Process nodes at the top level
collectMissingNodesAndModels(graphData.nodes)
// Build map: subgraph definition UUID → full execution path prefix.
// Handles arbitrary nesting depth (e.g. root node 11 → "11", node 14 in sg 11 → "11:14").
collectMissingNodes(graphData.nodes)
const subgraphDefs = graphData.definitions?.subgraphs ?? []
const subgraphContainerIdMap = buildSubgraphExecutionPaths(
graphData.nodes,
graphData.definitions?.subgraphs ?? []
subgraphDefs
)
// Process nodes in subgraphs
if (graphData.definitions?.subgraphs) {
for (const subgraph of graphData.definitions.subgraphs) {
if (isSubgraphDefinition(subgraph)) {
const paths = subgraphContainerIdMap.get(subgraph.id) ?? []
for (const pathPrefix of paths) {
collectMissingNodesAndModels(
subgraph.nodes,
pathPrefix,
subgraph.name || subgraph.id
)
}
}
}
}
// Merge models from the workflow's root-level 'models' field
const workflowSchemaV1Models = graphData.models
if (workflowSchemaV1Models?.length)
embeddedModels.push(...workflowSchemaV1Models)
const getModelKey = (model: ModelFile) => model.url || model.hash
const validModels = embeddedModels.filter(getModelKey)
const uniqueModels = _.uniqBy(validModels, getModelKey)
if (
uniqueModels.length &&
useSettingStore().get('Comfy.Workflow.ShowMissingModelsWarning')
) {
const modelStore = useModelStore()
await modelStore.loadModelFolders()
for (const m of uniqueModels) {
const modelFolder = await modelStore.getLoadedModelFolder(m.directory)
const modelsAvailable = modelFolder?.models
const modelExists =
modelsAvailable &&
Object.values(modelsAvailable).some(
(model) => model.file_name === m.name
for (const subgraph of subgraphDefs) {
if (isSubgraphDefinition(subgraph)) {
const paths = subgraphContainerIdMap.get(subgraph.id) ?? []
for (const pathPrefix of paths) {
collectMissingNodes(
subgraph.nodes,
pathPrefix,
subgraph.name || subgraph.id
)
if (!modelExists) missingModels.push(m)
}
}
}
@@ -1423,21 +1389,12 @@ export class ComfyApp {
requestAnimationFrame(() => fitView())
}
// Store pending warnings on the workflow for deferred display
const activeWf = useWorkspaceStore().workflow.activeWorkflow
if (activeWf) {
const warnings: PendingWarnings = {}
if (missingNodeTypes.length && showMissingNodesDialog) {
warnings.missingNodeTypes = missingNodeTypes
}
if (missingModels.length && showMissingModelsDialog) {
const paths = await api.getFolderPaths()
warnings.missingModels = { missingModels: missingModels, paths }
}
if (warnings.missingNodeTypes || warnings.missingModels) {
activeWf.pendingWarnings = warnings
}
}
await this.runMissingModelPipeline(
graphData,
missingNodeTypes,
showMissingNodesDialog,
showMissingModelsDialog
)
if (!deferWarnings) {
useWorkflowService().showPendingWarnings()
@@ -1451,6 +1408,97 @@ export class ComfyApp {
}
}
private async runMissingModelPipeline(
graphData: ComfyWorkflowJSON,
missingNodeTypes: MissingNodeType[],
showMissingNodesDialog: boolean,
showMissingModelsDialog: boolean
): Promise<{ missingModels: ModelFile[] }> {
const missingModelStore = useMissingModelStore()
const candidates = isCloud
? scanAllModelCandidates(
this.rootGraph,
(nodeType, widgetName) =>
assetService.shouldUseAssetBrowser(nodeType, widgetName),
(nodeType) => useModelToNodeStore().getCategoryForNodeType(nodeType)
)
: []
const modelStore = useModelStore()
await modelStore.loadModelFolders()
const enrichedCandidates = await enrichWithEmbeddedMetadata(
candidates,
graphData,
async (name, directory) => {
const folder = await modelStore.getLoadedModelFolder(directory)
const models = folder?.models
return !!(
models && Object.values(models).some((m) => m.file_name === name)
)
},
isCloud
? (nodeType, widgetName) =>
assetService.shouldUseAssetBrowser(nodeType, widgetName)
: undefined
)
const missingModels: ModelFile[] = enrichedCandidates
.filter((c) => c.isMissing === true && c.url)
.map((c) => ({
name: c.name,
url: c.url ?? '',
directory: c.directory ?? '',
hash: c.hash,
hash_type: c.hashType
}))
const activeWf = useWorkspaceStore().workflow.activeWorkflow
if (activeWf) {
const warnings: PendingWarnings = {}
if (missingNodeTypes.length && showMissingNodesDialog) {
warnings.missingNodeTypes = missingNodeTypes
}
if (missingModels.length && showMissingModelsDialog) {
const paths = await api.getFolderPaths()
warnings.missingModels = { missingModels, paths }
}
if (warnings.missingNodeTypes || warnings.missingModels) {
activeWf.pendingWarnings = warnings
}
}
if (isCloud && enrichedCandidates.length) {
const controller = missingModelStore.createVerificationAbortController()
verifyAssetSupportedCandidates(enrichedCandidates, controller.signal)
.then(() => {
if (controller.signal.aborted) return
const confirmed = enrichedCandidates.filter(
(c) => c.isMissing === true
)
if (confirmed.length) {
useExecutionErrorStore().surfaceMissingModels(confirmed)
}
})
.catch((err) => {
console.warn(
'[Missing Model Pipeline] Asset verification failed:',
err
)
useToastStore().add({
severity: 'warn',
summary: st(
'toastMessages.missingModelVerificationFailed',
'Failed to verify missing models. Some models may not be shown in the Errors tab.'
),
life: 5000
})
})
}
return { missingModels }
}
async graphToPrompt(graph = this.rootGraph) {
return graphToPrompt(graph, {
sortNodes: useSettingStore().get('Comfy.Workflow.SortNodeIdOnSave')

View File

@@ -295,6 +295,7 @@ export const useAssetsStore = defineStore('assets', () => {
>()
const pendingRequestByCategory = new Map<string, ModelPaginationState>()
const pendingPromiseByCategory = new Map<string, Promise<void>>()
function createState(
existingAssets?: Map<string, AssetItem>
@@ -400,9 +401,8 @@ export const useAssetsStore = defineStore('assets', () => {
category: string,
fetcher: (options: PaginationOptions) => Promise<AssetItem[]>
): Promise<void> {
// 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)
}
/**

View File

@@ -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', () => {

View File

@@ -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<Record<NodeId, NodeError> | null>(null)
const lastExecutionError = ref<ExecutionErrorWsMessage | null>(null)
const lastPromptError = ref<PromptError | null>(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<string[]>(() => {
@@ -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<Set<string>>(() => {
const ids = new Set<string>()
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,

View File

@@ -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<NodeExecutionId>
): Set<string> {
const ids = new Set<string>()
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