diff --git a/src/components/rightSidePanel/errors/SwapNodeGroupRow.vue b/src/components/rightSidePanel/errors/SwapNodeGroupRow.vue new file mode 100644 index 0000000000..82dcf3b6be --- /dev/null +++ b/src/components/rightSidePanel/errors/SwapNodeGroupRow.vue @@ -0,0 +1,150 @@ + + + diff --git a/src/components/rightSidePanel/errors/SwapNodesCard.vue b/src/components/rightSidePanel/errors/SwapNodesCard.vue new file mode 100644 index 0000000000..d27e24b7dc --- /dev/null +++ b/src/components/rightSidePanel/errors/SwapNodesCard.vue @@ -0,0 +1,38 @@ + + + diff --git a/src/components/rightSidePanel/errors/TabErrors.vue b/src/components/rightSidePanel/errors/TabErrors.vue index c743eafca2..7f40b9e63b 100644 --- a/src/components/rightSidePanel/errors/TabErrors.vue +++ b/src/components/rightSidePanel/errors/TabErrors.vue @@ -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" > @@ -82,8 +103,16 @@ @open-manager-info="handleOpenManagerInfo" /> + + + -
+
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) } diff --git a/src/components/rightSidePanel/errors/types.ts b/src/components/rightSidePanel/errors/types.ts index 718d0e9966..223fe4650d 100644 --- a/src/components/rightSidePanel/errors/types.ts +++ b/src/components/rightSidePanel/errors/types.ts @@ -22,3 +22,4 @@ export type ErrorGroup = priority: number } | { type: 'missing_node'; title: string; priority: number } + | { type: 'swap_nodes'; title: string; priority: number } diff --git a/src/components/rightSidePanel/errors/useErrorGroups.ts b/src/components/rightSidePanel/errors/useErrorGroups.ts index 610a56e720..08135703a7 100644 --- a/src/components/rightSidePanel/errors/useErrorGroups.ts +++ b/src/components/rightSidePanel/errors/useErrorGroups.ts @@ -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() 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(() => { + const nodeTypes = executionErrorStore.missingNodesError?.nodeTypes ?? [] + const map = new Map() + + 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(() => { @@ -564,6 +607,7 @@ export function useErrorGroups( errorNodeCache, missingNodeCache, groupedErrorMessages, - missingPackGroups + missingPackGroups, + swapNodeGroups } } diff --git a/src/composables/graph/useGraphNodeManager.ts b/src/composables/graph/useGraphNodeManager.ts index be3d8bd8e0..71438e6acb 100644 --- a/src/composables/graph/useGraphNodeManager.ts +++ b/src/composables/graph/useGraphNodeManager.ts @@ -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, { diff --git a/src/composables/useMissingNodeScan.ts b/src/composables/useMissingNodeScan.ts new file mode 100644 index 0000000000..4c7be563ee --- /dev/null +++ b/src/composables/useMissingNodeScan.ts @@ -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) +} diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 213869a7e4..1af245a216 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -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", diff --git a/src/platform/nodeReplacement/useNodeReplacement.ts b/src/platform/nodeReplacement/useNodeReplacement.ts index 5b39cb90dc..270fdea6e5 100644 --- a/src/platform/nodeReplacement/useNodeReplacement.ts +++ b/src/platform/nodeReplacement/useNodeReplacement.ts @@ -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() } diff --git a/src/scripts/app.ts b/src/scripts/app.ts index 8661d6134e..14851afea4 100644 --- a/src/scripts/app.ts +++ b/src/scripts/app.ts @@ -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) diff --git a/src/stores/executionErrorStore.ts b/src/stores/executionErrorStore.ts index 620a1ffcf1..dbe6935d44 100644 --- a/src/stores/executionErrorStore.ts +++ b/src/stores/executionErrorStore.ts @@ -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, diff --git a/src/workbench/extensions/manager/types/missingNodeErrorTypes.ts b/src/workbench/extensions/manager/types/missingNodeErrorTypes.ts deleted file mode 100644 index 8af9b21d26..0000000000 --- a/src/workbench/extensions/manager/types/missingNodeErrorTypes.ts +++ /dev/null @@ -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 -} diff --git a/src/workbench/extensions/manager/utils/missingNodeErrorUtil.test.ts b/src/workbench/extensions/manager/utils/missingNodeErrorUtil.test.ts index 85b7485218..242d46c233 100644 --- a/src/workbench/extensions/manager/utils/missingNodeErrorUtil.test.ts +++ b/src/workbench/extensions/manager/utils/missingNodeErrorUtil.test.ts @@ -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() }) }) diff --git a/src/workbench/extensions/manager/utils/missingNodeErrorUtil.ts b/src/workbench/extensions/manager/utils/missingNodeErrorUtil.ts index 06761b3f4b..99cb381659 100644 --- a/src/workbench/extensions/manager/utils/missingNodeErrorUtil.ts +++ b/src/workbench/extensions/manager/utils/missingNodeErrorUtil.ts @@ -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