feat: add node replacement UI to Errors Tab (#9253)

## Summary

Adds a node replacement UI to the Errors Tab so users can swap missing
nodes with compatible alternatives directly from the error panel,
without opening a separate dialog.

## Changes

- **What**: New `SwapNodesCard` and `SwapNodeGroupRow` components render
swap groups in the Errors Tab; each group shows the missing node type,
its instances (with locate buttons), and a Replace button. Added
`useMissingNodeScan` composable to scan the graph for missing nodes and
populate `executionErrorStore`. Added `removeMissingNodesByType()` to
`executionErrorStore` so replaced nodes are pruned from the error list
reactively.

## Bug Fixes Found During Implementation

### Bug 1: Replaced nodes render as empty shells until page refresh

`replaceWithMapping()` directly mutates `_nodes[idx]`, bypassing the Vue
rendering pipeline entirely. Because the replacement node reuses the
same ID, `vueNodeData` retains the stale entry from the old placeholder
(`hasErrors: true`, empty widgets/inputs). `graph.setDirtyCanvas()` only
repaints the LiteGraph canvas and has no effect on Vue.

**Fix**: After `replaceWithMapping()`, manually call
`nodeGraph.onNodeAdded?.(newNode)` to trigger `handleNodeAdded` in
`useGraphNodeManager`, which runs `extractVueNodeData(newNode)` and
updates `vueNodeData` correctly. Also added a guard in `handleNodeAdded`
to skip `layoutStore.createNode()` when a layout for the same ID already
exists, preventing a duplicate `spatialIndex.insert()`.

### Bug 2: Missing node error list overwritten by incomplete server
response

Two compounding issues: (A) the server's `missing_node_type` error only
reports the *first* missing node — the old handler parsed this and
called `surfaceMissingNodes([singleNode])`, overwriting the full list
collected at load time. (B) `queuePrompt()` calls `clearAllErrors()`
before the API request; if the subsequent rescan used the stale
`has_errors` flag and found nothing, the missing nodes were permanently
lost.

**Fix**: Created `useMissingNodeScan.ts` which scans
`LiteGraph.registered_node_types` directly (not `has_errors`). The
`missing_node_type` catch block in `app.ts` now calls
`rescanAndSurfaceMissingNodes(this.rootGraph)` instead of parsing the
server's partial response.

## Review Focus

- `handleReplaceNode` removes the group from the store only when
`replaceNodesInPlace` returns at least one replaced node — should we
always clear, or only on full success?
- `useMissingNodeScan` re-scans on every execution-error change; confirm
no performance concerns for large graphs with many subgraphs.


## Screenshots 


https://github.com/user-attachments/assets/78310fc4-0424-4920-b369-cef60a123d50



https://github.com/user-attachments/assets/3d2fd5e1-5e85-4c20-86aa-8bf920e86987



┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9253-feat-add-node-replacement-UI-to-Errors-Tab-3136d73d365081718d4ddfd628cb4449)
by [Unito](https://www.unito.io)
This commit is contained in:
jaeone94
2026-02-27 10:37:48 +09:00
committed by GitHub
parent 367d96715b
commit 1c3984a178
14 changed files with 424 additions and 169 deletions

View File

@@ -0,0 +1,150 @@
<template>
<div class="flex flex-col w-full mb-4">
<!-- Type header row: type name + chevron -->
<div class="flex h-8 items-center w-full">
<p
class="flex-1 min-w-0 text-sm font-medium overflow-hidden text-ellipsis whitespace-nowrap text-foreground"
>
{{ `${group.type} (${group.nodeTypes.length})` }}
</p>
<Button
variant="textonly"
size="icon-sm"
:class="
cn(
'size-8 shrink-0 transition-transform duration-200 hover:bg-transparent',
{ 'rotate-180': expanded }
)
"
:aria-label="
expanded
? t('rightSidePanel.missingNodePacks.collapse', 'Collapse')
: t('rightSidePanel.missingNodePacks.expand', 'Expand')
"
@click="toggleExpand"
>
<i
class="icon-[lucide--chevron-down] size-4 text-muted-foreground group-hover:text-base-foreground"
/>
</Button>
</div>
<!-- Sub-labels: individual node instances, each with their own Locate button -->
<TransitionCollapse>
<div
v-if="expanded"
class="flex flex-col gap-0.5 pl-2 mb-2 overflow-hidden"
>
<div
v-for="nodeType in group.nodeTypes"
:key="getKey(nodeType)"
class="flex h-7 items-center"
>
<span
v-if="
showNodeIdBadge &&
typeof nodeType !== 'string' &&
nodeType.nodeId != null
"
class="shrink-0 rounded-md bg-secondary-background-selected px-2 py-0.5 text-xs font-mono text-muted-foreground font-bold mr-1"
>
#{{ nodeType.nodeId }}
</span>
<p class="flex-1 min-w-0 text-xs text-muted-foreground truncate">
{{ getLabel(nodeType) }}
</p>
<Button
v-if="typeof nodeType !== 'string' && nodeType.nodeId != null"
variant="textonly"
size="icon-sm"
class="size-6 text-muted-foreground hover:text-base-foreground shrink-0 mr-1"
:aria-label="t('rightSidePanel.locateNode', 'Locate Node')"
@click="handleLocateNode(nodeType)"
>
<i class="icon-[lucide--locate] size-3" />
</Button>
</div>
</div>
</TransitionCollapse>
<!-- Description rows: what it is replaced by -->
<div class="flex flex-col text-[13px] mb-2 mt-1 px-1 gap-0.5">
<span class="text-muted-foreground">{{
t('nodeReplacement.willBeReplacedBy', 'This node will be replaced by:')
}}</span>
<span class="font-bold text-foreground">{{
group.newNodeId ?? t('nodeReplacement.unknownNode', 'Unknown')
}}</span>
</div>
<!-- Replace Action Button -->
<div class="flex items-start w-full pt-1 pb-1">
<Button
variant="secondary"
size="md"
class="flex flex-1 w-full"
@click="handleReplaceNode"
>
<i class="icon-[lucide--repeat] size-4 text-foreground shrink-0 mr-1" />
<span class="text-sm text-foreground truncate min-w-0">
{{ t('nodeReplacement.replaceNode', 'Replace Node') }}
</span>
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { cn } from '@/utils/tailwindUtil'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
import type { MissingNodeType } from '@/types/comfy'
import type { SwapNodeGroup } from './useErrorGroups'
import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacement'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
const props = defineProps<{
group: SwapNodeGroup
showNodeIdBadge: boolean
}>()
const emit = defineEmits<{
'locate-node': [nodeId: string]
}>()
const { t } = useI18n()
const { replaceNodesInPlace } = useNodeReplacement()
const executionErrorStore = useExecutionErrorStore()
const expanded = ref(false)
function toggleExpand() {
expanded.value = !expanded.value
}
function getKey(nodeType: MissingNodeType): string {
if (typeof nodeType === 'string') return nodeType
return nodeType.nodeId != null ? String(nodeType.nodeId) : nodeType.type
}
function getLabel(nodeType: MissingNodeType): string {
return typeof nodeType === 'string' ? nodeType : nodeType.type
}
function handleLocateNode(nodeType: MissingNodeType) {
if (typeof nodeType === 'string') return
if (nodeType.nodeId != null) {
emit('locate-node', String(nodeType.nodeId))
}
}
function handleReplaceNode() {
const replaced = replaceNodesInPlace(props.group.nodeTypes)
if (replaced.length > 0) {
executionErrorStore.removeMissingNodesByType([props.group.type])
}
}
</script>

View File

@@ -0,0 +1,38 @@
<template>
<div class="px-4 pb-2 mt-2">
<!-- Sub-label: guidance message shown above all swap groups -->
<p class="m-0 pb-5 text-sm text-muted-foreground leading-relaxed">
{{
t(
'nodeReplacement.swapNodesGuide',
'The following nodes can be automatically replaced with compatible alternatives.'
)
}}
</p>
<!-- Group Rows -->
<SwapNodeGroupRow
v-for="group in swapNodeGroups"
:key="group.type"
:group="group"
:show-node-id-badge="showNodeIdBadge"
@locate-node="emit('locate-node', $event)"
/>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import type { SwapNodeGroup } from './useErrorGroups'
import SwapNodeGroupRow from './SwapNodeGroupRow.vue'
const { t } = useI18n()
const { swapNodeGroups, showNodeIdBadge } = defineProps<{
swapNodeGroups: SwapNodeGroup[]
showNodeIdBadge: boolean
}>()
const emit = defineEmits<{
'locate-node': [nodeId: string]
}>()
</script>

View File

@@ -27,7 +27,11 @@
:key="group.title"
:collapse="collapseState[group.title] ?? false"
class="border-b border-interface-stroke"
:size="group.type === 'missing_node' ? 'lg' : 'default'"
:size="
group.type === 'missing_node' || group.type === 'swap_nodes'
? 'lg'
: 'default'
"
@update:collapse="collapseState[group.title] = $event"
>
<template #label>
@@ -40,7 +44,9 @@
{{
group.type === 'missing_node'
? `${group.title} (${missingPackGroups.length})`
: group.title
: group.type === 'swap_nodes'
? `${group.title} (${swapNodeGroups.length})`
: group.title
}}
</span>
<span
@@ -69,6 +75,21 @@
: t('rightSidePanel.missingNodePacks.installAll')
}}
</Button>
<Button
v-else-if="group.type === 'swap_nodes'"
v-tooltip.top="
t(
'nodeReplacement.replaceAllWarning',
'Replaces all available nodes in this group.'
)
"
variant="secondary"
size="sm"
class="shrink-0 mr-2 h-8 rounded-lg text-sm"
@click.stop="handleReplaceAll()"
>
{{ t('nodeReplacement.replaceAll', 'Replace All') }}
</Button>
</div>
</template>
@@ -82,8 +103,16 @@
@open-manager-info="handleOpenManagerInfo"
/>
<!-- Swap Nodes -->
<SwapNodesCard
v-else-if="group.type === 'swap_nodes'"
:swap-node-groups="swapNodeGroups"
:show-node-id-badge="showNodeIdBadge"
@locate-node="handleLocateMissingNode"
/>
<!-- Execution Errors -->
<div v-else class="px-4 space-y-3">
<div v-else-if="group.type === 'execution'" class="px-4 space-y-3">
<ErrorNodeCard
v-for="card in group.cards"
:key="card.id"
@@ -150,11 +179,14 @@ import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
import ErrorNodeCard from './ErrorNodeCard.vue'
import MissingNodeCard from './MissingNodeCard.vue'
import SwapNodesCard from './SwapNodesCard.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 { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacement'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
const { t } = useI18n()
const { copyToClipboard } = useCopyToClipboard()
@@ -167,6 +199,8 @@ const { shouldShowManagerButtons, shouldShowInstallButton, openManager } =
const { missingNodePacks } = useMissingNodes()
const { isInstalling: isInstallingAll, installAllPacks: installAll } =
usePackInstall(() => missingNodePacks.value)
const { replaceNodesInPlace } = useNodeReplacement()
const executionErrorStore = useExecutionErrorStore()
const searchQuery = ref('')
@@ -183,7 +217,8 @@ const {
isSingleNodeSelected,
errorNodeCache,
missingNodeCache,
missingPackGroups
missingPackGroups,
swapNodeGroups
} = useErrorGroups(searchQuery, t)
/**
@@ -229,6 +264,14 @@ function handleOpenManagerInfo(packId: string) {
}
}
function handleReplaceAll() {
const allNodeTypes = swapNodeGroups.value.flatMap((g) => g.nodeTypes)
const replaced = replaceNodesInPlace(allNodeTypes)
if (replaced.length > 0) {
executionErrorStore.removeMissingNodesByType(replaced)
}
}
function handleEnterSubgraph(nodeId: string) {
enterSubgraph(nodeId, errorNodeCache.value)
}

View File

@@ -22,3 +22,4 @@ export type ErrorGroup =
priority: number
}
| { type: 'missing_node'; title: string; priority: number }
| { type: 'swap_nodes'; title: string; priority: number }

View File

@@ -42,6 +42,12 @@ export interface MissingPackGroup {
isResolving: boolean
}
export interface SwapNodeGroup {
type: string
newNodeId: string | undefined
nodeTypes: MissingNodeType[]
}
interface GroupEntry {
type: 'execution'
priority: number
@@ -444,6 +450,8 @@ export function useErrorGroups(
const resolvingKeys = new Set<string | null>()
for (const nodeType of nodeTypes) {
if (typeof nodeType !== 'string' && nodeType.isReplaceable) continue
let packId: string | null
if (typeof nodeType === 'string') {
@@ -495,18 +503,53 @@ export function useErrorGroups(
}))
})
const swapNodeGroups = computed<SwapNodeGroup[]>(() => {
const nodeTypes = executionErrorStore.missingNodesError?.nodeTypes ?? []
const map = new Map<string, SwapNodeGroup>()
for (const nodeType of nodeTypes) {
if (typeof nodeType === 'string' || !nodeType.isReplaceable) continue
const typeName = nodeType.type
const existing = map.get(typeName)
if (existing) {
existing.nodeTypes.push(nodeType)
} else {
map.set(typeName, {
type: typeName,
newNodeId: nodeType.replacement?.new_node_id,
nodeTypes: [nodeType]
})
}
}
return Array.from(map.values()).sort((a, b) => a.type.localeCompare(b.type))
})
/** Builds an ErrorGroup from missingNodesError. Returns [] when none present. */
function buildMissingNodeGroups(): ErrorGroup[] {
const error = executionErrorStore.missingNodesError
if (!error) return []
return [
{
const groups: ErrorGroup[] = []
if (swapNodeGroups.value.length > 0) {
groups.push({
type: 'swap_nodes' as const,
title: st('nodeReplacement.swapNodesTitle', 'Swap Nodes'),
priority: 0
})
}
if (missingPackGroups.value.length > 0) {
groups.push({
type: 'missing_node' as const,
title: error.message,
priority: 0
}
]
priority: 1
})
}
return groups.sort((a, b) => a.priority - b.priority)
}
const allErrorGroups = computed<ErrorGroup[]>(() => {
@@ -564,6 +607,7 @@ export function useErrorGroups(
errorNodeCache,
missingNodeCache,
groupedErrorMessages,
missingPackGroups
missingPackGroups,
swapNodeGroups
}
}

View File

@@ -14,6 +14,7 @@ import type {
} from '@/lib/litegraph/src/interfaces'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { LayoutSource } from '@/renderer/core/layout/types'
import type { NodeId } from '@/renderer/core/layout/types'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
@@ -442,6 +443,11 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
const nodePosition = { x: node.pos[0], y: node.pos[1] }
const nodeSize = { width: node.size[0], height: node.size[1] }
// Skip layout creation if it already exists
// (e.g. in-place node replacement where the old node's layout is reused for the new node with the same ID).
const existingLayout = layoutStore.getNodeLayoutRef(id).value
if (existingLayout) return
// Add node to layout store with final positions
setSource(LayoutSource.Canvas)
void createNode(id, {

View File

@@ -0,0 +1,44 @@
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import { useNodeReplacementStore } from '@/platform/nodeReplacement/nodeReplacementStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import type { MissingNodeType } from '@/types/comfy'
import {
collectAllNodes,
getExecutionIdByNode
} from '@/utils/graphTraversalUtil'
import { getCnrIdFromNode } from '@/workbench/extensions/manager/utils/missingNodeErrorUtil'
/** Scan the live graph for unregistered node types and build a full MissingNodeType list. */
function scanMissingNodes(rootGraph: LGraph): MissingNodeType[] {
const nodeReplacementStore = useNodeReplacementStore()
const missingNodeTypes: MissingNodeType[] = []
const allNodes = collectAllNodes(rootGraph)
for (const node of allNodes) {
const originalType = node.last_serialization?.type ?? node.type ?? 'Unknown'
if (originalType in LiteGraph.registered_node_types) continue
const cnrId = getCnrIdFromNode(node)
const replacement = nodeReplacementStore.getReplacementFor(originalType)
const executionId = getExecutionIdByNode(rootGraph, node)
missingNodeTypes.push({
type: originalType,
nodeId: executionId ?? String(node.id),
cnrId,
isReplaceable: replacement !== null,
replacement: replacement ?? undefined
})
}
return missingNodeTypes
}
/** Re-scan the graph for missing nodes and update the error store. */
export function rescanAndSurfaceMissingNodes(rootGraph: LGraph): void {
const types = scanMissingNodes(rootGraph)
useExecutionErrorStore().surfaceMissingNodes(types)
}

View File

@@ -3078,7 +3078,14 @@
"openNodeManager": "Open Node Manager",
"skipForNow": "Skip for Now",
"installMissingNodes": "Install Missing Nodes",
"replaceWarning": "This will permanently modify the workflow. Save a copy first if unsure."
"replaceWarning": "This will permanently modify the workflow. Save a copy first if unsure.",
"swapNodesGuide": "The following nodes can be automatically replaced with compatible alternatives.",
"willBeReplacedBy": "This node will be replaced by:",
"replaceNode": "Replace Node",
"replaceAll": "Replace All",
"unknownNode": "Unknown",
"replaceAllWarning": "Replaces all available nodes in this group.",
"swapNodesTitle": "Swap Nodes"
},
"rightSidePanel": {
"togglePanel": "Toggle properties panel",

View File

@@ -261,6 +261,10 @@ export function useNodeReplacement() {
}
replaceWithMapping(node, newNode, effectiveReplacement, nodeGraph, idx)
// Refresh Vue node data — replaceWithMapping bypasses graph.add()
// so onNodeAdded must be called explicitly to update VueNodeData.
nodeGraph.onNodeAdded?.(newNode)
if (!replacedTypes.includes(match.type)) {
replacedTypes.push(match.type)
}
@@ -279,6 +283,19 @@ export function useNodeReplacement() {
life: 3000
})
}
} catch (error) {
console.error('Failed to replace nodes:', error)
if (replacedTypes.length > 0) {
graph.updateExecutionOrder()
graph.setDirtyCanvas(true, true)
}
toastStore.add({
severity: 'error',
summary: t('g.error', 'Error'),
detail: t('nodeReplacement.replaceFailed', 'Failed to replace nodes'),
life: 5000
})
return replacedTypes
} finally {
changeTracker?.afterChange()
}

View File

@@ -79,12 +79,8 @@ import type { ComfyExtension, MissingNodeType } from '@/types/comfy'
import type { ExtensionManager } from '@/types/extensionTypes'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import { graphToPrompt } from '@/utils/executionUtil'
import type { MissingNodeTypeExtraInfo } from '@/workbench/extensions/manager/types/missingNodeErrorTypes'
import {
createMissingNodeTypeFromError,
getCnrIdFromNode,
getCnrIdFromProperties
} from '@/workbench/extensions/manager/utils/missingNodeErrorUtil'
import { getCnrIdFromProperties } from '@/workbench/extensions/manager/utils/missingNodeErrorUtil'
import { rescanAndSurfaceMissingNodes } from '@/composables/useMissingNodeScan'
import { anyItemOverlapsRect } from '@/utils/mathUtil'
import {
collectAllNodes,
@@ -1190,7 +1186,7 @@ export class ComfyApp {
const embeddedModels: ModelFile[] = []
const nodeReplacementStore = useNodeReplacementStore()
await nodeReplacementStore.load()
const collectMissingNodesAndModels = (
nodes: ComfyWorkflowJSON['nodes'],
pathPrefix: string = '',
@@ -1527,35 +1523,8 @@ export class ComfyApp {
typeof error.response.error === 'object' &&
error.response.error?.type === 'missing_node_type'
) {
const extraInfo = (error.response.error.extra_info ??
{}) as MissingNodeTypeExtraInfo
let graphNode = null
if (extraInfo.node_id && this.rootGraph) {
graphNode = getNodeByExecutionId(
this.rootGraph,
extraInfo.node_id
)
}
const enrichedExtraInfo: MissingNodeTypeExtraInfo = {
...extraInfo,
class_type: extraInfo.class_type ?? graphNode?.type,
node_title: extraInfo.node_title ?? graphNode?.title
}
const missingNodeType =
createMissingNodeTypeFromError(enrichedExtraInfo)
if (
graphNode &&
typeof missingNodeType !== 'string' &&
!missingNodeType.cnrId
) {
missingNodeType.cnrId = getCnrIdFromNode(graphNode)
}
this.showMissingNodesError([missingNodeType])
// Re-scan the full graph instead of using the server's single-node response.
rescanAndSurfaceMissingNodes(this.rootGraph)
} else if (
!useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab') ||
!(error instanceof PromptExecutionError)

View File

@@ -112,6 +112,17 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
}
}
/** Remove specific node types from the missing nodes list (e.g. after replacement). */
function removeMissingNodesByType(typesToRemove: string[]) {
if (!missingNodesError.value) return
const removeSet = new Set(typesToRemove)
const remaining = missingNodesError.value.nodeTypes.filter((node) => {
const nodeType = typeof node === 'string' ? node : node.type
return !removeSet.has(nodeType)
})
setMissingNodeTypes(remaining)
}
function setMissingNodeTypes(types: MissingNodeType[]) {
if (!types.length) {
missingNodesError.value = null
@@ -406,6 +417,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
// Missing node actions
setMissingNodeTypes,
surfaceMissingNodes,
removeMissingNodesByType,
// Lookup helpers
getNodeErrors,

View File

@@ -1,9 +0,0 @@
/**
* Extra info returned by the backend for missing_node_type errors
* from the /prompt endpoint validation.
*/
export interface MissingNodeTypeExtraInfo {
class_type?: string | null
node_title?: string | null
node_id?: string
}

View File

@@ -1,93 +1,70 @@
import { describe, expect, it } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
buildMissingNodeHint,
createMissingNodeTypeFromError
getCnrIdFromProperties,
getCnrIdFromNode
} from './missingNodeErrorUtil'
describe('buildMissingNodeHint', () => {
it('returns hint with title and node ID when both available', () => {
expect(buildMissingNodeHint('My Node', 'MyNodeClass', '42')).toBe(
'"My Node" (Node ID #42)'
describe('getCnrIdFromProperties', () => {
it('returns cnr_id when present', () => {
expect(getCnrIdFromProperties({ cnr_id: 'my-pack' })).toBe('my-pack')
})
it('returns aux_id when cnr_id is absent', () => {
expect(getCnrIdFromProperties({ aux_id: 'my-aux-pack' })).toBe(
'my-aux-pack'
)
})
it('returns hint with title only when no node ID', () => {
expect(buildMissingNodeHint('My Node', 'MyNodeClass', undefined)).toBe(
'"My Node"'
)
})
it('returns hint with node ID only when title matches class type', () => {
expect(buildMissingNodeHint('MyNodeClass', 'MyNodeClass', '42')).toBe(
'Node ID #42'
)
})
it('returns undefined when title matches class type and no node ID', () => {
it('prefers cnr_id over aux_id', () => {
expect(
buildMissingNodeHint('MyNodeClass', 'MyNodeClass', undefined)
).toBeUndefined()
getCnrIdFromProperties({ cnr_id: 'primary', aux_id: 'secondary' })
).toBe('primary')
})
it('returns undefined when title is null and no node ID', () => {
expect(buildMissingNodeHint(null, 'MyNodeClass', undefined)).toBeUndefined()
it('returns undefined when neither is present', () => {
expect(getCnrIdFromProperties({})).toBeUndefined()
})
it('returns node ID hint when title is null but node ID exists', () => {
expect(buildMissingNodeHint(null, 'MyNodeClass', '42')).toBe('Node ID #42')
it('returns undefined for null properties', () => {
expect(getCnrIdFromProperties(null)).toBeUndefined()
})
it('returns undefined for undefined properties', () => {
expect(getCnrIdFromProperties(undefined)).toBeUndefined()
})
it('returns undefined when cnr_id is not a string', () => {
expect(getCnrIdFromProperties({ cnr_id: 123 })).toBeUndefined()
})
})
describe('createMissingNodeTypeFromError', () => {
it('returns string type when no hint is generated', () => {
const result = createMissingNodeTypeFromError({
class_type: 'MyNodeClass',
node_title: 'MyNodeClass'
})
expect(result).toBe('MyNodeClass')
describe('getCnrIdFromNode', () => {
it('returns cnr_id from node properties', () => {
const node = {
properties: { cnr_id: 'node-pack' }
} as unknown as LGraphNode
expect(getCnrIdFromNode(node)).toBe('node-pack')
})
it('returns object with hint when title differs from class type', () => {
const result = createMissingNodeTypeFromError({
class_type: 'MyNodeClass',
node_title: 'My Custom Title',
node_id: '42'
})
expect(result).toEqual({
type: 'MyNodeClass',
nodeId: '42',
hint: '"My Custom Title" (Node ID #42)'
})
it('returns aux_id when cnr_id is absent', () => {
const node = {
properties: { aux_id: 'node-aux-pack' }
} as unknown as LGraphNode
expect(getCnrIdFromNode(node)).toBe('node-aux-pack')
})
it('handles null class_type by defaulting to Unknown', () => {
const result = createMissingNodeTypeFromError({
class_type: null,
node_title: 'Some Title',
node_id: '42'
})
expect(result).toEqual({
type: 'Unknown',
nodeId: '42',
hint: '"Some Title" (Node ID #42)'
})
it('prefers cnr_id over aux_id in node properties', () => {
const node = {
properties: { cnr_id: 'primary', aux_id: 'secondary' }
} as unknown as LGraphNode
expect(getCnrIdFromNode(node)).toBe('primary')
})
it('handles empty extra_info', () => {
const result = createMissingNodeTypeFromError({})
expect(result).toBe('Unknown')
})
it('returns object with node ID hint when only node_id is available', () => {
const result = createMissingNodeTypeFromError({
class_type: 'MyNodeClass',
node_id: '123'
})
expect(result).toEqual({
type: 'MyNodeClass',
nodeId: '123',
hint: 'Node ID #123'
})
it('returns undefined when node has no cnr_id or aux_id', () => {
const node = { properties: {} } as unknown as LGraphNode
expect(getCnrIdFromNode(node)).toBeUndefined()
})
})

View File

@@ -1,48 +1,4 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { MissingNodeType } from '@/types/comfy'
import type { MissingNodeTypeExtraInfo } from '../types/missingNodeErrorTypes'
/**
* Builds a hint string from missing node metadata.
* Provides context about which node is missing (title, ID) when available.
*/
export function buildMissingNodeHint(
nodeTitle: string | null | undefined,
classType: string,
nodeId: string | undefined
): string | undefined {
const hasTitle = nodeTitle && nodeTitle !== classType
if (hasTitle && nodeId) {
return `"${nodeTitle}" (Node ID #${nodeId})`
} else if (hasTitle) {
return `"${nodeTitle}"`
} else if (nodeId) {
return `Node ID #${nodeId}`
}
return undefined
}
/**
* Creates a MissingNodeType from backend error extra_info.
* Used when the /prompt endpoint returns a missing_node_type error.
*/
export function createMissingNodeTypeFromError(
extraInfo: MissingNodeTypeExtraInfo
): MissingNodeType {
const classType = extraInfo.class_type ?? 'Unknown'
const nodeTitle = extraInfo.node_title ?? classType
const hint = buildMissingNodeHint(nodeTitle, classType, extraInfo.node_id)
if (hint) {
return {
type: classType,
...(extraInfo.node_id ? { nodeId: extraInfo.node_id } : {}),
...(hint ? { hint } : {})
}
}
return classType
}
/**
* Extracts the custom node registry ID (cnr_id or aux_id) from a raw