import { LiteGraph, LGraph, LGraphCanvas, LGraphNode, type LGraphNodeConstructor, type LGraphNodeExecutable, type SerializedLGraph, type SerializedLGraphGroup, type SerializedLGraphNode, type SerializedLLink, NodeMode, type Vector2, BuiltInSlotType, type INodeInputSlot } from "@litegraph-ts/core"; import type { LConnectionKind, INodeSlot } from "@litegraph-ts/core"; import ComfyAPI, { type ComfyAPIQueueStatus } from "$lib/api" import { getPngMetadata, importA1111 } from "$lib/pnginfo"; import EventEmitter from "events"; import type TypedEmitter from "typed-emitter"; // Import nodes import "@litegraph-ts/nodes-basic" import "@litegraph-ts/nodes-events" import "@litegraph-ts/nodes-logic" import "@litegraph-ts/nodes-math" import "@litegraph-ts/nodes-strings" import "$lib/nodes/index" import * as nodes from "$lib/nodes/index" import ComfyGraphCanvas, { type SerializedGraphCanvasState } from "$lib/ComfyGraphCanvas"; import type ComfyGraphNode from "$lib/nodes/ComfyGraphNode"; import * as widgets from "$lib/widgets/index" import queueState from "$lib/stores/queueState"; import { type SvelteComponentDev } from "svelte/internal"; import type IComfyInputSlot from "$lib/IComfyInputSlot"; import type { SerializedLayoutState } from "$lib/stores/layoutState"; import layoutState from "$lib/stores/layoutState"; import { toast } from '@zerodevx/svelte-toast' import ComfyGraph from "$lib/ComfyGraph"; import { ComfyBackendNode } from "$lib/nodes/ComfyBackendNode"; import { get } from "svelte/store"; import { tick } from "svelte"; import uiState from "$lib/stores/uiState"; import { download, jsonToJsObject, promptToGraphVis, range, workflowToGraphVis } from "$lib/utils"; import notify from "$lib/notify"; import configState from "$lib/stores/configState"; import { blankGraph } from "$lib/defaultGraph"; export const COMFYBOX_SERIAL_VERSION = 1; LiteGraph.catch_exceptions = false; LiteGraph.CANVAS_GRID_SIZE = 32; LiteGraph.default_subgraph_lgraph_factory = () => new ComfyGraph(); if (typeof window !== "undefined") { // Load default visibility nodes.ComfyReroute.setDefaultTextVisibility(!!localStorage["Comfy.ComfyReroute.DefaultVisibility"]); } type QueueItem = { num: number, batchCount: number } export type SerializedAppState = { createdBy: "ComfyBox", version: number, workflow: SerializedLGraph, layout: SerializedLayoutState, canvas: SerializedGraphCanvasState } /** [link origin, link index] | value */ export type SerializedPromptInput = [string, number] | any export type SerializedPromptInputs = { inputs: Record, class_type: string } export type SerializedPromptOutput = Record export type SerializedPrompt = { workflow: SerializedLGraph, output: SerializedPromptOutput } export type Progress = { value: number, max: number } type BackendComboNode = { comboNode: nodes.ComfyComboNode inputSlot: IComfyInputSlot, backendNode: ComfyBackendNode } function isActiveBackendNode(node: ComfyGraphNode, tag: string | null): boolean { if (!node.isBackendNode) return false; if (tag && !hasTag(node, tag)) { console.debug("Skipping tagged node", tag, node.properties.tags, node) return false; } if (node.mode === NodeMode.NEVER) { // Don't serialize muted nodes return false; } return true; } function hasTag(node: LGraphNode, tag: string): boolean { return "tags" in node.properties && node.properties.tags.indexOf(tag) !== -1 } export default class ComfyApp { api: ComfyAPI; rootEl: HTMLDivElement | null = null; canvasEl: HTMLCanvasElement | null = null; canvasCtx: CanvasRenderingContext2D | null = null; lGraph: ComfyGraph | null = null; lCanvas: ComfyGraphCanvas | null = null; dropZone: HTMLElement | null = null; nodeOutputs: Record = {}; dragOverNode: LGraphNode | null = null; shiftDown: boolean = false; selectedGroupMoving: boolean = false; private queueItems: QueueItem[] = []; private processingQueue: boolean = false; private alreadySetup = false; constructor() { this.api = new ComfyAPI(); } async setup(): Promise { if (this.alreadySetup) { console.error("Already setup") return; } this.setupColorScheme() this.rootEl = document.getElementById("app") as HTMLDivElement; this.canvasEl = document.getElementById("graph-canvas") as HTMLCanvasElement; this.lGraph = new ComfyGraph(); this.lCanvas = new ComfyGraphCanvas(this, this.canvasEl); this.canvasCtx = this.canvasEl.getContext("2d"); LiteGraph.release_link_on_empty_shows_menu = true; LiteGraph.alt_drag_do_clone_nodes = true; const uiUnlocked = get(uiState).uiUnlocked; this.lCanvas.allow_dragnodes = uiUnlocked; this.lCanvas.allow_interaction = uiUnlocked; (window as any).LiteGraph = LiteGraph; // await this.#invokeExtensionsAsync("init"); await this.registerNodes(); // Load previous workflow let restored = false; try { const json = localStorage.getItem("workflow"); if (json) { const state = JSON.parse(json) as SerializedAppState; await this.deserialize(state) restored = true; } } catch (err) { console.error("Error loading previous workflow", err); notify(`Error loading previous workflow:\n${err}`, { type: "error", timeout: null }) } // We failed to restore a workflow so load the default if (!restored) { await this.initDefaultGraph(); } // Save current workflow automatically // setInterval(this.saveStateToLocalStorage.bind(this), 1000); this.addApiUpdateHandlers(); this.addDropHandler(); this.addPasteHandler(); this.addKeyboardHandler(); // await this.#invokeExtensionsAsync("setup"); // Ensure the canvas fills the window this.resizeCanvas(); window.addEventListener("resize", this.resizeCanvas.bind(this)); this.requestPermissions(); this.alreadySetup = true; return Promise.resolve(); } resizeCanvas() { this.canvasEl.width = this.canvasEl.parentElement.offsetWidth; this.canvasEl.height = this.canvasEl.parentElement.offsetHeight; this.canvasEl.style.width = "" this.canvasEl.style.height = "" this.lCanvas.draw(true, true); } saveStateToLocalStorage() { try { uiState.update(s => { s.isSavingToLocalStorage = true; return s; }) const savedWorkflow = this.serialize(); const json = JSON.stringify(savedWorkflow); localStorage.setItem("workflow", json) notify("Saved to local storage.") } catch (err) { notify(`Failed saving to local storage:\n${err}`, { type: "error" }) } finally { uiState.update(s => { s.isSavingToLocalStorage = false; return s; }) } } static node_type_overrides: Record = {} static widget_type_overrides: Record = {} private async registerNodes() { const app = this; // Load node definitions from the backend const defs = await this.api.getNodeDefs(); // Register a node for each definition for (const nodeId in defs) { const nodeData = defs[nodeId]; const typeOverride = ComfyApp.node_type_overrides[nodeId] if (typeOverride) console.debug("Attaching custom type to received node:", nodeId, typeOverride) const baseClass: typeof ComfyBackendNode = typeOverride || ComfyBackendNode; const ctor = class extends baseClass { constructor(title?: string) { super(title, nodeId, nodeData); } } const node: LGraphNodeConstructor = { class: ctor, title: nodeData.name, type: nodeId, desc: `ComfyNode: ${nodeId}` } LiteGraph.registerNodeType(node); node.category = nodeData.category; } } private showDropZone() { if (this.dropZone) this.dropZone.style.display = "block"; } private hideDropZone() { if (this.dropZone) this.dropZone.style.display = "none"; } private allowDrag(event: DragEvent) { if (event.dataTransfer.items?.length > 0) { event.dataTransfer.dropEffect = 'copy'; this.showDropZone(); event.preventDefault(); } } private async handleDrop(event: DragEvent) { event.preventDefault(); event.stopPropagation(); this.hideDropZone(); if (event.dataTransfer.files.length > 0) { await this.handleFile(event.dataTransfer.files[0]); } } private addDropHandler() { // this.dropZone = document.getElementById("dropzone"); // if (this.dropZone) { // window.addEventListener('dragenter', this.allowDrag.bind(this)); // this.dropZone.addEventListener('dragover', this.allowDrag.bind(this)); // this.dropZone.addEventListener('dragleave', this.hideDropZone.bind(this)); // this.dropZone.addEventListener('drop', this.handleDrop.bind(this)); // } // else { // console.warn("No dropzone detected (probably on mobile).") // } } /** * Adds a handler on paste that extracts and loads workflows from pasted JSON data */ private addPasteHandler() { // document.addEventListener("paste", (e) => { // let data = (e.clipboardData || (window as any).clipboardData).getData("text/plain"); // let workflow; // try { // data = data.slice(data.indexOf("{")); // workflow = JSON.parse(data); // } catch (err) { // try { // data = data.slice(data.indexOf("workflow\n")); // data = data.slice(data.indexOf("{")); // workflow = JSON.parse(data); // } catch (error) { } // } // if (workflow && workflow.version && workflow.nodes && workflow.extra) { // this.loadGraphData(workflow); // } // }); } /** * Handles updates from the API socket */ private addApiUpdateHandlers() { this.api.addEventListener("status", ({ detail: ComfyAPIStatus }: CustomEvent) => { // this.ui.setStatus(detail); }); this.api.addEventListener("reconnecting", () => { // this.ui.dialog.show("Reconnecting..."); }); this.api.addEventListener("reconnected", () => { // this.ui.dialog.close(); }); this.api.addEventListener("progress", ({ detail }: CustomEvent) => { queueState.progressUpdated(detail); this.lGraph.setDirtyCanvas(true, false); }); this.api.addEventListener("executing", ({ detail }: CustomEvent) => { queueState.executingUpdated(detail.node); this.lGraph.setDirtyCanvas(true, false); }); this.api.addEventListener("status", (ev: CustomEvent) => { queueState.statusUpdated(ev.detail as ComfyAPIQueueStatus); }); this.api.addEventListener("executed", ({ detail }: CustomEvent) => { this.nodeOutputs[detail.node] = detail.output; const node = this.lGraph.getNodeById(detail.node) as ComfyGraphNode; if (node?.onExecuted) { node.onExecuted(detail.output); } }); this.api.addEventListener("execution_cached", ({ detail }: CustomEvent) => { // TODO detail.nodes }); this.api.addEventListener("execution_error", ({ detail }: CustomEvent) => { queueState.update(s => { s.progress = null; s.runningNodeId = null; return s; }) notify(`Execution error: ${detail.message}`, { type: "error", timeout: 10000 }) }); this.api.init(); } private addKeyboardHandler() { window.addEventListener("keydown", (e) => { this.shiftDown = e.shiftKey; // Queue prompt using ctrl or command + enter if ((e.ctrlKey || e.metaKey) && (e.key === "Enter" || e.keyCode === 13 || e.keyCode === 10)) { this.queuePrompt(e.shiftKey ? -1 : 0); } }); window.addEventListener("keyup", (e) => { this.shiftDown = e.shiftKey; }); } private requestPermissions() { if (Notification.permission === "default") { Notification.requestPermission() .then((result) => console.log("Notification status:", result)); } } private setupColorScheme() { const setColor = (type: any, color: string) => { LGraphCanvas.DEFAULT_LINK_TYPE_COLORS[type] = color LGraphCanvas.DEFAULT_CONNECTION_COLORS_BY_TYPE[type] = color } // Distinguish frontend/backend connections const BACKEND_TYPES = ["CLIP", "CLIP_VISION", "CLIP_VISION_OUTPUT", "CONDITIONING", "CONTROL_NET", "LATENT", "MASK", "MODEL", "STYLE_MODEL", "VAE", "UPSCALE_MODEL"] for (const type of BACKEND_TYPES) { setColor(type, "orange") } setColor("IMAGE", "rebeccapurple") setColor(BuiltInSlotType.EVENT, "lightseagreen") setColor(BuiltInSlotType.ACTION, "lightseagreen") } serialize(): SerializedAppState { const graph = this.lGraph; const serializedGraph = graph.serialize() const serializedLayout = layoutState.serialize() const serializedCanvas = this.lCanvas.serialize(); return { createdBy: "ComfyBox", version: COMFYBOX_SERIAL_VERSION, workflow: serializedGraph, layout: serializedLayout, canvas: serializedCanvas } } async deserialize(data: SerializedAppState) { if (data.version !== COMFYBOX_SERIAL_VERSION) { throw `Invalid ComfyBox saved data format: ${data.version}` } // Ensure loadGraphData does not trigger any state changes in layoutState // (isConfiguring is set to true here) // lGraph.configure will add new nodes, triggering onNodeAdded, but we // want to restore the layoutState ourselves layoutState.onStartConfigure(); this.loadGraphData(data.workflow) // Now restore the layout // Subsequent added nodes will add the UI data to layoutState layoutState.deserialize(data.layout, this.lGraph) // Restore canvas offset/zoom this.lCanvas.deserialize(data.canvas) await this.refreshComboInNodes(); this.lGraph.start(); this.lGraph.eventBus.on("afterExecute", () => this.lCanvas.draw(true)) } async initDefaultGraph() { let state = null; try { const graphResponse = await fetch("/workflows/defaultWorkflow.json"); state = await graphResponse.json() as SerializedAppState; } catch (error) { console.error("Failed to load default graph", error) notify(`Failed to load default graph: ${error}`, { type: "error" }) state = structuredClone(blankGraph) } await this.deserialize(state) } /** * Populates the graph with the specified workflow data * @param {*} graphData A serialized graph object */ loadGraphData(graphData: SerializedLGraph) { this.clean(); // Patch T2IAdapterLoader to ControlNetLoader since they are the same node now for (let n of graphData.nodes) { if (n.type == "T2IAdapterLoader") n.type = "ControlNetLoader"; } this.lGraph.configure(graphData); for (const node of this.lGraph._nodes) { const size = node.computeSize(); size[0] = Math.max(node.size[0], size[0]); size[1] = Math.max(node.size[1], size[1]); node.size = size; // this.#invokeExtensions("loadedGraphNode", node); } } clear() { this.clean(); const blankGraph: SerializedLGraph = { last_node_id: 0, last_link_id: 0, nodes: [], links: [], groups: [], config: {}, extra: {}, version: 0 } layoutState.onStartConfigure(); this.lGraph.configure(blankGraph) layoutState.initDefaultLayout(); uiState.update(s => { s.uiUnlocked = true; s.uiEditMode = "widgets"; return s; }) } runDefaultQueueAction() { for (const node of this.lGraph.iterateNodesInOrder()) { if ("onDefaultQueueAction" in node) { (node as ComfyGraphNode).onDefaultQueueAction() } } if (get(layoutState).attrs.queuePromptButtonRunWorkflow) { this.queuePrompt(0, 1); notify("Prompt queued."); } } querySave() { const promptFilename = get(configState).promptForWorkflowName; let filename = "workflow.json"; if (promptFilename) { filename = prompt("Save workflow as:", filename); if (!filename) return; if (!filename.toLowerCase().endsWith(".json")) { filename += ".json"; } } else { const date = new Date(); const formattedDate = date.toISOString().replace(/:/g, '-').replace(/\.\d{3}/g, '').replace('T', '_').replace("Z", ""); filename = `workflow-${formattedDate}.json` } const indent = 2 const json = JSON.stringify(this.serialize(), null, indent) download(filename, json, "application/json") console.debug(jsonToJsObject(json)) } /** * Converts the current graph workflow for sending to the API * @returns The workflow and node links */ async graphToPrompt(tag: string | null = null): Promise { // Run frontend-only logic this.lGraph.runStep(1) const workflow = this.lGraph.serialize(); const output = {}; // Process nodes in order of execution for (const node_ of this.lGraph.computeExecutionOrder(false, null)) { const n = workflow.nodes.find((n) => n.id === node_.id); if (!isActiveBackendNode(node_, tag)) { continue; } const node = node_ as ComfyBackendNode; const inputs = {}; // Store input values passed by frontend-only nodes if (node.inputs) { for (let i = 0; i < node.inputs.length; i++) { const inp = node.inputs[i]; const inputLink = node.getInputLink(i) const inputNode = node.getInputNode(i) // We don't check tags for non-backend nodes. // Just check for node inactivity (so you can toggle groups of // tagged frontend nodes on/off) if (inputNode && inputNode.mode === NodeMode.NEVER) { console.debug("Skipping inactive node", inputNode) continue; } if (!inputLink || !inputNode) { if ("config" in inp) { const defaultValue = (inp as IComfyInputSlot).config?.defaultValue if (defaultValue !== null && defaultValue !== undefined) inputs[inp.name] = defaultValue } continue; } let serialize = true; if ("config" in inp) serialize = (inp as IComfyInputSlot).serialize let isBackendNode = node.isBackendNode; let isInputBackendNode = false; if ("isBackendNode" in inputNode) isInputBackendNode = (inputNode as ComfyGraphNode).isBackendNode; // The reasoning behind this check: // We only want to serialize inputs to nodes with backend equivalents. // And in ComfyBox, the nodes in litegraph *never* have widgets, instead they're all inputs. // All values are passed by separate frontend-only nodes, // either UI-bound or something like ConstantInteger. // So we know that any value passed into a backend node *must* come from // a frontend node. // The rest (links between backend nodes) will be serialized after this bit runs. if (serialize && isBackendNode && !isInputBackendNode) { inputs[inp.name] = inputLink.data } } } // Store links between backend-only and hybrid nodes for (let i = 0; i < node.inputs.length; i++) { let parent: ComfyGraphNode = node.getInputNode(i) as ComfyGraphNode; if (parent) { const seen = {} let link = node.getInputLink(i); const isFrontendParent = (parent: ComfyGraphNode) => { if (!parent || parent.isBackendNode) return false; if (tag && !hasTag(parent, tag)) return false; return true; } // If there are frontend-only nodes between us and another // backend node, we have to traverse them first. This // behavior is dependent on the type of node. Reroute nodes // will simply follow their single input, while branching // nodes have conditional logic that determines which link // to follow backwards. while (isFrontendParent(parent)) { if (!("getUpstreamLink" in parent)) { console.warn("[graphToPrompt] Node does not support getUpstreamLink", parent.type) break; } const nextLink = parent.getUpstreamLink() if (nextLink == null) { console.warn("[graphToPrompt] No upstream link found in frontend node", parent) break; } if (nextLink && !seen[nextLink.id]) { seen[nextLink.id] = true const inputNode = parent.graph.getNodeById(nextLink.origin_id) as ComfyGraphNode; if (inputNode && tag && !hasTag(inputNode, tag)) { console.debug("[graphToPrompt] Skipping tagged intermediate frontend node", tag, node.properties.tags) parent = null; } else { console.debug("[graphToPrompt] Traverse upstream link", parent.id, inputNode?.id, inputNode?.isBackendNode) link = nextLink; parent = inputNode; } } else { parent = null; } } if (link && parent && parent.isBackendNode) { if (tag && !hasTag(parent, tag)) continue; console.debug("[graphToPrompt] final link", parent.id, node.id) const input = node.inputs[i] // TODO can null be a legitimate value in some cases? // Nodes like CLIPLoader will never have a value in the frontend, hence "null". if (!(input.name in inputs)) inputs[input.name] = [String(link.origin_id), link.origin_slot]; } } } output[String(node.id)] = { inputs, class_type: node.comfyClass, }; } // Remove inputs connected to removed nodes for (const nodeId in output) { for (const inputName in output[nodeId].inputs) { if (Array.isArray(output[nodeId].inputs[inputName]) && output[nodeId].inputs[inputName].length === 2 && !output[output[nodeId].inputs[inputName][0]]) { console.debug("Prune removed node link", nodeId, inputName, output[nodeId].inputs[inputName]) delete output[nodeId].inputs[inputName]; } } } // console.debug({ workflow, output }) // console.debug(promptToGraphVis({ workflow, output })) return { workflow, output }; } async queuePrompt(num: number, batchCount: number = 1, tag: string | null = null) { this.queueItems.push({ num, batchCount }); // Only have one action process the items so each one gets a unique seed correctly if (this.processingQueue) { return; } if (tag === "") tag = null; this.processingQueue = true; try { while (this.queueItems.length) { ({ num, batchCount } = this.queueItems.pop()); console.debug(`Queue get! ${num} ${batchCount} ${tag}`); for (let i = 0; i < batchCount; i++) { for (const node of this.lGraph._nodes_in_order) { if ("beforeQueued" in node) { (node as ComfyGraphNode).beforeQueued(tag); } } const p = await this.graphToPrompt(tag); console.debug(promptToGraphVis(p)) try { await this.api.queuePrompt(num, p); } catch (error) { // this.ui.dialog.show(error.response || error.toString()); const mes = error.response || error.toString() notify(`Error queuing prompt:\n${mes}`, { type: "error" }) console.error(promptToGraphVis(p)) console.error("Error queuing prompt", mes, num, p) break; } for (const n of p.workflow.nodes) { const node = this.lGraph.getNodeById(n.id); if ("afterQueued" in node) { (node as ComfyGraphNode).afterQueued(p, tag); } } this.lCanvas.draw(true, true); // await this.ui.queue.update(); } } } finally { console.log("Queue finished!"); this.processingQueue = false; } } /** * Loads workflow data from the specified file */ async handleFile(file: File) { if (file.type === "image/png") { const pngInfo = await getPngMetadata(file); if (pngInfo) { if (pngInfo.comfyBoxConfig) { this.deserialize(JSON.parse(pngInfo.comfyBoxConfig)); } else if (pngInfo.parameters) { throw "TODO import A111 import!" // importA1111(this.lGraph, pngInfo.parameters, this.api); } else { console.error("No metadata found in image file.", pngInfo) notify("No metadata found in image file.", { type: "error" }) } } } else if (file.type === "application/json" || file.name.endsWith(".json")) { const reader = new FileReader(); reader.onload = () => { this.deserialize(JSON.parse(reader.result as string)); }; reader.readAsText(file); } } // registerExtension(extension) { // if (!extension.name) { // throw new Error("Extensions must have a 'name' property."); // } // if (this.extensions.find((ext) => ext.name === extension.name)) { // throw new Error(`Extension named '${extension.name}' already registered.`); // } // this.extensions.push(extension); // } /** * Refresh combo list on whole nodes */ async refreshComboInNodes(flashUI: boolean = false) { const defs = await this.api.getNodeDefs(); const toUpdate: BackendComboNode[] = [] const isComfyComboNode = (node: LGraphNode): boolean => { return node && node.type === "ui/combo" && "doAutoConfig" in node; } const isComfyComboInput = (input: INodeInputSlot) => { return "config" in input && "widgetNodeType" in input && input.widgetNodeType === "ui/combo"; } // Node IDs of combo widgets attached to a backend node let backendCombos: Set = new Set() console.debug("[refreshComboInNodes] start") // Figure out which combo nodes to update. They need to be connected to // an input slot on a backend node with a backend config in the input // slot connected to. for (const node of this.lGraph.iterateNodesInOrder()) { if (!(node as any).isBackendNode) continue; const backendNode = (node as ComfyBackendNode) const found = range(backendNode.inputs.length) .filter(i => { const input = backendNode.inputs[i] const inputNode = backendNode.getInputNode(i) // Does this input autocreate a combo box on creation? const isComfyInput = isComfyComboInput(input) const isComfyCombo = isComfyComboNode(inputNode) console.debug("[refreshComboInNodes] CHECK", backendNode.type, input.name, "isComfyCombo", isComfyCombo, "isComfyInput", isComfyInput) return isComfyCombo && isComfyInput }); for (const inputIndex of found) { const comboNode = backendNode.getInputNode(inputIndex) as nodes.ComfyComboNode const inputSlot = backendNode.inputs[inputIndex] as IComfyInputSlot; const def = defs[backendNode.type]; const hasBackendConfig = def["input"]["required"][inputSlot.name] !== undefined if (hasBackendConfig) { backendCombos.add(comboNode.id) toUpdate.push({ comboNode, inputSlot, backendNode }) } } } console.debug("[refreshComboInNodes] found:", toUpdate.length, toUpdate) // Mark combo nodes without backend configs as being loaded already. for (const node of this.lGraph.iterateNodesInOrder()) { if (isComfyComboNode(node) && !backendCombos.has(node.id)) { const comboNode = node as nodes.ComfyComboNode; let values = comboNode.properties.values; // Frontend nodes can declare defaultWidgets which creates a // config inside their own inputs slots too. const foundInput = range(node.outputs.length) .flatMap(i => node.getInputSlotsConnectedTo(i)) .find(inp => "config" in inp && Array.isArray((inp.config as any).values)) if (foundInput != null) { const comfyInput = foundInput as IComfyInputSlot; console.warn("[refreshComboInNodes] found frontend config:", node.title, node.type, comfyInput.config.values) values = comfyInput.config.values; } comboNode.formatValues(values); } } await tick(); // Load definitions from the backend. for (const { comboNode, inputSlot, backendNode } of toUpdate) { const def = defs[backendNode.type]; const rawValues = def["input"]["required"][inputSlot.name][0]; console.debug("[ComfyApp] Reconfiguring combo widget", backendNode.type, "=>", comboNode.type, rawValues.length) comboNode.doAutoConfig(inputSlot, { includeProperties: new Set(["values"]), setWidgetTitle: false }) comboNode.formatValues(rawValues) if (!rawValues?.includes(get(comboNode.value))) { comboNode.setValue(rawValues[0]) } } } /** * Clean current state */ clean() { this.nodeOutputs = {}; } }