Version: 1.0
Last Updated: 2026-01-11
Document Type: Architecture & Feature Specification
This document specifies a UI-only subgraph system that allows users to encapsulate groups of nodes into reusable, hierarchical components without backend awareness. The implementation maintains full workflow flattening for execution while providing organizational and reusability features.
- ✅ UI-Only: Subgraphs exist purely in the frontend; backend receives flattened node graphs
- ✅ Execution Transparency: No backend changes required; existing execution engines work unchanged
- ✅ Hierarchical: Subgraphs can contain other subgraphs (nested composition)
- ✅ Reusable: Subgraph definitions can be instantiated multiple times
- ✅ Serializable: Complete state persists in workflow JSON
- Immutable template defining internal structure
- Contains: nodes, links, reroutes, groups, input/output configurations
- Has a unique UUID for identification
- Stored once per workflow in
definitions.subgraphs[] - Analogous to a "class" in OOP
interface SubgraphDefinition {
id: UUID // Unique identifier
name: string // Display name
version: number // Schema version
// Structure
nodes: Node[] // Internal nodes
links: Link[] // Internal connections
reroutes: Reroute[] // Internal reroute points
groups: Group[] // Visual groupings
// Interface
inputNode: IONodeConfig // Special input node
outputNode: IONodeConfig // Special output node
inputs: SubgraphInput[] // Ordered inputs
outputs: SubgraphOutput[] // Ordered outputs
widgets: ExposedWidget[] // Promoted widgets
// Metadata
state: GraphState // Canvas state (zoom, offset)
revision: number // Change tracking
config: GraphConfig // Graph settings
}- Concrete usage of a definition on a canvas
- Contains: position, size, custom properties, slot overrides
- References definition by UUID via
typefield - Can have multiple instances of same definition
- Analogous to an "object instance" in OOP
interface SubgraphInstance {
id: NodeId // Unique instance ID
type: UUID // References SubgraphDefinition.id
pos: [number, number] // Canvas position
size: [number, number] // Visual dimensions
// Instance customization
properties: Record<string, any> // Custom properties
inputs: SlotConfig[] // Input slot customization
outputs: SlotConfig[] // Output slot customization
// Visual state
flags: NodeFlags // Collapsed, bypassed, etc.
mode: NodeMode // Always, never, on_change, etc.
order: number // Execution order hint
color?: string // Custom color
bgcolor?: string // Background color
}Subgraphs have two special virtual nodes that manage data flow:
- Appears inside subgraph on the left edge
- Acts as output slots within the subgraph (data flows OUT of it)
- Each slot connects to internal node inputs
- Adds an empty slot ("+") for creating new inputs on-demand
Parent Graph Subgraph Interior
┌──────────────┐ ┌────────────────────┐
│ [NodeA] │ │ [Input Node] │
│ output ────┼───────────────►│ • input1 ────►... │
│ │ │ • input2 ────►... │
└──────────────┘ │ • [+] │
└────────────────────┘
- Appears inside subgraph on the right edge
- Acts as input slots within the subgraph (data flows INTO it)
- Each slot receives from internal node outputs
- Adds an empty slot ("+") for creating new outputs on-demand
Subgraph Interior Parent Graph
┌────────────────────┐ ┌──────────────┐
│ ...────►output1 • │ │ [NodeB] │
│ ...────►output2 • │◄─────────┼────input │
│ [+] • │ │ │
└────────────────────┘ └──────────────┘
[Output Node]
Key Properties:
- Virtual nodes (not part of node registry)
- Fixed IDs (
SUBGRAPH_INPUT_ID = -1,SUBGRAPH_OUTPUT_ID = -2) - Not deletable, always present in subgraphs
- Render as rounded rectangles on canvas edges
- Support double-click to rename slots
- Right-click for context menu (rename/remove)
interface SubgraphInput {
id: UUID // Unique slot ID
name: string // Slot name
type: string // Data type
linkIds: LinkId[] // Connected links (multiple allowed)
// Optional metadata
label?: string // Display override
localized_name?: string // i18n name
color_on?: string // Active color
color_off?: string // Inactive color
shape?: SlotShape // Visual shape (circle, square, etc.)
dir?: SlotDirection // UP, DOWN, LEFT, RIGHT
// Widget binding (if input is from promoted widget)
_widget?: WeakRef<Widget> // Linked widget reference
}interface SubgraphOutput {
id: UUID // Unique slot ID
name: string // Slot name
type: string // Data type
linkIds: [LinkId] | [] // Single link only
// Optional metadata (same as inputs)
label?: string
localized_name?: string
color_on?: string
color_off?: string
shape?: SlotShape
dir?: SlotDirection
}Important Distinction:
- Subgraph inputs are outputs when viewed from inside (data flows OUT to nodes)
- Subgraph outputs are inputs when viewed from inside (data flows IN from nodes)
- Similar to "reroutes" but with special handling for hierarchy traversal
- Select multiple nodes on canvas (minimum 2)
- Invoke "Convert to Subgraph" (right-click menu, toolbar, or keyboard shortcut)
function convertToSubgraph(selectedItems: Set<Node | Reroute | Group>) {
// 1. Validate selection (must have nodes)
if (selectedItems.size === 0) throw Error("Nothing to convert")
// 2. Expand groups to include children
if (selectedItems has LGraphGroup) {
group.recomputeInsideNodes()
selectedItems.add(...group.children)
}
// 3. Analyze connections
const analysis = analyzeBoundaryConnections(selectedItems)
// analysis = {
// boundaryInputLinks: Links entering from outside
// boundaryOutputLinks: Links exiting to outside
// internalLinks: Links within selection
// boundaryFloatingLinks: Floating links crossing boundary
// }
// 4. Create subgraph definition
const subgraphDef = {
id: generateUUID(),
name: "New Subgraph",
nodes: cloneNodes(selectedItems.nodes),
links: cloneLinks(analysis.internalLinks),
reroutes: cloneReroutes(selectedItems.reroutes),
groups: cloneGroups(selectedItems.groups),
inputs: mapInputsFromBoundaryLinks(analysis.boundaryInputLinks),
outputs: mapOutputsFromBoundaryLinks(analysis.boundaryOutputLinks),
inputNode: createIONode(position: [left, center]),
outputNode: createIONode(position: [right, center])
}
// 5. Create subgraph instance node
const instanceNode = new SubgraphNode(subgraphDef)
instanceNode.pos = computeCenter(selectedItems)
// 6. Reconnect external links to instance
reconnectBoundaryLinks(instanceNode, analysis)
// 7. Remove original nodes from parent graph
removeNodes(selectedItems)
// 8. Add subgraph to graph registry
parentGraph.subgraphs.set(subgraphDef.id, subgraphDef)
parentGraph.add(instanceNode)
// 9. Dispatch event for UI updates
parentGraph.events.dispatch('subgraph-created', { subgraph: subgraphDef })
return { subgraph: subgraphDef, node: instanceNode }
}function mapInputsFromBoundaryLinks(boundaryLinks: Link[]): SubgraphInput[] {
// Group links by target (node inside subgraph)
const grouped = groupBy(boundaryLinks, link => `${link.target_id}:${link.target_slot}`)
const inputs: SubgraphInput[] = []
for (const [key, connections] of Object.entries(grouped)) {
const firstConnection = connections[0]
const targetNode = getNode(firstConnection.target_id)
const targetSlot = targetNode.inputs[firstConnection.target_slot]
// Create input slot matching target's type
const input: SubgraphInput = {
id: generateUUID(),
name: generateUniqueName(targetSlot.name, existingInputs),
type: targetSlot.type,
linkIds: [], // Will be filled when reconnecting
color_on: targetSlot.color_on,
shape: targetSlot.shape
}
inputs.push(input)
// Update internal link to point from SubgraphInputNode
for (const conn of connections) {
conn.origin_id = SUBGRAPH_INPUT_ID
conn.origin_slot = inputs.length - 1
}
}
return inputs
}
function mapOutputsFromBoundaryLinks(boundaryLinks: Link[]): SubgraphOutput[] {
// Similar process but for outputs
// Group by origin (node inside subgraph emitting data)
const grouped = groupBy(boundaryLinks, link => `${link.origin_id}:${link.origin_slot}`)
const outputs: SubgraphOutput[] = []
for (const [key, connections] of Object.entries(grouped)) {
const firstConnection = connections[0]
const originNode = getNode(firstConnection.origin_id)
const originSlot = originNode.outputs[firstConnection.origin_slot]
// Create output slot matching origin's type
const output: SubgraphOutput = {
id: generateUUID(),
name: generateUniqueName(originSlot.name, existingOutputs),
type: originSlot.type,
linkIds: [], // Only one link per output
color_on: originSlot.color_on,
shape: originSlot.shape
}
outputs.push(output)
// Update internal link to point to SubgraphOutputNode
for (const conn of connections) {
conn.target_id = SUBGRAPH_OUTPUT_ID
conn.target_slot = outputs.length - 1
}
}
return outputs
}- Select subgraph instance node
- Invoke "Unpack Subgraph" (right-click menu or command)
function unpackSubgraph(subgraphNode: SubgraphInstance, options?: { skipMissingNodes: boolean }) {
const subgraph = subgraphNode.subgraph
const skipMissing = options?.skipMissingNodes ?? false
// 1. Calculate positioning
const bounds = computeBounds(subgraph.nodes, subgraph.reroutes, subgraph.groups)
const center = [bounds.x + bounds.width/2, bounds.y + bounds.height/2]
const offsetX = subgraphNode.pos[0] - center[0]
const offsetY = subgraphNode.pos[1] - center[1]
// 2. Clone internal nodes to parent graph
const nodeIdMap = new Map<NodeId, NodeId>()
const newNodes = []
for (const nodeData of subgraph.nodes) {
const newId = parentGraph.getNextNodeId()
nodeIdMap.set(nodeData.id, newId)
const newNode = createNode(nodeData.type)
if (!newNode && skipMissing) continue // Skip if node type not found
newNode.id = newId
newNode.pos = [nodeData.pos[0] + offsetX, nodeData.pos[1] + offsetY]
newNode.size = nodeData.size
// ... copy all properties
parentGraph.add(newNode)
newNodes.push(newNode)
}
// 3. Recreate internal links
const linkIdMap = new Map<LinkId, LinkId[]>()
for (const linkData of subgraph.links) {
// Skip links to/from I/O nodes (will be recreated from boundary)
if (linkData.origin_id === SUBGRAPH_INPUT_ID ||
linkData.target_id === SUBGRAPH_OUTPUT_ID) continue
const newOriginId = nodeIdMap.get(linkData.origin_id)
const newTargetId = nodeIdMap.get(linkData.target_id)
if (!newOriginId || !newTargetId) continue
const newLink = parentGraph.createLink(
newOriginId, linkData.origin_slot,
newTargetId, linkData.target_slot
)
linkIdMap.set(linkData.id, [newLink.id])
}
// 4. Reconnect boundary links (inputs)
for (const input of subgraph.inputs) {
for (const linkId of input.linkIds) {
const internalLink = subgraph.getLink(linkId)
const newTargetId = nodeIdMap.get(internalLink.target_id)
if (!newTargetId) continue
// Find external link connected to this input
const externalLink = parentGraph.findLinkToSlot(subgraphNode.id, inputIndex)
if (externalLink) {
// Reconnect external origin to internal target
parentGraph.createLink(
externalLink.origin_id, externalLink.origin_slot,
newTargetId, internalLink.target_slot
)
parentGraph.removeLink(externalLink.id)
}
}
}
// 5. Reconnect boundary links (outputs)
for (const output of subgraph.outputs) {
const linkId = output.linkIds[0]
if (!linkId) continue
const internalLink = subgraph.getLink(linkId)
const newOriginId = nodeIdMap.get(internalLink.origin_id)
if (!newOriginId) continue
// Find all external links from this output
const externalLinks = parentGraph.findLinksFromSlot(subgraphNode.id, outputIndex)
for (const externalLink of externalLinks) {
// Reconnect internal origin to external targets
parentGraph.createLink(
newOriginId, internalLink.origin_slot,
externalLink.target_id, externalLink.target_slot
)
parentGraph.removeLink(externalLink.id)
}
}
// 6. Restore reroutes, groups
restoreReroutes(subgraph.reroutes, offsetX, offsetY, linkIdMap)
restoreGroups(subgraph.groups, offsetX, offsetY)
// 7. Remove subgraph instance node
parentGraph.remove(subgraphNode)
// 8. Select unpacked nodes
canvas.select(...newNodes)
}- Double-click subgraph instance node OR
- Select node and click "Edit" button OR
- Use breadcrumb navigation
interface SubgraphNavigationStore {
activeSubgraph: Subgraph | undefined
idStack: UUID[] // Path from root to current subgraph
viewportCache: LRU<UUID, ViewportState> // Per-subgraph viewport
// Navigation methods
openSubgraph(subgraphNode: SubgraphInstance): void
exitSubgraph(): void
exitToRoot(): void
restoreState(subgraphIds: UUID[]): void
}
function openSubgraph(subgraphNode: SubgraphInstance) {
const subgraph = getSubgraphDefinition(subgraphNode.type)
// 1. Save current viewport
const currentGraphId = activeSubgraph?.id ?? 'root'
viewportCache.set(currentGraphId, getCurrentViewport())
// 2. Push to navigation stack
idStack.push(subgraphNode.id)
activeSubgraph = subgraph
// 3. Set canvas to subgraph
canvas.setGraph(subgraph)
// 4. Restore subgraph's saved viewport (if any)
const savedViewport = viewportCache.get(subgraph.id)
if (savedViewport) {
canvas.ds.scale = savedViewport.scale
canvas.ds.offset = savedViewport.offset
} else {
canvas.centerView() // Default: center on content
}
// 5. Dispatch event
canvas.dispatchEvent('subgraph-opened', {
subgraph,
fromNode: subgraphNode
})
}
function exitSubgraph() {
if (idStack.length === 0) return // Already at root
// 1. Save current viewport
viewportCache.set(activeSubgraph.id, getCurrentViewport())
// 2. Pop navigation stack
idStack.pop()
// 3. Get parent graph
const parentGraph = (idStack.length === 0)
? rootGraph
: getSubgraphDefinition(idStack[idStack.length - 1])
activeSubgraph = (idStack.length > 0) ? parentGraph : undefined
// 4. Set canvas to parent
canvas.setGraph(parentGraph)
// 5. Restore parent's viewport
const parentId = parentGraph.id ?? 'root'
const savedViewport = viewportCache.get(parentId)
if (savedViewport) {
canvas.ds.scale = savedViewport.scale
canvas.ds.offset = savedViewport.offset
}
}interface BreadcrumbItem {
label: string // Subgraph name
subgraph: Subgraph // Reference to definition
command: () => void // Click handler to navigate
updateTitle: (newTitle: string) => void // Rename handler
}
function computeBreadcrumbs(): BreadcrumbItem[] {
const items: BreadcrumbItem[] = [
{
label: workflowName,
subgraph: rootGraph,
command: () => exitToRoot(),
updateTitle: (title) => renameWorkflow(title)
}
]
for (const subgraphId of idStack) {
const subgraph = getSubgraphDefinition(subgraphId)
items.push({
label: subgraph.name,
subgraph: subgraph,
command: () => navigateToSubgraph(subgraph),
updateTitle: (title) => renameSubgraph(subgraph, title)
})
}
return items
}UI Features:
- Displays workflow name → subgraph 1 → subgraph 2 → ...
- Click any breadcrumb to jump to that level
- Double-click breadcrumb to rename
- Hover shows tooltip with full path
- Auto-collapse if too many levels (show "..." with overflow menu)
Method 1: Auto-create from connections
// When dragging link from external node to empty slot ("+")
function onDropLinkToEmptySlot(
externalOutput: NodeOutput,
emptySlot: EmptySubgraphInput
) {
// 1. Create new input slot matching type
const newInput = {
id: generateUUID(),
name: generateUniqueName(externalOutput.name, subgraph.inputs),
type: externalOutput.type,
linkIds: [],
color_on: externalOutput.color_on,
shape: externalOutput.shape
}
subgraph.inputs.push(newInput)
// 2. Update subgraph instance
subgraphNode.addInput(newInput.name, newInput.type)
// 3. Create external link
const externalLink = parentGraph.createLink(
externalOutput.node.id, externalOutput.slot,
subgraphNode.id, subgraph.inputs.length - 1
)
newInput.linkIds.push(externalLink.id)
// 4. Dispatch event
subgraph.events.dispatch('input-added', { input: newInput })
}Method 2: Manual creation via context menu
// Right-click on I/O node → "Add Input/Output"
function addInputSlot(subgraph: Subgraph) {
const name = await promptForName("New Input")
const type = await selectType() // "*" (any), "number", "string", etc.
const newInput = {
id: generateUUID(),
name: generateUniqueName(name, subgraph.inputs),
type: type,
linkIds: [],
}
subgraph.inputs.push(newInput)
// Update all instances
forEachSubgraphInstance(subgraph, (instance) => {
instance.addInput(newInput.name, newInput.type)
})
subgraph.events.dispatch('input-added', { input: newInput })
}function removeInput(subgraph: Subgraph, input: SubgraphInput) {
const index = subgraph.inputs.indexOf(input)
if (index === -1) return
// 1. Dispatch pre-removal event
subgraph.events.dispatch('removing-input', { input, index })
// 2. Disconnect all internal links
for (const linkId of input.linkIds) {
const link = subgraph.getLink(linkId)
link.disconnect(subgraph)
}
// 3. Remove slot from definition
subgraph.inputs.splice(index, 1)
// 4. Update all instances
forEachSubgraphInstance(subgraph, (instance) => {
instance.removeInput(index)
// Disconnect external links
const externalLinks = parentGraph.findLinksToSlot(instance.id, index)
externalLinks.forEach(link => parentGraph.removeLink(link.id))
})
// 5. Decrement slot indices for later slots
for (let i = index; i < subgraph.inputs.length; i++) {
// Update internal links pointing to this input
// ...decrementSlotIndex logic
}
}function renameInput(subgraph: Subgraph, input: SubgraphInput, newName: string) {
const index = subgraph.inputs.indexOf(input)
const oldName = input.name
// 1. Validate uniqueness
if (subgraph.inputs.some((i, idx) => i.name === newName && idx !== index)) {
throw new Error(`Input name "${newName}" already exists`)
}
// 2. Dispatch pre-rename event
subgraph.events.dispatch('renaming-input', { input, index, oldName, newName })
// 3. Update definition
input.name = newName
input.label = newName
// 4. Update all instances
forEachSubgraphInstance(subgraph, (instance) => {
const slot = instance.inputs[index]
slot.name = newName
slot.label = newName
})
}Concept: Expose internal node widgets on the subgraph instance for easy parameter access without opening the subgraph.
interface ExposedWidget {
nodeId: NodeId // Node inside subgraph
widgetName: string // Widget name on that node
}
// Stored in subgraph instance properties
subgraphNode.properties.proxyWidgets = [
[nodeId, widgetName], // Array of tuples
...
]function promoteWidget(subgraph: Subgraph, node: LGraphNode, widget: IWidget) {
const subgraphNode = findSubgraphInstance(subgraph)
if (!subgraphNode) return
// 1. Create proxy widget on subgraph node
const proxyWidget = createProxyWidget(widget, {
graph: subgraph,
nodeId: node.id,
widgetName: widget.name
})
subgraphNode.widgets.push(proxyWidget)
// 2. Update properties
subgraphNode.properties.proxyWidgets.push([node.id, widget.name])
// 3. Mark widget as promoted
widget.promoted = true
// 4. Dispatch event
subgraph.events.dispatch('widget-promoted', {
widget,
subgraphNode
})
}
function createProxyWidget(targetWidget: IWidget, overlay: WidgetOverlay): ProxyWidget {
// Create a Proxy that intercepts property access
return new Proxy(targetWidget, {
get(target, prop) {
// Properties in overlay take precedence
if (prop === '_overlay') return overlay
if (prop in overlay) return overlay[prop]
// Fall back to target widget
return target[prop]
},
set(target, prop, value) {
// Some properties go to overlay, others to target
if (prop in overlay) {
overlay[prop] = value
return true
}
target[prop] = value
return true
}
})
}-
Subgraph Editor Panel (right sidebar):
- Lists all internal nodes and their widgets
- Checkbox per widget to promote/demote
- Drag-to-reorder promoted widgets
- Search/filter widgets by name
- Group by node for organization
-
Automatic Promotion:
- When converting to subgraph, auto-promote "recommended" widgets:
- Not connected to any link
- Has
recommendproperty set - Common parameters (seed, steps, cfg_scale, etc.)
- When converting to subgraph, auto-promote "recommended" widgets:
function promoteRecommendedWidgets(subgraphNode: SubgraphInstance) {
const subgraph = subgraphNode.subgraph
for (const node of subgraph.nodes) {
for (const widget of node.widgets ?? []) {
if (isRecommendedWidget(widget, node)) {
promoteWidget(subgraph, node, widget)
}
}
}
}
function isRecommendedWidget(widget: IWidget, node: LGraphNode): boolean {
// 1. Already promoted?
if (widget.promoted) return false
// 2. Has explicit recommend flag?
if (widget.options?.recommend) return true
// 3. Not connected to any link?
const connectedToInput = node.inputs.some(input =>
input.widget === widget && input.link != null
)
if (connectedToInput) return false
// 4. Common parameter names
const recommendedNames = [
'seed', 'control_after_generate',
'steps', 'cfg', 'cfg_scale',
'sampler', 'scheduler',
'denoise', 'strength'
]
if (recommendedNames.includes(widget.name)) return true
return false
}{
"version": 0.4,
"last_node_id": 123,
"last_link_id": 456,
"nodes": [
// Regular nodes
{ "id": 1, "type": "LoadImage", ... },
// Subgraph instance nodes
{
"id": 42,
"type": "uuid-1234-5678-abcd", // References subgraph definition
"pos": [100, 200],
"size": [200, 150],
"properties": {
"proxyWidgets": [[5, "seed"], [7, "steps"]]
},
"inputs": [...],
"outputs": [...]
}
],
"links": [
// Links between nodes (including subgraph instances)
[linkId, originId, originSlot, targetId, targetSlot, dataType]
],
"definitions": {
"subgraphs": [
{
"id": "uuid-1234-5678-abcd",
"name": "My Subgraph",
"version": 0.4,
"inputNode": {
"id": -1,
"bounding": [0, 0, 75, 100]
},
"outputNode": {
"id": -2,
"bounding": [0, 0, 75, 100]
},
"inputs": [
{
"id": "uuid-input-1",
"name": "image",
"type": "IMAGE",
"linkIds": [101, 102]
}
],
"outputs": [
{
"id": "uuid-output-1",
"name": "result",
"type": "IMAGE",
"linkIds": [201]
}
],
"nodes": [
// Internal nodes (IDs scoped to subgraph)
{ "id": 5, "type": "KSampler", ... }
],
"links": [
// Internal links (including to/from I/O nodes)
[101, -1, 0, 5, 2, "IMAGE"], // From SubgraphInputNode
[201, 5, 0, -2, 0, "IMAGE"] // To SubgraphOutputNode
],
"reroutes": [...],
"groups": [...],
"widgets": [[5, "seed"], [5, "steps"]],
"state": { ... },
"revision": 0,
"config": { ... }
}
]
}
}function loadWorkflow(jsonData: WorkflowJSON) {
// 1. Load subgraph definitions first
if (jsonData.definitions?.subgraphs) {
for (const subgraphData of jsonData.definitions.subgraphs) {
const subgraph = rootGraph.createSubgraph(subgraphData)
// Register as node definition in node library
registerSubgraphNodeDef(subgraph)
}
}
// 2. Load regular nodes and subgraph instances
for (const nodeData of jsonData.nodes) {
if (isSubgraphInstanceNode(nodeData)) {
const subgraphDef = rootGraph.subgraphs.get(nodeData.type)
if (!subgraphDef) {
console.error(`Subgraph definition ${nodeData.type} not found`)
continue
}
const instance = new SubgraphNode(rootGraph, subgraphDef, nodeData)
rootGraph.add(instance)
} else {
const node = createNode(nodeData.type)
configure(node, nodeData)
rootGraph.add(node)
}
}
// 3. Recreate links
for (const linkData of jsonData.links) {
const [id, originId, originSlot, targetId, targetSlot, type] = linkData
rootGraph.createLink(originId, originSlot, targetId, targetSlot, id)
}
// 4. Restore groups, reroutes, etc.
// ...
}Key Principle: Backend receives a completely flat graph with no subgraph nodes.
function flattenForExecution(rootGraph: LGraph): ExecutableWorkflow {
const flatNodes: ExecutableNode[] = []
const flatLinks: ExecutableLink[] = []
// Recursive traversal
function traverse(
graph: LGraph | Subgraph,
pathPrefix: string = ""
) {
for (const node of graph.nodes) {
if (node.isSubgraphNode()) {
// Recurse into subgraph
const newPrefix = pathPrefix ? `${pathPrefix}:${node.id}` : `${node.id}`
traverse(node.subgraph, newPrefix)
} else {
// Add regular node with execution ID
const executionId = pathPrefix ? `${pathPrefix}:${node.id}` : `${node.id}`
flatNodes.push({
id: executionId, // Hierarchical ID
class_type: node.type,
inputs: flattenInputs(node, graph, pathPrefix)
})
}
}
// Handle links (excluding I/O node links which are virtual)
for (const link of graph.links) {
if (link.origin_id === SUBGRAPH_INPUT_ID ||
link.target_id === SUBGRAPH_OUTPUT_ID) continue
flatLinks.push({
id: link.id,
origin: pathPrefix ? `${pathPrefix}:${link.origin_id}` : `${link.origin_id}`,
target: pathPrefix ? `${pathPrefix}:${link.target_id}` : `${link.target_id}`,
origin_slot: link.origin_slot,
target_slot: link.target_slot
})
}
}
traverse(rootGraph)
return { nodes: flatNodes, links: flatLinks }
}
function flattenInputs(
node: LGraphNode,
graph: LGraph | Subgraph,
pathPrefix: string
): Record<string, any> {
const inputs: Record<string, any> = {}
for (const input of node.inputs) {
if (input.link != null) {
const link = graph.getLink(input.link)
// Resolve link origin (may cross subgraph boundaries)
const originNode = resolveOriginNode(link, graph)
const originId = pathPrefix
? `${pathPrefix}:${originNode.id}`
: `${originNode.id}`
inputs[input.name] = [originId, link.origin_slot]
} else if (input.widget) {
// Widget value
inputs[input.name] = input.widget.value
}
}
return inputs
}
function resolveOriginNode(link: LLink, graph: LGraph | Subgraph): LGraphNode {
// If origin is SubgraphInputNode, trace back to parent graph
if (link.origin_id === SUBGRAPH_INPUT_ID) {
const subgraphNode = findParentSubgraphNode(graph)
const inputIndex = link.origin_slot
const externalLink = findExternalLinkToInput(subgraphNode, inputIndex)
return resolveOriginNode(externalLink, subgraphNode.graph)
}
return graph.getNodeById(link.origin_id)
}Hierarchical ID: <subgraph_node_id>:<subgraph_node_id>:...<node_id>
Examples:
- Root node:
"7"(just node ID) - Node in subgraph:
"42:7"(subgraph 42, node 7) - Nested:
"10:42:7"(root → subgraph 10 → subgraph 42 → node 7)
Usage:
// Parsing execution ID
function parseExecutionId(executionId: string): string[] {
return executionId.split(':')
}
function getLocalNodeId(executionId: string): string {
const parts = parseExecutionId(executionId)
return parts[parts.length - 1]
}
function getSubgraphPath(executionId: string): string[] {
const parts = parseExecutionId(executionId)
return parts.slice(0, -1) // All but last
}
// Example: "10:42:7"
// getLocalNodeId("10:42:7") → "7"
// getSubgraphPath("10:42:7") → ["10", "42"]function onExecutionProgress(executionId: string, status: ExecutionStatus) {
const [path, localId] = splitExecutionId(executionId)
// 1. Navigate to correct graph
const targetGraph = traverseSubgraphPath(rootGraph, path)
if (!targetGraph) {
console.warn(`Invalid subgraph path: ${path}`)
return
}
// 2. Find node in target graph
const node = targetGraph.getNodeById(localId)
if (!node) {
console.warn(`Node ${localId} not found in graph`)
return
}
// 3. Update node status
node.executionStatus = status
node.progressPercent = status.progress / status.max
// 4. Redraw canvas (if graph is visible)
if (canvas.graph === targetGraph) {
canvas.setDirty(true)
}
}┌──────────────────────────────┐
│ 🔧 My Subgraph ▼│ ← Title bar (icon + name + collapse)
├──────────────────────────────┤
│ ● image ───────────→ [node] │ ← Input slots (left side)
│ ● strength │
│ └─ [0.75] ←────────────────│ ← Proxy widget (indented under input)
├──────────────────────────────┤
│ [node] ───────────→ result ●│ ← Output slots (right side)
│ latent ● │
└──────────────────────────────┘
Properties:
- Custom icon (🔧 or workflow SVG)
- Badge showing node count:
(5 nodes) - Double-click title bar → Open subgraph
- Collapse button → Hide widgets (show only I/O)
- Right-click → Context menu
Rendering Code:
function drawSubgraphNode(ctx: CanvasRenderingContext2D, node: SubgraphNode) {
const { x, y, width, height } = node.boundingRect
// Background
ctx.fillStyle = node.bgcolor || '#2a2a2a'
ctx.fillRect(x, y, width, height)
// Border (highlight if selected)
ctx.strokeStyle = node.selected ? '#FFA500' : '#555'
ctx.lineWidth = node.selected ? 2 : 1
ctx.strokeRect(x, y, width, height)
// Title bar
const titleHeight = LiteGraph.NODE_TITLE_HEIGHT
ctx.fillStyle = node.color || '#353535'
ctx.fillRect(x, y, width, titleHeight)
// Icon
const icon = node.subgraph.icon || '🔧'
ctx.font = '14px sans-serif'
ctx.fillStyle = '#FFF'
ctx.fillText(icon, x + 8, y + titleHeight - 8)
// Title text
ctx.font = 'bold 14px sans-serif'
ctx.fillText(node.title, x + 30, y + titleHeight - 8)
// Badge
const nodeCount = node.subgraph.nodes.length
const badge = `(${nodeCount} nodes)`
ctx.font = '10px sans-serif'
ctx.fillStyle = '#999'
ctx.fillText(badge, x + width - 60, y + titleHeight - 8)
// Input slots
let slotY = y + titleHeight + 10
for (const input of node.inputs) {
drawSlot(ctx, x, slotY, 'input', input)
// Draw proxy widget if exists
if (input._widget) {
slotY += drawWidget(ctx, x + 20, slotY + 20, input._widget)
}
slotY += LiteGraph.NODE_SLOT_HEIGHT
}
// Output slots
slotY = y + titleHeight + 10
for (const output of node.outputs) {
drawSlot(ctx, x + width, slotY, 'output', output)
slotY += LiteGraph.NODE_SLOT_HEIGHT
}
}┌─────────────┐ ┌─────────────┐
│ Inputs │ │ Outputs │
│ │ │ │
│ image ───● │ │ ●─── result│
│ strength ● │ │ ●─── latent│
│ ●─│ │─●─ │
│ [+] │ Add slot │ [+] │ Add slot
└─────────────┘ └─────────────┘
Key Features:
- Fixed position: InputNode on left edge, OutputNode on right edge
- Auto-sizing: Height adjusts to number of slots
- Rounded corners: Distinct from regular nodes
- Empty slot ("+"): Always visible at bottom for quick add
- Hover effects: Slot highlights on hover
- Drag-from-slot: Start new connection
- Drop-to-slot: Accept connection (auto-creates slot if empty)
Rendering Code:
function drawSubgraphIONode(
ctx: CanvasRenderingContext2D,
ioNode: SubgraphInputNode | SubgraphOutputNode
) {
const isInput = ioNode instanceof SubgraphInputNode
const { x, y, width, height } = ioNode.boundingRect
// Rounded rectangle background
ctx.fillStyle = '#1a1a1a'
roundRect(ctx, x, y, width, height, 8)
ctx.fill()
// Border
ctx.strokeStyle = '#444'
ctx.lineWidth = 1
roundRect(ctx, x, y, width, height, 8)
ctx.stroke()
// Title
ctx.font = 'bold 12px sans-serif'
ctx.fillStyle = '#AAA'
const title = isInput ? 'Inputs' : 'Outputs'
ctx.fillText(title, x + 10, y + 20)
// Slots
let slotY = y + 40
for (const slot of ioNode.allSlots) { // Includes empty slot
if (slot instanceof EmptySubgraphInput || slot instanceof EmptySubgraphOutput) {
// Draw "+" button
ctx.fillStyle = '#555'
ctx.fillRect(x + 10, slotY, width - 20, 20)
ctx.font = 'bold 16px sans-serif'
ctx.fillStyle = '#AAA'
ctx.fillText('+', x + width/2 - 4, slotY + 15)
} else {
// Draw slot
drawSubgraphSlot(ctx, slot, isInput)
}
slotY += LiteGraph.NODE_SLOT_HEIGHT
}
}
function drawSubgraphSlot(
ctx: CanvasRenderingContext2D,
slot: SubgraphInput | SubgraphOutput,
isInput: boolean
) {
const { x, y, width, height } = slot.boundingRect
// Slot circle
const circleX = isInput ? x + width - 8 : x + 8
const circleY = y + height / 2
ctx.beginPath()
ctx.arc(circleX, circleY, 6, 0, Math.PI * 2)
ctx.fillStyle = slot.isConnected ? slot.color_on : slot.color_off
ctx.fill()
ctx.strokeStyle = '#FFF'
ctx.lineWidth = 2
ctx.stroke()
// Label
ctx.font = '12px sans-serif'
ctx.fillStyle = '#CCC'
const textX = isInput ? x + 10 : x + 20
ctx.fillText(slot.displayName, textX, y + height/2 + 4)
// Hover highlight
if (slot.isPointerOver) {
ctx.strokeStyle = '#FFF'
ctx.lineWidth = 2
ctx.strokeRect(x, y, width, height)
}
}┌─────────────────────────────────────────────────────┐
│ Workflow → SubgraphA → SubgraphB → [Current] │
└─────────────────────────────────────────────────────┘
Component:
<template>
<div class="subgraph-breadcrumb">
<Breadcrumb :model="breadcrumbItems">
<template #item="{ item }">
<div
class="breadcrumb-item"
:class="{ active: item === breadcrumbItems.at(-1) }"
@click="item.command"
@dblclick="() => handleRename(item)"
>
<i v-if="item.icon" :class="item.icon" />
<span>{{ item.label }}</span>
<Badge v-if="item.badge" :value="item.badge" />
</div>
</template>
<template #separator>
<span class="separator">/</span>
</template>
</Breadcrumb>
</div>
</template>
<script setup>
const breadcrumbItems = computed(() => {
const items = []
// Root
items.push({
label: workflowStore.activeWorkflow?.filename ?? 'Workflow',
icon: 'pi pi-home',
command: () => navigationStore.exitToRoot()
})
// Subgraph path
for (const subgraph of navigationStore.navigationStack) {
items.push({
label: subgraph.name,
icon: 'pi pi-sitemap',
badge: subgraph.nodes.length,
command: () => canvas.setGraph(subgraph),
updateTitle: (newName) => {
subgraph.name = newName
// Update all instances
forEachSubgraphInstance(subgraph, node => {
node.title = newName
})
}
})
}
return items
})
function handleRename(item) {
const newName = prompt('Enter new name:', item.label)
if (newName && item.updateTitle) {
item.updateTitle(newName)
}
}
</script>Layout:
┌───────────────────────────────────┐
│ Subgraph Widgets │
│ ─────────────────────────────────│
│ [Search widgets...] │
│ │
│ ✓ Promoted (3) │
│ ⋮ seed (KSampler) │
│ ⋮ steps (KSampler) │
│ ⋮ cfg (KSampler) │
│ │
│ ▼ Available (12) │
│ ☐ sampler_name (KSampler) │
│ ☐ denoise (KSampler) │
│ ☐ width (EmptyLatentImage) │
│ ... │
└───────────────────────────────────┘
Component:
<template>
<div class="subgraph-editor">
<SidePanelSearch @search="handleSearch" />
<!-- Promoted widgets (drag-to-reorder) -->
<div class="promoted-section">
<h3>Promoted Widgets ({{ promotedWidgets.length }})</h3>
<DraggableList
v-model="promotedWidgets"
@update="handleReorder"
>
<template #item="{ widget }">
<div class="widget-item">
<i class="icon-drag" />
<input
type="checkbox"
:checked="true"
@change="() => demoteWidget(widget)"
/>
<span class="widget-name">{{ widget.name }}</span>
<span class="widget-node">({{ widget.node.title }})</span>
</div>
</template>
</DraggableList>
</div>
<!-- Available widgets -->
<div class="available-section">
<h3>Available Widgets ({{ availableWidgets.length }})</h3>
<div
v-for="widget in filteredAvailableWidgets"
:key="`${widget.node.id}:${widget.name}`"
class="widget-item"
>
<input
type="checkbox"
:checked="false"
@change="() => promoteWidget(widget)"
/>
<span class="widget-name">{{ widget.name }}</span>
<span class="widget-node">({{ widget.node.title }})</span>
<Badge
v-if="isRecommended(widget)"
value="Recommended"
severity="success"
/>
</div>
</div>
</div>
</template>
<script setup>
const activeSubgraph = computed(() => navigationStore.activeSubgraph)
const promotedWidgets = computed({
get() {
const subgraphNode = findSubgraphInstanceNode(activeSubgraph.value)
return parseProxyWidgets(subgraphNode.properties.proxyWidgets)
.map(([nodeId, widgetName]) => {
const node = activeSubgraph.value.getNodeById(nodeId)
const widget = node.widgets.find(w => w.name === widgetName)
return { node, widget }
})
},
set(value) {
const subgraphNode = findSubgraphInstanceNode(activeSubgraph.value)
subgraphNode.properties.proxyWidgets = value.map(({ node, widget }) =>
[node.id, widget.name]
)
}
})
const availableWidgets = computed(() => {
const widgets = []
for (const node of activeSubgraph.value.nodes) {
for (const widget of node.widgets ?? []) {
if (!widget.promoted) {
widgets.push({ node, widget })
}
}
}
return widgets
})
function handleSearch(query) {
searchQuery.value = query.toLowerCase()
}
const filteredAvailableWidgets = computed(() => {
if (!searchQuery.value) return availableWidgets.value
return availableWidgets.value.filter(({ widget, node }) =>
widget.name.toLowerCase().includes(searchQuery.value) ||
node.title.toLowerCase().includes(searchQuery.value)
)
})
function promoteWidget({ node, widget }) {
// Call promotion logic
promoteWidgetToSubgraph(activeSubgraph.value, node, widget)
}
function demoteWidget({ node, widget }) {
// Call demotion logic
demoteWidgetFromSubgraph(activeSubgraph.value, node, widget)
}
</script>Concept: Subgraphs can contain other subgraph instances (recursive composition).
Implementation:
- No special handling needed; subgraph instances are regular nodes
- Flattening automatically handles nesting via recursive traversal
- Execution IDs encode full path:
"10:42:7"
Limitation:
function preventCircularReference(
graph: LGraph,
subgraphDefId: UUID
): boolean {
// Check if subgraphDefId already exists in ancestor chain
let currentGraph = graph
while (currentGraph.isSubgraph) {
if (currentGraph.id === subgraphDefId) {
alert("Cannot create circular subgraph reference!")
return false
}
currentGraph = currentGraph.parentGraph
}
return true
}Concept: Save subgraph definitions as standalone files for reuse across workflows.
async function saveSubgraphBlueprint(subgraphNode: SubgraphInstance) {
const subgraph = subgraphNode.subgraph
// 1. Prompt for name
const name = await prompt('Blueprint name:', subgraph.name)
if (!name) return
// 2. Create minimal workflow (single subgraph node)
const blueprintWorkflow = {
version: 0.4,
last_node_id: 1,
last_link_id: 0,
nodes: [
{
id: 1,
type: subgraph.id,
pos: [0, 0],
size: [200, 100],
properties: {},
inputs: [],
outputs: []
}
],
links: [],
definitions: {
subgraphs: [subgraph.serialize()]
}
}
// 3. Save to user library
const path = `subgraphs/${name}.json`
await api.saveUserData(path, JSON.stringify(blueprintWorkflow, null, 2))
// 4. Register as node definition in library
registerBlueprintNodeDef(name, subgraph)
// 5. Show toast
showToast({
severity: 'success',
summary: 'Blueprint saved',
detail: `"${name}" added to library`
})
}async function loadSubgraphBlueprints() {
// 1. List user subgraph files
const files = await api.listUserData('subgraphs/')
const jsonFiles = files.filter(f => f.path.endsWith('.json'))
// 2. Load each blueprint
for (const file of jsonFiles) {
const content = await api.getUserData(file.path)
const workflow = JSON.parse(content)
// 3. Extract subgraph definition
const subgraphDef = workflow.definitions.subgraphs[0]
if (!subgraphDef) continue
// 4. Register as node def
const nodeDef = {
name: file.name.replace('.json', ''),
display_name: file.name.replace('.json', ''),
category: 'Subgraph Blueprints',
description: 'User-created subgraph blueprint',
input: createInputSpec(subgraphDef.inputs),
output: subgraphDef.outputs.map(o => o.type),
output_name: subgraphDef.outputs.map(o => o.name)
}
nodeDefStore.addNodeDef(nodeDef)
// 5. Store blueprint data
blueprintCache.set(nodeDef.name, { workflow, subgraphDef })
}
}
// When user adds blueprint node from library
function createBlueprintInstance(blueprintName: string) {
const { workflow, subgraphDef } = blueprintCache.get(blueprintName)
// Create subgraph definition in current workflow
const subgraph = rootGraph.createSubgraph(subgraphDef)
// Create instance
const instanceData = {
id: rootGraph.getNextNodeId(),
type: subgraph.id,
pos: [100, 100],
size: [200, 100],
properties: {},
inputs: [],
outputs: []
}
const instance = new SubgraphNode(rootGraph, subgraph, instanceData)
rootGraph.add(instance)
return instance
}Blueprint Constraint: A subgraph blueprint file must contain exactly one subgraph instance node at the root level.
function validateSubgraphBlueprint(workflow: WorkflowJSON): boolean {
const { nodes, definitions } = workflow
const { subgraphs } = definitions ?? {}
if (!subgraphs || subgraphs.length === 0) {
throw new Error('Blueprint must contain at least one subgraph definition')
}
// Check root nodes
let subgraphNodeCount = 0
for (const node of nodes) {
if (subgraphs.some(s => s.id === node.type)) {
subgraphNodeCount++
}
}
if (subgraphNodeCount === 0) {
throw new Error('Blueprint root must contain a subgraph instance node')
}
if (subgraphNodeCount > 1 || nodes.length > 1) {
throw new Error('Blueprint must contain exactly one subgraph instance node')
}
return true
}
// Auto-validate on save
async function saveBlueprint() {
try {
validateSubgraphBlueprint(currentWorkflow)
await saveBlueprintFile()
} catch (error) {
// Show error UI
highlightInvalidNodes()
showToast({
severity: 'error',
summary: 'Invalid blueprint',
detail: error.message
})
}
}State Tracking:
class SubgraphChangeTracker {
beforeState: GraphState
afterState: GraphState
constructor(graph: LGraph | Subgraph) {
this.graph = graph
}
beginChange() {
this.beforeState = this.graph.serialize()
}
endChange() {
this.afterState = this.graph.serialize()
// Push to history
undoManager.push({
undo: () => this.graph.loadState(this.beforeState),
redo: () => this.graph.loadState(this.afterState)
})
}
}
// Usage in operations
function convertToSubgraph(items: Set<Node>) {
graph.beforeChange()
try {
const result = _convertToSubgraphImpl(items)
graph.afterChange()
return result
} catch (error) {
graph.cancelChange() // Rollback
throw error
}
}Operations to Track:
- Convert to subgraph
- Unpack subgraph
- Add/remove subgraph I/O
- Rename subgraph
- Promote/demote widgets
- Reorder proxy widgets
Global Search:
function searchInWorkflow