feat: add node replacement UI to Errors Tab

Integrate the existing node replacement functionality into the Errors
Tab, allowing users to replace missing nodes directly from the side
panel without opening the modal dialog.
New components:
- SwapNodesCard: container with guidance label and grouped rows
- SwapNodeGroupRow: per-type replacement row with expand/collapse,
  node instance list, locate button, and replace action
Bug fixes discovered during implementation:
- Fix stale canvas rendering after replacement by calling onNodeAdded
  to refresh VueNodeData (bypassed by replaceWithMapping)
- Guard initializeVueNodeLayout against duplicate layout creation
- Fix missing node list being overwritten by incomplete server 400
  response — replaced with full graph rescan via useMissingNodeScan
- Add removeMissingNodesByType to prune replaced types from error list
Cleanup:
- Remove dead code: buildMissingNodeHint, createMissingNodeTypeFromError
This commit is contained in:
jaeone94
2026-02-26 19:53:15 +09:00
parent 45ca1beea2
commit 0d58a92e34
14 changed files with 396 additions and 171 deletions

View File

@@ -0,0 +1,148 @@
<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() {
replaceNodesInPlace(props.group.nodeTypes)
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 props = 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(swapNodeGroups)"
>
{{ 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,15 @@ 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 type { SwapNodeGroup } from './useErrorGroups'
import { useErrorGroups } from './useErrorGroups'
import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacement'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
const { t } = useI18n()
const { copyToClipboard } = useCopyToClipboard()
@@ -167,6 +200,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 +218,8 @@ const {
isSingleNodeSelected,
errorNodeCache,
missingNodeCache,
missingPackGroups
missingPackGroups,
swapNodeGroups
} = useErrorGroups(searchQuery, t)
/**
@@ -229,6 +265,13 @@ function handleOpenManagerInfo(packId: string) {
}
}
function handleReplaceAll(swapNodeGroups: SwapNodeGroup[]) {
const allNodeTypes = swapNodeGroups.flatMap((g) => g.nodeTypes)
replaceNodesInPlace(allNodeTypes)
const typesToRemove = swapNodeGroups.map((g) => g.type)
executionErrorStore.removeMissingNodesByType(typesToRemove)
}
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: 'Swap Nodes',
priority: 1
})
}
if (missingPackGroups.value.length > 0) {
groups.push({
type: 'missing_node' as const,
title: error.message,
priority: 0
}
]
})
}
return groups
}
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. */
export 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

@@ -3073,7 +3073,13 @@
"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."
},
"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)
}

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,
@@ -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,56 @@
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('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('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