[feat] Node replacement UI (#8604)

## Summary
Add node replacement UI to the missing nodes dialog. Users can select
and replace deprecated/missing nodes with compatible alternatives
directly from the dialog.

## Changes
- Classify missing nodes into **Replaceable** (quick fix) and **Install
Required** sections
- Add select-all checkbox + per-node checkboxes for batch replacement
- `useNodeReplacement` composable handles in-place node replacement on
the graph:
  - Simple replacement (configure+copy) for nodes without mapping
  - Input/output connection remapping for nodes with mapping
  - Widget value transfer via `old_widget_ids`
  - Dot-notation input handling for Autogrow/DynamicCombo
  - Undo/redo support via `changeTracker` (try/finally)
  - Title and properties preservation
- Footer UX: "Skip for Now" button when all nodes are replaceable (cloud
+ OSS)
- Auto-close dialog when all replaceable nodes are replaced and no
non-replaceable remain
- Settings navigation link from "Don't show again" checkbox
- 505-line unit test suite for `useNodeReplacement`

## Review Focus
- `useNodeReplacement.ts` — core graph manipulation logic
- `MissingNodesContent.vue` — checkbox selection state management
- `MissingNodesFooter.vue` — conditional button rendering (cloud vs OSS
vs all-replaceable)


[screen-capture.webm](https://github.com/user-attachments/assets/7dae891c-926c-4f26-987f-9637c4a2ca16)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8604-feat-Node-replacement-UI-2fd6d73d36508148a371dabb8f4115af)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
Jin Yi
2026-02-17 16:33:41 +09:00
committed by GitHub
parent e70484d596
commit efe78b799f
14 changed files with 1707 additions and 58 deletions

View File

@@ -0,0 +1,654 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { NodeReplacement } from './types'
import type { MissingNodeType } from '@/types/comfy'
vi.mock('@/lib/litegraph/src/litegraph', () => ({
LiteGraph: {
createNode: vi.fn(),
registered_node_types: {}
}
}))
vi.mock('@/scripts/app', () => ({
app: { rootGraph: null },
sanitizeNodeName: (name: string) => name.replace(/[&<>"'`=]/g, '')
}))
vi.mock('@/utils/graphTraversalUtil', () => ({
collectAllNodes: vi.fn()
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: vi.fn(() => ({
add: vi.fn()
}))
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: vi.fn(() => ({
activeWorkflow: {
changeTracker: {
beforeChange: vi.fn(),
afterChange: vi.fn()
}
}
}))
}))
vi.mock('@/i18n', () => ({
t: (key: string, params?: Record<string, unknown>) =>
params ? `${key}:${JSON.stringify(params)}` : key
}))
import { app } from '@/scripts/app'
import { collectAllNodes } from '@/utils/graphTraversalUtil'
import { useNodeReplacement } from './useNodeReplacement'
function createMockLink(
id: number,
originId: number,
originSlot: number,
targetId: number,
targetSlot: number
) {
return {
id,
origin_id: originId,
origin_slot: originSlot,
target_id: targetId,
target_slot: targetSlot,
type: 'IMAGE'
}
}
function createMockGraph(
nodes: LGraphNode[],
links: ReturnType<typeof createMockLink>[] = []
): LGraph {
const linksMap = new Map(links.map((l) => [l.id, l]))
return {
_nodes: nodes,
_nodes_by_id: Object.fromEntries(nodes.map((n) => [n.id, n])),
links: linksMap,
updateExecutionOrder: vi.fn(),
setDirtyCanvas: vi.fn()
} as unknown as LGraph
}
function createPlaceholderNode(
id: number,
type: string,
inputs: { name: string; link: number | null }[] = [],
outputs: { name: string; links: number[] | null }[] = [],
graph?: LGraph
): LGraphNode {
return {
id,
type,
pos: [100, 200],
size: [200, 100],
order: 0,
mode: 0,
flags: {},
has_errors: true,
last_serialization: {
id,
type,
pos: [100, 200],
size: [200, 100],
flags: {},
order: 0,
mode: 0,
inputs: inputs.map((i) => ({ ...i, type: 'IMAGE' })),
outputs: outputs.map((o) => ({ ...o, type: 'IMAGE' })),
widgets_values: []
},
inputs: inputs.map((i) => ({ ...i, type: 'IMAGE' })),
outputs: outputs.map((o) => ({ ...o, type: 'IMAGE' })),
graph: graph ?? null,
serialize: vi.fn(() => ({
id,
type,
pos: [100, 200],
size: [200, 100],
flags: {},
order: 0,
mode: 0,
inputs: inputs.map((i) => ({ ...i, type: 'IMAGE' })),
outputs: outputs.map((o) => ({ ...o, type: 'IMAGE' })),
widgets_values: []
}))
} as unknown as LGraphNode
}
function createNewNode(
inputs: { name: string; link: number | null }[] = [],
outputs: { name: string; links: number[] | null }[] = [],
widgets: { name: string; value: unknown }[] = []
): LGraphNode {
return {
id: 0,
type: '',
pos: [0, 0],
size: [100, 50],
order: 0,
mode: 0,
flags: {},
has_errors: false,
inputs: inputs.map((i) => ({ ...i, type: 'IMAGE' })),
outputs: outputs.map((o) => ({ ...o, type: 'IMAGE' })),
widgets: widgets.map((w) => ({ ...w, type: 'combo', options: {} })),
configure: vi.fn(),
serialize: vi.fn()
} as unknown as LGraphNode
}
function makeMissingNodeType(
type: string,
replacement: NodeReplacement
): MissingNodeType {
return {
type,
isReplaceable: true,
replacement
}
}
describe('useNodeReplacement', () => {
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createPinia())
})
describe('replaceNodesInPlace', () => {
it('should return empty array when no placeholders exist', () => {
const graph = createMockGraph([])
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([])
const { replaceNodesInPlace } = useNodeReplacement()
const result = replaceNodesInPlace([])
expect(result).toEqual([])
})
it('should use default mapping when no explicit mapping exists', () => {
const placeholder = createPlaceholderNode(1, 'Load3DAnimation')
const graph = createMockGraph([placeholder])
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
const newNode = createNewNode()
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
const { replaceNodesInPlace } = useNodeReplacement()
const result = replaceNodesInPlace([
makeMissingNodeType('Load3DAnimation', {
new_node_id: 'Load3D',
old_node_id: 'Load3DAnimation',
old_widget_ids: null,
input_mapping: null,
output_mapping: null
})
])
expect(result).toEqual(['Load3DAnimation'])
expect(newNode.configure).not.toHaveBeenCalled()
expect(newNode.id).toBe(1)
expect(newNode.has_errors).toBe(false)
})
it('should transfer input connections using input_mapping', () => {
const link = createMockLink(10, 5, 0, 1, 0)
const placeholder = createPlaceholderNode(
1,
'T2IAdapterLoader',
[{ name: 't2i_adapter_name', link: 10 }],
[]
)
const graph = createMockGraph([placeholder], [link])
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
const newNode = createNewNode(
[{ name: 'control_net_name', link: null }],
[]
)
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
const { replaceNodesInPlace } = useNodeReplacement()
const result = replaceNodesInPlace([
makeMissingNodeType('T2IAdapterLoader', {
new_node_id: 'ControlNetLoader',
old_node_id: 'T2IAdapterLoader',
old_widget_ids: null,
input_mapping: [
{ new_id: 'control_net_name', old_id: 't2i_adapter_name' }
],
output_mapping: null
})
])
expect(result).toEqual(['T2IAdapterLoader'])
// Link should be updated to point at new node's input
expect(link.target_id).toBe(1)
expect(link.target_slot).toBe(0)
expect(newNode.inputs[0].link).toBe(10)
})
it('should transfer output connections using output_mapping', () => {
const link = createMockLink(20, 1, 0, 5, 0)
const placeholder = createPlaceholderNode(
1,
'ResizeImagesByLongerEdge',
[],
[{ name: 'IMAGE', links: [20] }]
)
const graph = createMockGraph([placeholder], [link])
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
const newNode = createNewNode(
[{ name: 'image', link: null }],
[{ name: 'IMAGE', links: null }]
)
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
const { replaceNodesInPlace } = useNodeReplacement()
replaceNodesInPlace([
makeMissingNodeType('ResizeImagesByLongerEdge', {
new_node_id: 'ImageScaleToMaxDimension',
old_node_id: 'ResizeImagesByLongerEdge',
old_widget_ids: ['longer_edge'],
input_mapping: [
{ new_id: 'image', old_id: 'images' },
{ new_id: 'largest_size', old_id: 'longer_edge' },
{ new_id: 'upscale_method', set_value: 'lanczos' }
],
output_mapping: [{ new_idx: 0, old_idx: 0 }]
})
])
// Output link should be remapped
expect(link.origin_id).toBe(1)
expect(link.origin_slot).toBe(0)
expect(newNode.outputs[0].links).toEqual([20])
})
it('should apply set_value to widget', () => {
const placeholder = createPlaceholderNode(1, 'ImageScaleBy')
const graph = createMockGraph([placeholder])
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
const newNode = createNewNode(
[{ name: 'input', link: null }],
[],
[
{ name: 'resize_type', value: '' },
{ name: 'scale_method', value: '' }
]
)
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
const { replaceNodesInPlace } = useNodeReplacement()
replaceNodesInPlace([
makeMissingNodeType('ImageScaleBy', {
new_node_id: 'ResizeImageMaskNode',
old_node_id: 'ImageScaleBy',
old_widget_ids: ['upscale_method', 'scale_by'],
input_mapping: [
{ new_id: 'input', old_id: 'image' },
{ new_id: 'resize_type', set_value: 'scale by multiplier' },
{ new_id: 'resize_type.multiplier', old_id: 'scale_by' },
{ new_id: 'scale_method', old_id: 'upscale_method' }
],
output_mapping: null
})
])
// set_value should be applied to the widget
expect(newNode.widgets![0].value).toBe('scale by multiplier')
})
it('should transfer widget values using old_widget_ids', () => {
const placeholder = createPlaceholderNode(1, 'ResizeImagesByLongerEdge')
// Set widget values in serialized data
placeholder.last_serialization!.widgets_values = [512]
const graph = createMockGraph([placeholder])
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
const newNode = createNewNode(
[
{ name: 'image', link: null },
{ name: 'largest_size', link: null }
],
[{ name: 'IMAGE', links: null }],
[{ name: 'largest_size', value: 0 }]
)
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
const { replaceNodesInPlace } = useNodeReplacement()
replaceNodesInPlace([
makeMissingNodeType('ResizeImagesByLongerEdge', {
new_node_id: 'ImageScaleToMaxDimension',
old_node_id: 'ResizeImagesByLongerEdge',
old_widget_ids: ['longer_edge'],
input_mapping: [
{ new_id: 'image', old_id: 'images' },
{ new_id: 'largest_size', old_id: 'longer_edge' },
{ new_id: 'upscale_method', set_value: 'lanczos' }
],
output_mapping: [{ new_idx: 0, old_idx: 0 }]
})
])
// Widget value should be transferred: old "longer_edge" (idx 0, value 512) → new "largest_size"
expect(newNode.widgets![0].value).toBe(512)
})
it('should skip replacement when new node type is not registered', () => {
const placeholder = createPlaceholderNode(1, 'UnknownNode')
const graph = createMockGraph([placeholder])
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
vi.mocked(LiteGraph.createNode).mockReturnValue(null)
const { replaceNodesInPlace } = useNodeReplacement()
const result = replaceNodesInPlace([
makeMissingNodeType('UnknownNode', {
new_node_id: 'NonExistentNode',
old_node_id: 'UnknownNode',
old_widget_ids: null,
input_mapping: null,
output_mapping: null
})
])
expect(result).toEqual([])
})
it('should replace multiple different node types at once', () => {
const placeholder1 = createPlaceholderNode(1, 'Load3DAnimation')
const placeholder2 = createPlaceholderNode(
2,
'ConditioningAverage',
[],
[]
)
// sanitizeNodeName strips & from type names (HTML entity chars)
placeholder2.type = 'ConditioningAverage'
const graph = createMockGraph([placeholder1, placeholder2])
placeholder1.graph = graph
placeholder2.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([placeholder1, placeholder2])
const newNode1 = createNewNode()
const newNode2 = createNewNode()
vi.mocked(LiteGraph.createNode)
.mockReturnValueOnce(newNode1)
.mockReturnValueOnce(newNode2)
const { replaceNodesInPlace } = useNodeReplacement()
const result = replaceNodesInPlace([
makeMissingNodeType('Load3DAnimation', {
new_node_id: 'Load3D',
old_node_id: 'Load3DAnimation',
old_widget_ids: null,
input_mapping: null,
output_mapping: null
}),
makeMissingNodeType('ConditioningAverage&', {
new_node_id: 'ConditioningAverage',
old_node_id: 'ConditioningAverage&',
old_widget_ids: null,
input_mapping: null,
output_mapping: null
})
])
expect(result).toHaveLength(2)
expect(result).toContain('Load3DAnimation')
expect(result).toContain('ConditioningAverage&')
})
it('should copy position and identity for mapped replacements', () => {
const link = createMockLink(10, 5, 0, 1, 0)
const placeholder = createPlaceholderNode(
42,
'T2IAdapterLoader',
[{ name: 't2i_adapter_name', link: 10 }],
[]
)
placeholder.pos = [300, 400]
placeholder.size = [250, 150]
const graph = createMockGraph([placeholder], [link])
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
const newNode = createNewNode(
[{ name: 'control_net_name', link: null }],
[]
)
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
const { replaceNodesInPlace } = useNodeReplacement()
replaceNodesInPlace([
makeMissingNodeType('T2IAdapterLoader', {
new_node_id: 'ControlNetLoader',
old_node_id: 'T2IAdapterLoader',
old_widget_ids: null,
input_mapping: [
{ new_id: 'control_net_name', old_id: 't2i_adapter_name' }
],
output_mapping: null
})
])
expect(newNode.id).toBe(42)
expect(newNode.pos).toEqual([300, 400])
expect(newNode.size).toEqual([250, 150])
expect(graph._nodes[0]).toBe(newNode)
})
it('should transfer all widget values for ImageScaleBy with real workflow data', () => {
const placeholder = createPlaceholderNode(
12,
'ImageScaleBy',
[{ name: 'image', link: 2 }],
[{ name: 'IMAGE', links: [3, 4] }]
)
// Real workflow data: widgets_values: ["lanczos", 2.0]
placeholder.last_serialization!.widgets_values = ['lanczos', 2.0]
const graph = createMockGraph([placeholder])
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
const newNode = createNewNode(
[{ name: 'input', link: null }],
[],
[
{ name: 'resize_type', value: '' },
{ name: 'scale_method', value: '' }
]
)
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
const { replaceNodesInPlace } = useNodeReplacement()
replaceNodesInPlace([
makeMissingNodeType('ImageScaleBy', {
new_node_id: 'ResizeImageMaskNode',
old_node_id: 'ImageScaleBy',
old_widget_ids: ['upscale_method', 'scale_by'],
input_mapping: [
{ new_id: 'input', old_id: 'image' },
{ new_id: 'resize_type', set_value: 'scale by multiplier' },
{ new_id: 'resize_type.multiplier', old_id: 'scale_by' },
{ new_id: 'scale_method', old_id: 'upscale_method' }
],
output_mapping: null
})
])
// set_value should be applied
expect(newNode.widgets![0].value).toBe('scale by multiplier')
// upscale_method (idx 0, value "lanczos") → scale_method widget
expect(newNode.widgets![1].value).toBe('lanczos')
})
it('should transfer widget value for ResizeImagesByLongerEdge with real workflow data', () => {
const link = createMockLink(1, 5, 0, 8, 0)
const placeholder = createPlaceholderNode(
8,
'ResizeImagesByLongerEdge',
[{ name: 'images', link: 1 }],
[{ name: 'IMAGE', links: [2] }]
)
// Real workflow data: widgets_values: [1024]
placeholder.last_serialization!.widgets_values = [1024]
const graph = createMockGraph([placeholder], [link])
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
const newNode = createNewNode(
[
{ name: 'image', link: null },
{ name: 'largest_size', link: null }
],
[{ name: 'IMAGE', links: null }],
[
{ name: 'largest_size', value: 0 },
{ name: 'upscale_method', value: '' }
]
)
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
const { replaceNodesInPlace } = useNodeReplacement()
replaceNodesInPlace([
makeMissingNodeType('ResizeImagesByLongerEdge', {
new_node_id: 'ImageScaleToMaxDimension',
old_node_id: 'ResizeImagesByLongerEdge',
old_widget_ids: ['longer_edge'],
input_mapping: [
{ new_id: 'image', old_id: 'images' },
{ new_id: 'largest_size', old_id: 'longer_edge' },
{ new_id: 'upscale_method', set_value: 'lanczos' }
],
output_mapping: [{ new_idx: 0, old_idx: 0 }]
})
])
// longer_edge (idx 0, value 1024) → largest_size widget
expect(newNode.widgets![0].value).toBe(1024)
// set_value "lanczos" → upscale_method widget
expect(newNode.widgets![1].value).toBe('lanczos')
})
it('should transfer ConditioningAverage widget value with real workflow data', () => {
const link = createMockLink(4, 7, 0, 13, 0)
// sanitizeNodeName doesn't strip spaces, so placeholder keeps trailing space
const placeholder = createPlaceholderNode(
13,
'ConditioningAverage ',
[
{ name: 'conditioning_to', link: 4 },
{ name: 'conditioning_from', link: null }
],
[{ name: 'CONDITIONING', links: [6] }]
)
placeholder.last_serialization!.widgets_values = [0.75]
const graph = createMockGraph([placeholder], [link])
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
const newNode = createNewNode(
[
{ name: 'conditioning_to', link: null },
{ name: 'conditioning_from', link: null }
],
[{ name: 'CONDITIONING', links: null }],
[{ name: 'conditioning_average', value: 0 }]
)
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
const { replaceNodesInPlace } = useNodeReplacement()
replaceNodesInPlace([
makeMissingNodeType('ConditioningAverage ', {
new_node_id: 'ConditioningAverage',
old_node_id: 'ConditioningAverage ',
old_widget_ids: null,
input_mapping: null,
output_mapping: null
})
])
// Default mapping transfers connections and widget values by name
expect(newNode.id).toBe(13)
expect(newNode.inputs[0].link).toBe(4)
expect(newNode.outputs[0].links).toEqual([6])
expect(newNode.widgets![0].value).toBe(0.75)
})
it('should skip dot-notation input connections but still transfer widget values', () => {
const placeholder = createPlaceholderNode(1, 'ImageBatch')
const graph = createMockGraph([placeholder])
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
const newNode = createNewNode([], [])
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
const { replaceNodesInPlace } = useNodeReplacement()
const result = replaceNodesInPlace([
makeMissingNodeType('ImageBatch', {
new_node_id: 'BatchImagesNode',
old_node_id: 'ImageBatch',
old_widget_ids: null,
input_mapping: [
{ new_id: 'images.image0', old_id: 'image1' },
{ new_id: 'images.image1', old_id: 'image2' }
],
output_mapping: null
})
])
// Should still succeed (dot-notation skipped gracefully)
expect(result).toEqual(['ImageBatch'])
})
})
})

View File

@@ -0,0 +1,292 @@
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { ISerialisedNode } from '@/lib/litegraph/src/types/serialisation'
import type { TWidgetValue } from '@/lib/litegraph/src/types/widgets'
import { t } from '@/i18n'
import type { NodeReplacement } from '@/platform/nodeReplacement/types'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { app, sanitizeNodeName } from '@/scripts/app'
import type { MissingNodeType } from '@/types/comfy'
import { collectAllNodes } from '@/utils/graphTraversalUtil'
/** Compares sanitized type strings to match placeholder → missing node type. */
function findMatchingType(
node: LGraphNode,
selectedTypes: MissingNodeType[]
): Extract<MissingNodeType, { type: string }> | undefined {
const nodeType = node.type
for (const selected of selectedTypes) {
if (typeof selected !== 'object' || !selected.isReplaceable) continue
if (sanitizeNodeName(selected.type) === nodeType) return selected
}
return undefined
}
function transferInputConnection(
oldNode: LGraphNode,
oldInputName: string,
newNode: LGraphNode,
newInputName: string,
graph: LGraph
): void {
const oldSlotIdx = oldNode.inputs?.findIndex((i) => i.name === oldInputName)
const newSlotIdx = newNode.inputs?.findIndex((i) => i.name === newInputName)
if (oldSlotIdx == null || oldSlotIdx === -1) return
if (newSlotIdx == null || newSlotIdx === -1) return
const linkId = oldNode.inputs[oldSlotIdx].link
if (linkId == null) return
const link = graph.links.get(linkId)
if (!link) return
link.target_id = newNode.id
link.target_slot = newSlotIdx
newNode.inputs[newSlotIdx].link = linkId
oldNode.inputs[oldSlotIdx].link = null
}
function transferOutputConnections(
oldNode: LGraphNode,
oldOutputIdx: number,
newNode: LGraphNode,
newOutputIdx: number,
graph: LGraph
): void {
const oldLinks = oldNode.outputs?.[oldOutputIdx]?.links
if (!oldLinks?.length) return
if (!newNode.outputs?.[newOutputIdx]) return
for (const linkId of oldLinks) {
const link = graph.links.get(linkId)
if (!link) continue
link.origin_id = newNode.id
link.origin_slot = newOutputIdx
}
newNode.outputs[newOutputIdx].links = [...oldLinks]
oldNode.outputs[oldOutputIdx].links = []
}
/** Uses old_widget_ids as name→index lookup into widgets_values. */
function transferWidgetValue(
serialized: ISerialisedNode,
oldWidgetIds: string[] | null,
oldInputName: string,
newNode: LGraphNode,
newInputName: string
): void {
if (!oldWidgetIds || !serialized.widgets_values) return
const oldWidgetIdx = oldWidgetIds.indexOf(oldInputName)
if (oldWidgetIdx === -1) return
const oldValue = serialized.widgets_values[oldWidgetIdx]
if (oldValue === undefined) return
const newWidget = newNode.widgets?.find((w) => w.name === newInputName)
if (newWidget) {
newWidget.value = oldValue
newWidget.callback?.(oldValue)
}
}
function applySetValue(
newNode: LGraphNode,
inputName: string,
value: unknown
): void {
const widget = newNode.widgets?.find((w) => w.name === inputName)
if (widget) {
widget.value = value as TWidgetValue
widget.callback?.(widget.value)
}
}
function isDotNotation(id: string): boolean {
return id.includes('.')
}
/** Auto-generates identity mapping by name for same-structure replacements without backend mapping. */
function generateDefaultMapping(
serialized: ISerialisedNode,
newNode: LGraphNode
): Pick<
NodeReplacement,
'input_mapping' | 'output_mapping' | 'old_widget_ids'
> {
const oldInputNames = new Set(serialized.inputs?.map((i) => i.name) ?? [])
const inputMapping: { old_id: string; new_id: string }[] = []
for (const newInput of newNode.inputs ?? []) {
if (oldInputNames.has(newInput.name)) {
inputMapping.push({ old_id: newInput.name, new_id: newInput.name })
}
}
const oldWidgetIds = (newNode.widgets ?? []).map((w) => w.name)
for (const widget of newNode.widgets ?? []) {
if (!oldInputNames.has(widget.name)) {
inputMapping.push({ old_id: widget.name, new_id: widget.name })
}
}
const outputMapping: { old_idx: number; new_idx: number }[] = []
for (const [oldIdx, oldOutput] of (serialized.outputs ?? []).entries()) {
const newIdx = newNode.outputs?.findIndex((o) => o.name === oldOutput.name)
if (newIdx != null && newIdx !== -1) {
outputMapping.push({ old_idx: oldIdx, new_idx: newIdx })
}
}
return {
input_mapping: inputMapping.length > 0 ? inputMapping : null,
output_mapping: outputMapping.length > 0 ? outputMapping : null,
old_widget_ids: oldWidgetIds.length > 0 ? oldWidgetIds : null
}
}
function replaceWithMapping(
node: LGraphNode,
newNode: LGraphNode,
replacement: NodeReplacement,
nodeGraph: LGraph,
idx: number
): void {
newNode.id = node.id
newNode.pos = [...node.pos]
newNode.size = [...node.size]
newNode.order = node.order
newNode.mode = node.mode
if (node.flags) newNode.flags = { ...node.flags }
nodeGraph._nodes[idx] = newNode
newNode.graph = nodeGraph
nodeGraph._nodes_by_id[newNode.id] = newNode
const serialized = node.last_serialization ?? node.serialize()
if (serialized.title != null) newNode.title = serialized.title
if (serialized.properties) {
newNode.properties = { ...serialized.properties }
if ('Node name for S&R' in newNode.properties) {
newNode.properties['Node name for S&R'] = replacement.new_node_id
}
}
if (replacement.input_mapping) {
for (const inputMap of replacement.input_mapping) {
if ('old_id' in inputMap) {
if (isDotNotation(inputMap.new_id)) continue // Autogrow/DynamicCombo
transferInputConnection(
node,
inputMap.old_id,
newNode,
inputMap.new_id,
nodeGraph
)
transferWidgetValue(
serialized,
replacement.old_widget_ids,
inputMap.old_id,
newNode,
inputMap.new_id
)
} else {
if (!isDotNotation(inputMap.new_id)) {
applySetValue(newNode, inputMap.new_id, inputMap.set_value)
}
}
}
}
if (replacement.output_mapping) {
for (const outMap of replacement.output_mapping) {
transferOutputConnections(
node,
outMap.old_idx,
newNode,
outMap.new_idx,
nodeGraph
)
}
}
newNode.has_errors = false
}
export function useNodeReplacement() {
const toastStore = useToastStore()
function replaceNodesInPlace(selectedTypes: MissingNodeType[]): string[] {
const replacedTypes: string[] = []
const graph = app.rootGraph
const changeTracker =
useWorkflowStore().activeWorkflow?.changeTracker ?? null
changeTracker?.beforeChange()
try {
const placeholders = collectAllNodes(
graph,
(n) => !!n.has_errors && !!n.last_serialization
)
for (const node of placeholders) {
const match = findMatchingType(node, selectedTypes)
if (!match?.replacement) continue
const replacement = match.replacement
const nodeGraph = node.graph
if (!nodeGraph) continue
const idx = nodeGraph._nodes.indexOf(node)
if (idx === -1) continue
const newNode = LiteGraph.createNode(replacement.new_node_id)
if (!newNode) continue
const hasMapping =
replacement.input_mapping != null ||
replacement.output_mapping != null
const effectiveReplacement = hasMapping
? replacement
: {
...replacement,
...generateDefaultMapping(
node.last_serialization ?? node.serialize(),
newNode
)
}
replaceWithMapping(node, newNode, effectiveReplacement, nodeGraph, idx)
if (!replacedTypes.includes(match.type)) {
replacedTypes.push(match.type)
}
}
if (replacedTypes.length > 0) {
graph.updateExecutionOrder()
graph.setDirtyCanvas(true, true)
toastStore.add({
severity: 'success',
summary: t('g.success'),
detail: t('nodeReplacement.replacedAllNodes', {
count: replacedTypes.length
}),
life: 3000
})
}
} finally {
changeTracker?.afterChange()
}
return replacedTypes
}
return {
replaceNodesInPlace
}
}