2013 lines
65 KiB
JavaScript
2013 lines
65 KiB
JavaScript
import { app } from "../../scripts/app.js";
|
|
import { PromptExecutionError, api } from "../../scripts/api.js";
|
|
|
|
const REMOTE_PROPERTY = "is_modal_remote";
|
|
const MODAL_ROUTE = "/modal/queue_prompt";
|
|
const MODAL_ANALYZE_ROUTE = MODAL_ROUTE.replace(/\/queue_prompt$/, "/analyze_remote_nodes");
|
|
const INTERNAL_NODE_PREFIX = "ModalUniversalExecutor";
|
|
|
|
const IDLE_BORDER_COLOR = "#1d9bf0";
|
|
const SETUP_BORDER_COLOR = "#f59e0b";
|
|
const READY_BORDER_COLOR = "#22c55e";
|
|
const ACTIVE_BORDER_COLOR = "#a855f7";
|
|
const COMPLETE_BORDER_COLOR = "#16a34a";
|
|
const ERROR_BORDER_COLOR = "#ef4444";
|
|
|
|
const STATE_SETUP = "setup";
|
|
const STATE_WAITING = "waiting";
|
|
const STATE_FINALIZING = "finalizing";
|
|
const EXECUTION_PHASE = "executing";
|
|
const STATE_READY = "ready";
|
|
const STATE_ACTIVE = "active";
|
|
const STATE_COMPLETE = "complete";
|
|
const STATE_ERROR = "error";
|
|
const ERROR_CLEAR_DELAY_MS = 5000;
|
|
|
|
const modalNodeStates = new Map();
|
|
const modalNodeProgress = new Map();
|
|
const modalNodeProgressLanes = new Map();
|
|
const modalNodeBatchProgress = new Map();
|
|
const modalNodeClearTimers = new Map();
|
|
const modalPromptStates = new Map();
|
|
const syntheticPromptUiStates = new Map();
|
|
const modalGlobalStatusStates = new Map();
|
|
|
|
let animationFrameHandle = null;
|
|
let modalGlobalStatusElement = null;
|
|
|
|
/**
|
|
* Return whether a node should show the Modal toggle.
|
|
* @param {LGraphNode} node
|
|
* @returns {boolean}
|
|
*/
|
|
function isEligibleNode(node) {
|
|
return Boolean(node?.comfyClass) && !String(node.comfyClass).startsWith(INTERNAL_NODE_PREFIX);
|
|
}
|
|
|
|
/**
|
|
* Read the remote execution flag from node properties.
|
|
* @param {LGraphNode} node
|
|
* @returns {boolean}
|
|
*/
|
|
function isRemoteNode(node) {
|
|
return Boolean(node?.properties?.[REMOTE_PROPERTY]);
|
|
}
|
|
|
|
/**
|
|
* Return a websocket event detail payload.
|
|
* @param {CustomEvent | object} event
|
|
* @returns {Record<string, any>}
|
|
*/
|
|
function eventDetail(event) {
|
|
return event?.detail ?? event ?? {};
|
|
}
|
|
|
|
/**
|
|
* Build a prompt id for queue requests.
|
|
* @returns {string}
|
|
*/
|
|
function createPromptId() {
|
|
if (globalThis.crypto?.randomUUID) {
|
|
return globalThis.crypto.randomUUID();
|
|
}
|
|
return `modal-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
}
|
|
|
|
/**
|
|
* Return the current monotonic clock value for UI state ordering.
|
|
* @returns {number}
|
|
*/
|
|
function nowMs() {
|
|
return Date.now();
|
|
}
|
|
|
|
/**
|
|
* Read a node id as a stable string.
|
|
* @param {LGraphNode} node
|
|
* @returns {string}
|
|
*/
|
|
function nodeId(node) {
|
|
return String(node?.id ?? "");
|
|
}
|
|
|
|
/**
|
|
* Ensure the global Modal execution status badge exists.
|
|
* @returns {HTMLDivElement | null}
|
|
*/
|
|
function ensureGlobalStatusElement() {
|
|
if (typeof document === "undefined") {
|
|
return null;
|
|
}
|
|
if (modalGlobalStatusElement?.isConnected) {
|
|
return modalGlobalStatusElement;
|
|
}
|
|
|
|
const element = document.createElement("div");
|
|
element.id = "comfy-modal-global-status";
|
|
element.style.position = "fixed";
|
|
element.style.top = "14px";
|
|
element.style.right = "18px";
|
|
element.style.zIndex = "9999";
|
|
element.style.display = "none";
|
|
element.style.alignItems = "center";
|
|
element.style.gap = "10px";
|
|
element.style.padding = "10px 14px";
|
|
element.style.borderRadius = "999px";
|
|
element.style.border = "1px solid rgba(255, 255, 255, 0.16)";
|
|
element.style.background = "rgba(15, 23, 42, 0.94)";
|
|
element.style.boxShadow = "0 10px 30px rgba(0, 0, 0, 0.28)";
|
|
element.style.color = "#f8fafc";
|
|
element.style.fontFamily = "ui-sans-serif, system-ui, sans-serif";
|
|
element.style.fontSize = "13px";
|
|
element.style.fontWeight = "600";
|
|
element.style.pointerEvents = "none";
|
|
element.innerHTML =
|
|
'<span class="modal-status-dot"></span><span class="modal-status-text"></span>';
|
|
document.body.appendChild(element);
|
|
modalGlobalStatusElement = element;
|
|
return element;
|
|
}
|
|
|
|
/**
|
|
* Remove orphaned global status entries that no longer have any live prompt state.
|
|
*/
|
|
function pruneGlobalStatusStates() {
|
|
for (const promptId of Array.from(modalGlobalStatusStates.keys())) {
|
|
if (modalPromptStates.has(promptId) || syntheticPromptUiStates.has(promptId)) {
|
|
continue;
|
|
}
|
|
modalGlobalStatusStates.delete(promptId);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Return all current node states for a prompt id.
|
|
* @param {string} promptId
|
|
* @returns {Array<{ phase: string, promptId: string }>}
|
|
*/
|
|
function promptNodeStates(promptId) {
|
|
return Array.from(modalNodeStates.values()).filter((state) => state?.promptId === promptId);
|
|
}
|
|
|
|
/**
|
|
* Derive the effective global phase for one prompt from its live node state.
|
|
* @param {string} promptId
|
|
* @param {string} phase
|
|
* @returns {string}
|
|
*/
|
|
function effectiveGlobalStatusPhase(promptId, phase) {
|
|
const promptState = modalPromptStates.get(promptId);
|
|
const nodeStates = promptNodeStates(promptId);
|
|
|
|
if (phase === STATE_ERROR) {
|
|
return STATE_ERROR;
|
|
}
|
|
if (phase === STATE_SETUP || phase === STATE_WAITING) {
|
|
return phase;
|
|
}
|
|
if (nodeStates.some((state) => state.phase === STATE_ERROR)) {
|
|
return STATE_ERROR;
|
|
}
|
|
if (phase === STATE_FINALIZING) {
|
|
if (
|
|
promptState?.activeNodeId ||
|
|
nodeStates.some((state) => state.phase === STATE_ACTIVE || state.phase === STATE_READY)
|
|
) {
|
|
return EXECUTION_PHASE;
|
|
}
|
|
return STATE_FINALIZING;
|
|
}
|
|
if (promptState?.activeNodeId) {
|
|
return EXECUTION_PHASE;
|
|
}
|
|
if (nodeStates.some((state) => state.phase === STATE_ACTIVE)) {
|
|
return EXECUTION_PHASE;
|
|
}
|
|
if (nodeStates.some((state) => state.phase === STATE_READY || state.phase === STATE_COMPLETE)) {
|
|
return EXECUTION_PHASE;
|
|
}
|
|
return phase;
|
|
}
|
|
|
|
/**
|
|
* Return the most important active global Modal state.
|
|
* @returns {{ phase: string, promptId: string, nodeCount: number } | null}
|
|
*/
|
|
function currentGlobalStatus() {
|
|
pruneGlobalStatusStates();
|
|
if (modalGlobalStatusStates.size === 0) {
|
|
return null;
|
|
}
|
|
|
|
const phases = Array.from(modalGlobalStatusStates.entries()).map(([promptId, state]) => ({
|
|
promptId,
|
|
phase: effectiveGlobalStatusPhase(promptId, state.phase),
|
|
nodeCount: state.nodeCount,
|
|
batchValue: state.batchValue ?? null,
|
|
batchMax: state.batchMax ?? null,
|
|
statusMessage: state.statusMessage ?? null,
|
|
statusCurrent: state.statusCurrent ?? null,
|
|
statusTotal: state.statusTotal ?? null,
|
|
updatedAt: state.updatedAt,
|
|
}));
|
|
phases.sort((left, right) => right.updatedAt - left.updatedAt);
|
|
|
|
return (
|
|
phases.find((state) => state.phase === STATE_ERROR) ??
|
|
phases.find((state) => state.phase === STATE_SETUP) ??
|
|
phases.find((state) => state.phase === STATE_WAITING) ??
|
|
phases.find((state) => state.phase === EXECUTION_PHASE) ??
|
|
phases.find((state) => state.phase === STATE_FINALIZING) ??
|
|
phases[0]
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Redraw the global Modal execution badge.
|
|
*/
|
|
function refreshGlobalStatusElement() {
|
|
const element = ensureGlobalStatusElement();
|
|
if (!element) {
|
|
return;
|
|
}
|
|
|
|
const activeState = currentGlobalStatus();
|
|
if (!activeState) {
|
|
element.style.display = "none";
|
|
element.dataset.phase = "";
|
|
return;
|
|
}
|
|
|
|
const dot = element.querySelector(".modal-status-dot");
|
|
const text = element.querySelector(".modal-status-text");
|
|
const nodeLabel = activeState.nodeCount === 1 ? "node" : "nodes";
|
|
const hasBatchProgress =
|
|
activeState.phase === EXECUTION_PHASE &&
|
|
Number(activeState.batchMax ?? 0) > 1;
|
|
const batchValue = hasBatchProgress
|
|
? Math.max(0, Math.min(Number(activeState.batchMax), Number(activeState.batchValue ?? 0)))
|
|
: 0;
|
|
const batchMax = hasBatchProgress ? Math.max(1, Number(activeState.batchMax)) : 1;
|
|
const batchRatio = hasBatchProgress ? batchValue / batchMax : 0;
|
|
const hasStatusProgress = Number(activeState.statusTotal ?? 0) > 1;
|
|
const statusValue = hasStatusProgress
|
|
? Math.max(
|
|
0,
|
|
Math.min(Number(activeState.statusTotal), Number(activeState.statusCurrent ?? 0)),
|
|
)
|
|
: 0;
|
|
const statusMax = hasStatusProgress ? Math.max(1, Number(activeState.statusTotal)) : 1;
|
|
const statusRatio = hasStatusProgress ? statusValue / statusMax : 0;
|
|
|
|
element.style.display = "inline-flex";
|
|
element.dataset.phase = activeState.phase;
|
|
|
|
if (activeState.phase === STATE_SETUP) {
|
|
element.style.borderColor = "rgba(245, 158, 11, 0.55)";
|
|
element.style.background = hasStatusProgress
|
|
? `linear-gradient(90deg, rgba(180, 83, 9, 0.94) 0%, rgba(180, 83, 9, 0.94) ${(
|
|
statusRatio * 100
|
|
).toFixed(2)}%, rgba(61, 42, 9, 0.94) ${(statusRatio * 100).toFixed(2)}%, rgba(61, 42, 9, 0.94) 100%)`
|
|
: "rgba(61, 42, 9, 0.94)";
|
|
dot.style.background = SETUP_BORDER_COLOR;
|
|
dot.style.boxShadow = "0 0 0 6px rgba(245, 158, 11, 0.18)";
|
|
dot.style.animation = "modal-status-pulse 1.1s ease-in-out infinite";
|
|
text.textContent = activeState.statusMessage ?? "Syncing graph with Modal";
|
|
} else if (activeState.phase === STATE_WAITING) {
|
|
element.style.borderColor = "rgba(245, 158, 11, 0.55)";
|
|
element.style.background = "rgba(61, 42, 9, 0.94)";
|
|
dot.style.background = SETUP_BORDER_COLOR;
|
|
dot.style.boxShadow = "0 0 0 6px rgba(245, 158, 11, 0.18)";
|
|
dot.style.animation = "modal-status-pulse 1.1s ease-in-out infinite";
|
|
text.textContent = activeState.statusMessage ?? "Waiting for Modal startup";
|
|
} else if (activeState.phase === EXECUTION_PHASE) {
|
|
element.style.borderColor = "rgba(34, 197, 94, 0.55)";
|
|
element.style.background = hasBatchProgress
|
|
? `linear-gradient(90deg, rgba(22, 163, 74, 0.92) 0%, rgba(22, 163, 74, 0.92) ${(
|
|
batchRatio * 100
|
|
).toFixed(2)}%, rgba(8, 49, 28, 0.94) ${(batchRatio * 100).toFixed(2)}%, rgba(8, 49, 28, 0.94) 100%)`
|
|
: "rgba(8, 49, 28, 0.94)";
|
|
dot.style.background = ACTIVE_BORDER_COLOR;
|
|
dot.style.boxShadow = "0 0 0 6px rgba(34, 197, 94, 0.18)";
|
|
dot.style.animation = "modal-status-pulse 1.1s ease-in-out infinite";
|
|
text.textContent = hasBatchProgress
|
|
? `Modal workflow running on ${activeState.nodeCount} ${nodeLabel} · ${Math.round(batchValue)}/${Math.round(batchMax)}`
|
|
: `Modal workflow running on ${activeState.nodeCount} ${nodeLabel}`;
|
|
} else if (activeState.phase === STATE_FINALIZING) {
|
|
element.style.borderColor = "rgba(59, 130, 246, 0.55)";
|
|
element.style.background = "rgba(15, 23, 42, 0.94)";
|
|
dot.style.background = READY_BORDER_COLOR;
|
|
dot.style.boxShadow = "0 0 0 6px rgba(59, 130, 246, 0.18)";
|
|
dot.style.animation = "modal-status-pulse 1.1s ease-in-out infinite";
|
|
text.textContent = activeState.statusMessage ?? "Receiving Modal outputs";
|
|
} else if (activeState.phase === STATE_ERROR) {
|
|
element.style.borderColor = "rgba(239, 68, 68, 0.55)";
|
|
element.style.background = "rgba(69, 10, 10, 0.94)";
|
|
dot.style.background = ERROR_BORDER_COLOR;
|
|
dot.style.boxShadow = "0 0 0 6px rgba(239, 68, 68, 0.18)";
|
|
dot.style.animation = "none";
|
|
text.textContent = "Modal workflow failed";
|
|
} else {
|
|
element.style.borderColor = "rgba(29, 155, 240, 0.5)";
|
|
element.style.background = "rgba(15, 23, 42, 0.94)";
|
|
dot.style.background = IDLE_BORDER_COLOR;
|
|
dot.style.boxShadow = "0 0 0 6px rgba(29, 155, 240, 0.18)";
|
|
dot.style.animation = "none";
|
|
text.textContent = "Modal workflow active";
|
|
}
|
|
|
|
dot.style.width = "10px";
|
|
dot.style.height = "10px";
|
|
dot.style.borderRadius = "999px";
|
|
dot.style.display = "inline-block";
|
|
}
|
|
|
|
/**
|
|
* Record one prompt's global Modal execution phase.
|
|
* @param {string} promptId
|
|
* @param {string} phase
|
|
* @param {number} nodeCount
|
|
* @param {{ message?: string | null, current?: number | null, total?: number | null } | null} details
|
|
*/
|
|
function setGlobalStatusPhase(promptId, phase, nodeCount, details = null) {
|
|
if (!promptId) {
|
|
return;
|
|
}
|
|
const existingState = modalGlobalStatusStates.get(promptId);
|
|
modalGlobalStatusStates.set(promptId, {
|
|
phase: effectiveGlobalStatusPhase(promptId, phase),
|
|
nodeCount: Math.max(1, Number(nodeCount) || 1),
|
|
batchValue: existingState?.batchValue ?? null,
|
|
batchMax: existingState?.batchMax ?? null,
|
|
statusMessage: details?.message ?? null,
|
|
statusCurrent: details?.current ?? null,
|
|
statusTotal: details?.total ?? null,
|
|
updatedAt: nowMs(),
|
|
});
|
|
refreshGlobalStatusElement();
|
|
}
|
|
|
|
/**
|
|
* Record aggregate mapped-batch progress for one prompt's global Modal status pill.
|
|
* @param {string} promptId
|
|
* @param {number} value
|
|
* @param {number} maxValue
|
|
*/
|
|
function setGlobalStatusBatchProgress(promptId, value, maxValue) {
|
|
if (!promptId) {
|
|
return;
|
|
}
|
|
const existingState = modalGlobalStatusStates.get(promptId);
|
|
const safeMaxValue = Math.max(1, Number(maxValue) || 1);
|
|
const safeValue = Math.max(0, Math.min(safeMaxValue, Number(value) || 0));
|
|
modalGlobalStatusStates.set(promptId, {
|
|
phase: effectiveGlobalStatusPhase(promptId, existingState?.phase ?? EXECUTION_PHASE),
|
|
nodeCount: Math.max(1, Number(existingState?.nodeCount) || 1),
|
|
batchValue: safeValue,
|
|
batchMax: safeMaxValue,
|
|
updatedAt: nowMs(),
|
|
});
|
|
refreshGlobalStatusElement();
|
|
}
|
|
|
|
/**
|
|
* Clear one prompt from the global Modal execution badge.
|
|
* @param {string} promptId
|
|
*/
|
|
function clearGlobalStatusPhase(promptId) {
|
|
if (!promptId) {
|
|
return;
|
|
}
|
|
modalGlobalStatusStates.delete(promptId);
|
|
refreshGlobalStatusElement();
|
|
}
|
|
|
|
/**
|
|
* Refresh the badge and canvas when the tab regains visibility.
|
|
*/
|
|
function refreshModalUiAfterVisibilityChange() {
|
|
refreshGlobalStatusElement();
|
|
if (Array.from(modalNodeStates.values()).length > 0) {
|
|
ensureAnimationLoop();
|
|
}
|
|
app.graph?.setDirtyCanvas(true, true);
|
|
}
|
|
|
|
/**
|
|
* Return the prompt metadata bucket, creating it if needed.
|
|
* @param {string} promptId
|
|
* @returns {{ startedAt: number, remoteNodeIds: string[], componentsByRepresentative: Map<string, string[]>, componentNodeIdsByMember: Map<string, string[]>, representativeNodeIdByMember: Map<string, string>, laneNodeIdsByLane: Map<string, string> }}
|
|
*/
|
|
function ensurePromptState(promptId) {
|
|
if (!modalPromptStates.has(promptId)) {
|
|
modalPromptStates.set(promptId, {
|
|
startedAt: nowMs(),
|
|
remoteNodeIds: [],
|
|
componentsByRepresentative: new Map(),
|
|
componentNodeIdsByMember: new Map(),
|
|
representativeNodeIdByMember: new Map(),
|
|
descendantNodeIdsByAncestor: new Map(),
|
|
laneNodeIdsByLane: new Map(),
|
|
activeNodeId: null,
|
|
hasStreamedProgress: false,
|
|
});
|
|
}
|
|
return modalPromptStates.get(promptId);
|
|
}
|
|
|
|
/**
|
|
* Return whether an incoming prompt update should replace the current node state.
|
|
* @param {string} nodeIdValue
|
|
* @param {string} promptId
|
|
* @returns {boolean}
|
|
*/
|
|
function shouldApplyPromptState(nodeIdValue, promptId) {
|
|
const incomingPromptState = modalPromptStates.get(promptId);
|
|
if (!incomingPromptState) {
|
|
return true;
|
|
}
|
|
|
|
const currentState = modalNodeStates.get(nodeIdValue);
|
|
if (!currentState?.promptId) {
|
|
return true;
|
|
}
|
|
|
|
const currentPromptState = modalPromptStates.get(currentState.promptId);
|
|
if (!currentPromptState) {
|
|
return true;
|
|
}
|
|
|
|
return incomingPromptState.startedAt >= currentPromptState.startedAt;
|
|
}
|
|
|
|
/**
|
|
* Mark the canvas dirty and keep animation alive while visual states are active.
|
|
*/
|
|
function refreshCanvasAnimation() {
|
|
app.graph?.setDirtyCanvas(true, true);
|
|
const hasAnimatedState = Array.from(modalNodeStates.values()).some((state) =>
|
|
[STATE_SETUP, STATE_READY, STATE_ACTIVE, STATE_ERROR].includes(state.phase),
|
|
);
|
|
if (!hasAnimatedState) {
|
|
animationFrameHandle = null;
|
|
return;
|
|
}
|
|
animationFrameHandle = requestAnimationFrame(refreshCanvasAnimation);
|
|
}
|
|
|
|
/**
|
|
* Ensure the redraw loop is running while remote visual effects are active.
|
|
*/
|
|
function ensureAnimationLoop() {
|
|
if (animationFrameHandle !== null) {
|
|
return;
|
|
}
|
|
animationFrameHandle = requestAnimationFrame(refreshCanvasAnimation);
|
|
}
|
|
|
|
/**
|
|
* Clear any pending visual-state timeout for a node.
|
|
* @param {string} nodeIdValue
|
|
*/
|
|
function clearNodeTimer(nodeIdValue) {
|
|
const timerId = modalNodeClearTimers.get(nodeIdValue);
|
|
if (timerId !== undefined) {
|
|
clearTimeout(timerId);
|
|
modalNodeClearTimers.delete(nodeIdValue);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Schedule a node visual state to clear after a delay.
|
|
* @param {string} nodeIdValue
|
|
* @param {string} promptId
|
|
* @param {number} delayMs
|
|
*/
|
|
function scheduleNodeClear(nodeIdValue, promptId, delayMs) {
|
|
clearNodeTimer(nodeIdValue);
|
|
const timerId = setTimeout(() => {
|
|
const state = modalNodeStates.get(nodeIdValue);
|
|
if (state?.promptId !== promptId) {
|
|
return;
|
|
}
|
|
modalNodeStates.delete(nodeIdValue);
|
|
modalNodeClearTimers.delete(nodeIdValue);
|
|
ensureAnimationLoop();
|
|
app.graph?.setDirtyCanvas(true, true);
|
|
}, delayMs);
|
|
modalNodeClearTimers.set(nodeIdValue, timerId);
|
|
}
|
|
|
|
/**
|
|
* Set the current visual phase for a list of remote nodes.
|
|
* @param {string[]} nodeIds
|
|
* @param {string} phase
|
|
* @param {string} promptId
|
|
* @param {string | undefined} errorMessage
|
|
*/
|
|
function setNodesPhase(nodeIds, phase, promptId, errorMessage) {
|
|
const affectedAncestorNodeIds = new Set();
|
|
for (const currentNodeId of nodeIds) {
|
|
if (!shouldApplyPromptState(currentNodeId, promptId)) {
|
|
continue;
|
|
}
|
|
clearNodeTimer(currentNodeId);
|
|
modalNodeStates.set(currentNodeId, {
|
|
phase,
|
|
promptId,
|
|
errorMessage,
|
|
updatedAt: nowMs(),
|
|
});
|
|
if (phase === STATE_ERROR) {
|
|
scheduleNodeClear(currentNodeId, promptId, ERROR_CLEAR_DELAY_MS);
|
|
}
|
|
if (phase === STATE_COMPLETE || phase === STATE_ERROR) {
|
|
clearNodeProgress(currentNodeId, promptId);
|
|
}
|
|
for (const ancestorNodeId of ancestorNodeIds(currentNodeId)) {
|
|
affectedAncestorNodeIds.add(ancestorNodeId);
|
|
}
|
|
}
|
|
for (const ancestorNodeId of affectedAncestorNodeIds) {
|
|
refreshAncestorNodePhase(promptId, ancestorNodeId, errorMessage);
|
|
}
|
|
ensureAnimationLoop();
|
|
app.graph?.setDirtyCanvas(true, true);
|
|
}
|
|
|
|
/**
|
|
* Register remote component membership for a prompt.
|
|
* @param {string} promptId
|
|
* @param {string[]} remoteNodeIds
|
|
* @param {{ representative_node_id: string, node_ids: string[] }[]} components
|
|
*/
|
|
function registerPromptComponents(promptId, remoteNodeIds, components) {
|
|
const promptState = ensurePromptState(promptId);
|
|
if (components.length > 0) {
|
|
const mergedRemoteNodeIds = new Set(remoteNodeIds.map((nodeIdValue) => String(nodeIdValue)));
|
|
promptState.componentsByRepresentative.clear();
|
|
promptState.componentNodeIdsByMember.clear();
|
|
promptState.representativeNodeIdByMember.clear();
|
|
promptState.activeNodeId = null;
|
|
promptState.hasStreamedProgress = false;
|
|
for (const component of components) {
|
|
const componentNodeIds = Array.from(
|
|
new Set(component.node_ids.map((nodeIdValue) => String(nodeIdValue))),
|
|
);
|
|
const representativeNodeId = String(component.representative_node_id);
|
|
promptState.componentsByRepresentative.set(
|
|
representativeNodeId,
|
|
componentNodeIds,
|
|
);
|
|
promptState.componentNodeIdsByMember.set(representativeNodeId, componentNodeIds);
|
|
promptState.representativeNodeIdByMember.set(representativeNodeId, representativeNodeId);
|
|
for (const componentNodeId of componentNodeIds) {
|
|
promptState.componentNodeIdsByMember.set(componentNodeId, componentNodeIds);
|
|
promptState.representativeNodeIdByMember.set(componentNodeId, representativeNodeId);
|
|
mergedRemoteNodeIds.add(componentNodeId);
|
|
}
|
|
}
|
|
if (mergedRemoteNodeIds.size > 0) {
|
|
promptState.remoteNodeIds = Array.from(mergedRemoteNodeIds);
|
|
}
|
|
rebuildPromptAncestorMap(promptState);
|
|
return;
|
|
}
|
|
if (remoteNodeIds.length > 0) {
|
|
if (promptState.remoteNodeIds.length === 0) {
|
|
promptState.remoteNodeIds = [...remoteNodeIds];
|
|
} else {
|
|
const mergedRemoteNodeIds = new Set(promptState.remoteNodeIds);
|
|
for (const remoteNodeId of remoteNodeIds) {
|
|
mergedRemoteNodeIds.add(String(remoteNodeId));
|
|
}
|
|
promptState.remoteNodeIds = Array.from(mergedRemoteNodeIds);
|
|
}
|
|
rebuildPromptAncestorMap(promptState);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Return ancestor node ids for a composed subgraph prompt id like `24:23`.
|
|
* @param {string} nodeIdValue
|
|
* @returns {string[]}
|
|
*/
|
|
function ancestorNodeIds(nodeIdValue) {
|
|
const segments = String(nodeIdValue).split(":");
|
|
const ancestorNodeIds = [];
|
|
for (let index = 1; index < segments.length; index += 1) {
|
|
ancestorNodeIds.push(segments.slice(0, index).join(":"));
|
|
}
|
|
return ancestorNodeIds;
|
|
}
|
|
|
|
/**
|
|
* Rebuild descendant-to-ancestor mappings for one prompt state.
|
|
* @param {{ remoteNodeIds: string[], componentsByRepresentative: Map<string, string[]>, descendantNodeIdsByAncestor: Map<string, Set<string>> }} promptState
|
|
*/
|
|
function rebuildPromptAncestorMap(promptState) {
|
|
promptState.descendantNodeIdsByAncestor.clear();
|
|
const candidateNodeIds = new Set(promptState.remoteNodeIds);
|
|
for (const componentNodeIds of promptState.componentsByRepresentative.values()) {
|
|
for (const componentNodeId of componentNodeIds) {
|
|
candidateNodeIds.add(String(componentNodeId));
|
|
}
|
|
}
|
|
|
|
for (const candidateNodeId of candidateNodeIds) {
|
|
for (const ancestorNodeId of ancestorNodeIds(candidateNodeId)) {
|
|
if (!promptState.descendantNodeIdsByAncestor.has(ancestorNodeId)) {
|
|
promptState.descendantNodeIdsByAncestor.set(ancestorNodeId, new Set());
|
|
}
|
|
promptState.descendantNodeIdsByAncestor.get(ancestorNodeId).add(candidateNodeId);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Recompute one visible ancestor node's phase from its descendant remote prompt nodes.
|
|
* @param {string} promptId
|
|
* @param {string} ancestorNodeId
|
|
* @param {string | undefined} errorMessage
|
|
*/
|
|
function refreshAncestorNodePhase(promptId, ancestorNodeId, errorMessage) {
|
|
const promptState = modalPromptStates.get(promptId);
|
|
const descendantNodeIds = promptState?.descendantNodeIdsByAncestor.get(ancestorNodeId);
|
|
if (!promptState || !descendantNodeIds || descendantNodeIds.size === 0) {
|
|
return;
|
|
}
|
|
|
|
const descendantStates = Array.from(descendantNodeIds)
|
|
.map((descendantNodeId) => modalNodeStates.get(descendantNodeId))
|
|
.filter((state) => state?.promptId === promptId);
|
|
if (descendantStates.length === 0) {
|
|
return;
|
|
}
|
|
|
|
let ancestorPhase = STATE_SETUP;
|
|
if (descendantStates.some((state) => state.phase === STATE_ERROR)) {
|
|
ancestorPhase = STATE_ERROR;
|
|
} else if (descendantStates.some((state) => state.phase === STATE_ACTIVE)) {
|
|
ancestorPhase = STATE_ACTIVE;
|
|
} else if (descendantStates.every((state) => state.phase === STATE_COMPLETE)) {
|
|
ancestorPhase = STATE_COMPLETE;
|
|
} else if (
|
|
descendantStates.some((state) => state.phase === STATE_READY || state.phase === STATE_COMPLETE)
|
|
) {
|
|
ancestorPhase = STATE_READY;
|
|
}
|
|
|
|
if (!shouldApplyPromptState(ancestorNodeId, promptId)) {
|
|
return;
|
|
}
|
|
clearNodeTimer(ancestorNodeId);
|
|
modalNodeStates.set(ancestorNodeId, {
|
|
phase: ancestorPhase,
|
|
promptId,
|
|
errorMessage,
|
|
updatedAt: nowMs(),
|
|
});
|
|
if (ancestorPhase === STATE_ERROR) {
|
|
scheduleNodeClear(ancestorNodeId, promptId, ERROR_CLEAR_DELAY_MS);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Record the currently active remote node inside one prompt.
|
|
* @param {string} promptId
|
|
* @param {string | null} activeNodeId
|
|
*/
|
|
function setPromptActiveNode(promptId, activeNodeId) {
|
|
const promptState = ensurePromptState(promptId);
|
|
promptState.activeNodeId = activeNodeId ? String(activeNodeId) : null;
|
|
}
|
|
|
|
/**
|
|
* Return the numeric progress payload for one node when it belongs to the prompt.
|
|
* @param {string} nodeIdValue
|
|
* @param {string} promptId
|
|
* @returns {{ promptId: string, value: number, max: number, updatedAt: number } | null}
|
|
*/
|
|
function nodeProgressState(nodeIdValue, promptId) {
|
|
const progressState = modalNodeProgress.get(String(nodeIdValue)) ?? null;
|
|
return progressState?.promptId === promptId ? progressState : null;
|
|
}
|
|
|
|
/**
|
|
* Return sorted per-lane progress payloads for one node when they belong to the prompt.
|
|
* @param {string} nodeIdValue
|
|
* @param {string} promptId
|
|
* @returns {{ laneId: string, value: number, max: number, itemIndex: number | null, updatedAt: number }[]}
|
|
*/
|
|
function nodeProgressLanes(nodeIdValue, promptId) {
|
|
const progressLaneState = modalNodeProgressLanes.get(String(nodeIdValue)) ?? null;
|
|
if (progressLaneState?.promptId !== promptId) {
|
|
return [];
|
|
}
|
|
return Array.from(progressLaneState.lanes.values()).sort(
|
|
(left, right) => Number(left.laneId) - Number(right.laneId),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Return whether one node currently has live numeric or per-lane progress.
|
|
* @param {string} nodeIdValue
|
|
* @param {string} promptId
|
|
* @returns {boolean}
|
|
*/
|
|
function hasLiveNodeProgress(nodeIdValue, promptId) {
|
|
return (
|
|
Boolean(nodeProgressState(nodeIdValue, promptId)) ||
|
|
nodeProgressLanes(nodeIdValue, promptId).length > 0
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Derive the displayed node phase from the stored phase plus live progress.
|
|
* @param {string | undefined} phase
|
|
* @param {boolean} hasLiveProgress
|
|
* @returns {string | undefined}
|
|
*/
|
|
function deriveRemoteNodePhase(phase, hasLiveProgress) {
|
|
if ([STATE_ERROR, STATE_SETUP].includes(phase ?? "")) {
|
|
return phase;
|
|
}
|
|
if (hasLiveProgress) {
|
|
return STATE_ACTIVE;
|
|
}
|
|
return phase;
|
|
}
|
|
|
|
/**
|
|
* Remove one worker lane from a node and its visible ancestors without triggering a redraw.
|
|
* @param {string} nodeIdValue
|
|
* @param {string} promptId
|
|
* @param {string} laneId
|
|
*/
|
|
function deleteNodeProgressLane(nodeIdValue, promptId, laneId) {
|
|
const progressNodeIds = [String(nodeIdValue), ...ancestorNodeIds(nodeIdValue)];
|
|
|
|
for (const progressNodeId of progressNodeIds) {
|
|
const laneState = modalNodeProgressLanes.get(progressNodeId);
|
|
if (!laneState || laneState.promptId !== promptId) {
|
|
continue;
|
|
}
|
|
laneState.lanes.delete(laneId);
|
|
if (laneState.lanes.size === 0) {
|
|
modalNodeProgressLanes.delete(progressNodeId);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove stored numeric progress for one node and its visible ancestors.
|
|
* @param {string} nodeIdValue
|
|
* @param {string | undefined} promptId
|
|
*/
|
|
function clearNodeProgress(nodeIdValue, promptId) {
|
|
const progressNodeIds = [String(nodeIdValue), ...ancestorNodeIds(nodeIdValue)];
|
|
for (const progressNodeId of progressNodeIds) {
|
|
const progressState = modalNodeProgress.get(progressNodeId);
|
|
if (!progressState) {
|
|
modalNodeProgress.delete(progressNodeId);
|
|
} else if (!promptId || progressState.promptId === promptId) {
|
|
modalNodeProgress.delete(progressNodeId);
|
|
}
|
|
const batchState = modalNodeBatchProgress.get(progressNodeId);
|
|
if (batchState && (!promptId || batchState.promptId === promptId)) {
|
|
modalNodeBatchProgress.delete(progressNodeId);
|
|
}
|
|
const laneState = modalNodeProgressLanes.get(progressNodeId);
|
|
if (!laneState) {
|
|
continue;
|
|
}
|
|
if (promptId && laneState.promptId !== promptId) {
|
|
continue;
|
|
}
|
|
modalNodeProgressLanes.delete(progressNodeId);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Record aggregate mapped-batch progress on the representative remote node only.
|
|
* @param {string} nodeIdValue
|
|
* @param {string} promptId
|
|
* @param {number} value
|
|
* @param {number} maxValue
|
|
*/
|
|
function setNodeBatchProgress(nodeIdValue, promptId, value, maxValue) {
|
|
const safeMaxValue = Math.max(1, Number(maxValue) || 1);
|
|
const safeValue = Math.max(0, Math.min(safeMaxValue, Number(value) || 0));
|
|
const progressNodeId = String(nodeIdValue);
|
|
if (!shouldApplyPromptState(progressNodeId, promptId)) {
|
|
return;
|
|
}
|
|
modalNodeBatchProgress.set(progressNodeId, {
|
|
promptId,
|
|
value: safeValue,
|
|
max: safeMaxValue,
|
|
updatedAt: nowMs(),
|
|
});
|
|
ensureAnimationLoop();
|
|
app.graph?.setDirtyCanvas(true, true);
|
|
}
|
|
|
|
/**
|
|
* Record numeric progress for one node and its visible ancestors.
|
|
* @param {string} nodeIdValue
|
|
* @param {string} promptId
|
|
* @param {number} value
|
|
* @param {number} maxValue
|
|
*/
|
|
function setNodeProgress(nodeIdValue, promptId, value, maxValue) {
|
|
const safeMaxValue = Math.max(1, Number(maxValue) || 1);
|
|
const safeValue = Math.max(0, Math.min(safeMaxValue, Number(value) || 0));
|
|
const progressNodeIds = [String(nodeIdValue), ...ancestorNodeIds(nodeIdValue)];
|
|
|
|
for (const progressNodeId of progressNodeIds) {
|
|
if (!shouldApplyPromptState(progressNodeId, promptId)) {
|
|
continue;
|
|
}
|
|
modalNodeProgress.set(progressNodeId, {
|
|
promptId,
|
|
value: safeValue,
|
|
max: safeMaxValue,
|
|
updatedAt: nowMs(),
|
|
});
|
|
}
|
|
|
|
ensureAnimationLoop();
|
|
app.graph?.setDirtyCanvas(true, true);
|
|
}
|
|
|
|
/**
|
|
* Record numeric progress for one worker lane on a node and its visible ancestors.
|
|
* @param {string} nodeIdValue
|
|
* @param {string} promptId
|
|
* @param {string} laneId
|
|
* @param {number} value
|
|
* @param {number} maxValue
|
|
* @param {number | null | undefined} itemIndex
|
|
*/
|
|
function setNodeProgressLane(nodeIdValue, promptId, laneId, value, maxValue, itemIndex) {
|
|
const safeLaneId = String(laneId ?? "");
|
|
if (!safeLaneId) {
|
|
return;
|
|
}
|
|
const safeNodeIdValue = String(nodeIdValue);
|
|
const safeLaneKey = laneOwnerKey(promptId, safeNodeIdValue, safeLaneId);
|
|
const safeMaxValue = Math.max(1, Number(maxValue) || 1);
|
|
const safeValue = Math.max(0, Math.min(safeMaxValue, Number(value) || 0));
|
|
const promptState = ensurePromptState(promptId);
|
|
const previousNodeId = promptState.laneNodeIdsByLane.get(safeLaneKey);
|
|
if (previousNodeId && previousNodeId !== safeNodeIdValue) {
|
|
deleteNodeProgressLane(previousNodeId, promptId, safeLaneId);
|
|
}
|
|
promptState.laneNodeIdsByLane.set(safeLaneKey, safeNodeIdValue);
|
|
const progressNodeIds = [safeNodeIdValue, ...ancestorNodeIds(safeNodeIdValue)];
|
|
|
|
for (const progressNodeId of progressNodeIds) {
|
|
if (!shouldApplyPromptState(progressNodeId, promptId)) {
|
|
continue;
|
|
}
|
|
const existingLaneState = modalNodeProgressLanes.get(progressNodeId);
|
|
const laneState =
|
|
existingLaneState?.promptId === promptId
|
|
? existingLaneState
|
|
: {
|
|
promptId,
|
|
lanes: new Map(),
|
|
};
|
|
laneState.lanes.set(safeLaneId, {
|
|
laneId: safeLaneId,
|
|
value: safeValue,
|
|
max: safeMaxValue,
|
|
itemIndex: Number.isFinite(Number(itemIndex)) ? Number(itemIndex) : null,
|
|
updatedAt: nowMs(),
|
|
});
|
|
modalNodeProgressLanes.set(progressNodeId, laneState);
|
|
}
|
|
|
|
ensureAnimationLoop();
|
|
app.graph?.setDirtyCanvas(true, true);
|
|
}
|
|
|
|
/**
|
|
* Remove one worker lane progress bar from a node and its visible ancestors.
|
|
* @param {string} nodeIdValue
|
|
* @param {string} promptId
|
|
* @param {string} laneId
|
|
*/
|
|
function clearNodeProgressLane(nodeIdValue, promptId, laneId) {
|
|
const safeLaneId = String(laneId ?? "");
|
|
if (!safeLaneId) {
|
|
return;
|
|
}
|
|
const safeNodeIdValue = String(nodeIdValue);
|
|
const safeLaneKey = laneOwnerKey(promptId, safeNodeIdValue, safeLaneId);
|
|
const promptState = modalPromptStates.get(promptId);
|
|
if (promptState?.laneNodeIdsByLane.get(safeLaneKey) === safeNodeIdValue) {
|
|
promptState.laneNodeIdsByLane.delete(safeLaneKey);
|
|
}
|
|
deleteNodeProgressLane(safeNodeIdValue, promptId, safeLaneId);
|
|
|
|
ensureAnimationLoop();
|
|
app.graph?.setDirtyCanvas(true, true);
|
|
}
|
|
|
|
/**
|
|
* Return remote component node ids for an executing proxy node event.
|
|
* @param {string} promptId
|
|
* @param {string} representativeNodeId
|
|
* @returns {string[] | null}
|
|
*/
|
|
function resolveComponentNodeIds(promptId, representativeNodeId) {
|
|
const promptState = modalPromptStates.get(promptId);
|
|
if (!promptState) {
|
|
return null;
|
|
}
|
|
const candidateNodeId = String(representativeNodeId);
|
|
if (promptState.componentNodeIdsByMember.has(candidateNodeId)) {
|
|
return promptState.componentNodeIdsByMember.get(candidateNodeId) ?? null;
|
|
}
|
|
if (promptState.remoteNodeIds.includes(candidateNodeId)) {
|
|
return [candidateNodeId];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Return the representative node id for one remote component member when known.
|
|
* @param {string} promptId
|
|
* @param {string} nodeIdValue
|
|
* @returns {string | null}
|
|
*/
|
|
function resolveComponentRepresentativeNodeId(promptId, nodeIdValue) {
|
|
const promptState = modalPromptStates.get(promptId);
|
|
if (!promptState) {
|
|
return null;
|
|
}
|
|
return promptState.representativeNodeIdByMember.get(String(nodeIdValue)) ?? null;
|
|
}
|
|
|
|
/**
|
|
* Return the stable prompt-local lane owner key for one component worker lane.
|
|
* @param {string} promptId
|
|
* @param {string} nodeIdValue
|
|
* @param {string} laneId
|
|
* @returns {string}
|
|
*/
|
|
function laneOwnerKey(promptId, nodeIdValue, laneId) {
|
|
const ownerNodeId = resolveComponentRepresentativeNodeId(promptId, nodeIdValue) ?? String(nodeIdValue);
|
|
return `${ownerNodeId}:${String(laneId)}`;
|
|
}
|
|
|
|
/**
|
|
* Extract remote node ids from a workflow snapshot.
|
|
* @param {object | undefined} workflow
|
|
* @returns {string[]}
|
|
*/
|
|
function extractRemoteNodeIds(workflow) {
|
|
const remoteNodeIds = [];
|
|
for (const node of workflow?.nodes ?? []) {
|
|
if (node?.properties?.[REMOTE_PROPERTY]) {
|
|
remoteNodeIds.push(String(node.id));
|
|
}
|
|
}
|
|
return remoteNodeIds;
|
|
}
|
|
|
|
/**
|
|
* Return the root workflow graph, including subgraphs when available.
|
|
* @returns {LGraph | null}
|
|
*/
|
|
function rootGraph() {
|
|
return app.rootGraph ?? app.graph?.rootGraph ?? app.graph ?? null;
|
|
}
|
|
|
|
/**
|
|
* Look up a graph-local node id without assuming whether ids are numeric or strings.
|
|
* @param {LGraph | null | undefined} graph
|
|
* @param {string} id
|
|
* @returns {LGraphNode | null}
|
|
*/
|
|
function getGraphNodeById(graph, id) {
|
|
if (!graph || id == null) {
|
|
return null;
|
|
}
|
|
const directMatch = graph.getNodeById?.(id) ?? null;
|
|
if (directMatch) {
|
|
return directMatch;
|
|
}
|
|
const numericId = Number(id);
|
|
if (Number.isFinite(numericId)) {
|
|
return graph.getNodeById?.(numericId) ?? null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Search every live subgraph for one matching value.
|
|
* @param {(graph: LGraph) => any} matcher
|
|
* @returns {any}
|
|
*/
|
|
function findSomethingInAllSubgraphs(matcher) {
|
|
const graph = rootGraph();
|
|
if (!graph) {
|
|
return null;
|
|
}
|
|
|
|
const subgraphs = [graph];
|
|
if (graph.subgraphs?.values) {
|
|
subgraphs.push(...graph.subgraphs.values());
|
|
}
|
|
for (const subgraph of subgraphs) {
|
|
const match = matcher(subgraph);
|
|
if (match) {
|
|
return match;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Return the workflow node that owns one nested subgraph graph id.
|
|
* @param {number | string | undefined} subgraphId
|
|
* @returns {LGraphNode | null}
|
|
*/
|
|
function findContainingSubgraphNode(subgraphId) {
|
|
if (subgraphId == null) {
|
|
return null;
|
|
}
|
|
return (
|
|
findSomethingInAllSubgraphs((graph) =>
|
|
(graph?.nodes ?? []).find(
|
|
(candidate) =>
|
|
typeof candidate?.isSubgraphNode === "function" &&
|
|
candidate.isSubgraphNode() &&
|
|
candidate.subgraph?.id === subgraphId,
|
|
),
|
|
) ?? null
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Return one node's composed workflow path, including any subgraph ancestors.
|
|
* @param {LGraphNode} node
|
|
* @returns {string}
|
|
*/
|
|
function workflowNodePath(node) {
|
|
const pathSegments = [String(node?.id ?? "")];
|
|
let currentGraph = node?.graph ?? null;
|
|
const currentRootGraph = rootGraph();
|
|
|
|
while (currentGraph && currentRootGraph && currentGraph !== currentRootGraph) {
|
|
const parentNode = findContainingSubgraphNode(currentGraph.id);
|
|
if (!parentNode) {
|
|
break;
|
|
}
|
|
pathSegments.unshift(String(parentNode.id));
|
|
currentGraph = parentNode.graph ?? null;
|
|
}
|
|
|
|
return pathSegments.filter(Boolean).join(":");
|
|
}
|
|
|
|
/**
|
|
* Resolve a composed workflow path like `24:23` to a live node instance.
|
|
* @param {string} workflowPath
|
|
* @returns {LGraphNode | null}
|
|
*/
|
|
function findNodeByWorkflowPath(workflowPath) {
|
|
const pathSegments = String(workflowPath)
|
|
.split(":")
|
|
.filter(Boolean);
|
|
if (pathSegments.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
let currentGraph = rootGraph();
|
|
let currentNode = null;
|
|
for (const pathSegment of pathSegments) {
|
|
currentNode = getGraphNodeById(currentGraph, pathSegment);
|
|
if (!currentNode) {
|
|
return null;
|
|
}
|
|
currentGraph = currentNode.subgraph ?? null;
|
|
}
|
|
return currentNode;
|
|
}
|
|
|
|
/**
|
|
* Return the workflow-node paths that a context-menu action should expand from.
|
|
* @param {LGraphNode} node
|
|
* @returns {string[]}
|
|
*/
|
|
function selectedWorkflowNodePaths(node) {
|
|
const selectedNodes = Object.values(app.canvas?.selected_nodes ?? {}).filter(
|
|
(candidate) => candidate?.graph === node?.graph && isEligibleNode(candidate),
|
|
);
|
|
if (selectedNodes.some((candidate) => candidate === node) && selectedNodes.length > 1) {
|
|
return selectedNodes.map((candidate) => workflowNodePath(candidate));
|
|
}
|
|
return [workflowNodePath(node)];
|
|
}
|
|
|
|
/**
|
|
* Return the graph snapshot shape ComfyUI already uses for queue submission.
|
|
* @returns {Promise<{ output: object, workflow: object }>}
|
|
*/
|
|
async function serializeCurrentGraphForModal() {
|
|
if (typeof app.graphToPrompt !== "function") {
|
|
throw new Error("ComfyUI graph serialization is unavailable.");
|
|
}
|
|
const prompt = await app.graphToPrompt();
|
|
if (!prompt?.output || !prompt?.workflow) {
|
|
throw new Error("ComfyUI did not return prompt and workflow data.");
|
|
}
|
|
return {
|
|
output: prompt.output,
|
|
workflow: prompt.workflow,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Show a short frontend notification without taking over the whole UI.
|
|
* @param {string} value
|
|
*/
|
|
function notifyModal(value) {
|
|
dispatchSyntheticApiEvent("notification", {
|
|
id: `modal-analysis-${Date.now()}`,
|
|
value,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Apply the remote marker to the workflow nodes named by composed workflow paths.
|
|
* @param {string[]} workflowNodePaths
|
|
* @returns {number}
|
|
*/
|
|
function markWorkflowNodePathsRemote(workflowNodePaths) {
|
|
let appliedCount = 0;
|
|
for (const workflowPath of workflowNodePaths) {
|
|
const node = findNodeByWorkflowPath(workflowPath);
|
|
if (!node) {
|
|
console.warn("Unable to find Modal workflow node path in the live graph.", workflowPath);
|
|
continue;
|
|
}
|
|
setRemoteFlag(node, true);
|
|
appliedCount += 1;
|
|
}
|
|
return appliedCount;
|
|
}
|
|
|
|
/**
|
|
* Request required upstream nodes from the backend and mark them remote in the UI.
|
|
* @param {LGraphNode} node
|
|
*/
|
|
async function analyzeAndMarkRequiredRemoteNodes(node) {
|
|
const seedNodeIds = selectedWorkflowNodePaths(node);
|
|
const graphSnapshot = await serializeCurrentGraphForModal();
|
|
const response = await api.fetchApi(MODAL_ANALYZE_ROUTE, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
prompt: graphSnapshot.output,
|
|
workflow: graphSnapshot.workflow,
|
|
seed_node_ids: seedNodeIds,
|
|
}),
|
|
});
|
|
if (response.status !== 200) {
|
|
throw new PromptExecutionError(await response.json());
|
|
}
|
|
|
|
const result = await response.json();
|
|
const resolvedWorkflowNodePaths = result.resolved_workflow_node_paths ?? [];
|
|
const addedWorkflowNodePaths = result.added_workflow_node_paths ?? [];
|
|
const appliedCount = markWorkflowNodePathsRemote(resolvedWorkflowNodePaths);
|
|
app.graph?.setDirtyCanvas(true, true);
|
|
|
|
if (addedWorkflowNodePaths.length > 0) {
|
|
notifyModal(`Marked ${addedWorkflowNodePaths.length} node${addedWorkflowNodePaths.length === 1 ? "" : "s"} for Modal.`);
|
|
return;
|
|
}
|
|
notifyModal(
|
|
appliedCount > 0
|
|
? "No extra upstream Modal nodes were required."
|
|
: "Modal analysis finished, but no matching live nodes were found to update.",
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Return whether one node definition should expose Modal UI affordances.
|
|
* @param {object | undefined} nodeData
|
|
* @returns {boolean}
|
|
*/
|
|
function isEligibleNodeDef(nodeData) {
|
|
return Boolean(nodeData?.name) && !String(nodeData.name).startsWith(INTERNAL_NODE_PREFIX);
|
|
}
|
|
|
|
/**
|
|
* Inject the Modal context-menu entry on a node type prototype.
|
|
* @param {typeof LGraphNode} nodeType
|
|
* @param {object | undefined} nodeData
|
|
*/
|
|
function installModalContextMenu(nodeType, nodeData) {
|
|
if (!isEligibleNodeDef(nodeData) || nodeType?.prototype?.__modalContextMenuInjected) {
|
|
return;
|
|
}
|
|
|
|
const originalGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
|
|
nodeType.prototype.getExtraMenuOptions = function getExtraMenuOptionsWithModalAnalysis(
|
|
canvas,
|
|
options,
|
|
) {
|
|
const menuOptions = originalGetExtraMenuOptions?.call(this, canvas, options) ?? options ?? [];
|
|
const targetOptions = options ?? menuOptions;
|
|
if (!Array.isArray(targetOptions)) {
|
|
return menuOptions;
|
|
}
|
|
|
|
const selectedNodePaths = selectedWorkflowNodePaths(this);
|
|
const menuItemLabel =
|
|
selectedNodePaths.length > 1
|
|
? "Modal: Include Required Upstream Nodes for Selection"
|
|
: "Modal: Include Required Upstream Nodes";
|
|
if (!targetOptions.some((option) => option?.content === menuItemLabel)) {
|
|
targetOptions.push(null, {
|
|
content: menuItemLabel,
|
|
callback: () => {
|
|
void analyzeAndMarkRequiredRemoteNodes(this).catch((error) => {
|
|
console.error("Modal remote-node analysis failed.", error);
|
|
notifyModal(`Modal remote-node analysis failed: ${String(error?.message ?? error)}`);
|
|
});
|
|
},
|
|
});
|
|
}
|
|
|
|
return menuOptions;
|
|
};
|
|
nodeType.prototype.__modalContextMenuInjected = true;
|
|
}
|
|
|
|
/**
|
|
* Update the node state and redraw the canvas.
|
|
* @param {LGraphNode} node
|
|
* @param {boolean} value
|
|
*/
|
|
function setRemoteFlag(node, value) {
|
|
node.properties ||= {};
|
|
node.properties[REMOTE_PROPERTY] = Boolean(value);
|
|
app.graph.setDirtyCanvas(true, true);
|
|
}
|
|
|
|
/**
|
|
* Return the current remote visual state for a node.
|
|
* @param {LGraphNode} node
|
|
* @returns {{ phase: string, promptId: string } | null}
|
|
*/
|
|
function getRemoteVisualState(node) {
|
|
const state = modalNodeStates.get(nodeId(node)) ?? null;
|
|
if (!state?.promptId) {
|
|
return state;
|
|
}
|
|
const promptState = modalPromptStates.get(state.promptId);
|
|
const progressState = nodeProgressState(nodeId(node), state.promptId);
|
|
const progressLanes = nodeProgressLanes(nodeId(node), state.promptId);
|
|
const batchProgressState = modalNodeBatchProgress.get(nodeId(node)) ?? null;
|
|
const hasLiveProgress = hasLiveNodeProgress(nodeId(node), state.promptId);
|
|
return {
|
|
...state,
|
|
phase: deriveRemoteNodePhase(state.phase, hasLiveProgress),
|
|
isActiveRemoteNode: hasLiveProgress || promptState?.activeNodeId === nodeId(node),
|
|
progress: progressState,
|
|
batchProgress: batchProgressState?.promptId === state.promptId ? batchProgressState : null,
|
|
progressLanes,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Draw the remote execution border and shading for a node.
|
|
* @param {LGraphNode} node
|
|
* @param {CanvasRenderingContext2D} ctx
|
|
*/
|
|
function drawRemoteNodeDecoration(node, ctx) {
|
|
if (!isRemoteNode(node)) {
|
|
return;
|
|
}
|
|
|
|
const state = getRemoteVisualState(node);
|
|
const titleHeight = node.constructor?.title_height ?? LiteGraph.NODE_TITLE_HEIGHT ?? 24;
|
|
const scale = app.canvas?.ds?.scale ?? 1;
|
|
const borderWidth = 3 / scale;
|
|
const elapsed = performance.now() / 1000;
|
|
|
|
let borderColor = IDLE_BORDER_COLOR;
|
|
let shadowColor = "rgba(29, 155, 240, 0.35)";
|
|
let fillColor = null;
|
|
|
|
if (state?.phase === STATE_SETUP) {
|
|
const pulse = (Math.sin(elapsed * 5) + 1) / 2;
|
|
borderColor = `${SETUP_BORDER_COLOR}${Math.round((0.65 + pulse * 0.35) * 255)
|
|
.toString(16)
|
|
.padStart(2, "0")}`;
|
|
shadowColor = `rgba(245, 158, 11, ${0.25 + pulse * 0.35})`;
|
|
} else if (state?.phase === STATE_READY) {
|
|
const pulse = (Math.sin(elapsed * 6) + 1) / 2;
|
|
borderColor = READY_BORDER_COLOR;
|
|
shadowColor = "rgba(34, 197, 94, 0.35)";
|
|
fillColor = `rgba(134, 239, 172, ${0.12 + pulse * 0.08})`;
|
|
} else if (state?.phase === STATE_ACTIVE) {
|
|
const pulse = (Math.sin(elapsed * 7) + 1) / 2;
|
|
borderColor = `${ACTIVE_BORDER_COLOR}${Math.round((0.7 + pulse * 0.3) * 255)
|
|
.toString(16)
|
|
.padStart(2, "0")}`;
|
|
shadowColor = `rgba(168, 85, 247, ${0.28 + pulse * 0.32})`;
|
|
fillColor = `rgba(216, 180, 254, ${0.16 + pulse * 0.1})`;
|
|
} else if (state?.phase === STATE_COMPLETE) {
|
|
borderColor = COMPLETE_BORDER_COLOR;
|
|
shadowColor = "rgba(34, 197, 94, 0.28)";
|
|
fillColor = "rgba(134, 239, 172, 0.14)";
|
|
} else if (state?.phase === STATE_ERROR) {
|
|
const pulse = (Math.sin(elapsed * 6) + 1) / 2;
|
|
borderColor = `${ERROR_BORDER_COLOR}${Math.round((0.7 + pulse * 0.3) * 255)
|
|
.toString(16)
|
|
.padStart(2, "0")}`;
|
|
shadowColor = `rgba(239, 68, 68, ${0.22 + pulse * 0.28})`;
|
|
}
|
|
|
|
ctx.save();
|
|
if (fillColor) {
|
|
ctx.fillStyle = fillColor;
|
|
ctx.fillRect(
|
|
borderWidth,
|
|
-titleHeight + borderWidth,
|
|
Math.max(0, node.size[0] - borderWidth * 2),
|
|
Math.max(0, node.size[1] + titleHeight - borderWidth * 2),
|
|
);
|
|
}
|
|
ctx.strokeStyle = borderColor;
|
|
ctx.lineWidth = borderWidth;
|
|
ctx.shadowColor = shadowColor;
|
|
ctx.shadowBlur = 8 / scale;
|
|
ctx.strokeRect(
|
|
-borderWidth,
|
|
-titleHeight,
|
|
node.size[0] + borderWidth * 2,
|
|
node.size[1] + titleHeight + borderWidth,
|
|
);
|
|
ctx.restore();
|
|
|
|
const progressLanes = Array.isArray(state?.progressLanes) ? state.progressLanes : [];
|
|
const batchProgress = state?.batchProgress ?? null;
|
|
const hasAggregateProgress = Boolean(state?.progress && state.phase === STATE_ACTIVE);
|
|
const hasLaneProgress = progressLanes.length > 0 && state?.phase === STATE_ACTIVE;
|
|
const hasBatchBadge = Boolean(batchProgress && state?.phase === STATE_ACTIVE && !hasAggregateProgress);
|
|
if (!hasAggregateProgress && !hasLaneProgress && !hasBatchBadge) {
|
|
return;
|
|
}
|
|
|
|
ctx.save();
|
|
const barWidth = node.size[0] + borderWidth * 2;
|
|
const aggregateHeight = 8 / scale;
|
|
const laneHeight = 5 / scale;
|
|
const laneGap = 2 / scale;
|
|
const panelY = node.size[1] + 6 / scale;
|
|
const panelPaddingX = 6 / scale;
|
|
const panelPaddingY = 6 / scale;
|
|
const headerHeight = 16 / scale;
|
|
const laneBlockHeight = hasLaneProgress
|
|
? progressLanes.length * laneHeight + (progressLanes.length - 1) * laneGap
|
|
: 0;
|
|
const bodyHeight =
|
|
(hasLaneProgress ? laneBlockHeight + laneGap : 0) + (hasAggregateProgress ? aggregateHeight : 0);
|
|
const panelHeight = panelPaddingY * 2 + headerHeight + bodyHeight;
|
|
const laneColors = [
|
|
"rgba(196, 181, 253, 0.94)",
|
|
"rgba(147, 197, 253, 0.94)",
|
|
"rgba(110, 231, 183, 0.94)",
|
|
"rgba(253, 224, 71, 0.94)",
|
|
"rgba(251, 146, 60, 0.94)",
|
|
"rgba(244, 114, 182, 0.94)",
|
|
];
|
|
const badgeText = hasBatchBadge
|
|
? `${Math.round(batchProgress.value)}/${Math.round(batchProgress.max)}`
|
|
: null;
|
|
|
|
ctx.fillStyle = "rgba(15, 23, 42, 0.88)";
|
|
ctx.beginPath();
|
|
ctx.roundRect(-borderWidth, panelY, barWidth, panelHeight, 10 / scale);
|
|
ctx.fill();
|
|
ctx.strokeStyle = shadowColor;
|
|
ctx.lineWidth = 1 / scale;
|
|
ctx.stroke();
|
|
|
|
const headerText = hasAggregateProgress
|
|
? `${Math.round((state.progress.value / state.progress.max) * 100)}%`
|
|
: hasLaneProgress
|
|
? `${progressLanes.length}x`
|
|
: null;
|
|
const headerBaselineY = panelY + panelPaddingY + headerHeight / 2;
|
|
if (headerText) {
|
|
ctx.fillStyle = "#f8fafc";
|
|
ctx.font = `${Math.max(10 / scale, 8)}px ui-sans-serif, system-ui, sans-serif`;
|
|
ctx.textAlign = "left";
|
|
ctx.textBaseline = "middle";
|
|
ctx.fillText(headerText, panelPaddingX, headerBaselineY);
|
|
}
|
|
|
|
if (badgeText) {
|
|
ctx.font = `${Math.max(10 / scale, 8)}px ui-sans-serif, system-ui, sans-serif`;
|
|
const badgePaddingX = 6 / scale;
|
|
const badgeWidth = ctx.measureText(badgeText).width + badgePaddingX * 2;
|
|
const badgeHeight = 16 / scale;
|
|
const badgeX = Math.max(0, node.size[0] - badgeWidth - 4 / scale);
|
|
const badgeY = panelY + panelPaddingY;
|
|
ctx.fillStyle = "rgba(2, 6, 23, 0.82)";
|
|
ctx.beginPath();
|
|
ctx.roundRect(badgeX, badgeY, badgeWidth, badgeHeight, 8 / scale);
|
|
ctx.fill();
|
|
ctx.strokeStyle = "rgba(34, 197, 94, 0.55)";
|
|
ctx.lineWidth = 1 / scale;
|
|
ctx.stroke();
|
|
ctx.fillStyle = "#f8fafc";
|
|
ctx.textAlign = "center";
|
|
ctx.textBaseline = "middle";
|
|
ctx.fillText(badgeText, badgeX + badgeWidth / 2, badgeY + badgeHeight / 2);
|
|
}
|
|
|
|
const barY = panelY + panelPaddingY + headerHeight + laneGap;
|
|
|
|
if (hasLaneProgress) {
|
|
let laneY = barY;
|
|
for (const [laneIndex, laneProgress] of progressLanes.entries()) {
|
|
const laneRatio = Math.max(0, Math.min(1, laneProgress.value / laneProgress.max));
|
|
const laneWidth = Math.max(0, barWidth * laneRatio);
|
|
ctx.fillStyle = "rgba(15, 23, 42, 0.66)";
|
|
ctx.fillRect(-borderWidth, laneY, barWidth, laneHeight);
|
|
ctx.fillStyle = laneColors[laneIndex % laneColors.length];
|
|
ctx.fillRect(-borderWidth, laneY, laneWidth, laneHeight);
|
|
laneY += laneHeight + laneGap;
|
|
}
|
|
}
|
|
|
|
if (hasAggregateProgress) {
|
|
const progressRatio = Math.max(0, Math.min(1, state.progress.value / state.progress.max));
|
|
const progressWidth = Math.max(0, barWidth * progressRatio);
|
|
const aggregateY = hasLaneProgress ? barY + laneBlockHeight + laneGap : barY;
|
|
ctx.fillStyle = "rgba(15, 23, 42, 0.72)";
|
|
ctx.fillRect(-borderWidth, aggregateY, barWidth, aggregateHeight);
|
|
ctx.fillStyle = "rgba(216, 180, 254, 0.92)";
|
|
ctx.fillRect(-borderWidth, aggregateY, progressWidth, aggregateHeight);
|
|
}
|
|
ctx.restore();
|
|
}
|
|
|
|
/**
|
|
* Inject the toggle widget and remote border behavior into a node.
|
|
* @param {LGraphNode} node
|
|
*/
|
|
function decorateNode(node) {
|
|
if (!isEligibleNode(node) || node.__modalToggleInjected) {
|
|
return;
|
|
}
|
|
|
|
node.properties ||= {};
|
|
node.properties[REMOTE_PROPERTY] = Boolean(node.properties[REMOTE_PROPERTY]);
|
|
|
|
const widget = node.addWidget(
|
|
"toggle",
|
|
"Run on Modal",
|
|
node.properties[REMOTE_PROPERTY],
|
|
(value) => setRemoteFlag(node, value),
|
|
{
|
|
on: "Enabled",
|
|
off: "Disabled",
|
|
serialize: false,
|
|
},
|
|
);
|
|
|
|
widget.value = node.properties[REMOTE_PROPERTY];
|
|
node.__modalToggleInjected = true;
|
|
node.__modalToggleWidget = widget;
|
|
|
|
const originalDrawForeground = node.onDrawForeground;
|
|
node.onDrawForeground = function onDrawForeground(ctx) {
|
|
originalDrawForeground?.apply(this, arguments);
|
|
drawRemoteNodeDecoration(this, ctx);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Apply an incoming Modal websocket status event.
|
|
* @param {CustomEvent} event
|
|
*/
|
|
function handleModalStatus(event) {
|
|
const detail = eventDetail(event);
|
|
const promptId = String(detail.prompt_id ?? "");
|
|
if (!promptId) {
|
|
return;
|
|
}
|
|
const nodeIds = (detail.node_ids ?? []).map((value) => String(value));
|
|
const components = detail.components ?? [];
|
|
if (components.length > 0 || nodeIds.length > 0) {
|
|
registerPromptComponents(promptId, nodeIds, components);
|
|
}
|
|
const promptState = ensurePromptState(promptId);
|
|
|
|
if (detail.phase === STATE_SETUP) {
|
|
beginSyntheticExecutionUi(promptId, nodeIds);
|
|
setGlobalStatusPhase(promptId, STATE_SETUP, nodeIds.length, {
|
|
message: detail.status_message ?? null,
|
|
current: detail.status_current ?? null,
|
|
total: detail.status_total ?? null,
|
|
});
|
|
setPromptActiveNode(promptId, null);
|
|
setNodesPhase(nodeIds, STATE_SETUP, promptId);
|
|
return;
|
|
}
|
|
|
|
if (detail.phase === STATE_WAITING) {
|
|
setGlobalStatusPhase(promptId, STATE_WAITING, nodeIds.length, {
|
|
message: detail.status_message ?? null,
|
|
current: detail.status_current ?? null,
|
|
total: detail.status_total ?? null,
|
|
});
|
|
setPromptActiveNode(promptId, null);
|
|
return;
|
|
}
|
|
|
|
if (detail.phase === STATE_FINALIZING) {
|
|
setGlobalStatusPhase(promptId, STATE_FINALIZING, nodeIds.length, {
|
|
message: detail.status_message ?? null,
|
|
current: detail.status_current ?? null,
|
|
total: detail.status_total ?? null,
|
|
});
|
|
setPromptActiveNode(promptId, null);
|
|
return;
|
|
}
|
|
|
|
if (detail.phase === STATE_ERROR) {
|
|
endSyntheticExecutionUi(promptId, true);
|
|
setGlobalStatusPhase(promptId, STATE_ERROR, nodeIds.length);
|
|
setTimeout(() => clearGlobalStatusPhase(promptId), ERROR_CLEAR_DELAY_MS);
|
|
setPromptActiveNode(promptId, null);
|
|
for (const nodeIdValue of nodeIds) {
|
|
clearNodeProgress(nodeIdValue, promptId);
|
|
}
|
|
setNodesPhase(nodeIds, STATE_ERROR, promptId, detail.error_message);
|
|
return;
|
|
}
|
|
|
|
if (detail.phase === EXECUTION_PHASE) {
|
|
endSyntheticExecutionUi(promptId);
|
|
const nextActiveNodeId =
|
|
detail.active_node_id != null ? String(detail.active_node_id) : null;
|
|
const previousActiveNodeId = promptState.activeNodeId;
|
|
promptState.hasStreamedProgress = true;
|
|
setGlobalStatusPhase(promptId, EXECUTION_PHASE, nodeIds.length);
|
|
setNodesPhase(nodeIds, STATE_READY, promptId);
|
|
if (previousActiveNodeId && previousActiveNodeId !== nextActiveNodeId) {
|
|
clearNodeProgress(previousActiveNodeId, promptId);
|
|
setNodesPhase([previousActiveNodeId], STATE_COMPLETE, promptId);
|
|
}
|
|
if (nextActiveNodeId) {
|
|
setNodesPhase([nextActiveNodeId], STATE_ACTIVE, promptId);
|
|
}
|
|
setPromptActiveNode(promptId, nextActiveNodeId);
|
|
return;
|
|
}
|
|
|
|
if (detail.phase === "execution_success") {
|
|
promptState.hasStreamedProgress = true;
|
|
if (promptState.activeNodeId) {
|
|
clearNodeProgress(promptState.activeNodeId, promptId);
|
|
setNodesPhase([promptState.activeNodeId], STATE_COMPLETE, promptId);
|
|
}
|
|
setPromptActiveNode(promptId, null);
|
|
setNodesPhase(nodeIds, STATE_COMPLETE, promptId);
|
|
return;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Apply a streamed numeric Modal node-progress event.
|
|
* @param {CustomEvent} event
|
|
*/
|
|
function handleModalProgress(event) {
|
|
const detail = eventDetail(event);
|
|
const promptId = String(detail.prompt_id ?? "");
|
|
const progressNodeId = String(detail.real_node_id ?? detail.display_node_id ?? detail.node_id ?? "");
|
|
if (!promptId || !progressNodeId) {
|
|
return;
|
|
}
|
|
|
|
endSyntheticExecutionUi(promptId);
|
|
const promptState = ensurePromptState(promptId);
|
|
const componentNodeIds = resolveComponentNodeIds(promptId, progressNodeId);
|
|
const readyNodeIds = (componentNodeIds ?? []).filter((nodeIdValue) => nodeIdValue !== progressNodeId);
|
|
promptState.hasStreamedProgress = true;
|
|
if (detail.aggregate_only) {
|
|
if (componentNodeIds?.length) {
|
|
setNodesPhase(componentNodeIds, STATE_READY, promptId);
|
|
}
|
|
setGlobalStatusBatchProgress(promptId, Number(detail.value ?? 0), Number(detail.max ?? 1));
|
|
setNodeBatchProgress(
|
|
progressNodeId,
|
|
promptId,
|
|
Number(detail.value ?? 0),
|
|
Number(detail.max ?? 1),
|
|
);
|
|
setGlobalStatusPhase(promptId, EXECUTION_PHASE, promptState.remoteNodeIds.length || 1);
|
|
return;
|
|
}
|
|
setGlobalStatusPhase(promptId, EXECUTION_PHASE, promptState.remoteNodeIds.length || 1);
|
|
if (detail.lane_id != null) {
|
|
setPromptActiveNode(promptId, null);
|
|
if (componentNodeIds?.length) {
|
|
setNodesPhase(componentNodeIds, STATE_READY, promptId);
|
|
} else {
|
|
setNodesPhase([progressNodeId], STATE_READY, promptId);
|
|
}
|
|
if (detail.clear) {
|
|
clearNodeProgressLane(progressNodeId, promptId, String(detail.lane_id));
|
|
return;
|
|
}
|
|
setNodeProgressLane(
|
|
progressNodeId,
|
|
promptId,
|
|
String(detail.lane_id),
|
|
Number(detail.value ?? 0),
|
|
Number(detail.max ?? 1),
|
|
detail.item_index,
|
|
);
|
|
return;
|
|
}
|
|
if (detail.clear) {
|
|
clearNodeProgress(progressNodeId, promptId);
|
|
return;
|
|
}
|
|
if (readyNodeIds.length > 0) {
|
|
setNodesPhase(readyNodeIds, STATE_READY, promptId);
|
|
}
|
|
setPromptActiveNode(promptId, progressNodeId);
|
|
setNodesPhase([progressNodeId], STATE_ACTIVE, promptId);
|
|
setNodeProgress(
|
|
progressNodeId,
|
|
promptId,
|
|
Number(detail.value ?? 0),
|
|
Number(detail.max ?? 1),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Update remote component visuals from a native ComfyUI execution event.
|
|
* @param {CustomEvent} event
|
|
* @param {string} phase
|
|
*/
|
|
function handleExecutionPhase(event, phase) {
|
|
const detail = eventDetail(event);
|
|
const promptId = String(detail.prompt_id ?? "");
|
|
const representativeNodeId = String(detail.display_node ?? detail.node ?? detail.node_id ?? "");
|
|
if (!promptId || !representativeNodeId) {
|
|
return;
|
|
}
|
|
|
|
const componentNodeIds = resolveComponentNodeIds(promptId, representativeNodeId);
|
|
if (!componentNodeIds) {
|
|
return;
|
|
}
|
|
const promptState = ensurePromptState(promptId);
|
|
if (promptState.hasStreamedProgress && phase === EXECUTION_PHASE) {
|
|
return;
|
|
}
|
|
if (phase === EXECUTION_PHASE) {
|
|
setGlobalStatusPhase(
|
|
promptId,
|
|
promptState.hasStreamedProgress ? EXECUTION_PHASE : STATE_WAITING,
|
|
componentNodeIds.length,
|
|
);
|
|
setNodesPhase(componentNodeIds, STATE_READY, promptId, detail.exception_message);
|
|
return;
|
|
}
|
|
if (phase === STATE_ERROR) {
|
|
setGlobalStatusPhase(promptId, STATE_ERROR, componentNodeIds.length);
|
|
setTimeout(() => clearGlobalStatusPhase(promptId), ERROR_CLEAR_DELAY_MS);
|
|
setPromptActiveNode(promptId, null);
|
|
for (const nodeIdValue of componentNodeIds) {
|
|
clearNodeProgress(nodeIdValue, promptId);
|
|
}
|
|
setNodesPhase(componentNodeIds, STATE_ERROR, promptId, detail.exception_message);
|
|
return;
|
|
}
|
|
if (phase === STATE_COMPLETE) {
|
|
setPromptActiveNode(promptId, null);
|
|
for (const nodeIdValue of componentNodeIds) {
|
|
clearNodeProgress(nodeIdValue, promptId);
|
|
}
|
|
setNodesPhase(componentNodeIds, STATE_COMPLETE, promptId, detail.exception_message);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clear all temporary remote execution visuals for a completed prompt.
|
|
* @param {string} promptId
|
|
*/
|
|
function clearPromptRemoteStates(promptId) {
|
|
const promptState = modalPromptStates.get(promptId);
|
|
if (!promptState) {
|
|
pruneGlobalStatusStates();
|
|
refreshGlobalStatusElement();
|
|
return;
|
|
}
|
|
for (const remoteNodeId of promptState.remoteNodeIds) {
|
|
clearNodeTimer(remoteNodeId);
|
|
clearNodeProgress(remoteNodeId, promptId);
|
|
const currentState = modalNodeStates.get(remoteNodeId);
|
|
if (currentState?.promptId === promptId) {
|
|
modalNodeStates.delete(remoteNodeId);
|
|
}
|
|
}
|
|
for (const ancestorNodeId of promptState.descendantNodeIdsByAncestor.keys()) {
|
|
clearNodeTimer(ancestorNodeId);
|
|
clearNodeProgress(ancestorNodeId, promptId);
|
|
const currentState = modalNodeStates.get(ancestorNodeId);
|
|
if (currentState?.promptId === promptId) {
|
|
modalNodeStates.delete(ancestorNodeId);
|
|
}
|
|
}
|
|
modalPromptStates.delete(promptId);
|
|
pruneGlobalStatusStates();
|
|
refreshGlobalStatusElement();
|
|
app.graph?.setDirtyCanvas(true, true);
|
|
}
|
|
|
|
/**
|
|
* Apply a queue-time failure to all remote nodes in the just-submitted workflow.
|
|
* @param {string[]} remoteNodeIds
|
|
* @param {string} promptId
|
|
* @param {Error} error
|
|
*/
|
|
function markQueueFailure(remoteNodeIds, promptId, error) {
|
|
if (remoteNodeIds.length === 0) {
|
|
return;
|
|
}
|
|
setNodesPhase(remoteNodeIds, STATE_ERROR, promptId, String(error?.message ?? error));
|
|
}
|
|
|
|
/**
|
|
* Dispatch a synthetic frontend API event when the underlying API supports EventTarget semantics.
|
|
* @param {string} eventType
|
|
* @param {any} detail
|
|
*/
|
|
function dispatchSyntheticApiEvent(eventType, detail) {
|
|
if (typeof api.dispatchEvent !== "function") {
|
|
return;
|
|
}
|
|
api.dispatchEvent(new CustomEvent(eventType, { detail }));
|
|
}
|
|
|
|
/**
|
|
* Return the minimal queue status payload expected by ComfyUI's status listeners.
|
|
* @param {number} queueRemaining
|
|
* @returns {{ exec_info: { queue_remaining: number } }}
|
|
*/
|
|
function statusPayload(queueRemaining) {
|
|
return {
|
|
exec_info: {
|
|
queue_remaining: queueRemaining,
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Start a synthetic running state so ComfyUI shows active execution while the Modal route is still preparing.
|
|
* @param {string} promptId
|
|
* @param {string[]} remoteNodeIds
|
|
*/
|
|
function beginSyntheticExecutionUi(promptId, remoteNodeIds) {
|
|
if (remoteNodeIds.length === 0 || syntheticPromptUiStates.has(promptId)) {
|
|
return;
|
|
}
|
|
|
|
const displayNode = remoteNodeIds[0];
|
|
syntheticPromptUiStates.set(promptId, { displayNode });
|
|
setGlobalStatusPhase(promptId, STATE_SETUP, remoteNodeIds.length);
|
|
dispatchSyntheticApiEvent("status", statusPayload(1));
|
|
dispatchSyntheticApiEvent("notification", {
|
|
id: promptId,
|
|
value: "Waiting for a machine on Modal.",
|
|
});
|
|
dispatchSyntheticApiEvent("execution_start", {
|
|
prompt_id: promptId,
|
|
timestamp: nowMs(),
|
|
});
|
|
dispatchSyntheticApiEvent("executing", displayNode);
|
|
}
|
|
|
|
/**
|
|
* End a synthetic running state after real queue/execution events take over or the request fails.
|
|
* @param {string} promptId
|
|
* @param {boolean} failed
|
|
*/
|
|
function endSyntheticExecutionUi(promptId, failed = false) {
|
|
const syntheticState = syntheticPromptUiStates.get(promptId);
|
|
if (!syntheticState) {
|
|
return;
|
|
}
|
|
|
|
syntheticPromptUiStates.delete(promptId);
|
|
if (failed) {
|
|
setGlobalStatusPhase(promptId, STATE_ERROR, 1);
|
|
setTimeout(() => clearGlobalStatusPhase(promptId), ERROR_CLEAR_DELAY_MS);
|
|
} else {
|
|
clearGlobalStatusPhase(promptId);
|
|
}
|
|
dispatchSyntheticApiEvent("notification", {
|
|
id: promptId,
|
|
value: "Modal setup finished.",
|
|
});
|
|
dispatchSyntheticApiEvent("status", statusPayload(0));
|
|
if (failed) {
|
|
dispatchSyntheticApiEvent("execution_error", {
|
|
prompt_id: promptId,
|
|
node_id: syntheticState.displayNode,
|
|
node_type: "ModalRemoteComponent",
|
|
executed: [],
|
|
exception_message: "Modal queue request failed before prompt execution started.",
|
|
exception_type: "ModalQueueError",
|
|
traceback: [],
|
|
current_inputs: [],
|
|
current_outputs: [],
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Register websocket listeners for Modal and execution status updates.
|
|
*/
|
|
function registerExecutionListeners() {
|
|
if (api.__modalExecutionListenersRegistered) {
|
|
return;
|
|
}
|
|
|
|
api.addEventListener("modal_status", handleModalStatus);
|
|
api.addEventListener("modal_progress", handleModalProgress);
|
|
api.addEventListener("executing", (event) => handleExecutionPhase(event, EXECUTION_PHASE));
|
|
api.addEventListener("executed", (event) => {
|
|
handleExecutionPhase(event, STATE_COMPLETE);
|
|
});
|
|
api.addEventListener("execution_error", (event) => {
|
|
endSyntheticExecutionUi(String(eventDetail(event).prompt_id ?? ""), true);
|
|
handleExecutionPhase(event, STATE_ERROR);
|
|
});
|
|
api.addEventListener("execution_interrupted", (event) => {
|
|
endSyntheticExecutionUi(String(eventDetail(event).prompt_id ?? ""), true);
|
|
handleExecutionPhase(event, STATE_ERROR);
|
|
});
|
|
api.addEventListener("execution_success", (event) => {
|
|
const detail = eventDetail(event);
|
|
const promptId = String(detail.prompt_id ?? "");
|
|
if (!promptId) {
|
|
return;
|
|
}
|
|
endSyntheticExecutionUi(promptId);
|
|
clearGlobalStatusPhase(promptId);
|
|
clearPromptRemoteStates(promptId);
|
|
});
|
|
if (typeof document !== "undefined") {
|
|
document.addEventListener("visibilitychange", () => {
|
|
if (document.visibilityState === "visible") {
|
|
refreshModalUiAfterVisibilityChange();
|
|
}
|
|
});
|
|
}
|
|
if (typeof window !== "undefined") {
|
|
window.addEventListener("focus", refreshModalUiAfterVisibilityChange);
|
|
}
|
|
api.__modalExecutionListenersRegistered = true;
|
|
}
|
|
|
|
/**
|
|
* Patch the queue API so prompt submission goes through the Modal route.
|
|
*/
|
|
function patchQueuePrompt() {
|
|
if (api.__modalQueuePromptPatched) {
|
|
return;
|
|
}
|
|
|
|
api.queuePrompt = async function modalQueuePrompt(number, data, options) {
|
|
const { output: prompt, workflow } = data;
|
|
const promptId = createPromptId();
|
|
const remoteNodeIds = extractRemoteNodeIds(workflow);
|
|
registerPromptComponents(promptId, remoteNodeIds, []);
|
|
if (remoteNodeIds.length > 0) {
|
|
setNodesPhase(remoteNodeIds, STATE_SETUP, promptId);
|
|
beginSyntheticExecutionUi(promptId, remoteNodeIds);
|
|
}
|
|
|
|
const body = {
|
|
client_id: this.clientId ?? "",
|
|
prompt_id: promptId,
|
|
prompt,
|
|
...(options?.partialExecutionTargets && {
|
|
partial_execution_targets: options.partialExecutionTargets,
|
|
}),
|
|
extra_data: {
|
|
auth_token_comfy_org: this.authToken,
|
|
api_key_comfy_org: this.apiKey,
|
|
extra_pnginfo: { workflow },
|
|
},
|
|
};
|
|
|
|
if (number === -1) {
|
|
body.front = true;
|
|
} else if (number !== 0) {
|
|
body.number = number;
|
|
}
|
|
|
|
try {
|
|
const response = await this.fetchApi(MODAL_ROUTE, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify(body),
|
|
});
|
|
|
|
if (response.status !== 200) {
|
|
throw new PromptExecutionError(await response.json());
|
|
}
|
|
|
|
const responsePayload = await response.json();
|
|
if (remoteNodeIds.length > 0) {
|
|
const resolvedRemoteNodeIds = (responsePayload.modal_remote_node_ids ?? []).map((nodeIdValue) =>
|
|
String(nodeIdValue),
|
|
);
|
|
const resolvedComponents = Array.isArray(responsePayload.modal_components)
|
|
? responsePayload.modal_components
|
|
: [];
|
|
if (resolvedRemoteNodeIds.length > 0 || resolvedComponents.length > 0) {
|
|
registerPromptComponents(promptId, resolvedRemoteNodeIds, resolvedComponents);
|
|
}
|
|
const promptState = ensurePromptState(promptId);
|
|
const acceptedRemoteNodeIds =
|
|
promptState.remoteNodeIds.length > 0 ? promptState.remoteNodeIds : remoteNodeIds;
|
|
endSyntheticExecutionUi(promptId);
|
|
setGlobalStatusPhase(promptId, STATE_WAITING, acceptedRemoteNodeIds.length, {
|
|
message: "Waiting for Modal startup",
|
|
});
|
|
setNodesPhase(acceptedRemoteNodeIds, STATE_READY, promptId);
|
|
}
|
|
|
|
return responsePayload;
|
|
} catch (error) {
|
|
endSyntheticExecutionUi(promptId, true);
|
|
markQueueFailure(remoteNodeIds, promptId, error);
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
api.__modalQueuePromptPatched = true;
|
|
}
|
|
|
|
/**
|
|
* Install CSS keyframes used by the global Modal status badge.
|
|
*/
|
|
function installGlobalStatusStyles() {
|
|
if (typeof document === "undefined" || document.getElementById("comfy-modal-status-styles")) {
|
|
return;
|
|
}
|
|
|
|
const style = document.createElement("style");
|
|
style.id = "comfy-modal-status-styles";
|
|
style.textContent = `
|
|
@keyframes modal-status-pulse {
|
|
0% { transform: scale(0.9); opacity: 0.7; }
|
|
50% { transform: scale(1.08); opacity: 1; }
|
|
100% { transform: scale(0.9); opacity: 0.7; }
|
|
}
|
|
`;
|
|
document.head.appendChild(style);
|
|
}
|
|
|
|
app.registerExtension({
|
|
name: "Comfy.ModalSync.Toggle",
|
|
|
|
async init() {
|
|
installGlobalStatusStyles();
|
|
patchQueuePrompt();
|
|
registerExecutionListeners();
|
|
},
|
|
|
|
async beforeRegisterNodeDef(nodeType, nodeData) {
|
|
installModalContextMenu(nodeType, nodeData);
|
|
},
|
|
|
|
async nodeCreated(node) {
|
|
decorateNode(node);
|
|
},
|
|
});
|