Compare commits

..

1 Commits

Author SHA1 Message Date
Terry Jia
56da748234 feat: add node event subscription API (on/off/emit) 2026-04-13 20:49:49 -04:00
30 changed files with 768 additions and 635 deletions

View File

@@ -58,7 +58,9 @@ const config: KnipConfig = {
// Pending integration in stacked PR
'src/components/sidebar/tabs/nodeLibrary/CustomNodesPanel.vue',
// Agent review check config, not part of the build
'.agents/checks/eslint.strict.config.js'
'.agents/checks/eslint.strict.config.js',
// Public API shim for custom nodes, consumed by comfyAPIPlugin at build time
'src/scripts/nodeEvents.ts'
],
vite: {
config: ['vite?(.*).config.mts']

View File

@@ -1,4 +1,5 @@
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { NodeEvent } from '@/lib/litegraph/src/infrastructure/LGraphNodeEventMap'
import type { NodeOutputWith } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
@@ -18,12 +19,9 @@ useExtensionService().registerExtension({
const [oldWidth, oldHeight] = node.size
node.setSize([Math.max(oldWidth, 400), Math.max(oldHeight, 350)])
const onExecuted = node.onExecuted
node.onExecuted = function (output: ImageCompareOutput) {
onExecuted?.call(this, output)
const { a_images: aImages, b_images: bImages } = output
node.on(NodeEvent.EXECUTED, ({ output }) => {
const { a_images: aImages, b_images: bImages } =
output as ImageCompareOutput
const rand = app.getRandParam()
const toUrl = (record: Record<string, string>) => {
@@ -42,6 +40,6 @@ useExtensionService().registerExtension({
widget.value = { beforeImages, afterImages }
widget.callback?.(widget.value)
}
}
})
}
})

View File

@@ -13,6 +13,7 @@ import { SUPPORTED_EXTENSIONS_ACCEPT } from '@/extensions/core/load3d/constants'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { NodeEvent } from '@/lib/litegraph/src/infrastructure/LGraphNodeEventMap'
import type { IContextMenuValue } from '@/lib/litegraph/src/interfaces'
import type { IStringWidget } from '@/lib/litegraph/src/types/widgets'
import { useToastStore } from '@/platform/updates/common/toastStore'
@@ -475,8 +476,6 @@ useExtensionService().registerExtension({
await nextTick()
const onExecuted = node.onExecuted
useLoad3d(node).waitForLoad3d((load3d) => {
const config = new Load3DConfiguration(load3d, node.properties)
@@ -502,10 +501,8 @@ useExtensionService().registerExtension({
config.configure(settings)
}
node.onExecuted = function (output: Load3dPreviewOutput) {
onExecuted?.call(this, output)
const result = output.result
node.on(NodeEvent.EXECUTED, ({ output }) => {
const result = (output as Load3dPreviewOutput).result
const filePath = result?.[0]
if (!filePath) {
@@ -533,7 +530,7 @@ useExtensionService().registerExtension({
if (bgImagePath) {
load3d.setBackgroundImage(bgImagePath)
}
}
})
}
})
}

View File

@@ -5,6 +5,7 @@ import { useLoad3d } from '@/composables/useLoad3d'
import { createExportMenuItems } from '@/extensions/core/load3d/exportMenuHelper'
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { NodeEvent } from '@/lib/litegraph/src/infrastructure/LGraphNodeEventMap'
import type { IContextMenuValue } from '@/lib/litegraph/src/interfaces'
import type { NodeOutputWith, ResultItem } from '@/schemas/apiSchema'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
@@ -81,12 +82,8 @@ useExtensionService().registerExtension({
await nextTick()
const onExecuted = node.onExecuted
node.onExecuted = function (output: SaveMeshOutput) {
onExecuted?.call(this, output)
const fileInfo = output['3d']?.[0]
node.on(NodeEvent.EXECUTED, ({ output }) => {
const fileInfo = (output as SaveMeshOutput)['3d']?.[0]
if (!fileInfo) return
@@ -119,6 +116,6 @@ useExtensionService().registerExtension({
}
}
})
}
})
}
})

View File

@@ -1,5 +1,5 @@
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import { LGraphNode, LiteGraph, NodeEvent } from '@/lib/litegraph/src/litegraph'
import type {
INodeInputSlot,
INodeOutputSlot,
@@ -315,6 +315,12 @@ export class PrimitiveNode extends LGraphNode {
{} as CanvasPointerEvent
)
this.onWidgetChanged?.(widget.name, newValue, oldValue, widget)
this.emit(NodeEvent.WIDGET_CHANGED, {
name: widget.name,
value: newValue,
oldValue,
widget
})
}
})
}

View File

@@ -29,6 +29,7 @@ import { LGraphCanvas } from './LGraphCanvas'
import { LGraphGroup } from './LGraphGroup'
import { LGraphNode } from './LGraphNode'
import type { NodeId } from './LGraphNode'
import { NodeEvent } from './infrastructure/LGraphNodeEventMap'
import { LLink } from './LLink'
import type { LinkId, SerialisedLLinkArray } from './LLink'
import { MapProxyHandler } from './MapProxyHandler'
@@ -394,6 +395,8 @@ export class LGraph
if (this._nodes) {
for (const _node of this._nodes) {
_node.onRemoved?.()
_node.emit?.(NodeEvent.REMOVED)
_node._removeAllListeners?.()
this.onNodeRemoved?.(_node)
}
}
@@ -1004,6 +1007,7 @@ export class LGraph
this._nodes_by_id[node.id] = node
node.onAdded?.(this)
node.emit?.(NodeEvent.ADDED, { graph: this })
if (this.config.align_to_grid) node.alignToGrid()
@@ -1100,6 +1104,8 @@ export class LGraph
if (!hasRemainingReferences) {
forEachNode(node.subgraph, (innerNode) => {
innerNode.onRemoved?.()
innerNode.emit?.(NodeEvent.REMOVED)
innerNode._removeAllListeners?.()
innerNode.graph?.onNodeRemoved?.(innerNode)
})
this.rootGraph.subgraphs.delete(node.subgraph.id)
@@ -1108,6 +1114,8 @@ export class LGraph
// callback
node.onRemoved?.()
node.emit?.(NodeEvent.REMOVED)
node._removeAllListeners?.()
node.graph = null
this._version++

View File

@@ -21,6 +21,7 @@ import type { LGraph } from './LGraph'
import { LGraphGroup } from './LGraphGroup'
import { LGraphNode } from './LGraphNode'
import type { NodeId, NodeProperty } from './LGraphNode'
import { NodeEvent } from './infrastructure/LGraphNodeEventMap'
import { LLink } from './LLink'
import type { LinkId } from './LLink'
import { Reroute } from './Reroute'
@@ -3080,6 +3081,12 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
// value changed
if (oldValue != widget.value) {
node.onWidgetChanged?.(widget.name, widget.value, oldValue, widget)
node.emit(NodeEvent.WIDGET_CHANGED, {
name: widget.name,
value: widget.value,
oldValue,
widget
})
if (!node.graph) throw new NullGraphError()
node.graph._version++
}

View File

@@ -87,6 +87,10 @@ import type {
TWidgetType,
TWidgetValue
} from './types/widgets'
import type { INodeEventEmitter } from './infrastructure/NodeEventEmitter'
import { applyNodeEventEmitter } from './infrastructure/NodeEventEmitter'
import type { LGraphNodeEventMap } from './infrastructure/LGraphNodeEventMap'
import { NodeEvent } from './infrastructure/LGraphNodeEventMap'
import { findFreeSlotOfType } from './utils/collections'
import { warnDeprecated } from './utils/feedback'
import { distributeSpace } from './utils/spaceDistribution'
@@ -229,9 +233,15 @@ export interface LGraphNode {
* @param type a type for the node
*/
export interface LGraphNode extends INodeEventEmitter<LGraphNodeEventMap> {}
export class LGraphNode
implements NodeLike, Positionable, IPinnable, IColorable
{
/** @internal Lazily-created instance event listener map. */
declare _eventListeners?: Map<string, Set<(detail: unknown) => void>>
/** @internal Class-level event listener map (static). */
static _classEventListeners?: Map<string, Set<(detail: unknown) => void>>
// Static properties used by dynamic child classes
static title?: string
static MAX_CONSOLE?: number
@@ -877,6 +887,13 @@ export class LGraphNode
? this.graph._links.get(input.link)
: null
this.onConnectionsChange?.(NodeSlotType.INPUT, i, true, link, input)
this.emit(NodeEvent.CONNECTIONS_CHANGE, {
type: NodeSlotType.INPUT,
index: i,
isConnected: true,
link,
inputOrOutput: input
})
this.onInputAdded?.(input)
}
@@ -890,6 +907,13 @@ export class LGraphNode
for (const linkId of output.links) {
const link = this.graph ? this.graph._links.get(linkId) : null
this.onConnectionsChange?.(NodeSlotType.OUTPUT, i, true, link, output)
this.emit(NodeEvent.CONNECTIONS_CHANGE, {
type: NodeSlotType.OUTPUT,
index: i,
isConnected: true,
link,
inputOrOutput: output
})
}
this.onOutputAdded?.(output)
}
@@ -935,6 +959,7 @@ export class LGraphNode
}
this.onConfigure?.(info)
this.emit(NodeEvent.CONFIGURED, { serialisedNode: info })
}
/**
@@ -1594,6 +1619,7 @@ export class LGraphNode
setSize(size: Size): void {
this.size = size
this.onResize?.(this.size)
this.emit(NodeEvent.RESIZE, { size: this.size })
}
/**
@@ -2996,6 +3022,13 @@ export class LGraphNode
link,
output
)
this.emit(NodeEvent.CONNECTIONS_CHANGE, {
type: NodeSlotType.OUTPUT,
index: outputIndex,
isConnected: true,
link,
inputOrOutput: output
})
inputNode.onConnectionsChange?.(
NodeSlotType.INPUT,
@@ -3004,6 +3037,13 @@ export class LGraphNode
link,
input
)
inputNode.emit(NodeEvent.CONNECTIONS_CHANGE, {
type: NodeSlotType.INPUT,
index: inputIndex,
isConnected: true,
link,
inputOrOutput: input
})
this.setDirtyCanvas(false, true)
graph.afterChange()
@@ -3145,6 +3185,13 @@ export class LGraphNode
link_info,
input
)
target.emit(NodeEvent.CONNECTIONS_CHANGE, {
type: NodeSlotType.INPUT,
index: link_info.target_slot,
isConnected: false,
link: link_info,
inputOrOutput: input
})
this.onConnectionsChange?.(
NodeSlotType.OUTPUT,
slot,
@@ -3152,6 +3199,13 @@ export class LGraphNode
link_info,
output
)
this.emit(NodeEvent.CONNECTIONS_CHANGE, {
type: NodeSlotType.OUTPUT,
index: slot,
isConnected: false,
link: link_info,
inputOrOutput: output
})
break
}
@@ -3197,6 +3251,13 @@ export class LGraphNode
link_info,
input
)
target.emit(NodeEvent.CONNECTIONS_CHANGE, {
type: NodeSlotType.INPUT,
index: link_info.target_slot,
isConnected: false,
link: link_info,
inputOrOutput: input
})
}
// remove the link from the links pool
link_info.disconnect(graph, 'input')
@@ -3208,6 +3269,13 @@ export class LGraphNode
link_info,
output
)
this.emit(NodeEvent.CONNECTIONS_CHANGE, {
type: NodeSlotType.OUTPUT,
index: slot,
isConnected: false,
link: link_info,
inputOrOutput: output
})
}
output.links = null
}
@@ -3310,6 +3378,13 @@ export class LGraphNode
link_info,
input
)
this.emit(NodeEvent.CONNECTIONS_CHANGE, {
type: NodeSlotType.INPUT,
index: slot,
isConnected: false,
link: link_info,
inputOrOutput: input
})
target_node.onConnectionsChange?.(
NodeSlotType.OUTPUT,
i,
@@ -3317,6 +3392,13 @@ export class LGraphNode
link_info,
output
)
target_node.emit(NodeEvent.CONNECTIONS_CHANGE, {
type: NodeSlotType.OUTPUT,
index: i,
isConnected: false,
link: link_info,
inputOrOutput: output
})
}
}
@@ -4283,3 +4365,5 @@ export class LGraphNode
ctx.fillStyle = originalFillStyle
}
}
applyNodeEventEmitter(LGraphNode)

View File

@@ -5,6 +5,7 @@ import { LGraph } from './LGraph'
import { LGraphCanvas } from './LGraphCanvas'
import { LGraphGroup } from './LGraphGroup'
import { LGraphNode } from './LGraphNode'
import { NodeEvent } from './infrastructure/LGraphNodeEventMap'
import { LLink } from './LLink'
import { Reroute } from './Reroute'
import { InputIndicators } from './canvas/InputIndicators'
@@ -571,6 +572,7 @@ export class LiteGraphGlobal {
// callback
node.onNodeCreated?.()
node.emit?.(NodeEvent.NODE_CREATED)
return node
}

View File

@@ -0,0 +1,43 @@
import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type { LLink } from '@/lib/litegraph/src/LLink'
import type {
INodeInputSlot,
INodeOutputSlot,
ISlotType,
Size
} from '@/lib/litegraph/src/interfaces'
import type { ISerialisedNode } from '@/lib/litegraph/src/types/serialisation'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
export const NodeEvent = {
NODE_CREATED: 'node-created',
CONFIGURED: 'configured',
CONNECTIONS_CHANGE: 'connections-change',
WIDGET_CHANGED: 'widget-changed',
ADDED: 'added',
REMOVED: 'removed',
RESIZE: 'resize',
EXECUTED: 'executed'
} as const
export type LGraphNodeEventMap = {
[NodeEvent.NODE_CREATED]: never
[NodeEvent.CONFIGURED]: { serialisedNode: ISerialisedNode }
[NodeEvent.CONNECTIONS_CHANGE]: {
type: ISlotType
index: number
isConnected: boolean
link: LLink | null | undefined
inputOrOutput: INodeInputSlot | INodeOutputSlot
}
[NodeEvent.WIDGET_CHANGED]: {
name: string
value: unknown
oldValue: unknown
widget: IBaseWidget
}
[NodeEvent.ADDED]: { graph: LGraph }
[NodeEvent.REMOVED]: never
[NodeEvent.RESIZE]: { size: Size }
[NodeEvent.EXECUTED]: { output: Record<string, unknown> }
}

View File

@@ -0,0 +1,399 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
import {
LGraph,
LGraphNode,
LiteGraph,
NodeEvent,
onAllNodeEvents,
offAllNodeEvents
} from '@/lib/litegraph/src/litegraph'
import type { ISerialisedNode } from '@/lib/litegraph/src/litegraph'
function createGraph(): LGraph {
return new LGraph()
}
function createConnectedNodes(graph: LGraph) {
const source = new LGraphNode('Source')
source.addOutput('out', 'number')
const target = new LGraphNode('Target')
target.addInput('in', 'number')
graph.add(source)
graph.add(target)
return { source, target }
}
describe('NodeEventEmitter', () => {
let origLiteGraph: typeof LiteGraph
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
origLiteGraph = Object.assign({}, LiteGraph)
// @ts-expect-error Intended: Force remove an otherwise readonly non-optional property
delete origLiteGraph.Classes
Object.assign(LiteGraph, {
NODE_TITLE_HEIGHT: 20,
NODE_SLOT_HEIGHT: 15,
NODE_TEXT_SIZE: 14,
DEFAULT_SHADOW_COLOR: 'rgba(0,0,0,0.5)',
DEFAULT_GROUP_FONT_SIZE: 24,
isValidConnection: vi.fn().mockReturnValue(true)
})
})
afterEach(() => {
Object.assign(LiteGraph, origLiteGraph)
})
describe('instance-level on/off/emit', () => {
test('fires listener when event is emitted', () => {
const node = new LGraphNode('Test')
const listener = vi.fn()
node.on(NodeEvent.NODE_CREATED, listener)
node.emit(NodeEvent.NODE_CREATED)
expect(listener).toHaveBeenCalledOnce()
})
test('passes detail payload to listener', () => {
const node = new LGraphNode('Test')
const listener = vi.fn()
node.on(NodeEvent.EXECUTED, listener)
node.emit(NodeEvent.EXECUTED, { output: { text: ['hello'] } })
expect(listener).toHaveBeenCalledWith({ output: { text: ['hello'] } })
})
test('supports multiple listeners on the same event', () => {
const node = new LGraphNode('Test')
const listener1 = vi.fn()
const listener2 = vi.fn()
node.on(NodeEvent.EXECUTED, listener1)
node.on(NodeEvent.EXECUTED, listener2)
node.emit(NodeEvent.EXECUTED, { output: {} })
expect(listener1).toHaveBeenCalledOnce()
expect(listener2).toHaveBeenCalledOnce()
})
test('removes listener with off()', () => {
const node = new LGraphNode('Test')
const listener = vi.fn()
node.on(NodeEvent.EXECUTED, listener)
node.off(NodeEvent.EXECUTED, listener)
node.emit(NodeEvent.EXECUTED, { output: {} })
expect(listener).not.toHaveBeenCalled()
})
test('on() returns an unsubscribe function', () => {
const node = new LGraphNode('Test')
const listener = vi.fn()
const unsub = node.on(NodeEvent.EXECUTED, listener)
unsub()
node.emit(NodeEvent.EXECUTED, { output: {} })
expect(listener).not.toHaveBeenCalled()
})
test('does not fire listeners for other event types', () => {
const node = new LGraphNode('Test')
const listener = vi.fn()
node.on(NodeEvent.EXECUTED, listener)
node.emit(NodeEvent.NODE_CREATED)
expect(listener).not.toHaveBeenCalled()
})
test('isolates listeners between node instances', () => {
const node1 = new LGraphNode('A')
const node2 = new LGraphNode('B')
const listener = vi.fn()
node1.on(NodeEvent.EXECUTED, listener)
node2.emit(NodeEvent.EXECUTED, { output: {} })
expect(listener).not.toHaveBeenCalled()
})
})
describe('once()', () => {
test('fires listener only once', () => {
const node = new LGraphNode('Test')
const listener = vi.fn()
node.once(NodeEvent.EXECUTED, listener)
node.emit(NodeEvent.EXECUTED, { output: { a: 1 } })
node.emit(NodeEvent.EXECUTED, { output: { a: 2 } })
expect(listener).toHaveBeenCalledOnce()
expect(listener).toHaveBeenCalledWith({ output: { a: 1 } })
})
test('returns an unsubscribe function that cancels before firing', () => {
const node = new LGraphNode('Test')
const listener = vi.fn()
const unsub = node.once(NodeEvent.EXECUTED, listener)
unsub()
node.emit(NodeEvent.EXECUTED, { output: {} })
expect(listener).not.toHaveBeenCalled()
})
test('does not interfere with other listeners', () => {
const node = new LGraphNode('Test')
const onceListener = vi.fn()
const permanentListener = vi.fn()
node.once(NodeEvent.EXECUTED, onceListener)
node.on(NodeEvent.EXECUTED, permanentListener)
node.emit(NodeEvent.EXECUTED, { output: {} })
node.emit(NodeEvent.EXECUTED, { output: {} })
expect(onceListener).toHaveBeenCalledOnce()
expect(permanentListener).toHaveBeenCalledTimes(2)
})
})
describe('class-level onAllNodeEvents', () => {
afterEach(() => {
LGraphNode._classEventListeners?.clear()
})
test('fires class-level listener on any instance emit', () => {
const listener = vi.fn()
onAllNodeEvents(LGraphNode, NodeEvent.EXECUTED, listener)
const node = new LGraphNode('Test')
node.emit(NodeEvent.EXECUTED, { output: { data: 1 } })
expect(listener).toHaveBeenCalledWith({ output: { data: 1 } })
})
test('fires class-level listener with node as this', () => {
let capturedThis: unknown
const listener = function (this: unknown) {
capturedThis = this
}
onAllNodeEvents(LGraphNode, NodeEvent.NODE_CREATED, listener)
const node = new LGraphNode('Test')
node.emit(NodeEvent.NODE_CREATED)
expect(capturedThis).toBe(node)
})
test('fires both instance and class listeners', () => {
const instanceListener = vi.fn()
const classListener = vi.fn()
onAllNodeEvents(LGraphNode, NodeEvent.EXECUTED, classListener)
const node = new LGraphNode('Test')
node.on(NodeEvent.EXECUTED, instanceListener)
node.emit(NodeEvent.EXECUTED, { output: {} })
expect(instanceListener).toHaveBeenCalledOnce()
expect(classListener).toHaveBeenCalledOnce()
})
test('removes class-level listener with offAllNodeEvents', () => {
const listener = vi.fn()
onAllNodeEvents(LGraphNode, NodeEvent.EXECUTED, listener)
offAllNodeEvents(LGraphNode, NodeEvent.EXECUTED, listener)
const node = new LGraphNode('Test')
node.emit(NodeEvent.EXECUTED, { output: {} })
expect(listener).not.toHaveBeenCalled()
})
test('onAllNodeEvents returns an unsubscribe function', () => {
const listener = vi.fn()
const unsub = onAllNodeEvents(LGraphNode, NodeEvent.EXECUTED, listener)
unsub()
const node = new LGraphNode('Test')
node.emit(NodeEvent.EXECUTED, { output: {} })
expect(listener).not.toHaveBeenCalled()
})
})
describe('error isolation', () => {
test('continues calling listeners if one throws', () => {
const node = new LGraphNode('Test')
const errorListener = vi.fn(() => {
throw new Error('boom')
})
const normalListener = vi.fn()
vi.spyOn(console, 'error').mockImplementation(() => {})
node.on(NodeEvent.EXECUTED, errorListener)
node.on(NodeEvent.EXECUTED, normalListener)
node.emit(NodeEvent.EXECUTED, { output: {} })
expect(errorListener).toHaveBeenCalledOnce()
expect(normalListener).toHaveBeenCalledOnce()
expect(console.error).toHaveBeenCalledOnce()
})
})
describe('automatic cleanup on node removal', () => {
test('clears all listeners when node is removed from graph', () => {
const graph = createGraph()
const node = new LGraphNode('Test')
graph.add(node)
const listener = vi.fn()
node.on(NodeEvent.EXECUTED, listener)
graph.remove(node)
node.emit(NodeEvent.EXECUTED, { output: {} })
expect(listener).not.toHaveBeenCalled()
})
test('clears listeners on graph.clear()', () => {
const graph = createGraph()
const node = new LGraphNode('Test')
graph.add(node)
const listener = vi.fn()
node.on(NodeEvent.EXECUTED, listener)
graph.clear()
node.emit(NodeEvent.EXECUTED, { output: {} })
expect(listener).not.toHaveBeenCalled()
})
})
describe('lifecycle event integration', () => {
test('emits added when node is added to graph', () => {
const graph = createGraph()
const node = new LGraphNode('Test')
const listener = vi.fn()
node.on(NodeEvent.ADDED, listener)
graph.add(node)
expect(listener).toHaveBeenCalledOnce()
expect(listener).toHaveBeenCalledWith({ graph })
})
test('emits removed when node is removed from graph', () => {
const graph = createGraph()
const node = new LGraphNode('Test')
graph.add(node)
const listener = vi.fn()
node.on(NodeEvent.REMOVED, listener)
graph.remove(node)
expect(listener).toHaveBeenCalledOnce()
})
test('emits configured after node.configure()', () => {
const node = new LGraphNode('Test')
const graph = createGraph()
graph.add(node)
const listener = vi.fn()
node.on(NodeEvent.CONFIGURED, listener)
const serialised: ISerialisedNode = {
id: node.id,
type: 'Test',
pos: [0, 0],
size: [100, 100],
flags: {},
order: 0,
mode: 0
}
node.configure(serialised)
expect(listener).toHaveBeenCalledOnce()
expect(listener).toHaveBeenCalledWith({
serialisedNode: serialised
})
})
test('emits resize when setSize is called', () => {
const node = new LGraphNode('Test')
const listener = vi.fn()
node.on(NodeEvent.RESIZE, listener)
node.setSize([200, 300])
expect(listener).toHaveBeenCalledOnce()
const detail = listener.mock.calls[0][0]
expect(detail.size[0]).toBe(200)
expect(detail.size[1]).toBe(300)
})
})
describe('connections-change event', () => {
test('emits on both nodes when connected', () => {
const graph = createGraph()
const { source, target } = createConnectedNodes(graph)
const sourceListener = vi.fn()
const targetListener = vi.fn()
source.on(NodeEvent.CONNECTIONS_CHANGE, sourceListener)
target.on(NodeEvent.CONNECTIONS_CHANGE, targetListener)
source.connect(0, target, 0)
expect(sourceListener).toHaveBeenCalledWith(
expect.objectContaining({
type: 2, // NodeSlotType.OUTPUT
index: 0,
isConnected: true
})
)
expect(targetListener).toHaveBeenCalledWith(
expect.objectContaining({
type: 1, // NodeSlotType.INPUT
index: 0,
isConnected: true
})
)
})
test('emits on both nodes when disconnected', () => {
const graph = createGraph()
const { source, target } = createConnectedNodes(graph)
source.connect(0, target, 0)
const sourceListener = vi.fn()
const targetListener = vi.fn()
source.on(NodeEvent.CONNECTIONS_CHANGE, sourceListener)
target.on(NodeEvent.CONNECTIONS_CHANGE, targetListener)
target.disconnectInput(0)
expect(sourceListener).toHaveBeenCalledWith(
expect.objectContaining({
isConnected: false
})
)
expect(targetListener).toHaveBeenCalledWith(
expect.objectContaining({
isConnected: false
})
)
})
})
})

View File

@@ -0,0 +1,143 @@
type Listener<T = unknown> = (detail: T) => void
type ListenerMap = Map<string, Set<Listener>>
export type Unsubscribe = () => void
interface NodeWithEvents {
_eventListeners?: ListenerMap
constructor: { _classEventListeners?: ListenerMap }
}
type EventMapConstraint = { [K: string]: unknown }
export interface INodeEventEmitter<EventMap extends EventMapConstraint> {
on<K extends keyof EventMap & string>(
type: K,
listener: Listener<EventMap[K]>
): Unsubscribe
once<K extends keyof EventMap & string>(
type: K,
listener: Listener<EventMap[K]>
): Unsubscribe
off<K extends keyof EventMap & string>(
type: K,
listener: Listener<EventMap[K]>
): void
emit<K extends keyof EventMap & string>(
type: K,
...args: EventMap[K] extends never ? [] : [detail: EventMap[K]]
): void
_removeAllListeners?(): void
}
function addListener(
node: NodeWithEvents,
type: string,
listener: Listener
): Unsubscribe {
if (!node._eventListeners) node._eventListeners = new Map()
let listeners = node._eventListeners.get(type)
if (!listeners) {
listeners = new Set()
node._eventListeners.set(type, listeners)
}
listeners.add(listener)
return () => node._eventListeners?.get(type)?.delete(listener)
}
function nodeOn(
this: NodeWithEvents,
type: string,
listener: Listener
): Unsubscribe {
return addListener(this, type, listener)
}
function nodeOnce(
this: NodeWithEvents,
type: string,
listener: Listener
): Unsubscribe {
const wrapper: Listener = (detail) => {
this._eventListeners?.get(type)?.delete(wrapper)
listener(detail)
}
return addListener(this, type, wrapper)
}
function nodeOff(this: NodeWithEvents, type: string, listener: Listener) {
this._eventListeners?.get(type)?.delete(listener)
}
function nodeEmit(this: NodeWithEvents, type: string, detail?: unknown) {
const listeners = this._eventListeners?.get(type)
if (listeners) {
for (const listener of listeners) {
try {
listener(detail)
} catch (e) {
console.error(
`[ComfyUI] Error in node event listener for "${type}":`,
e
)
}
}
}
const classListeners = this.constructor._classEventListeners?.get(type)
if (classListeners) {
for (const listener of classListeners) {
try {
listener.call(this, detail)
} catch (e) {
console.error(
`[ComfyUI] Error in class event listener for "${type}":`,
e
)
}
}
}
}
function nodeRemoveAllListeners(this: NodeWithEvents) {
this._eventListeners?.clear()
}
export function applyNodeEventEmitter(target: { prototype: object }): void {
Object.assign(target.prototype, {
on: nodeOn,
once: nodeOnce,
off: nodeOff,
emit: nodeEmit,
_removeAllListeners: nodeRemoveAllListeners
})
}
export function onAllNodeEvents(
nodeClass: { _classEventListeners?: ListenerMap },
type: string,
listener: Listener
): Unsubscribe {
if (!nodeClass._classEventListeners)
nodeClass._classEventListeners = new Map()
let listeners = nodeClass._classEventListeners.get(type)
if (!listeners) {
listeners = new Set()
nodeClass._classEventListeners.set(type, listeners)
}
listeners.add(listener)
return () => nodeClass._classEventListeners?.get(type)?.delete(listener)
}
export function offAllNodeEvents(
nodeClass: { _classEventListeners?: ListenerMap },
type: string,
listener: Listener
): void {
nodeClass._classEventListeners?.get(type)?.delete(listener)
}

View File

@@ -86,6 +86,13 @@ export { ContextMenu } from './ContextMenu'
export { DragAndScale } from './DragAndScale'
export { NodeEvent } from './infrastructure/LGraphNodeEventMap'
export type { LGraphNodeEventMap } from './infrastructure/LGraphNodeEventMap'
export {
onAllNodeEvents,
offAllNodeEvents
} from './infrastructure/NodeEventEmitter'
export type { Unsubscribe } from './infrastructure/NodeEventEmitter'
export { Rectangle } from './infrastructure/Rectangle'
export type { SubgraphEventMap } from './infrastructure/SubgraphEventMap'
export type {

View File

@@ -11,6 +11,7 @@ import type {
Size
} from '@/lib/litegraph/src/litegraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { NodeEvent } from '@/lib/litegraph/src/infrastructure/LGraphNodeEventMap'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import type {
IBaseWidget,
@@ -436,6 +437,12 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
this.callback?.(this.value, canvas, node, pos, e)
node.onWidgetChanged?.(this.name ?? '', v, oldValue, this)
node.emit(NodeEvent.WIDGET_CHANGED, {
name: this.name ?? '',
value: v,
oldValue,
widget: this
})
if (node.graph) node.graph._version++
}

View File

@@ -1,128 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { HubWorkflowIndexEntry } from '../schemas/hubWorkflowIndexSchema'
import {
mapHubWorkflowIndexEntryToTemplate,
mapHubWorkflowIndexToCategories
} from './hubWorkflowIndexMapper'
const makeEntry = (
overrides?: Partial<HubWorkflowIndexEntry>
): HubWorkflowIndexEntry => ({
name: 'sdxl_simple',
title: 'SDXL Simple',
status: 'approved',
...overrides
})
describe('mapHubWorkflowIndexEntryToTemplate', () => {
it('maps template metadata used by the selector dialog', () => {
const result = mapHubWorkflowIndexEntryToTemplate(
makeEntry({
description: 'Starter SDXL workflow',
tags: ['Image', 'Text to Image'],
models: ['SDXL'],
requiresCustomNodes: ['comfy-custom-pack'],
thumbnailVariant: 'compareSlider',
mediaType: 'image',
mediaSubtype: 'webp',
size: 1024,
vram: 2048,
openSource: true,
tutorialUrl: 'https://docs.comfy.org/tutorials/sdxl',
logos: [
{
provider: 'OpenAI',
label: 'OpenAI',
opacity: 0.7
}
],
date: '2026-04-14',
includeOnDistributions: ['cloud', 'desktop', 'unsupported'],
thumbnailUrl: 'https://cdn.example.com/thumb.webp',
thumbnailComparisonUrl: 'https://cdn.example.com/compare.webp',
shareId: 'share-123',
usage: 42,
searchRank: 7,
isEssential: true,
useCase: 'Image generation',
license: 'MIT'
})
)
expect(result).toEqual({
name: 'sdxl_simple',
title: 'SDXL Simple',
description: 'Starter SDXL workflow',
mediaType: 'image',
mediaSubtype: 'webp',
thumbnailVariant: 'compareSlider',
isEssential: true,
shareId: 'share-123',
tags: ['Image', 'Text to Image'],
models: ['SDXL'],
date: '2026-04-14',
useCase: 'Image generation',
license: 'MIT',
vram: 2048,
size: 1024,
openSource: true,
thumbnailUrl: 'https://cdn.example.com/thumb.webp',
thumbnailComparisonUrl: 'https://cdn.example.com/compare.webp',
requiresCustomNodes: ['comfy-custom-pack'],
searchRank: 7,
usage: 42,
includeOnDistributions: ['cloud', 'desktop'],
logos: [{ provider: 'OpenAI', label: 'OpenAI', opacity: 0.7 }],
tutorialUrl: 'https://docs.comfy.org/tutorials/sdxl'
})
})
it('infers video thumbnails from preview URLs', () => {
const result = mapHubWorkflowIndexEntryToTemplate(
makeEntry({
mediaType: 'image',
mediaSubtype: 'webp',
thumbnailUrl: 'https://cdn.example.com/preview.mp4'
})
)
expect(result.mediaType).toBe('video')
expect(result.mediaSubtype).toBe('mp4')
})
it('drops invalid logo and distribution values', () => {
const result = mapHubWorkflowIndexEntryToTemplate(
makeEntry({
logos: [
{ provider: ['OpenAI', 'Runway'], gap: -4 },
{ provider: 123 }
] as Array<Record<string, unknown>>,
includeOnDistributions: ['local', 'desktop', 'invalid']
})
)
expect(result.logos).toEqual([{ provider: ['OpenAI', 'Runway'], gap: -4 }])
expect(result.includeOnDistributions).toEqual(['local', 'desktop'])
})
})
describe('mapHubWorkflowIndexToCategories', () => {
it('wraps flat hub entries in a single default category', () => {
const result = mapHubWorkflowIndexToCategories(
[
makeEntry({ name: 'template-a', title: 'Template A' }),
makeEntry({ name: 'template-b', title: 'Template B' })
],
'All Templates'
)
expect(result).toHaveLength(1)
expect(result[0].moduleName).toBe('default')
expect(result[0].title).toBe('All Templates')
expect(result[0].templates.map((template) => template.name)).toEqual([
'template-a',
'template-b'
])
})
})

View File

@@ -1,137 +0,0 @@
import { TemplateIncludeOnDistributionEnum } from '../types/template'
import type {
LogoInfo,
TemplateInfo,
WorkflowTemplates
} from '../types/template'
import type { HubWorkflowIndexEntry } from '../schemas/hubWorkflowIndexSchema'
const distributionValues = new Set(
Object.values(TemplateIncludeOnDistributionEnum)
)
function getPreviewExtension(url?: string): string | undefined {
if (!url) return undefined
try {
const { pathname } = new URL(url)
const extension = pathname.split('.').pop()?.toLowerCase()
return extension || undefined
} catch {
return undefined
}
}
function getPreviewMediaType(
thumbnailUrl?: string,
mediaType?: string
): string | undefined {
const extension = getPreviewExtension(thumbnailUrl)
if (extension && ['mp4', 'webm', 'mov'].includes(extension)) {
return 'video'
}
if (extension && ['png', 'jpg', 'jpeg', 'webp', 'gif'].includes(extension)) {
return 'image'
}
return mediaType
}
function getPreviewMediaSubtype(
thumbnailUrl?: string,
mediaSubtype?: string
): string {
return getPreviewExtension(thumbnailUrl) ?? mediaSubtype ?? 'webp'
}
function mapLogo(logo: Record<string, unknown>): LogoInfo | null {
const provider = logo.provider
if (
typeof provider !== 'string' &&
!(
Array.isArray(provider) &&
provider.length > 0 &&
provider.every((value) => typeof value === 'string')
)
) {
return null
}
return {
provider,
...(typeof logo.label === 'string' ? { label: logo.label } : {}),
...(typeof logo.gap === 'number' ? { gap: logo.gap } : {}),
...(typeof logo.position === 'string' ? { position: logo.position } : {}),
...(typeof logo.opacity === 'number' ? { opacity: logo.opacity } : {})
}
}
function mapLogos(
logos?: Array<Record<string, unknown>>
): LogoInfo[] | undefined {
const mapped = logos?.map(mapLogo).filter((logo): logo is LogoInfo => !!logo)
return mapped?.length ? mapped : undefined
}
function mapIncludeOnDistributions(
includeOnDistributions?: string[]
): TemplateIncludeOnDistributionEnum[] | undefined {
const mapped = includeOnDistributions?.filter(
(value): value is TemplateIncludeOnDistributionEnum =>
distributionValues.has(value as TemplateIncludeOnDistributionEnum)
)
return mapped?.length ? mapped : undefined
}
export function mapHubWorkflowIndexEntryToTemplate(
entry: HubWorkflowIndexEntry
): TemplateInfo {
return {
name: entry.name,
title: entry.title,
description: entry.description ?? '',
mediaType:
getPreviewMediaType(entry.thumbnailUrl, entry.mediaType) ?? 'image',
mediaSubtype: getPreviewMediaSubtype(
entry.thumbnailUrl,
entry.mediaSubtype
),
thumbnailVariant: entry.thumbnailVariant,
isEssential: entry.isEssential,
shareId: entry.shareId,
tags: entry.tags,
models: entry.models,
date: entry.date,
useCase: entry.useCase,
license: entry.license,
vram: entry.vram,
size: entry.size,
openSource: entry.openSource,
thumbnailUrl: entry.thumbnailUrl,
thumbnailComparisonUrl: entry.thumbnailComparisonUrl,
tutorialUrl: entry.tutorialUrl,
requiresCustomNodes: entry.requiresCustomNodes,
searchRank: entry.searchRank,
usage: entry.usage,
includeOnDistributions: mapIncludeOnDistributions(
entry.includeOnDistributions
),
logos: mapLogos(entry.logos)
}
}
export function mapHubWorkflowIndexToCategories(
entries: HubWorkflowIndexEntry[],
title: string = 'All'
): WorkflowTemplates[] {
return [
{
moduleName: 'default',
title,
templates: entries.map(mapHubWorkflowIndexEntryToTemplate)
}
]
}

View File

@@ -17,15 +17,6 @@ const preservedQueryMocks = vi.hoisted(() => ({
clearPreservedQuery: vi.fn()
}))
const distributionState = vi.hoisted(() => ({
isCloud: false
}))
const workflowTemplateStoreMocks = vi.hoisted(() => ({
getTemplateByName: vi.fn(),
getTemplateByShareId: vi.fn()
}))
// Mock vue-router
let mockQueryParams: Record<string, string | string[] | undefined> = {}
const mockRouterReplace = vi.fn()
@@ -44,19 +35,6 @@ vi.mock(
() => preservedQueryMocks
)
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return distributionState.isCloud
}
}))
vi.mock(
'@/platform/workflow/templates/repositories/workflowTemplatesStore',
() => ({
useWorkflowTemplatesStore: () => workflowTemplateStoreMocks
})
)
// Mock template workflows composable
const mockLoadTemplates = vi.fn().mockResolvedValue(true)
const mockLoadWorkflowTemplate = vi.fn().mockResolvedValue(true)
@@ -107,7 +85,6 @@ describe('useTemplateUrlLoader', () => {
vi.clearAllMocks()
mockQueryParams = {}
mockCanvasStore.linearMode = false
distributionState.isCloud = false
})
it('does not load template when no query param present', () => {
@@ -265,21 +242,6 @@ describe('useTemplateUrlLoader', () => {
})
})
it('resolves cloud template URLs by share id before loading', async () => {
distributionState.isCloud = true
mockQueryParams = { template: 'share-123' }
workflowTemplateStoreMocks.getTemplateByName.mockReturnValue(undefined)
workflowTemplateStoreMocks.getTemplateByShareId.mockReturnValue({
name: 'sdxl_simple',
sourceModule: 'hub'
})
const { loadTemplateFromUrl } = useTemplateUrlLoader()
await loadTemplateFromUrl()
expect(mockLoadWorkflowTemplate).toHaveBeenCalledWith('sdxl_simple', 'hub')
})
it('removes template params from URL after successful load', async () => {
mockQueryParams = {
template: 'flux_simple',

View File

@@ -2,11 +2,9 @@ import { useToast } from 'primevue/usetoast'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { isCloud } from '@/platform/distribution/types'
import { clearPreservedQuery } from '@/platform/navigation/preservedQueryManager'
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
import { useTelemetry } from '@/platform/telemetry'
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
// eslint-disable-next-line import-x/no-restricted-paths
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
@@ -110,24 +108,9 @@ export function useTemplateUrlLoader() {
try {
await templateWorkflows.loadTemplates()
let resolvedTemplate = templateParam
let resolvedSource = sourceParam
if (isCloud) {
const workflowTemplatesStore = useWorkflowTemplatesStore()
const matchedTemplate =
workflowTemplatesStore.getTemplateByName(templateParam) ??
workflowTemplatesStore.getTemplateByShareId(templateParam)
if (matchedTemplate) {
resolvedTemplate = matchedTemplate.name
resolvedSource = matchedTemplate.sourceModule ?? resolvedSource
}
}
const success = await templateWorkflows.loadWorkflowTemplate(
resolvedTemplate,
resolvedSource
templateParam,
sourceParam
)
if (!success) {

View File

@@ -15,16 +15,6 @@ vi.mock(
})
)
const distributionState = vi.hoisted(() => ({
isCloud: false
}))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return distributionState.isCloud
}
}))
// Mock the API
vi.mock('@/scripts/api', () => ({
api: {
@@ -59,14 +49,6 @@ vi.mock('@/stores/dialogStore', () => ({
}))
}))
const mockGetSharedWorkflow = vi.hoisted(() => vi.fn())
vi.mock('@/platform/workflow/sharing/services/workflowShareService', () => ({
useWorkflowShareService: () => ({
getSharedWorkflow: mockGetSharedWorkflow
})
}))
// Mock fetch
global.fetch = vi.fn()
@@ -76,20 +58,9 @@ describe('useTemplateWorkflows', () => {
let mockWorkflowTemplatesStore: MockWorkflowTemplatesStore
beforeEach(() => {
distributionState.isCloud = false
mockWorkflowTemplatesStore = {
isLoaded: false,
loadWorkflowTemplates: vi.fn().mockResolvedValue(true),
getTemplateByName: vi.fn((name: string) =>
name === 'template1'
? {
name: 'template1',
mediaType: 'image',
mediaSubtype: 'jpg',
description: 'Template 1 description'
}
: undefined
),
groupedTemplates: [
{
label: 'ComfyUI Examples',
@@ -144,16 +115,6 @@ describe('useTemplateWorkflows', () => {
vi.mocked(fetch).mockResolvedValue({
json: vi.fn().mockResolvedValue({ workflow: 'data' })
} as Partial<Response> as Response)
mockGetSharedWorkflow.mockResolvedValue({
shareId: 'share-123',
workflowId: 'workflow-123',
name: 'Shared Template',
listed: true,
publishedAt: null,
workflowJson: { workflow: 'shared' },
assets: []
})
})
it('should load templates from store', async () => {
@@ -217,25 +178,6 @@ describe('useTemplateWorkflows', () => {
)
})
it('should prefer absolute thumbnail URLs when provided', () => {
const { getTemplateThumbnailUrl } = useTemplateWorkflows()
const template = {
name: 'hub-template',
mediaSubtype: 'webp',
mediaType: 'image',
description: 'Hub template',
thumbnailUrl: 'https://cdn.example.com/thumb.webp',
thumbnailComparisonUrl: 'https://cdn.example.com/compare.webp'
}
expect(getTemplateThumbnailUrl(template, 'hub', '1')).toBe(
'https://cdn.example.com/thumb.webp'
)
expect(getTemplateThumbnailUrl(template, 'hub', '2')).toBe(
'https://cdn.example.com/compare.webp'
)
})
it('should format template titles correctly', () => {
const { getTemplateTitle } = useTemplateWorkflows()
@@ -365,27 +307,4 @@ describe('useTemplateWorkflows', () => {
// Restore console.error
consoleSpy.mockRestore()
})
it('should load cloud templates by share id through the shared workflow service', async () => {
distributionState.isCloud = true
mockWorkflowTemplatesStore.isLoaded = true
vi.mocked(fetch).mockClear()
mockWorkflowTemplatesStore.getTemplateByName = vi.fn(() => ({
name: 'template1',
shareId: 'share-123',
sourceModule: 'default',
title: 'Template 1',
mediaType: 'image',
mediaSubtype: 'jpg',
description: 'Template 1 description'
}))
const { loadWorkflowTemplate } = useTemplateWorkflows()
const result = await loadWorkflowTemplate('template1', 'hub')
expect(result).toBe(true)
expect(mockGetSharedWorkflow).toHaveBeenCalledWith('share-123')
expect(fetch).not.toHaveBeenCalled()
})
})

View File

@@ -9,7 +9,6 @@ import type {
TemplateInfo,
WorkflowTemplates
} from '@/platform/workflow/templates/types/template'
import { useWorkflowShareService } from '@/platform/workflow/sharing/services/workflowShareService'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useDialogStore } from '@/stores/dialogStore'
@@ -17,7 +16,6 @@ import { useDialogStore } from '@/stores/dialogStore'
export function useTemplateWorkflows() {
const { t } = useI18n()
const workflowTemplatesStore = useWorkflowTemplatesStore()
const workflowShareService = useWorkflowShareService()
const dialogStore = useDialogStore()
// State
@@ -66,14 +64,6 @@ export function useTemplateWorkflows() {
sourceModule: string,
index = '1'
) => {
if (template.thumbnailUrl) {
if (index === '2' && template.thumbnailComparisonUrl) {
return template.thumbnailComparisonUrl
}
return template.thumbnailUrl
}
const basePath =
sourceModule === 'default'
? api.fileURL(`/templates/${template.name}`)
@@ -167,15 +157,6 @@ export function useTemplateWorkflows() {
* Fetches template JSON from the appropriate endpoint
*/
const fetchTemplateJson = async (id: string, sourceModule: string) => {
const template = workflowTemplatesStore.getTemplateByName(id)
if (isCloud && template?.shareId) {
const workflow = await workflowShareService.getSharedWorkflow(
template.shareId
)
return workflow.workflowJson
}
if (sourceModule === 'default') {
// Default templates provided by frontend are served on this separate endpoint
return fetch(api.fileURL(`/templates/${id}.json`)).then((r) => r.json())

View File

@@ -1,100 +0,0 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { useWorkflowTemplatesStore } from './workflowTemplatesStore'
const distributionState = vi.hoisted(() => ({
isCloud: true
}))
const apiMocks = vi.hoisted(() => ({
getWorkflowTemplates: vi.fn(),
getHubWorkflowTemplateIndex: vi.fn(),
getCoreWorkflowTemplates: vi.fn(),
fileURL: vi.fn((path: string) => path)
}))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return distributionState.isCloud
}
}))
vi.mock('@/scripts/api', () => ({
api: apiMocks
}))
vi.mock('@/i18n', () => ({
i18n: {
global: {
locale: ref('en')
}
},
st: (_key: string, fallback: string) => fallback
}))
describe('workflowTemplatesStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
distributionState.isCloud = true
apiMocks.getWorkflowTemplates.mockResolvedValue({})
apiMocks.getHubWorkflowTemplateIndex.mockResolvedValue([
{
name: 'starter-template',
title: 'Starter Template',
status: 'approved',
description: 'A cloud starter workflow',
shareId: 'share-123',
usage: 10,
searchRank: 5,
isEssential: true,
thumbnailUrl: 'https://cdn.example.com/thumb.webp'
}
])
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
headers: {
get: vi.fn(() => 'application/json')
},
json: vi.fn().mockResolvedValue({})
})
)
})
it('loads cloud templates from the hub index and resolves share ids', async () => {
const store = useWorkflowTemplatesStore()
await store.loadWorkflowTemplates()
const template = store.getTemplateByShareId('share-123')
expect(template?.name).toBe('starter-template')
expect(template?.shareId).toBe('share-123')
expect(store.knownTemplateNames.has('starter-template')).toBe(true)
})
it('creates a generic getting started nav item for essential cloud templates', async () => {
const store = useWorkflowTemplatesStore()
await store.loadWorkflowTemplates()
const navItem = store.navGroupedTemplates.find(
(item) => 'id' in item && item.id === 'basics-getting-started'
)
expect(navItem).toEqual({
id: 'basics-getting-started',
label: 'Getting Started',
icon: expect.any(String)
})
expect(
store
.filterTemplatesByCategory('basics-getting-started')
.map((template) => template.name)
).toEqual(['starter-template'])
})
})

View File

@@ -8,7 +8,6 @@ import type { NavGroupData, NavItemData } from '@/types/navTypes'
import { generateCategoryId, getCategoryIcon } from '@/utils/categoryUtil'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { mapHubWorkflowIndexToCategories } from '../adapters/hubWorkflowIndexMapper'
import { zLogoIndex } from '../schemas/templateSchema'
import type { LogoIndex } from '../schemas/templateSchema'
import type {
@@ -42,14 +41,6 @@ export const useWorkflowTemplatesStore = defineStore(
return enhancedTemplates.value.find((template) => template.name === name)
}
const getTemplateByShareId = (
shareId: string
): EnhancedTemplate | undefined => {
return enhancedTemplates.value.find(
(template) => template.shareId === shareId
)
}
// Store filter mappings for dynamic categories
type FilterData = {
category?: string
@@ -213,7 +204,7 @@ export const useWorkflowTemplatesStore = defineStore(
category: category.title,
categoryType: category.type,
categoryGroup: category.category,
isEssential: template.isEssential ?? category.isEssential,
isEssential: category.isEssential,
isPartnerNode: template.openSource === false,
searchableText: [
template.title || template.name,
@@ -270,16 +261,12 @@ export const useWorkflowTemplatesStore = defineStore(
}
if (categoryId.startsWith('basics-')) {
const basicsCategory = categoryId.replace('basics-', '')
// Filter for templates from categories marked as essential
return enhancedTemplates.value.filter(
(t) =>
t.isEssential &&
(t.category?.toLowerCase().replace(/\s+/g, '-') ===
basicsCategory ||
(basicsCategory === 'getting-started' &&
(!t.category || t.sourceModule === 'default')))
t.category?.toLowerCase().replace(/\s+/g, '-') ===
categoryId.replace('basics-', '')
)
}
@@ -368,17 +355,6 @@ export const useWorkflowTemplatesStore = defineStore(
getCategoryIcon(essentialCat.type || 'getting-started')
})
})
} else if (
enhancedTemplates.value.some((template) => template.isEssential)
) {
items.push({
id: generateCategoryId('basics', 'Getting Started'),
label: st(
'templateWorkflows.category.Getting Started',
'Getting Started'
),
icon: getCategoryIcon('getting-started')
})
}
// 3. Group categories from JSON dynamically
@@ -497,31 +473,10 @@ export const useWorkflowTemplatesStore = defineStore(
})
async function fetchCoreTemplates() {
if (isCloud) {
const [hubIndexResult, logoIndexResult] = await Promise.all([
api.getHubWorkflowTemplateIndex(),
fetchLogoIndex()
])
coreTemplates.value = mapHubWorkflowIndexToCategories(
hubIndexResult,
st('templateWorkflows.category.All', 'All')
)
englishTemplates.value = []
logoIndex.value = logoIndexResult
const coreNames = coreTemplates.value.flatMap((category) =>
category.templates.map((template) => template.name)
)
const customNames = Object.values(customTemplates.value).flat()
knownTemplateNames.value = new Set([...coreNames, ...customNames])
return
}
const locale = i18n.global.locale.value
const [coreResult, englishResult, logoIndexResult] = await Promise.all([
api.getCoreWorkflowTemplates(locale),
locale !== 'en'
isCloud && locale !== 'en'
? api.getCoreWorkflowTemplates('en')
: Promise.resolve([]),
fetchLogoIndex()
@@ -628,7 +583,6 @@ export const useWorkflowTemplatesStore = defineStore(
loadWorkflowTemplates,
knownTemplateNames,
getTemplateByName,
getTemplateByShareId,
getEnglishMetadata,
getLogoUrl
}

View File

@@ -1,16 +0,0 @@
import { zHubWorkflowTemplateEntry } from '@comfyorg/ingest-types/zod'
import { z } from 'zod'
// The live cloud index response currently includes fields that are not yet
// present in the generated ingest OpenAPI types.
export const zHubWorkflowIndexEntry = zHubWorkflowTemplateEntry.extend({
usage: z.number().optional(),
searchRank: z.number().optional(),
isEssential: z.boolean().optional(),
useCase: z.string().optional(),
license: z.string().optional()
})
export const zHubWorkflowIndexResponse = z.array(zHubWorkflowIndexEntry)
export type HubWorkflowIndexEntry = z.infer<typeof zHubWorkflowIndexEntry>

View File

@@ -26,7 +26,6 @@ export interface TemplateInfo {
localizedDescription?: string
isEssential?: boolean
sourceModule?: string
shareId?: string
tags?: string[]
models?: string[]
date?: string
@@ -41,8 +40,6 @@ export interface TemplateInfo {
* Whether this template uses open source models. When false, indicates partner/API node templates.
*/
openSource?: boolean
thumbnailUrl?: string
thumbnailComparisonUrl?: string
/**
* Array of custom node package IDs required for this template (from Custom Node Registry).
* Templates with this field will be hidden on local installations temporarily.

View File

@@ -4,6 +4,7 @@ import Popover from 'primevue/popover'
import { computed, ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
import { useToastStore } from '@/platform/updates/common/toastStore'
import type {
@@ -50,6 +51,7 @@ interface Props {
}
const { t } = useI18n()
const overlayProps = useTransformCompatOverlayProps()
const {
placeholder,
@@ -209,6 +211,7 @@ function handleSelection(item: FormDropdownItem, index: number) {
ref="popoverRef"
:dismissable="true"
:close-on-escape="true"
:append-to="overlayProps.appendTo"
unstyled
:pt="{
root: {

View File

@@ -4,6 +4,7 @@ import { ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
import type {
FilterOption,
OwnershipFilterOption,
@@ -15,6 +16,7 @@ import FormSearchInput from '../FormSearchInput.vue'
import type { LayoutMode, SortOption } from './types'
const { t } = useI18n()
const overlayProps = useTransformCompatOverlayProps()
defineProps<{
sortOptions: SortOption[]
@@ -133,6 +135,7 @@ function toggleBaseModelSelection(item: FilterOption) {
ref="sortPopoverRef"
:dismissable="true"
:close-on-escape="true"
:append-to="overlayProps.appendTo"
unstyled
:pt="{
root: {
@@ -195,6 +198,7 @@ function toggleBaseModelSelection(item: FilterOption) {
ref="ownershipPopoverRef"
:dismissable="true"
:close-on-escape="true"
:append-to="overlayProps.appendTo"
unstyled
:pt="{
root: {
@@ -257,6 +261,7 @@ function toggleBaseModelSelection(item: FilterOption) {
ref="baseModelPopoverRef"
:dismissable="true"
:close-on-escape="true"
:append-to="overlayProps.appendTo"
unstyled
:pt="{
root: {

View File

@@ -3,7 +3,7 @@ import { ref } from 'vue'
import MultiSelectWidget from '@/components/graph/widgets/MultiSelectWidget.vue'
import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { isComboWidget } from '@/lib/litegraph/src/litegraph'
import { isComboWidget, NodeEvent } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { assetService } from '@/platform/assets/services/assetService'
import { getAssetFilename } from '@/platform/assets/utils/assetMetadataUtils'
@@ -137,6 +137,12 @@ function createAssetBrowserWidget(
defaultValue,
onValueChange: (widget, newValue, oldValue) => {
node.onWidgetChanged?.(widget.name, newValue, oldValue, widget)
node.emit(NodeEvent.WIDGET_CHANGED, {
name: widget.name,
value: newValue,
oldValue,
widget
})
}
})
}

View File

@@ -21,8 +21,6 @@ import type {
TemplateInfo,
WorkflowTemplates
} from '@/platform/workflow/templates/types/template'
import { zHubWorkflowIndexResponse } from '@/platform/workflow/templates/schemas/hubWorkflowIndexSchema'
import type { HubWorkflowIndexEntry } from '@/platform/workflow/templates/schemas/hubWorkflowIndexSchema'
import type {
ComfyApiWorkflow,
ComfyWorkflowJSON,
@@ -829,21 +827,6 @@ export class ComfyApi extends EventTarget {
}
}
async getHubWorkflowTemplateIndex(): Promise<HubWorkflowIndexEntry[]> {
const res = await this.fetchApi('/hub/workflows/index')
if (!res.ok) {
throw new Error(`Failed to load hub workflow index: ${res.status}`)
}
const data = await res.json()
const parsed = zHubWorkflowIndexResponse.safeParse(data)
if (!parsed.success) {
throw new Error('Invalid hub workflow index response')
}
return parsed.data
}
/**
* Gets a list of embedding names
*/

View File

@@ -16,7 +16,8 @@ import {
LGraph,
LGraphCanvas,
LGraphNode,
LiteGraph
LiteGraph,
NodeEvent
} from '@/lib/litegraph/src/litegraph'
import { snapPoint } from '@/lib/litegraph/src/measure'
import type { Vector2 } from '@/lib/litegraph/src/litegraph'
@@ -738,8 +739,9 @@ export class ComfyApp {
})
const node = getNodeByExecutionId(this.rootGraph, executionId)
if (node && node.onExecuted) {
node.onExecuted(detail.output)
if (node) {
node.onExecuted?.(detail.output)
node.emit(NodeEvent.EXECUTED, { output: detail.output })
}
})

19
src/scripts/nodeEvents.ts Normal file
View File

@@ -0,0 +1,19 @@
/**
* Public API for node event subscriptions.
*
* Exposed via window.comfyAPI.nodeEvents for custom node extensions.
*
* Usage:
* const { NodeEvent, onAllNodeEvents, offAllNodeEvents } = window.comfyAPI.nodeEvents
*
* // In beforeRegisterNodeDef:
* onAllNodeEvents(nodeType, NodeEvent.EXECUTED, function(detail) {
* // `this` is the node instance
* console.log('Node executed:', detail.output)
* })
*/
export { NodeEvent } from '@/lib/litegraph/src/infrastructure/LGraphNodeEventMap'
export {
onAllNodeEvents,
offAllNodeEvents
} from '@/lib/litegraph/src/infrastructure/NodeEventEmitter'