Compare commits

...

8 Commits

Author SHA1 Message Date
jaeone94
4dc67b375b fix: node replacement fails after execution and modal sync
- Detect missing nodes by unregistered type instead of has_errors flag,
  which gets cleared by clearAllNodeErrorFlags during execution
- Sync modal replace action with executionErrorStore so Errors Tab
  updates immediately when nodes are replaced from the dialog
2026-02-27 10:37:47 +09:00
jaeone94
1d8a01cdf8 fix: ensure node replacement data loads before workflow processing
Await nodeReplacementStore.load() before collectMissingNodesAndModels
to prevent race condition where replacement mappings are not yet
available when determining isReplaceable flag.
2026-02-27 00:14:43 +09:00
jaeone94
b585dfa4fc fix: address review feedback for handleReplaceAll
- Remove redundant parameter that shadowed composable ref
- Only remove actually replaced types from error list on partial success
2026-02-26 23:05:12 +09:00
jaeone94
1be6d27024 refactor: Destructure defineProps in SwapNodesCard.vue 2026-02-26 22:03:42 +09:00
jaeone94
5aa4baf116 fix: address review feedback for node replacement
- Use i18n key for 'Swap Nodes' group title
- Preserve partial replacement results on error instead of returning empty array
2026-02-26 21:53:00 +09:00
jaeone94
7d69a0db5b fix: remove unused export from scanMissingNodes 2026-02-26 20:28:10 +09:00
jaeone94
83bb4300e3 fix: address code review feedback on node replacement
- Add error toast in replaceNodesInPlace for user-visible failure
  feedback, returning empty array on error instead of throwing
- Guard removeMissingNodesByType behind replacement success check
  (replaced.length > 0) to prevent stale error list updates
- Sort buildMissingNodeGroups by priority for deterministic UI order
  (Swap Nodes 0 → Missing Node Packs 1 → Execution Errors)
- Add aux_id fallback and cnr_id precedence tests for getCnrIdFromNode
- Split replaceAllWarning from replaceWarning to fix i18n key mismatch
  between TabErrors tooltip and MissingNodesContent dialog
2026-02-26 20:24:56 +09:00
jaeone94
0d58a92e34 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
2026-02-26 20:24:44 +09:00
15 changed files with 437 additions and 170 deletions

View File

@@ -234,6 +234,7 @@ import { isCloud } from '@/platform/distribution/types'
import type { NodeReplacement } from '@/platform/nodeReplacement/types' import type { NodeReplacement } from '@/platform/nodeReplacement/types'
import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacement' import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacement'
import { useDialogStore } from '@/stores/dialogStore' import { useDialogStore } from '@/stores/dialogStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import type { MissingNodeType } from '@/types/comfy' import type { MissingNodeType } from '@/types/comfy'
import { cn } from '@/utils/tailwindUtil' import { cn } from '@/utils/tailwindUtil'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes' import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
@@ -245,6 +246,7 @@ const { missingNodeTypes } = defineProps<{
const { missingCoreNodes } = useMissingNodes() const { missingCoreNodes } = useMissingNodes()
const { replaceNodesInPlace } = useNodeReplacement() const { replaceNodesInPlace } = useNodeReplacement()
const dialogStore = useDialogStore() const dialogStore = useDialogStore()
const executionErrorStore = useExecutionErrorStore()
interface ProcessedNode { interface ProcessedNode {
label: string label: string
@@ -339,6 +341,11 @@ function handleReplaceSelected() {
replacedTypes.value = nextReplaced replacedTypes.value = nextReplaced
selectedTypes.value = nextSelected selectedTypes.value = nextSelected
// Sync with execution error store so the Errors Tab updates immediately
if (result.length > 0) {
executionErrorStore.removeMissingNodesByType(result)
}
// Auto-close when all replaceable nodes replaced and no non-replaceable remain // Auto-close when all replaceable nodes replaced and no non-replaceable remain
const allReplaced = replaceableNodes.value.every((n) => const allReplaced = replaceableNodes.value.every((n) =>
nextReplaced.has(n.label) nextReplaced.has(n.label)

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" :key="group.title"
:collapse="collapseState[group.title] ?? false" :collapse="collapseState[group.title] ?? false"
class="border-b border-interface-stroke" 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" @update:collapse="collapseState[group.title] = $event"
> >
<template #label> <template #label>
@@ -40,7 +44,9 @@
{{ {{
group.type === 'missing_node' group.type === 'missing_node'
? `${group.title} (${missingPackGroups.length})` ? `${group.title} (${missingPackGroups.length})`
: group.title : group.type === 'swap_nodes'
? `${group.title} (${swapNodeGroups.length})`
: group.title
}} }}
</span> </span>
<span <span
@@ -69,6 +75,21 @@
: t('rightSidePanel.missingNodePacks.installAll') : t('rightSidePanel.missingNodePacks.installAll')
}} }}
</Button> </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> </div>
</template> </template>
@@ -82,8 +103,16 @@
@open-manager-info="handleOpenManagerInfo" @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 --> <!-- 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 <ErrorNodeCard
v-for="card in group.cards" v-for="card in group.cards"
:key="card.id" :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 FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
import ErrorNodeCard from './ErrorNodeCard.vue' import ErrorNodeCard from './ErrorNodeCard.vue'
import MissingNodeCard from './MissingNodeCard.vue' import MissingNodeCard from './MissingNodeCard.vue'
import SwapNodesCard from './SwapNodesCard.vue'
import Button from '@/components/ui/button/Button.vue' import Button from '@/components/ui/button/Button.vue'
import DotSpinner from '@/components/common/DotSpinner.vue' import DotSpinner from '@/components/common/DotSpinner.vue'
import { usePackInstall } from '@/workbench/extensions/manager/composables/nodePack/usePackInstall' import { usePackInstall } from '@/workbench/extensions/manager/composables/nodePack/usePackInstall'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes' import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
import { useErrorGroups } from './useErrorGroups' import { useErrorGroups } from './useErrorGroups'
import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacement'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
const { t } = useI18n() const { t } = useI18n()
const { copyToClipboard } = useCopyToClipboard() const { copyToClipboard } = useCopyToClipboard()
@@ -167,6 +199,8 @@ const { shouldShowManagerButtons, shouldShowInstallButton, openManager } =
const { missingNodePacks } = useMissingNodes() const { missingNodePacks } = useMissingNodes()
const { isInstalling: isInstallingAll, installAllPacks: installAll } = const { isInstalling: isInstallingAll, installAllPacks: installAll } =
usePackInstall(() => missingNodePacks.value) usePackInstall(() => missingNodePacks.value)
const { replaceNodesInPlace } = useNodeReplacement()
const executionErrorStore = useExecutionErrorStore()
const searchQuery = ref('') const searchQuery = ref('')
@@ -183,7 +217,8 @@ const {
isSingleNodeSelected, isSingleNodeSelected,
errorNodeCache, errorNodeCache,
missingNodeCache, missingNodeCache,
missingPackGroups missingPackGroups,
swapNodeGroups
} = useErrorGroups(searchQuery, t) } = 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) { function handleEnterSubgraph(nodeId: string) {
enterSubgraph(nodeId, errorNodeCache.value) enterSubgraph(nodeId, errorNodeCache.value)
} }

View File

@@ -22,3 +22,4 @@ export type ErrorGroup =
priority: number priority: number
} }
| { type: 'missing_node'; title: string; 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 isResolving: boolean
} }
export interface SwapNodeGroup {
type: string
newNodeId: string | undefined
nodeTypes: MissingNodeType[]
}
interface GroupEntry { interface GroupEntry {
type: 'execution' type: 'execution'
priority: number priority: number
@@ -444,6 +450,8 @@ export function useErrorGroups(
const resolvingKeys = new Set<string | null>() const resolvingKeys = new Set<string | null>()
for (const nodeType of nodeTypes) { for (const nodeType of nodeTypes) {
if (typeof nodeType !== 'string' && nodeType.isReplaceable) continue
let packId: string | null let packId: string | null
if (typeof nodeType === 'string') { 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. */ /** Builds an ErrorGroup from missingNodesError. Returns [] when none present. */
function buildMissingNodeGroups(): ErrorGroup[] { function buildMissingNodeGroups(): ErrorGroup[] {
const error = executionErrorStore.missingNodesError const error = executionErrorStore.missingNodesError
if (!error) return [] 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, type: 'missing_node' as const,
title: error.message, title: error.message,
priority: 0 priority: 1
} })
] }
return groups.sort((a, b) => a.priority - b.priority)
} }
const allErrorGroups = computed<ErrorGroup[]>(() => { const allErrorGroups = computed<ErrorGroup[]>(() => {
@@ -564,6 +607,7 @@ export function useErrorGroups(
errorNodeCache, errorNodeCache,
missingNodeCache, missingNodeCache,
groupedErrorMessages, groupedErrorMessages,
missingPackGroups missingPackGroups,
swapNodeGroups
} }
} }

View File

@@ -14,6 +14,7 @@ import type {
} from '@/lib/litegraph/src/interfaces' } from '@/lib/litegraph/src/interfaces'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets' import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations' import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { LayoutSource } from '@/renderer/core/layout/types' import { LayoutSource } from '@/renderer/core/layout/types'
import type { NodeId } from '@/renderer/core/layout/types' import type { NodeId } from '@/renderer/core/layout/types'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2' 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 nodePosition = { x: node.pos[0], y: node.pos[1] }
const nodeSize = { width: node.size[0], height: node.size[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 // Add node to layout store with final positions
setSource(LayoutSource.Canvas) setSource(LayoutSource.Canvas)
void createNode(id, { 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

@@ -3073,7 +3073,14 @@
"openNodeManager": "Open Node Manager", "openNodeManager": "Open Node Manager",
"skipForNow": "Skip for Now", "skipForNow": "Skip for Now",
"installMissingNodes": "Install Missing Nodes", "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": { "rightSidePanel": {
"togglePanel": "Toggle properties panel", "togglePanel": "Toggle properties panel",

View File

@@ -229,7 +229,12 @@ export function useNodeReplacement() {
try { try {
const placeholders = collectAllNodes( const placeholders = collectAllNodes(
graph, graph,
(n) => !!n.has_errors && !!n.last_serialization (n) =>
!!n.last_serialization &&
!(
(n.last_serialization.type ?? n.type ?? '') in
LiteGraph.registered_node_types
)
) )
for (const node of placeholders) { for (const node of placeholders) {
@@ -261,6 +266,10 @@ export function useNodeReplacement() {
} }
replaceWithMapping(node, newNode, effectiveReplacement, nodeGraph, idx) 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)) { if (!replacedTypes.includes(match.type)) {
replacedTypes.push(match.type) replacedTypes.push(match.type)
} }
@@ -279,6 +288,19 @@ export function useNodeReplacement() {
life: 3000 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 { } finally {
changeTracker?.afterChange() changeTracker?.afterChange()
} }

View File

@@ -79,12 +79,8 @@ import type { ComfyExtension, MissingNodeType } from '@/types/comfy'
import type { ExtensionManager } from '@/types/extensionTypes' import type { ExtensionManager } from '@/types/extensionTypes'
import type { NodeExecutionId } from '@/types/nodeIdentification' import type { NodeExecutionId } from '@/types/nodeIdentification'
import { graphToPrompt } from '@/utils/executionUtil' import { graphToPrompt } from '@/utils/executionUtil'
import type { MissingNodeTypeExtraInfo } from '@/workbench/extensions/manager/types/missingNodeErrorTypes' import { getCnrIdFromProperties } from '@/workbench/extensions/manager/utils/missingNodeErrorUtil'
import { import { rescanAndSurfaceMissingNodes } from '@/composables/useMissingNodeScan'
createMissingNodeTypeFromError,
getCnrIdFromNode,
getCnrIdFromProperties
} from '@/workbench/extensions/manager/utils/missingNodeErrorUtil'
import { anyItemOverlapsRect } from '@/utils/mathUtil' import { anyItemOverlapsRect } from '@/utils/mathUtil'
import { import {
collectAllNodes, collectAllNodes,
@@ -1190,7 +1186,7 @@ export class ComfyApp {
const embeddedModels: ModelFile[] = [] const embeddedModels: ModelFile[] = []
const nodeReplacementStore = useNodeReplacementStore() const nodeReplacementStore = useNodeReplacementStore()
await nodeReplacementStore.load()
const collectMissingNodesAndModels = ( const collectMissingNodesAndModels = (
nodes: ComfyWorkflowJSON['nodes'], nodes: ComfyWorkflowJSON['nodes'],
pathPrefix: string = '', pathPrefix: string = '',
@@ -1527,35 +1523,8 @@ export class ComfyApp {
typeof error.response.error === 'object' && typeof error.response.error === 'object' &&
error.response.error?.type === 'missing_node_type' error.response.error?.type === 'missing_node_type'
) { ) {
const extraInfo = (error.response.error.extra_info ?? // Re-scan the full graph instead of using the server's single-node response.
{}) as MissingNodeTypeExtraInfo rescanAndSurfaceMissingNodes(this.rootGraph)
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])
} else if ( } else if (
!useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab') || !useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab') ||
!(error instanceof PromptExecutionError) !(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[]) { function setMissingNodeTypes(types: MissingNodeType[]) {
if (!types.length) { if (!types.length) {
missingNodesError.value = null missingNodesError.value = null
@@ -406,6 +417,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
// Missing node actions // Missing node actions
setMissingNodeTypes, setMissingNodeTypes,
surfaceMissingNodes, surfaceMissingNodes,
removeMissingNodesByType,
// Lookup helpers // Lookup helpers
getNodeErrors, 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 { describe, expect, it } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { import {
buildMissingNodeHint, getCnrIdFromProperties,
createMissingNodeTypeFromError getCnrIdFromNode
} from './missingNodeErrorUtil' } from './missingNodeErrorUtil'
describe('buildMissingNodeHint', () => { describe('getCnrIdFromProperties', () => {
it('returns hint with title and node ID when both available', () => { it('returns cnr_id when present', () => {
expect(buildMissingNodeHint('My Node', 'MyNodeClass', '42')).toBe( expect(getCnrIdFromProperties({ cnr_id: 'my-pack' })).toBe('my-pack')
'"My Node" (Node ID #42)' })
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', () => { it('prefers cnr_id over aux_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', () => {
expect( expect(
buildMissingNodeHint('MyNodeClass', 'MyNodeClass', undefined) getCnrIdFromProperties({ cnr_id: 'primary', aux_id: 'secondary' })
).toBeUndefined() ).toBe('primary')
}) })
it('returns undefined when title is null and no node ID', () => { it('returns undefined when neither is present', () => {
expect(buildMissingNodeHint(null, 'MyNodeClass', undefined)).toBeUndefined() expect(getCnrIdFromProperties({})).toBeUndefined()
}) })
it('returns node ID hint when title is null but node ID exists', () => { it('returns undefined for null properties', () => {
expect(buildMissingNodeHint(null, 'MyNodeClass', '42')).toBe('Node ID #42') 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', () => { describe('getCnrIdFromNode', () => {
it('returns string type when no hint is generated', () => { it('returns cnr_id from node properties', () => {
const result = createMissingNodeTypeFromError({ const node = {
class_type: 'MyNodeClass', properties: { cnr_id: 'node-pack' }
node_title: 'MyNodeClass' } as unknown as LGraphNode
}) expect(getCnrIdFromNode(node)).toBe('node-pack')
expect(result).toBe('MyNodeClass')
}) })
it('returns object with hint when title differs from class type', () => { it('returns aux_id when cnr_id is absent', () => {
const result = createMissingNodeTypeFromError({ const node = {
class_type: 'MyNodeClass', properties: { aux_id: 'node-aux-pack' }
node_title: 'My Custom Title', } as unknown as LGraphNode
node_id: '42' expect(getCnrIdFromNode(node)).toBe('node-aux-pack')
})
expect(result).toEqual({
type: 'MyNodeClass',
nodeId: '42',
hint: '"My Custom Title" (Node ID #42)'
})
}) })
it('handles null class_type by defaulting to Unknown', () => { it('prefers cnr_id over aux_id in node properties', () => {
const result = createMissingNodeTypeFromError({ const node = {
class_type: null, properties: { cnr_id: 'primary', aux_id: 'secondary' }
node_title: 'Some Title', } as unknown as LGraphNode
node_id: '42' expect(getCnrIdFromNode(node)).toBe('primary')
})
expect(result).toEqual({
type: 'Unknown',
nodeId: '42',
hint: '"Some Title" (Node ID #42)'
})
}) })
it('handles empty extra_info', () => { it('returns undefined when node has no cnr_id or aux_id', () => {
const result = createMissingNodeTypeFromError({}) const node = { properties: {} } as unknown as LGraphNode
expect(result).toBe('Unknown') expect(getCnrIdFromNode(node)).toBeUndefined()
})
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'
})
}) })
}) })

View File

@@ -1,48 +1,4 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph' 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 * Extracts the custom node registry ID (cnr_id or aux_id) from a raw