Files

8071 lines
267 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { app } from "../../scripts/app.js";
import { PromptExecutionError, api } from "../../scripts/api.js";
import { openRemoteEnvironmentManager } from "./remote_environments.js";
const REMOTE_PROPERTY = "is_modal_remote";
const REMOTE_WIDGET_NAME = "Run Remotely";
const LEGACY_REMOTE_WIDGET_NAME = "Run on Modal";
const MODAL_ROUTE = "/modal/queue_prompt";
const MODAL_ANALYZE_ROUTE = MODAL_ROUTE.replace(/\/queue_prompt$/, "/analyze_remote_nodes");
const MODAL_PROGRESS_STATE_ROUTE = MODAL_ROUTE.replace(/\/queue_prompt$/, "/progress_state");
const MODAL_CONTAINER_STATUS_ROUTE = MODAL_ROUTE.replace(/\/queue_prompt$/, "/container_status");
const MODAL_CONTAINER_STOP_ROUTE = MODAL_ROUTE.replace(/\/queue_prompt$/, "/container_stop");
const MODAL_DELETE_CACHES_ROUTE = MODAL_ROUTE.replace(/\/queue_prompt$/, "/delete_caches");
const MODAL_DELETE_VOLUME_ROUTE = MODAL_ROUTE.replace(/\/queue_prompt$/, "/delete_volume");
const MODAL_CANCEL_PREPARATION_ROUTE = MODAL_ROUTE.replace(
/\/queue_prompt$/,
"/cancel_preparation",
);
const R2_STORAGE_USAGE_ROUTE = "/remote/storage/r2/usage";
const R2_KEYCHAIN_UNLOCK_ROUTE = "/remote/storage/r2/keychain/unlock";
const R2_KEYCHAIN_UNLOCK_REQUIRED_CODE = "keychain_unlock_required";
const VAST_STATUS_ROUTE = "/remote/vast/status";
const VAST_DESTROY_ROUTE = "/remote/vast/destroy";
const COMFY_QUEUE_ROUTE = "/queue";
const COMFY_HISTORY_ROUTE = "/history";
const INTERNAL_NODE_PREFIX = "ModalUniversalExecutor";
const LOCAL_MODAL_NODE_IDS = new Set([
"ModalEndpointChat",
"VastAILeaseConfiguration",
"ModalRemoteConfiguration",
"VastRemoteConfiguration",
"SshRemoteConfiguration",
"R2StorageBackingConfiguration",
"RemoteExecutionConfigurator",
]);
const WORKFLOW_MODAL_CONFIG_KEY = "comfy_modal";
const WORKFLOW_MODAL_GPU_KEY = "gpu";
const WORKFLOW_REMOTE_CONFIG_KEY = "remote_execution";
const WORKFLOW_REMOTE_POLICY_KEY = "policy";
const WORKFLOW_REMOTE_AUTO_PLACE_KEY = "auto_place";
const EXECUTION_POLICIES = [
["modal", "Modal only"],
["self_hosted", "Self-hosted only"],
["vast", "Vast.ai only"],
["automatic", "Automatic (lowest cost compatible)"],
];
const DEFAULT_MODAL_GPU = "RTX-PRO-6000";
const MODAL_GPU_TYPES = [
"T4",
"L4",
"A10",
"L40S",
"A100",
"A100-40GB",
"A100-80GB",
"RTX-PRO-6000",
"H100",
"H100!",
"H200",
"B200",
"B200+",
"B300",
];
const IDLE_BORDER_COLOR = "#1d9bf0";
const SETUP_BORDER_COLOR = "#f59e0b";
const STARTING_BORDER_COLOR = "#eab308";
const FINALIZING_BORDER_COLOR = "#3b82f6";
const CANCELLING_BORDER_COLOR = "#fb7185";
const READY_ACTIVE_COMPONENT_BORDER_COLOR = "#22c55e";
const READY_INACTIVE_COMPONENT_BORDER_COLOR = "#166534";
const ACTIVE_BORDER_COLOR = "#a855f7";
const COMPLETE_BORDER_COLOR = "#004FA4";
const COMPLETE_FILL_COLOR = "#001C71";
const FINALIZING_NODE_BORDER_COLOR = "#00358A";
const ERROR_BORDER_COLOR = "#ef4444";
const LOCAL_BOTTLENECK_BADGE_BORDER_COLOR = "rgba(148, 163, 184, 0.72)";
const LOCAL_BOTTLENECK_TOOLTIP = "Did you mean to make this node execute on Modal?";
const REMOTE_LOCATION_ICON_SOURCES = {
modal: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 368 192"><path fill="#34D058" d="M0 128 73 0l37 64-73 128z"/><path fill="#00B75A" d="m110 64 74 0-74 128H37z"/><path fill="#8AEF74" d="M73 0h74l37 64h-74z"/><path fill="#34D058" d="M184 64 220 0l74 128-37 64z"/><path fill="#A7F59A" d="M220 0h74l74 128h-74z"/><path fill="#00B75A" d="m294 128h74l-37 64h-74z"/></svg>',
)}`,
ssh_docker: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 73 73"><rect x="1" y="1" width="71" height="71" rx="14" fill="#fff" stroke="#000" stroke-width="2"/><path d="M60.24 17.02H12.66A1.66 1.66 0 0 0 11 18.68v8.26h50.9v-8.26a1.66 1.66 0 0 0-1.66-1.66Zm-17.2 7.18a2.21 2.21 0 1 1 0-4.43 2.21 2.21 0 0 1 0 4.43Zm6.03 0a2.21 2.21 0 1 1 0-4.43 2.21 2.21 0 0 1 0 4.43Zm6.03 0a2.21 2.21 0 1 1 0-4.43 2.21 2.21 0 0 1 0 4.43ZM11 30.26v23.83c0 .92.74 1.66 1.66 1.66h47.58c.92 0 1.66-.74 1.66-1.66V30.26H11Zm23.41 14.01-5.44 4.62a1.66 1.66 0 0 1-2.15-2.53l3.95-3.35-3.95-3.36a1.66 1.66 0 1 1 2.15-2.53l5.44 4.62a1.66 1.66 0 0 1 0 2.53ZM45 49.29h-6.55a1.66 1.66 0 1 1 0-3.32H45a1.66 1.66 0 1 1 0 3.32Z"/></svg>',
)}`,
vast: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54.3 46.28"><path fill="#fff" d="M45.46.04l-12.43,25.42-7.71,16.46c-.16.47-.49.84-.94,1.06-.44.22-.95.25-1.42.09-.47-.16-.85-.49-1.07-.94L4.1,5.73l3.34-1.63,17.08,34.94,1.67-3.56L8.84,0,0,4.32l19.14,39.15c.58,1.18,1.58,2.06,2.82,2.49,1.24.42,2.58.34,3.76-.24.25-.12.5-.27.73-.44.3-.22.56-.53.74-.91.5-1.04,1.81-3.74,1.87-3.85L46.86,4.14l3.34,1.63-17.8,36.4c-.22.44-.59.78-1.07.94-.47.16-.97.13-1.42-.09-.02,0-.04-.03-.06-.04l-1.3,2.78s.01,0,.02.01c.68.34,1.42.5,2.16.5.54,0,1.08-.09,1.6-.27,1.24-.43,2.24-1.31,2.82-2.49L54.3,4.36,45.46.04Z"/></svg>',
)}`,
};
const REMOTE_LOCATION_ICON_ASPECT_RATIOS = Object.freeze({
modal: 368 / 192,
ssh_docker: 1,
vast: 54.3 / 46.28,
});
const STATE_SETUP = "setup";
const STATE_STARTING = "starting";
const STATE_WAITING = "waiting";
const STATE_FINALIZING = "finalizing";
const STATE_CANCELLING = "cancelling";
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 TERMINAL_PROMPT_RETENTION_MS = 60000;
const PROGRESS_FADE_MS = 900;
const REFOCUS_STALE_PROMPT_GRACE_MS = 30000;
const MODAL_ANIMATION_FRAME_INTERVAL_MS = 100;
const ITERATION_RATE_SMOOTHING_FACTOR = 0.35;
const BYTE_PROGRESS_UNITS = new Set([
"b",
"byte",
"bytes",
"b/s",
"byte/s",
"bytes/s",
]);
const BYTE_RATE_UNITS = Object.freeze([
"B/s",
"KB/s",
"MB/s",
"GB/s",
"TB/s",
"PB/s",
"EB/s",
]);
const BYTE_COUNT_UNITS = Object.freeze(["B", "KB", "MB", "GB", "TB"]);
const BYTE_SCALE = 1000;
const CONTAINER_STATUS_FAST_POLL_MS = 1500;
const CONTAINER_STATUS_STABLE_POLL_MS = 5000;
const CONTAINER_STATUS_HIDDEN_POLL_MS = 15000;
const CONTAINER_STATUS_MAX_BACKOFF_MS = 30000;
const modalNodeStates = new Map();
const modalNodeProgress = new Map();
const modalNodeProgressLanes = new Map();
const modalNodeBatchProgress = new Map();
const modalNodeCachedStates = new Map();
const modalNodeClearTimers = new Map();
const modalPromptStates = new Map();
const modalTerminalPromptStates = new Map();
const modalCancellingPromptIds = new Set();
const modalQueuedPromptIds = new Set();
const modalSandwichedLocalNodeIds = new Set();
const modalRemoteDescendantNodeIdsByAncestor = new Map();
const syntheticPromptUiStates = new Map();
const modalGlobalStatusStates = new Map();
const remoteLocationIconImages = new Map();
const remoteConfiguratorPanels = new Map();
const REMOTE_CONFIGURATOR_WIDGET_WIDTH_VALUE = "__comfyModalResponsiveWidth";
const REMOTE_CONFIGURATOR_WIDGET_WIDTH_GUARD = "__comfyModalResponsiveWidthGuard";
let animationFrameHandle = null;
let modalLastAnimationRedrawAt = 0;
let modalGlobalStatusElement = null;
let modalVisibilityRefreshInFlight = null;
let modalReplayedEventUpdatedAtMs = null;
let vueNodeObserver = null;
let vueNodeSyncScheduled = false;
let remoteDescendantIndexRebuildScheduled = false;
let modalContainerStatuses = [];
let modalContainerStatusPromptId = null;
let modalContainerStatusLoaded = false;
let modalContainerStatusError = null;
let modalContainerStatusTimer = null;
let modalContainerStatusPollInFlight = false;
let modalContainerStatusUnchangedPolls = 0;
let modalContainerStatusFailureCount = 0;
let modalContainerEstimatedCostUsd = 0;
let modalContainerCostUpdatedAtSeconds = null;
let modalHourlyBillingStatus = null;
let modalHourlyBillingError = 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) &&
!LOCAL_MODAL_NODE_IDS.has(String(node.comfyClass))
);
}
/**
* Read the remote execution flag from node properties.
* @param {LGraphNode} node
* @returns {boolean}
*/
function isRemoteNode(node) {
return Boolean(node?.properties?.[REMOTE_PROPERTY]);
}
/**
* Return whether a node is a subgraph container with Modal-enabled descendants.
* @param {LGraphNode} node
* @returns {boolean}
*/
function hasRemoteDescendants(node) {
const workflowPath = workflowNodePath(node) || nodeId(node);
return (modalRemoteDescendantNodeIdsByAncestor.get(workflowPath)?.size ?? 0) > 0;
}
/**
* Return whether the latest planner result identified a local re-entry bottleneck.
* @param {LGraphNode} node
* @returns {boolean}
*/
function isSandwichedLocalNode(node) {
if (isRemoteNode(node)) {
return false;
}
const workflowPath = workflowNodePath(node) || nodeId(node);
return modalSandwichedLocalNodeIds.has(workflowPath);
}
/**
* Replace the local re-entry warnings with one planner result.
* @param {Array<string | number>} nodeIds
*/
function setSandwichedLocalNodeIds(nodeIds) {
modalSandwichedLocalNodeIds.clear();
for (const nodeIdValue of nodeIds ?? []) {
modalSandwichedLocalNodeIds.add(String(nodeIdValue));
}
refreshNodeDecorations();
}
/**
* Clear planner warnings after the user changes remote selection.
*/
function clearSandwichedLocalNodeWarnings() {
if (modalSandwichedLocalNodeIds.size === 0) {
return;
}
modalSandwichedLocalNodeIds.clear();
refreshNodeDecorations();
}
/**
* 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();
}
/**
* Return the event clock used for progress-rate samples, preserving backend time during replay.
* @returns {number}
*/
function progressEventTimestampMs() {
return modalReplayedEventUpdatedAtMs ?? nowMs();
}
/**
* Derive a smoothed iterations-per-second rate from two numeric progress samples.
* @param {{ value?: number, max?: number, updatedAt?: number, iterationRate?: number | null } | null | undefined} previousState
* @param {number} value
* @param {number} maxValue
* @param {number} updatedAt
* @returns {number | null}
*/
function progressIterationRate(previousState, value, maxValue, updatedAt) {
if (!previousState) {
return null;
}
const previousValue = Number(previousState.value);
const previousMax = Number(previousState.max);
const previousUpdatedAt = Number(previousState.updatedAt);
const previousRate =
previousState.iterationRate == null ? Number.NaN : Number(previousState.iterationRate);
if (
!Number.isFinite(previousValue) ||
!Number.isFinite(previousMax) ||
!Number.isFinite(previousUpdatedAt) ||
previousMax !== maxValue ||
value < previousValue
) {
return null;
}
if (value === previousValue || updatedAt <= previousUpdatedAt) {
return Number.isFinite(previousRate) && previousRate >= 0 ? previousRate : null;
}
const elapsedSeconds = (updatedAt - previousUpdatedAt) / 1000;
const sampleRate = (value - previousValue) / elapsedSeconds;
if (!Number.isFinite(sampleRate) || sampleRate < 0) {
return null;
}
if (!Number.isFinite(previousRate) || previousRate < 0) {
return sampleRate;
}
return (
previousRate * (1 - ITERATION_RATE_SMOOTHING_FACTOR) +
sampleRate * ITERATION_RATE_SMOOTHING_FACTOR
);
}
/**
* Format raw bytes per second with a compact decimal transfer-rate unit.
* @param {number} bytesPerSecond
* @returns {string}
*/
function formatByteRate(bytesPerSecond) {
let scaledRate = bytesPerSecond;
let unitIndex = 0;
while (scaledRate >= BYTE_SCALE && unitIndex < BYTE_RATE_UNITS.length - 1) {
scaledRate /= BYTE_SCALE;
unitIndex += 1;
}
const fractionDigits = scaledRate < 10 ? 2 : scaledRate < 100 ? 1 : 0;
return `${scaledRate.toFixed(fractionDigits)} ${BYTE_RATE_UNITS[unitIndex]}`;
}
/**
* Format current and total byte counts in one compact, comparable unit.
* @param {number} value
* @param {number} maxValue
* @returns {string}
*/
function formatByteProgress(value, maxValue) {
let scaledMaxValue = maxValue;
let unitIndex = 0;
while (scaledMaxValue >= BYTE_SCALE && unitIndex < BYTE_COUNT_UNITS.length - 1) {
scaledMaxValue /= BYTE_SCALE;
unitIndex += 1;
}
const divisor = BYTE_SCALE ** unitIndex;
const scaledValue = value / divisor;
const fractionDigits = unitIndex <= 2 ? 0 : unitIndex === 3 ? 1 : 2;
return `${scaledValue.toFixed(fractionDigits)}/${scaledMaxValue.toFixed(fractionDigits)} ${BYTE_COUNT_UNITS[unitIndex]}`;
}
/**
* Format a progress rate for the compact label over a node progress bar.
* @param {number | null | undefined} iterationRate
* @param {string | null | undefined} unit
* @returns {string}
*/
function formatIterationRate(iterationRate, unit = null) {
const safeRate = iterationRate == null ? Number.NaN : Number(iterationRate);
const normalizedUnit = String(unit ?? "").trim().toLowerCase();
const isByteRate = BYTE_PROGRESS_UNITS.has(normalizedUnit);
const rateUnit =
normalizedUnit === "tokens" ? "tok/s" : isByteRate ? "B/s" : "it/s";
if (!Number.isFinite(safeRate) || safeRate < 0) {
return `— ${rateUnit}`;
}
if (isByteRate) {
return formatByteRate(safeRate);
}
const fractionDigits = safeRate < 10 ? 2 : safeRate < 100 ? 1 : 0;
return `${safeRate.toFixed(fractionDigits)} ${rateUnit}`;
}
/**
* Trim one canvas label to fit a fixed width.
* @param {CanvasRenderingContext2D} ctx
* @param {string} text
* @param {number} maxWidth
* @returns {string}
*/
function fitCanvasText(ctx, text, maxWidth) {
const safeText = String(text ?? "");
if (!safeText || ctx.measureText(safeText).width <= maxWidth) {
return safeText;
}
let end = safeText.length;
while (end > 1 && ctx.measureText(`${safeText.slice(0, end)}…`).width > maxWidth) {
end -= 1;
}
return `${safeText.slice(0, end)}…`;
}
/**
* Return a lazily loaded provider icon for the canvas location line.
* @param {string | null | undefined} provider
* @returns {HTMLImageElement | null}
*/
function remoteLocationIcon(provider) {
const safeProvider = String(provider ?? "");
const source = REMOTE_LOCATION_ICON_SOURCES[safeProvider];
if (!source || typeof Image !== "function") {
return null;
}
if (!remoteLocationIconImages.has(safeProvider)) {
const icon = new Image();
icon.decoding = "async";
icon.src = source;
icon.addEventListener?.("load", refreshNodeDecorations, { once: true });
remoteLocationIconImages.set(safeProvider, icon);
}
return remoteLocationIconImages.get(safeProvider) ?? null;
}
/**
* Draw a provider icon in the column immediately before a progress bar.
* @param {CanvasRenderingContext2D} ctx
* @param {string | null | undefined} provider
* @param {number} columnX
* @param {number} centerY
* @param {number} columnWidth
* @param {number} barHeight
* @param {number} scale
*/
function drawProgressProviderIcon(
ctx,
provider,
columnX,
centerY,
columnWidth,
barHeight,
scale,
) {
const safeProvider = String(provider ?? "");
const icon = remoteLocationIcon(safeProvider);
if (!icon?.complete || !icon.naturalWidth || typeof ctx.drawImage !== "function") {
return;
}
const iconHeight = Math.max(0, Math.min(10 / scale, barHeight - 2 / scale));
const aspectRatio = REMOTE_LOCATION_ICON_ASPECT_RATIOS[safeProvider] ?? 1;
const iconWidth = Math.min(columnWidth - 4 / scale, iconHeight * aspectRatio);
const iconX = columnX + (columnWidth - iconWidth) / 2;
ctx.drawImage(icon, iconX, centerY - iconHeight / 2, iconWidth, iconHeight);
}
/**
* Draw an iteration-rate label over a progress bar with a tight opaque backing.
* @param {CanvasRenderingContext2D} ctx
* @param {number | null | undefined} iterationRate
* @param {number} rightX
* @param {number} centerY
* @param {number} barHeight
* @param {number} scale
* @param {string | null | undefined} unit
*/
function drawIterationRateOverlay(
ctx,
iterationRate,
rightX,
centerY,
barHeight,
scale,
unit = null,
) {
const label = formatIterationRate(iterationRate, unit);
const metrics = ctx.measureText(label);
const paddingX = 3 / scale;
const paddingY = 1 / scale;
const measuredHeight =
Number(metrics.actualBoundingBoxAscent ?? 0) +
Number(metrics.actualBoundingBoxDescent ?? 0);
const boxWidth = metrics.width + paddingX * 2;
const boxHeight = Math.min(barHeight, Math.max(7 / scale, measuredHeight) + paddingY * 2);
const boxX = rightX - boxWidth;
const boxY = centerY - boxHeight / 2;
ctx.fillStyle = "rgba(0, 0, 0, 0.9)";
ctx.beginPath();
ctx.roundRect(boxX, boxY, boxWidth, boxHeight, Math.min(2 / scale, boxHeight / 2));
ctx.fill();
ctx.fillStyle = "#e2e8f0";
ctx.textAlign = "right";
ctx.textBaseline = "middle";
ctx.fillText(label, rightX - paddingX, centerY);
}
/**
* Remove terminal prompt markers after their stale-event guard window expires.
*/
function pruneTerminalPromptStates() {
const cutoffMs = nowMs() - TERMINAL_PROMPT_RETENTION_MS;
for (const [promptId, terminalState] of modalTerminalPromptStates.entries()) {
if ((terminalState?.terminalAt ?? 0) < cutoffMs) {
modalTerminalPromptStates.delete(promptId);
}
}
}
/**
* Return whether late events for a prompt should be ignored.
* @param {string} promptId
* @returns {boolean}
*/
function isPromptTerminal(promptId) {
if (!promptId) {
return false;
}
pruneTerminalPromptStates();
return modalTerminalPromptStates.has(promptId);
}
/**
* Mark a prompt as terminal so late remote events cannot resurrect stale UI state.
* @param {string} promptId
* @param {string} phase
*/
function markPromptTerminal(promptId, phase) {
if (!promptId) {
return;
}
modalCancellingPromptIds.delete(promptId);
modalTerminalPromptStates.set(promptId, {
phase,
terminalAt: nowMs(),
});
}
/**
* Allow a newly queued prompt id to receive state updates.
* @param {string} promptId
*/
function clearPromptTerminal(promptId) {
if (!promptId) {
return;
}
modalTerminalPromptStates.delete(promptId);
modalCancellingPromptIds.delete(promptId);
clearPromptQueued(promptId);
}
/**
* Return whether one prompt has a user-requested cancellation in progress.
* @param {string} promptId
* @returns {boolean}
*/
function isPromptCancelling(promptId) {
return Boolean(promptId) && modalCancellingPromptIds.has(promptId);
}
/**
* 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 = "flex-start";
element.style.gap = "10px";
element.style.padding = "10px 14px";
element.style.borderRadius = "16px";
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-copy"><span class="modal-status-text"></span><span class="modal-status-gpu" hidden></span><span class="modal-status-cost" hidden></span><span class="modal-status-billing" hidden></span><span class="modal-status-containers" hidden></span></span>';
document.body.appendChild(element);
modalGlobalStatusElement = element;
return element;
}
/**
* Remove orphaned global status entries that no longer have any live prompt state.
*/
function pruneGlobalStatusStates() {
pruneTerminalPromptStates();
for (const promptId of Array.from(modalGlobalStatusStates.keys())) {
if (isPromptTerminal(promptId)) {
modalGlobalStatusStates.delete(promptId);
continue;
}
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);
}
/**
* Return the best known remote node count for one prompt.
* @param {string} promptId
* @param {number} fallbackCount
* @returns {number}
*/
function promptRemoteNodeCount(promptId, fallbackCount = 1) {
const promptState = modalPromptStates.get(promptId);
const remoteCount = promptState?.remoteNodeIds?.length ?? 0;
return Math.max(1, Number(remoteCount || fallbackCount) || 1);
}
/**
* 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_CANCELLING || nodeStates.some((state) => state.phase === STATE_CANCELLING)) {
return STATE_CANCELLING;
}
if (phase === STATE_SETUP || phase === STATE_STARTING || phase === STATE_WAITING) {
return phase;
}
if (nodeStates.some((state) => state.phase === STATE_ERROR)) {
return STATE_ERROR;
}
if (phase === STATE_FINALIZING) {
if (
promptState?.hasRemoteExecutionStarted ||
promptActiveNodeIsLive(promptId) ||
nodeStates.some((state) => state.phase === STATE_ACTIVE || state.phase === STATE_READY)
) {
return EXECUTION_PHASE;
}
return STATE_FINALIZING;
}
if (promptState?.hasRemoteExecutionStarted && promptActiveNodeIsLive(promptId)) {
return EXECUTION_PHASE;
}
if (promptState?.hasRemoteExecutionStarted && nodeStates.some((state) => state.phase === STATE_ACTIVE)) {
return EXECUTION_PHASE;
}
if (
promptState?.hasRemoteExecutionStarted &&
nodeStates.some((state) => state.phase === STATE_READY)
) {
return EXECUTION_PHASE;
}
return phase;
}
/**
* Return whether one prompt still has active remote work that should keep the global pill visible.
* @param {string} promptId
* @returns {boolean}
*/
function promptHasLiveRemoteWork(promptId) {
const promptState = modalPromptStates.get(promptId);
if (!promptState) {
return false;
}
if (promptActiveNodeIsLive(promptId)) {
return true;
}
if (promptState.remoteNodeIds.some((nodeIdValue) => hasLiveNodeProgress(nodeIdValue, promptId))) {
return true;
}
return promptState.remoteNodeIds.some((nodeIdValue) => {
const nodeState = modalNodeStates.get(String(nodeIdValue));
return (
nodeState?.promptId === promptId &&
[STATE_SETUP, STATE_STARTING, STATE_READY, STATE_ACTIVE].includes(nodeState.phase)
);
});
}
/**
* Return whether the prompt-wide active node still represents live execution.
* @param {string} promptId
* @returns {boolean}
*/
function promptActiveNodeIsLive(promptId) {
const promptState = modalPromptStates.get(promptId);
const activeNodeId = promptState?.activeNodeId;
if (!activeNodeId) {
return false;
}
if (hasLiveNodeProgress(activeNodeId, promptId)) {
return true;
}
const activeNodeState = modalNodeStates.get(String(activeNodeId));
return activeNodeState?.promptId === promptId && activeNodeState.phase === STATE_ACTIVE;
}
/**
* Return whether any older Modal prompt is still visually or remotely active.
* @param {string | null} excludedPromptId
* @returns {boolean}
*/
function hasActiveModalPrompt(excludedPromptId = null) {
for (const promptId of modalPromptStates.keys()) {
if (promptId === excludedPromptId || isPromptTerminal(promptId)) {
continue;
}
if (syntheticPromptUiStates.has(promptId) || promptHasLiveRemoteWork(promptId)) {
return true;
}
}
return false;
}
/**
* Mark a submitted prompt as queued behind an already active Modal prompt.
* @param {string} promptId
* @returns {boolean}
*/
function markPromptQueuedBehindActiveModal(promptId) {
if (!promptId || !hasActiveModalPrompt(promptId)) {
return false;
}
modalQueuedPromptIds.add(promptId);
return true;
}
/**
* Return whether queue-time UI updates should be suppressed for one prompt.
* @param {string} promptId
* @returns {boolean}
*/
function isPromptQueuedBehindActiveModal(promptId) {
return modalQueuedPromptIds.has(promptId);
}
/**
* Allow normal execution UI updates for a prompt once ComfyUI actually starts it.
* @param {string} promptId
*/
function clearPromptQueued(promptId) {
modalQueuedPromptIds.delete(promptId);
}
/**
* Drop one prompt's stale global-status entry once it no longer has live remote work.
* @param {string} promptId
*/
function reconcilePromptGlobalStatus(promptId) {
if (!promptId) {
return;
}
const globalStatusState = modalGlobalStatusStates.get(promptId);
if (!globalStatusState) {
return;
}
if (syntheticPromptUiStates.has(promptId)) {
return;
}
if (globalStatusState.phase === STATE_ERROR || globalStatusState.phase === STATE_SETUP) {
return;
}
if (promptHasLiveRemoteWork(promptId)) {
refreshGlobalStatusElement();
return;
}
modalGlobalStatusStates.delete(promptId);
refreshGlobalStatusElement();
}
/**
* Return the most important active global Modal state.
* @returns {{ phase: string, promptId: string, nodeCount: number, modalGpu: string | null } | 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,
modalGpu: state.modalGpu ?? null,
executionLabel: state.executionLabel ?? 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_CANCELLING) ??
phases.find((state) => state.phase === STATE_SETUP) ??
phases.find((state) => state.phase === STATE_STARTING) ??
phases.find((state) => state.phase === STATE_WAITING) ??
phases.find((state) => state.phase === EXECUTION_PHASE) ??
phases.find((state) => state.phase === STATE_FINALIZING) ??
phases[0]
);
}
/**
* Return whether active workflow state should poll Modal for container status.
* @param {{ phase?: string, modalGpu?: string | null } | null} activeState
* @returns {boolean}
*/
function shouldPollModalContainerStatus(activeState) {
return Boolean(
activeState && activeState.phase !== STATE_ERROR && activeState.modalGpu,
);
}
/**
* Return a provider-aware message while planned remote capacity starts.
* @param {Iterable<string>} providers
* @returns {string}
*/
function remoteCapacityWaitingMessage(providers) {
const providerSet = new Set(Array.from(providers, (provider) => String(provider)));
if (providerSet.size !== 1) {
return "Waiting for remote capacity";
}
if (providerSet.has("modal")) {
return "Waiting for Modal startup";
}
if (providerSet.has("vast")) {
return "Waiting for Vast.ai instance";
}
if (providerSet.has("ssh_docker")) {
return "Waiting for self-hosted worker";
}
return "Waiting for remote capacity";
}
/**
* Return one compact provider and environment label for the global status pill.
* @param {Record<string, any>} assignment
* @param {Map<string, Record<string, any>>} configurationsById
* @param {string | null} fallbackModalGpu
* @returns {string}
*/
function remoteExecutionEnvironmentLabel(
assignment,
configurationsById,
fallbackModalGpu,
) {
if (assignment?.provider === "modal") {
const configuration = configurationsById.get(
String(assignment?.configuration_id ?? ""),
);
const modalGpu = configuration?.gpu_type ?? fallbackModalGpu;
return modalGpu ? `Modal ${modalGpu}` : "Modal";
}
if (assignment?.provider === "vast") {
const destination = String(
assignment?.execution_location ?? assignment?.environment_id ?? "capacity",
);
return `Vast.ai ${destination}`;
}
return String(
assignment?.execution_location ??
assignment?.environment_id ??
"self-hosted",
);
}
/**
* Normalize one container-list payload for stable rendering and comparison.
* @param {any} payload
* @returns {Array<Record<string, any>>}
*/
function normalizedModalContainerStatuses(payload) {
const containers = Array.isArray(payload?.containers) ? payload.containers : [];
return containers
.filter((container) => container?.container_id)
.map((container) => ({
containerId: String(container.container_id),
appName: String(container.app_name ?? ""),
modalGpu: String(container.modal_gpu ?? ""),
estimatedGpuCostPerSecond:
Math.max(0, Number(container.estimated_gpu_cost_per_second)) || 0,
state: container.state === "running" ? "running" : "starting",
enqueuedAt: Number(container.enqueued_at) || null,
startedAt: Number(container.started_at) || null,
}))
.sort((left, right) =>
(left.enqueuedAt ?? left.startedAt ?? 0) - (right.enqueuedAt ?? right.startedAt ?? 0) ||
left.containerId.localeCompare(right.containerId),
);
}
/**
* Normalize one completed Modal hourly billing record.
* @param {any} payload
* @returns {Record<string, any> | null}
*/
function normalizedModalHourlyBillingStatus(payload) {
const billing = payload?.billing;
if (!billing?.app_name || !billing?.interval_end) {
return null;
}
return {
appName: String(billing.app_name),
environmentName: String(billing.environment_name ?? ""),
modalGpu: String(billing.modal_gpu ?? ""),
intervalStart: String(billing.interval_start ?? ""),
intervalEnd: String(billing.interval_end),
appCostUsdBeforeCredits:
Math.max(0, Number(billing.app_cost_usd_before_credits)) || 0,
hasUsage: Boolean(billing.has_usage),
fetchedAt: String(billing.fetched_at ?? ""),
nextRefreshAt: String(billing.next_refresh_at ?? ""),
};
}
/**
* Return a stable signature for one container status response.
* @param {Array<Record<string, any>>} containers
* @returns {string}
*/
function modalContainerStatusSignature(containers) {
return containers
.map(
(container) =>
`${container.containerId}:${container.state}:${container.startedAt ?? ""}:${container.estimatedGpuCostPerSecond}`,
)
.join("|");
}
/**
* Return containers whose billed GPU identity matches the active prompt.
* @param {string | null} promptId
* @param {Array<Record<string, any>>} containers
* @returns {Array<Record<string, any>>}
*/
function modalPromptCostContainers(promptId, containers) {
const selectedModalGpu = modalPromptStates.get(String(promptId ?? ""))?.modalGpu;
if (!selectedModalGpu) {
return containers;
}
return containers.filter((container) => container.modalGpu === selectedModalGpu);
}
/**
* Return the estimated GPU burn rate for active running containers.
* @param {Array<Record<string, any>>} containers
* @param {string | null} promptId
* @returns {number}
*/
function modalContainerGpuBurnRate(
containers = modalContainerStatuses,
promptId = modalContainerStatusPromptId,
) {
return modalPromptCostContainers(promptId, containers)
.filter((container) => container.state === "running" && container.startedAt)
.reduce(
(total, container) => total + Math.max(0, container.estimatedGpuCostPerSecond),
0,
);
}
/**
* Add one successful status interval to the prompt-scoped GPU cost estimate.
* @param {string} promptId
* @param {Array<Record<string, any>>} nextStatuses
* @param {number} polledAtSeconds
*/
function updateModalContainerCostEstimate(promptId, nextStatuses, polledAtSeconds) {
if (!promptId || !Number.isFinite(polledAtSeconds)) {
return;
}
const promptStartedAtSeconds = Math.max(
0,
Number(modalPromptStates.get(promptId)?.startedAt ?? nowMs()) / 1000,
);
const promptStatuses = modalPromptCostContainers(promptId, nextStatuses);
const previousPromptStatuses = modalPromptCostContainers(
promptId,
modalContainerStatuses,
);
let intervalCostUsd = 0;
if (modalContainerCostUpdatedAtSeconds == null) {
for (const container of promptStatuses) {
if (container.state !== "running" || !container.startedAt) {
continue;
}
const intervalStartedAt = Math.max(promptStartedAtSeconds, container.startedAt);
intervalCostUsd +=
Math.max(0, polledAtSeconds - intervalStartedAt) *
container.estimatedGpuCostPerSecond;
}
} else {
const previousIntervalSeconds = Math.max(
0,
polledAtSeconds - modalContainerCostUpdatedAtSeconds,
);
intervalCostUsd +=
previousIntervalSeconds *
modalContainerGpuBurnRate(previousPromptStatuses, promptId);
const previousRunningContainerIds = new Set(
previousPromptStatuses
.filter((container) => container.state === "running" && container.startedAt)
.map((container) => container.containerId),
);
for (const container of promptStatuses) {
if (
container.state !== "running" ||
!container.startedAt ||
previousRunningContainerIds.has(container.containerId)
) {
continue;
}
const intervalStartedAt = Math.max(
promptStartedAtSeconds,
modalContainerCostUpdatedAtSeconds,
container.startedAt,
);
intervalCostUsd +=
Math.max(0, polledAtSeconds - intervalStartedAt) *
container.estimatedGpuCostPerSecond;
}
}
modalContainerEstimatedCostUsd += Math.max(0, intervalCostUsd);
modalContainerCostUpdatedAtSeconds = polledAtSeconds;
}
/**
* Return the accumulated estimate plus the active interval since the last poll.
* @returns {number}
*/
function liveModalContainerCostEstimate() {
if (modalContainerCostUpdatedAtSeconds == null) {
return modalContainerEstimatedCostUsd;
}
const unpolledSeconds = Math.max(0, nowMs() / 1000 - modalContainerCostUpdatedAtSeconds);
return modalContainerEstimatedCostUsd +
unpolledSeconds * modalContainerGpuBurnRate(modalContainerStatuses);
}
/**
* Return the next adaptive container status poll delay.
* @returns {number}
*/
function modalContainerStatusPollDelay() {
if (modalContainerStatusFailureCount > 0) {
return Math.min(
CONTAINER_STATUS_MAX_BACKOFF_MS,
CONTAINER_STATUS_STABLE_POLL_MS * 2 ** (modalContainerStatusFailureCount - 1),
);
}
if (typeof document !== "undefined" && document.visibilityState !== "visible") {
return CONTAINER_STATUS_HIDDEN_POLL_MS;
}
return modalContainerStatusUnchangedPolls >= 2
? CONTAINER_STATUS_STABLE_POLL_MS
: CONTAINER_STATUS_FAST_POLL_MS;
}
/**
* Clear scheduled polling and prompt-scoped container UI state.
*/
function stopModalContainerStatusPolling() {
if (modalContainerStatusTimer != null) {
clearTimeout(modalContainerStatusTimer);
modalContainerStatusTimer = null;
}
modalContainerStatuses = [];
modalContainerStatusPromptId = null;
modalContainerStatusLoaded = false;
modalContainerStatusError = null;
modalContainerStatusUnchangedPolls = 0;
modalContainerStatusFailureCount = 0;
modalContainerEstimatedCostUsd = 0;
modalContainerCostUpdatedAtSeconds = null;
modalHourlyBillingStatus = null;
modalHourlyBillingError = null;
}
/**
* Schedule the next container status query while Modal work remains active.
* @param {number | null} requestedDelayMs
*/
function scheduleModalContainerStatusPoll(requestedDelayMs = null) {
const activeState = currentGlobalStatus();
if (!shouldPollModalContainerStatus(activeState)) {
stopModalContainerStatusPolling();
return;
}
if (modalContainerStatusPollInFlight || modalContainerStatusTimer != null) {
return;
}
const delayMs = requestedDelayMs ?? modalContainerStatusPollDelay();
modalContainerStatusTimer = setTimeout(() => {
modalContainerStatusTimer = null;
pollModalContainerStatus();
}, Math.max(0, delayMs));
}
/**
* Poll active containers through the local ComfyUI route.
* @returns {Promise<void>}
*/
async function pollModalContainerStatus() {
const activeState = currentGlobalStatus();
if (!shouldPollModalContainerStatus(activeState) || typeof api.fetchApi !== "function") {
stopModalContainerStatusPolling();
return;
}
const requestedPromptId = String(activeState.promptId ?? "");
const requestedModalGpu = String(activeState.modalGpu ?? DEFAULT_MODAL_GPU);
modalContainerStatusPollInFlight = true;
try {
const statusUrl =
`${MODAL_CONTAINER_STATUS_ROUTE}?modal_gpu=${encodeURIComponent(requestedModalGpu)}`;
const response = await api.fetchApi(statusUrl, { method: "GET" });
if (response.status !== 200) {
throw new Error(`Modal container status returned HTTP ${response.status}.`);
}
const payload = await response.json();
if (modalContainerStatusPromptId !== requestedPromptId) {
return;
}
const nextStatuses = normalizedModalContainerStatuses(payload);
modalHourlyBillingStatus = normalizedModalHourlyBillingStatus(payload);
modalHourlyBillingError = payload?.billing_error
? String(payload.billing_error)
: null;
const polledAtSeconds = Number(payload?.polled_at) || nowMs() / 1000;
updateModalContainerCostEstimate(requestedPromptId, nextStatuses, polledAtSeconds);
const changed =
modalContainerStatusSignature(nextStatuses) !==
modalContainerStatusSignature(modalContainerStatuses);
modalContainerStatuses = nextStatuses;
modalContainerStatusLoaded = true;
modalContainerStatusError = null;
modalContainerStatusFailureCount = 0;
modalContainerStatusUnchangedPolls = changed
? 0
: modalContainerStatusUnchangedPolls + 1;
} catch (error) {
modalContainerStatusLoaded = true;
modalContainerStatusError = String(error?.message ?? error);
modalHourlyBillingError = modalContainerStatusError;
modalContainerStatusFailureCount += 1;
console.debug("Unable to poll Modal container status.", error);
} finally {
modalContainerStatusPollInFlight = false;
refreshGlobalStatusElement();
scheduleModalContainerStatusPoll();
}
}
/**
* Ensure polling follows the prompt currently shown by the global status pill.
* @param {{ promptId?: string, phase?: string } | null} activeState
*/
function syncModalContainerStatusPolling(activeState) {
if (!shouldPollModalContainerStatus(activeState)) {
stopModalContainerStatusPolling();
return;
}
if (modalContainerStatusPromptId !== activeState.promptId) {
stopModalContainerStatusPolling();
modalContainerStatusPromptId = activeState.promptId;
scheduleModalContainerStatusPoll(0);
return;
}
scheduleModalContainerStatusPoll();
}
/**
* Force an immediate status refresh after the browser returns to the foreground.
*/
function requestImmediateModalContainerStatusPoll() {
if (modalContainerStatusTimer != null) {
clearTimeout(modalContainerStatusTimer);
modalContainerStatusTimer = null;
}
scheduleModalContainerStatusPoll(0);
}
/**
* Format a compact elapsed time for one running container.
* @param {number | null} startedAtSeconds
* @returns {string}
*/
function formatModalContainerAge(startedAtSeconds) {
if (!startedAtSeconds) {
return "";
}
const elapsedSeconds = Math.max(0, Math.floor(nowMs() / 1000 - startedAtSeconds));
if (elapsedSeconds < 60) {
return `${elapsedSeconds}s`;
}
const minutes = Math.floor(elapsedSeconds / 60);
const seconds = elapsedSeconds % 60;
return `${minutes}m ${seconds}s`;
}
/**
* Format a compact estimated US-dollar amount.
* @param {number} amountUsd
* @returns {string}
*/
function formatEstimatedModalUsd(amountUsd) {
const safeAmount = Math.max(0, Number(amountUsd) || 0);
if (safeAmount < 0.01) {
return `$${safeAmount.toFixed(4)}`;
}
if (safeAmount < 1) {
return `$${safeAmount.toFixed(3)}`;
}
return `$${safeAmount.toFixed(2)}`;
}
/**
* Render the prompt-scoped estimated GPU cost and current burn rate.
* @param {HTMLElement | null} costElement
*/
function renderModalCostEstimate(costElement) {
if (!costElement) {
return;
}
const estimatedCostUsd = liveModalContainerCostEstimate();
const burnRatePerMinuteUsd = modalContainerGpuBurnRate() * 60;
if (estimatedCostUsd <= 0 && burnRatePerMinuteUsd <= 0) {
costElement.textContent = "";
costElement.hidden = true;
return;
}
costElement.textContent =
`Estimated GPU cost ${formatEstimatedModalUsd(estimatedCostUsd)}` +
(burnRatePerMinuteUsd > 0
? ` · ${formatEstimatedModalUsd(burnRatePerMinuteUsd)}/min`
: "");
costElement.hidden = false;
}
/**
* Format the end of one completed hourly Modal billing interval.
* @param {string} intervalEnd
* @returns {string}
*/
function formatModalBillingIntervalEnd(intervalEnd) {
const intervalEndDate = new Date(intervalEnd);
if (!Number.isFinite(intervalEndDate.getTime())) {
return "unknown time";
}
return intervalEndDate.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
timeZoneName: "short",
});
}
/**
* Render actual app billing for Modal's latest buffered completed hour.
* @param {HTMLElement | null} billingElement
* @param {{ modalGpu?: string | null } | null} activeState
*/
function renderModalHourlyBilling(billingElement, activeState) {
if (!billingElement) {
return;
}
if (modalHourlyBillingError) {
billingElement.textContent = "Hourly billing temporarily unavailable";
billingElement.title = modalHourlyBillingError;
billingElement.hidden = false;
return;
}
if (
!modalHourlyBillingStatus ||
modalHourlyBillingStatus.modalGpu !== String(activeState?.modalGpu ?? DEFAULT_MODAL_GPU)
) {
billingElement.textContent = "Checking hourly Modal billing…";
billingElement.title = "Modal billing reports use completed hourly intervals.";
billingElement.hidden = false;
return;
}
const reportedCost = formatEstimatedModalUsd(
modalHourlyBillingStatus.appCostUsdBeforeCredits,
);
const intervalEnd = formatModalBillingIntervalEnd(
modalHourlyBillingStatus.intervalEnd,
);
billingElement.textContent =
`Reported app cost ${reportedCost} · hour ending ${intervalEnd}`;
billingElement.title =
`${modalHourlyBillingStatus.appName} in ${modalHourlyBillingStatus.environmentName}; ` +
"actual Modal metered cost before credits and reservations. " +
"Hourly reports exclude the partial current hour and use a 10-minute collection buffer.";
billingElement.hidden = false;
}
/**
* Render all active Modal containers beneath the GPU line.
* @param {HTMLElement | null} containerElement
*/
function renderModalContainerStatuses(containerElement) {
if (!containerElement) {
return;
}
containerElement.replaceChildren();
containerElement.hidden = false;
if (modalContainerStatusError) {
const line = document.createElement("span");
line.textContent = "Container status temporarily unavailable";
line.className = "modal-status-container modal-status-container-error";
containerElement.appendChild(line);
return;
}
if (!modalContainerStatusLoaded) {
const line = document.createElement("span");
line.textContent = "Checking Modal containers…";
line.className = "modal-status-container";
containerElement.appendChild(line);
return;
}
if (modalContainerStatuses.length === 0) {
const line = document.createElement("span");
line.textContent = "Waiting for container assignment";
line.className = "modal-status-container";
containerElement.appendChild(line);
return;
}
modalContainerStatuses.forEach((container, index) => {
const line = document.createElement("span");
const shortId = container.containerId.slice(-8);
const age = formatModalContainerAge(container.startedAt);
const state = container.state === "running" ? "Running" : "Starting";
line.textContent = `Container ${index + 1} · ${state}${age ? ` ${age}` : ""} · ${shortId}`;
line.className = `modal-status-container modal-status-container-${container.state}`;
containerElement.appendChild(line);
});
}
/**
* 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 = "";
element.dataset.modalGpu = "";
stopModalContainerStatusPolling();
return;
}
const promptState = modalPromptStates.get(String(activeState.promptId ?? ""));
const configuratorPanel = remoteConfiguratorPanel(promptState?.configuratorNodeId);
if (configuratorPanel) {
element.style.display = "none";
element.dataset.phase = activeState.phase;
element.dataset.modalGpu = activeState.modalGpu ?? "";
stopModalContainerStatusPolling();
if (configuratorPanel.promptId === activeState.promptId) {
renderRemoteConfiguratorStatus(configuratorPanel, activeState);
}
return;
}
const dot = element.querySelector(".modal-status-dot");
const text = element.querySelector(".modal-status-text");
const gpuText = element.querySelector(".modal-status-gpu");
const costText = element.querySelector(".modal-status-cost");
const billingText = element.querySelector(".modal-status-billing");
const containerText = element.querySelector(".modal-status-containers");
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;
element.dataset.modalGpu = activeState.modalGpu ?? "";
gpuText.textContent = activeState.modalGpu ?? "";
gpuText.hidden = !activeState.modalGpu;
if (activeState.modalGpu) {
syncModalContainerStatusPolling(activeState);
renderModalCostEstimate(costText);
renderModalHourlyBilling(billingText, activeState);
renderModalContainerStatuses(containerText);
} else {
stopModalContainerStatusPolling();
for (const modalOnlyElement of [costText, billingText, containerText]) {
if (modalOnlyElement) {
modalOnlyElement.textContent = "";
modalOnlyElement.hidden = true;
}
}
}
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 ?? "Preparing remote workflow";
} else if (activeState.phase === STATE_STARTING) {
element.style.borderColor = "rgba(234, 179, 8, 0.58)";
element.style.background = "rgba(54, 45, 6, 0.94)";
dot.style.background = STARTING_BORDER_COLOR;
dot.style.boxShadow = "0 0 0 6px rgba(234, 179, 8, 0.2)";
dot.style.animation = "modal-status-pulse 0.85s ease-in-out infinite";
text.textContent = activeState.statusMessage ?? "Starting remote component";
} 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 remote 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";
const executionLabel = activeState.executionLabel
? ` via ${activeState.executionLabel}`
: "";
text.textContent = hasBatchProgress
? `Remote workflow running on ${activeState.nodeCount} ${nodeLabel}${executionLabel} · ${Math.round(batchValue)}/${Math.round(batchMax)}`
: `Remote workflow running on ${activeState.nodeCount} ${nodeLabel}${executionLabel}`;
} 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 = FINALIZING_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 remote outputs";
} else if (activeState.phase === STATE_CANCELLING) {
element.style.borderColor = "rgba(251, 113, 133, 0.58)";
element.style.background = "rgba(76, 5, 25, 0.94)";
dot.style.background = CANCELLING_BORDER_COLOR;
dot.style.boxShadow = "0 0 0 6px rgba(251, 113, 133, 0.2)";
dot.style.animation = "modal-status-pulse 0.75s ease-in-out infinite";
text.textContent = activeState.statusMessage ?? "Cancelling remote workflow";
} 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 = "Remote 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 = "Remote workflow active";
}
dot.style.width = "10px";
dot.style.height = "10px";
dot.style.borderRadius = "999px";
dot.style.display = "inline-block";
dot.style.marginTop = "2px";
}
/**
* 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, modalGpu?: string | null } | null} details
*/
function setGlobalStatusPhase(promptId, phase, nodeCount, details = null) {
if (!promptId) {
return;
}
if (isPromptTerminal(promptId) && phase !== STATE_ERROR) {
return;
}
const existingState = modalGlobalStatusStates.get(promptId);
const promptState = ensurePromptState(promptId);
const detailModalGpu = MODAL_GPU_TYPES.includes(details?.modalGpu) ? details.modalGpu : null;
if (promptState && detailModalGpu) {
promptState.modalGpu = detailModalGpu;
}
modalGlobalStatusStates.set(promptId, {
phase: effectiveGlobalStatusPhase(promptId, phase),
nodeCount: promptRemoteNodeCount(promptId, nodeCount),
batchValue: existingState?.batchValue ?? null,
batchMax: existingState?.batchMax ?? null,
statusMessage: details?.message ?? existingState?.statusMessage ?? null,
statusCurrent: details?.current ?? existingState?.statusCurrent ?? null,
statusTotal: details?.total ?? existingState?.statusTotal ?? null,
modalGpu: detailModalGpu ?? existingState?.modalGpu ?? promptState?.modalGpu ?? null,
executionLabel:
existingState?.executionLabel ?? promptState?.executionLabel ?? null,
updatedAt: nowMs(),
});
refreshRemoteConfiguratorPanelForPrompt(promptId);
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;
}
if (isPromptTerminal(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: promptRemoteNodeCount(promptId, existingState?.nodeCount ?? 1),
batchValue: safeValue,
batchMax: safeMaxValue,
statusMessage: existingState?.statusMessage ?? null,
statusCurrent: existingState?.statusCurrent ?? null,
statusTotal: existingState?.statusTotal ?? null,
modalGpu:
existingState?.modalGpu ?? modalPromptStates.get(promptId)?.modalGpu ?? null,
executionLabel:
existingState?.executionLabel ??
modalPromptStates.get(promptId)?.executionLabel ??
null,
updatedAt: nowMs(),
});
refreshRemoteConfiguratorPanelForPrompt(promptId);
refreshGlobalStatusElement();
}
/**
* Clear one prompt from the global Modal execution badge.
* @param {string} promptId
*/
function clearGlobalStatusPhase(promptId) {
if (!promptId) {
return;
}
modalGlobalStatusStates.delete(promptId);
refreshGlobalStatusElement();
}
/**
* Show immediate cancellation feedback for one Modal prompt.
* @param {string} promptId
*/
function markPromptCancellationRequested(promptId) {
if (!promptId || isPromptTerminal(promptId)) {
return;
}
const promptState = modalPromptStates.get(promptId);
const stateNodeIds = Array.from(modalNodeStates.entries())
.filter(([, state]) => state?.promptId === promptId)
.map(([nodeIdValue]) => nodeIdValue);
const remoteNodeIds = promptState?.remoteNodeIds?.length ? promptState.remoteNodeIds : stateNodeIds;
if (remoteNodeIds.length === 0 && !modalGlobalStatusStates.has(promptId)) {
return;
}
modalCancellingPromptIds.add(promptId);
clearPromptQueued(promptId);
endSyntheticExecutionUi(promptId);
setPromptActiveNode(promptId, null);
setGlobalStatusPhase(promptId, STATE_CANCELLING, remoteNodeIds.length || 1, {
message: "Cancelling remote workflow",
});
if (remoteNodeIds.length > 0) {
setNodesPhase(remoteNodeIds, STATE_CANCELLING, promptId);
}
}
/**
* Remove older cancellation UI before a newer Modal prompt takes over the global badge.
* @param {string} activePromptId
*/
function clearSupersededCancellingPrompts(activePromptId) {
for (const [promptId, state] of Array.from(modalGlobalStatusStates.entries())) {
if (promptId === activePromptId) {
continue;
}
if (effectiveGlobalStatusPhase(promptId, state.phase) !== STATE_CANCELLING) {
continue;
}
if (promptHasLiveRemoteWork(promptId)) {
continue;
}
markPromptTerminal(promptId, "superseded_by_new_prompt");
clearPromptRemoteNodeVisuals(promptId);
clearPromptRemoteStates(promptId);
}
}
/**
* Return prompt ids targeted by one ComfyUI interrupt request.
* @param {any} resource
* @param {any} options
* @returns {string[]}
*/
function promptIdsFromInterruptRequest(resource, options) {
const route = String(resource?.url ?? resource ?? "");
const method = String(options?.method ?? resource?.method ?? "GET").toUpperCase();
if (!route.includes("/interrupt") || method !== "POST") {
return [];
}
const body = options?.body ?? resource?.body;
if (typeof body === "string" && body.trim()) {
try {
const payload = JSON.parse(body);
if (payload?.prompt_id != null) {
return [String(payload.prompt_id)];
}
} catch (error) {
console.debug("Unable to parse ComfyUI interrupt request body for Modal cancellation UI.", error);
}
}
return activeModalUiPromptIds();
}
/**
* Patch fetchApi so Modal prompts show cancellation feedback as soon as the user clicks cancel.
*/
function patchInterruptFeedback() {
if (api.__modalInterruptFeedbackPatched || typeof api.fetchApi !== "function") {
return;
}
const originalFetchApi = api.fetchApi;
api.fetchApi = async function modalFetchApi(resource, options) {
const promptIds = promptIdsFromInterruptRequest(resource, options);
for (const promptId of promptIds) {
markPromptCancellationRequested(promptId);
}
if (promptIds.length > 0) {
await Promise.all(promptIds.map(async (promptId) => {
try {
await originalFetchApi.call(this, MODAL_CANCEL_PREPARATION_ROUTE, {
method: "POST",
body: JSON.stringify({ prompt_id: promptId }),
});
} catch (error) {
console.warn(`Unable to cancel remote preparation for prompt ${promptId}.`, error);
}
}));
}
return originalFetchApi.call(this, resource, options);
};
api.__modalInterruptFeedbackPatched = true;
}
/**
* Replay one Modal UI event received from the backend refocus buffer.
* @param {{ event?: string, payload?: object }} eventRecord
*/
function replayModalUiEvent(eventRecord) {
const eventName = String(eventRecord?.event ?? "");
const payload = eventRecord?.payload;
if (!payload || typeof payload !== "object") {
return;
}
const updatedAtSeconds = Number(eventRecord?.updated_at ?? 0);
modalReplayedEventUpdatedAtMs = Number.isFinite(updatedAtSeconds) && updatedAtSeconds > 0
? updatedAtSeconds * 1000
: null;
try {
if (eventName === "modal_status") {
handleModalStatus({ detail: payload });
} else if (eventName === "modal_progress") {
handleModalProgress({ detail: payload });
}
} finally {
modalReplayedEventUpdatedAtMs = null;
}
}
/**
* Fetch recent Modal UI events that may have arrived while the browser was backgrounded.
* @returns {Promise<void>}
*/
async function replayModalUiEventsAfterVisibilityChange() {
const clientId = api.clientId == null ? "" : String(api.clientId);
if (!clientId || typeof api.fetchApi !== "function") {
return;
}
const response = await api.fetchApi(
`${MODAL_PROGRESS_STATE_ROUTE}?client_id=${encodeURIComponent(clientId)}`,
{ method: "GET" },
);
if (response.status !== 200) {
return;
}
const payload = await response.json();
const events = Array.isArray(payload?.events) ? payload.events : [];
for (const eventRecord of events) {
replayModalUiEvent(eventRecord);
}
}
/**
* Return prompt ids that currently have temporary Modal UI state.
* @returns {string[]}
*/
function activeModalUiPromptIds() {
const promptIds = new Set();
for (const promptId of modalPromptStates.keys()) {
promptIds.add(String(promptId));
}
for (const promptId of modalGlobalStatusStates.keys()) {
promptIds.add(String(promptId));
}
for (const promptId of modalQueuedPromptIds.values()) {
promptIds.add(String(promptId));
}
for (const promptId of syntheticPromptUiStates.keys()) {
promptIds.add(String(promptId));
}
for (const state of modalNodeStates.values()) {
if (state?.promptId) {
promptIds.add(String(state.promptId));
}
}
for (const progressState of modalNodeProgress.values()) {
if (progressState?.promptId) {
promptIds.add(String(progressState.promptId));
}
}
for (const laneState of modalNodeProgressLanes.values()) {
if (laneState?.promptId) {
promptIds.add(String(laneState.promptId));
}
}
for (const batchState of modalNodeBatchProgress.values()) {
if (batchState?.promptId) {
promptIds.add(String(batchState.promptId));
}
}
for (const cachedState of modalNodeCachedStates.values()) {
if (cachedState?.promptId) {
promptIds.add(String(cachedState.promptId));
}
}
return Array.from(promptIds).filter((promptId) => promptId && !isPromptTerminal(promptId));
}
/**
* Return the oldest age in milliseconds of a prompt's temporary Modal UI state.
* @param {string} promptId
* @returns {number}
*/
function promptUiStateAgeMs(promptId) {
const updatedAts = [];
const promptState = modalPromptStates.get(promptId);
if (promptState?.startedAt) {
updatedAts.push(promptState.startedAt);
}
const globalStatusState = modalGlobalStatusStates.get(promptId);
if (globalStatusState?.updatedAt) {
updatedAts.push(globalStatusState.updatedAt);
}
for (const state of modalNodeStates.values()) {
if (state?.promptId === promptId && state?.updatedAt) {
updatedAts.push(state.updatedAt);
}
}
for (const progressState of modalNodeProgress.values()) {
if (progressState?.promptId === promptId && progressState?.updatedAt) {
updatedAts.push(progressState.updatedAt);
}
}
for (const laneState of modalNodeProgressLanes.values()) {
if (laneState?.promptId !== promptId) {
continue;
}
for (const laneProgress of laneState.lanes.values()) {
if (laneProgress?.updatedAt) {
updatedAts.push(laneProgress.updatedAt);
}
}
}
for (const batchState of modalNodeBatchProgress.values()) {
if (batchState?.promptId === promptId && batchState?.updatedAt) {
updatedAts.push(batchState.updatedAt);
}
}
for (const cachedState of modalNodeCachedStates.values()) {
if (cachedState?.promptId === promptId && cachedState?.cachedAt) {
updatedAts.push(cachedState.cachedAt);
}
}
if (updatedAts.length === 0) {
return 0;
}
return Math.max(0, nowMs() - Math.min(...updatedAts));
}
/**
* Return prompt ids reported by ComfyUI's queue endpoint.
* @param {any} queuePayload
* @returns {Set<string>}
*/
function promptIdsFromQueuePayload(queuePayload) {
const promptIds = new Set();
for (const queueName of ["queue_running", "queue_pending"]) {
const queueEntries = Array.isArray(queuePayload?.[queueName]) ? queuePayload[queueName] : [];
for (const entry of queueEntries) {
if (Array.isArray(entry) && entry.length > 1) {
promptIds.add(String(entry[1]));
} else if (entry?.prompt_id != null) {
promptIds.add(String(entry.prompt_id));
}
}
}
return promptIds;
}
/**
* Return the retained ComfyUI history record for one prompt.
* @param {any} historyPayload
* @param {string} promptId
* @returns {Record<string, any> | null}
*/
function historyPromptRecord(historyPayload, promptId) {
if (!historyPayload || typeof historyPayload !== "object") {
return null;
}
const keyedRecord = historyPayload[promptId];
if (keyedRecord && typeof keyedRecord === "object") {
return keyedRecord;
}
return String(historyPayload.prompt_id ?? "") === promptId ? historyPayload : null;
}
/**
* Resolve a durable terminal phase and message from one ComfyUI history record.
* @param {any} historyPayload
* @param {string} promptId
* @returns {{ terminalPhase: string, displayPhase: string, message: string } | null}
*/
function historyPromptTerminalOutcome(historyPayload, promptId) {
const record = historyPromptRecord(historyPayload, promptId);
if (!record) {
return null;
}
const status = record.status && typeof record.status === "object" ? record.status : {};
const messages = Array.isArray(status.messages) ? status.messages : [];
for (const messageRecord of [...messages].reverse()) {
if (!Array.isArray(messageRecord) || messageRecord.length < 2) {
continue;
}
const eventName = String(messageRecord[0] ?? "");
const detail = messageRecord[1] && typeof messageRecord[1] === "object"
? messageRecord[1]
: {};
if (eventName === "execution_error") {
return {
terminalPhase: "execution_error",
displayPhase: STATE_ERROR,
message: String(
detail.exception_message ?? detail.error_message ?? "Remote workflow failed",
).trim(),
};
}
if (eventName === "execution_interrupted") {
return {
terminalPhase: "execution_interrupted",
displayPhase: STATE_ERROR,
message: String(
detail.exception_message ?? detail.error_message ?? "Remote workflow interrupted",
).trim(),
};
}
}
if (String(status.status_str ?? "").toLowerCase() === "error") {
return {
terminalPhase: "execution_error",
displayPhase: STATE_ERROR,
message: "Remote workflow failed",
};
}
return {
terminalPhase: "execution_success",
displayPhase: STATE_COMPLETE,
message: "Remote workflow complete",
};
}
/**
* Fetch JSON from the ComfyUI API when the endpoint returns a successful response.
* @param {string} route
* @returns {Promise<any | null>}
*/
async function fetchComfyJson(route) {
if (typeof api.fetchApi !== "function") {
return null;
}
const response = await api.fetchApi(route, { method: "GET" });
if (response.status !== 200) {
return null;
}
return response.json();
}
/**
* Clear refocus-stale Modal visuals once ComfyUI reports a prompt has finished.
* @param {string} promptId
* @param {{ terminalPhase: string, displayPhase: string, message: string }} outcome
*/
function clearRefocusCompletedPrompt(promptId, outcome) {
setRemoteConfiguratorTerminalStatus(
promptId,
outcome.displayPhase,
outcome.message,
);
markPromptTerminal(promptId, outcome.terminalPhase);
endSyntheticExecutionUi(promptId);
clearGlobalStatusPhase(promptId);
clearPromptRemoteStates(promptId);
}
/**
* Reconcile temporary Modal UI state against ComfyUI queue/history after refocus.
* @returns {Promise<void>}
*/
async function reconcileModalUiAfterVisibilityChange() {
const promptIds = activeModalUiPromptIds();
if (promptIds.length === 0 || typeof api.fetchApi !== "function") {
return;
}
const queuePayload = await fetchComfyJson(COMFY_QUEUE_ROUTE);
if (!queuePayload) {
return;
}
const queuedPromptIds = promptIdsFromQueuePayload(queuePayload);
for (const promptId of promptIds) {
if (queuedPromptIds.has(promptId)) {
continue;
}
const historyPayload = await fetchComfyJson(
`${COMFY_HISTORY_ROUTE}/${encodeURIComponent(promptId)}`,
);
const historyOutcome = historyPromptTerminalOutcome(historyPayload, promptId);
if (historyOutcome) {
clearRefocusCompletedPrompt(promptId, historyOutcome);
} else if (promptUiStateAgeMs(promptId) > REFOCUS_STALE_PROMPT_GRACE_MS) {
clearRefocusCompletedPrompt(promptId, {
terminalPhase: "stale_refocus_cleanup",
displayPhase: STATE_ERROR,
message: "Remote workflow is no longer running",
});
}
}
}
/**
* Refresh the badge and canvas when the tab regains visibility.
*/
function refreshModalUiAfterVisibilityChange() {
requestImmediateModalContainerStatusPoll();
refreshGlobalStatusElement();
if (Array.from(modalNodeStates.values()).length > 0) {
ensureAnimationLoop();
}
refreshNodeDecorations();
if (modalVisibilityRefreshInFlight) {
return;
}
modalVisibilityRefreshInFlight = replayModalUiEventsAfterVisibilityChange()
.then(() => reconcileModalUiAfterVisibilityChange())
.catch((error) => {
console.warn("Unable to refresh Modal UI state after visibility change.", error);
})
.finally(() => {
modalVisibilityRefreshInFlight = null;
refreshGlobalStatusElement();
if (Array.from(modalNodeStates.values()).length > 0) {
ensureAnimationLoop();
}
refreshNodeDecorations();
});
}
/**
* Return the prompt metadata bucket, creating it if needed.
* @param {string} promptId
* @returns {{ startedAt: number, modalGpu: string | null, executionLabel: string | null, scheduledEnvironmentCount: number, remoteNodeIds: string[], componentsByRepresentative: Map<string, string[]>, componentNodeIdsByMember: Map<string, string[]>, representativeNodeIdByMember: Map<string, string>, componentLabelByMember: Map<string, string>, executionAssignmentsByRepresentative: Map<string, object>, executionAssignmentByMember: Map<string, object>, laneNodeIdsByLane: Map<string, string> }}
*/
function ensurePromptState(promptId) {
if (isPromptTerminal(promptId)) {
return null;
}
if (!modalPromptStates.has(promptId)) {
const startedAt = modalReplayedEventUpdatedAtMs ?? nowMs();
modalPromptStates.set(promptId, {
startedAt,
modalGpu: null,
executionLabel: null,
scheduledEnvironmentCount: 0,
remoteNodeIds: [],
componentsByRepresentative: new Map(),
componentNodeIdsByMember: new Map(),
representativeNodeIdByMember: new Map(),
componentLabelByMember: new Map(),
executionAssignmentsByRepresentative: new Map(),
executionAssignmentByMember: new Map(),
configuratorNodeId: null,
remoteExecutionPlanAssignments: {},
remoteExecutionConfigurations: [],
remoteExecutionPlanInvalidated: false,
remoteEnvironmentStatuses: new Map(),
descendantNodeIdsByAncestor: new Map(),
laneNodeIdsByLane: new Map(),
activeNodeId: null,
hasStreamedProgress: false,
hasRemoteExecutionStarted: false,
});
}
return modalPromptStates.get(promptId);
}
/**
* Clear prompt-scoped progress and cache maps even when prompt metadata is already gone.
* @param {string} promptId
*/
function clearPromptProgressStates(promptId) {
for (const [nodeIdValue, progressState] of Array.from(modalNodeProgress.entries())) {
if (progressState?.promptId === promptId) {
modalNodeProgress.delete(nodeIdValue);
}
}
for (const [nodeIdValue, laneState] of Array.from(modalNodeProgressLanes.entries())) {
if (laneState?.promptId === promptId) {
modalNodeProgressLanes.delete(nodeIdValue);
}
}
for (const [nodeIdValue, batchState] of Array.from(modalNodeBatchProgress.entries())) {
if (batchState?.promptId === promptId) {
modalNodeBatchProgress.delete(nodeIdValue);
}
}
for (const [nodeIdValue, cachedState] of Array.from(modalNodeCachedStates.entries())) {
if (cachedState?.promptId === promptId) {
modalNodeCachedStates.delete(nodeIdValue);
}
}
stopAnimationLoopIfIdle();
}
/**
* Return whether an incoming prompt update should replace the current node state.
* @param {string} nodeIdValue
* @param {string} promptId
* @returns {boolean}
*/
function shouldApplyPromptState(nodeIdValue, promptId) {
if (isPromptTerminal(promptId)) {
return false;
}
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;
}
/**
* Return whether a remote phase changes visually over time.
* @param {string | undefined} phase
* @returns {boolean}
*/
function isPulsingNodePhase(phase) {
return [
STATE_SETUP,
STATE_STARTING,
STATE_READY,
STATE_ACTIVE,
STATE_FINALIZING,
STATE_CANCELLING,
STATE_ERROR,
].includes(phase);
}
/**
* Return whether a progress payload has an active time-based fade.
* @param {{ fadingStartedAt?: number | null } | null | undefined} progressState
* @returns {boolean}
*/
function progressFadeNeedsAnimation(progressState) {
return Boolean(progressState?.fadingStartedAt && progressVisualOpacity(progressState) > 0);
}
/**
* Return whether any progress overlay is currently changing without websocket input.
* @returns {boolean}
*/
function progressNeedsAnimation() {
for (const progressState of modalNodeProgress.values()) {
if (progressFadeNeedsAnimation(progressState)) {
return true;
}
}
for (const batchState of modalNodeBatchProgress.values()) {
if (progressFadeNeedsAnimation(batchState)) {
return true;
}
}
for (const laneState of modalNodeProgressLanes.values()) {
for (const laneProgress of laneState.lanes.values()) {
if (laneProgress.setupOnly || progressFadeNeedsAnimation(laneProgress)) {
return true;
}
}
}
return false;
}
/**
* Return whether any Modal canvas decoration needs a scheduled redraw.
* @returns {boolean}
*/
function shouldAnimateModalVisuals() {
const hasPulsingState = Array.from(modalNodeStates.values()).some((state) =>
isPulsingNodePhase(state.phase),
);
return hasPulsingState || progressNeedsAnimation();
}
/**
* Stop the scheduled redraw loop when no Modal visuals are moving.
*/
function stopAnimationLoopIfIdle() {
if (shouldAnimateModalVisuals()) {
return;
}
if (animationFrameHandle !== null && typeof cancelAnimationFrame === "function") {
cancelAnimationFrame(animationFrameHandle);
}
animationFrameHandle = null;
}
/**
* Mark the canvas dirty at a bounded cadence while visual states are active.
* @param {number | undefined} timestamp
*/
function refreshCanvasAnimation(timestamp = performance.now()) {
animationFrameHandle = null;
if (!shouldAnimateModalVisuals()) {
return;
}
if (timestamp - modalLastAnimationRedrawAt >= MODAL_ANIMATION_FRAME_INTERVAL_MS) {
refreshNodeDecorations();
modalLastAnimationRedrawAt = timestamp;
}
animationFrameHandle = requestAnimationFrame(refreshCanvasAnimation);
}
/**
* Ensure the redraw loop is running while remote visual effects are active.
*/
function ensureAnimationLoop() {
if (!shouldAnimateModalVisuals()) {
stopAnimationLoopIfIdle();
return;
}
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);
stopAnimationLoopIfIdle();
reconcilePromptGlobalStatus(promptId);
refreshNodeDecorations();
}, 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();
const promptState = promptId ? modalPromptStates.get(promptId) : null;
for (const currentNodeId of nodeIds) {
if (!shouldApplyPromptState(currentNodeId, promptId)) {
continue;
}
const existingState = modalNodeStates.get(currentNodeId);
if (
phase === STATE_READY &&
existingState?.promptId === promptId &&
[STATE_COMPLETE, STATE_FINALIZING, STATE_ERROR].includes(existingState.phase)
) {
continue;
}
clearNodeTimer(currentNodeId);
modalNodeStates.set(currentNodeId, {
phase,
promptId,
errorMessage,
updatedAt: nowMs(),
});
if (phase === STATE_ERROR) {
scheduleNodeClear(currentNodeId, promptId, ERROR_CLEAR_DELAY_MS);
}
if ([STATE_SETUP, STATE_STARTING, STATE_CANCELLING, STATE_ERROR].includes(phase)) {
clearNodeCached(currentNodeId, promptId);
}
if (phase === STATE_COMPLETE) {
fadeNodeProgress(currentNodeId, promptId);
if (promptState?.activeNodeId === String(currentNodeId)) {
promptState.activeNodeId = null;
}
}
if (phase === STATE_ERROR || phase === STATE_CANCELLING) {
clearNodeProgress(currentNodeId, promptId);
}
for (const ancestorNodeId of ancestorNodeIds(currentNodeId)) {
affectedAncestorNodeIds.add(ancestorNodeId);
}
}
for (const ancestorNodeId of affectedAncestorNodeIds) {
refreshAncestorNodePhase(promptId, ancestorNodeId, errorMessage);
}
ensureAnimationLoop();
if ([STATE_COMPLETE, STATE_FINALIZING, STATE_ERROR].includes(phase)) {
reconcilePromptGlobalStatus(promptId);
}
refreshNodeDecorations();
}
/**
* 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 (!promptState) {
return;
}
if (components.length > 0) {
const mergedRemoteNodeIds = new Set(remoteNodeIds.map((nodeIdValue) => String(nodeIdValue)));
promptState.componentsByRepresentative.clear();
promptState.componentNodeIdsByMember.clear();
promptState.representativeNodeIdByMember.clear();
promptState.componentLabelByMember.clear();
promptState.activeNodeId = null;
promptState.hasStreamedProgress = false;
for (const [componentIndex, component] of components.entries()) {
const componentNodeIds = Array.from(
new Set(component.node_ids.map((nodeIdValue) => String(nodeIdValue))),
);
const representativeNodeId = String(component.representative_node_id);
const componentLabel = String(componentIndex + 1);
promptState.componentsByRepresentative.set(
representativeNodeId,
componentNodeIds,
);
promptState.componentNodeIdsByMember.set(representativeNodeId, componentNodeIds);
promptState.representativeNodeIdByMember.set(representativeNodeId, representativeNodeId);
promptState.componentLabelByMember.set(representativeNodeId, componentLabel);
for (const componentNodeId of componentNodeIds) {
promptState.componentNodeIdsByMember.set(componentNodeId, componentNodeIds);
promptState.representativeNodeIdByMember.set(componentNodeId, representativeNodeId);
promptState.componentLabelByMember.set(componentNodeId, componentLabel);
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);
}
}
/**
* Store planner assignments by both representative and visible member node.
* @param {string} promptId
* @param {Record<string, Record<string, any>>} assignments
*/
function registerPromptExecutionAssignments(promptId, assignments) {
const promptState = ensurePromptState(promptId);
if (!promptState) {
return;
}
promptState.executionAssignmentsByRepresentative.clear();
promptState.executionAssignmentByMember.clear();
const environmentIds = new Set();
for (const [representativeNodeIdValue, rawAssignment] of Object.entries(assignments ?? {})) {
if (!rawAssignment || typeof rawAssignment !== "object") {
continue;
}
const representativeNodeId = String(representativeNodeIdValue);
const environmentId = String(rawAssignment.environment_id ?? "").trim();
const assignment = {
provider: String(rawAssignment.provider ?? "modal"),
environmentId,
location: String(rawAssignment.execution_location ?? "").trim() || null,
};
if (environmentId) {
environmentIds.add(environmentId);
}
promptState.executionAssignmentsByRepresentative.set(representativeNodeId, assignment);
const assignedNodeIds = Array.isArray(rawAssignment.node_ids) && rawAssignment.node_ids.length > 0
? rawAssignment.node_ids.map((nodeIdValue) => String(nodeIdValue))
: (promptState.componentNodeIdsByMember.get(representativeNodeId) ?? [representativeNodeId]);
for (const assignedNodeId of assignedNodeIds) {
promptState.executionAssignmentByMember.set(assignedNodeId, assignment);
}
promptState.executionAssignmentByMember.set(representativeNodeId, assignment);
}
promptState.scheduledEnvironmentCount = environmentIds.size;
}
/**
* Update one component's runtime identity from its streamed progress metadata.
* @param {string} promptId
* @param {string} nodeIdValue
* @param {Record<string, any>} metadata
*/
function updateNodeExecutionLocation(promptId, nodeIdValue, metadata) {
const promptState = modalPromptStates.get(promptId);
const location = String(metadata.execution_location ?? "").trim();
if (!promptState || !location) {
return;
}
const safeNodeIdValue = String(nodeIdValue);
const representativeNodeId =
promptState.representativeNodeIdByMember.get(safeNodeIdValue) ?? safeNodeIdValue;
const existingAssignment =
promptState.executionAssignmentByMember.get(safeNodeIdValue) ??
promptState.executionAssignmentsByRepresentative.get(representativeNodeId) ??
{};
const assignment = {
...existingAssignment,
provider: String(metadata.execution_provider ?? existingAssignment.provider ?? "modal"),
environmentId: String(
metadata.execution_environment_id ?? existingAssignment.environmentId ?? "",
),
location,
};
promptState.executionAssignmentsByRepresentative.set(representativeNodeId, assignment);
const componentNodeIds =
promptState.componentNodeIdsByMember.get(safeNodeIdValue) ?? [safeNodeIdValue];
for (const componentNodeId of componentNodeIds) {
promptState.executionAssignmentByMember.set(String(componentNodeId), assignment);
}
promptState.executionAssignmentByMember.set(representativeNodeId, assignment);
}
/**
* 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);
}
}
}
/**
* Return a descendant's effective phase, including live streamed progress.
* @param {string} promptId
* @param {string} descendantNodeId
* @returns {{ phase: string, state: Record<string, any> } | null}
*/
function remoteDescendantPhase(promptId, descendantNodeId) {
const state = modalNodeStates.get(descendantNodeId);
if (state?.promptId !== promptId) {
return null;
}
return {
phase: deriveRemoteNodePhase(
state.phase,
hasLiveNodeProgress(descendantNodeId, promptId),
),
state,
};
}
/**
* Select the most useful visible phase for a subgraph containing mixed remote work.
* @param {Record<string, number>} phaseCounts
* @returns {string}
*/
function dominantRemoteContainerPhase(phaseCounts) {
const phasePriority = [
STATE_ERROR,
STATE_CANCELLING,
STATE_ACTIVE,
STATE_STARTING,
STATE_SETUP,
STATE_READY,
STATE_FINALIZING,
STATE_COMPLETE,
];
return phasePriority.find((phase) => (phaseCounts[phase] ?? 0) > 0) ?? STATE_SETUP;
}
/**
* Aggregate all remote descendants beneath one subgraph into a container visual state.
* @param {string} promptId
* @param {string} ancestorNodeId
* @param {string | undefined} errorMessage
* @returns {Record<string, any> | null}
*/
function remoteContainerVisualState(promptId, ancestorNodeId, errorMessage) {
const promptState = modalPromptStates.get(promptId);
const descendantNodeIds = promptState?.descendantNodeIdsByAncestor.get(ancestorNodeId);
if (!promptState || !descendantNodeIds || descendantNodeIds.size === 0) {
return null;
}
const phaseCounts = {};
const descendantStates = [];
for (const descendantNodeId of descendantNodeIds) {
const descendantState = remoteDescendantPhase(promptId, descendantNodeId);
if (!descendantState) {
continue;
}
phaseCounts[descendantState.phase] = (phaseCounts[descendantState.phase] ?? 0) + 1;
descendantStates.push(descendantState.state);
}
if (descendantStates.length === 0) {
return null;
}
const phases = Object.keys(phaseCounts);
const descendantErrorMessage = descendantStates.find(
(state) => state.phase === STATE_ERROR && state.errorMessage,
)?.errorMessage;
return {
phase: dominantRemoteContainerPhase(phaseCounts),
promptId,
errorMessage: descendantErrorMessage ?? errorMessage,
updatedAt: Math.max(...descendantStates.map((state) => Number(state.updatedAt ?? 0))),
isRemoteContainer: true,
isMixedRemoteContainer: phases.length > 1,
phaseCounts,
remoteDescendantCount: descendantNodeIds.size,
};
}
/**
* 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 ancestorState = remoteContainerVisualState(promptId, ancestorNodeId, errorMessage);
if (!ancestorState) {
return;
}
if (!shouldApplyPromptState(ancestorNodeId, promptId)) {
return;
}
clearNodeTimer(ancestorNodeId);
modalNodeStates.set(ancestorNodeId, ancestorState);
if (ancestorState.phase === 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);
if (!promptState) {
return;
}
promptState.activeNodeId = activeNodeId ? String(activeNodeId) : null;
}
/**
* Mark known upstream remote nodes complete when a downstream node is executing.
* @param {string} promptId
* @param {any[] | undefined} ancestorNodeIds
* @param {string | null} activeNodeId
*/
function completeRemoteAncestorsBeforeActiveNode(promptId, ancestorNodeIds, activeNodeId) {
if (!Array.isArray(ancestorNodeIds) || ancestorNodeIds.length === 0) {
return;
}
const completedNodeIds = Array.from(
new Set(
ancestorNodeIds
.map((nodeIdValue) => String(nodeIdValue))
.filter((nodeIdValue) => nodeIdValue && nodeIdValue !== activeNodeId),
),
);
if (completedNodeIds.length === 0) {
return;
}
for (const completedNodeId of completedNodeIds) {
fadeNodeProgress(completedNodeId, promptId);
}
setNodesPhase(completedNodeIds, STATE_COMPLETE, promptId);
}
/**
* Return whether two nodes belong to the same known remote component.
* @param {string} promptId
* @param {string | null | undefined} leftNodeId
* @param {string | null | undefined} rightNodeId
* @returns {boolean}
*/
function nodesShareRemoteComponent(promptId, leftNodeId, rightNodeId) {
if (!leftNodeId || !rightNodeId) {
return false;
}
const leftComponentNodeIds = resolveComponentNodeIds(promptId, leftNodeId);
if (!leftComponentNodeIds?.length) {
return String(leftNodeId) === String(rightNodeId);
}
return leftComponentNodeIds.map((nodeIdValue) => String(nodeIdValue)).includes(String(rightNodeId));
}
/**
* 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, iterationRate: number | null } | 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, setupOnly: boolean, iterationRate: number | null }[]}
*/
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
);
}
/**
* Return opacity for one progress payload while it fades after completion.
* @param {{ fadingStartedAt?: number | null } | null | undefined} progressState
* @returns {number}
*/
function progressVisualOpacity(progressState) {
if (!progressState) {
return 0;
}
const fadingStartedAt = Number(progressState?.fadingStartedAt ?? 0);
if (!fadingStartedAt) {
return 1;
}
const fadeRatio = (nowMs() - fadingStartedAt) / PROGRESS_FADE_MS;
return Math.max(0, Math.min(1, 1 - fadeRatio));
}
/**
* 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, STATE_STARTING].includes(phase ?? "")) {
return phase;
}
if (phase === STATE_COMPLETE || phase === STATE_FINALIZING) {
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);
}
stopAnimationLoopIfIdle();
}
/**
* Remove one faded progress entry if it still belongs to the same prompt.
* @param {string} progressNodeId
* @param {string} promptId
* @param {number} fadingStartedAt
*/
function clearFadedNodeProgress(progressNodeId, promptId, fadingStartedAt) {
const progressState = modalNodeProgress.get(progressNodeId);
if (progressState?.promptId === promptId && progressState.fadingStartedAt === fadingStartedAt) {
modalNodeProgress.delete(progressNodeId);
}
const batchState = modalNodeBatchProgress.get(progressNodeId);
if (batchState?.promptId === promptId && batchState.fadingStartedAt === fadingStartedAt) {
modalNodeBatchProgress.delete(progressNodeId);
}
const laneState = modalNodeProgressLanes.get(progressNodeId);
if (laneState?.promptId === promptId) {
for (const [laneId, laneProgress] of Array.from(laneState.lanes.entries())) {
if (laneProgress.fadingStartedAt === fadingStartedAt) {
laneState.lanes.delete(laneId);
}
}
if (laneState.lanes.size === 0) {
modalNodeProgressLanes.delete(progressNodeId);
}
}
stopAnimationLoopIfIdle();
reconcilePromptGlobalStatus(promptId);
refreshNodeDecorations();
}
/**
* Fade stored progress for one completed node and its visible ancestors.
* @param {string} nodeIdValue
* @param {string | undefined} promptId
*/
function fadeNodeProgress(nodeIdValue, promptId) {
const fadingStartedAt = nowMs();
const progressNodeIds = [String(nodeIdValue), ...ancestorNodeIds(nodeIdValue)];
let fadedAnyProgress = false;
for (const progressNodeId of progressNodeIds) {
let fadedNodeProgress = false;
const progressState = modalNodeProgress.get(progressNodeId);
if (progressState && (!promptId || progressState.promptId === promptId)) {
progressState.fadingStartedAt = fadingStartedAt;
progressState.value = progressState.max;
fadedAnyProgress = true;
fadedNodeProgress = true;
}
const batchState = modalNodeBatchProgress.get(progressNodeId);
if (batchState && (!promptId || batchState.promptId === promptId)) {
batchState.fadingStartedAt = fadingStartedAt;
batchState.value = batchState.max;
fadedAnyProgress = true;
fadedNodeProgress = true;
}
const laneState = modalNodeProgressLanes.get(progressNodeId);
if (laneState && (!promptId || laneState.promptId === promptId)) {
for (const laneProgress of laneState.lanes.values()) {
laneProgress.fadingStartedAt = fadingStartedAt;
laneProgress.value = laneProgress.max;
fadedAnyProgress = true;
fadedNodeProgress = true;
}
}
if (fadedNodeProgress && promptId) {
setTimeout(
() => clearFadedNodeProgress(progressNodeId, promptId, fadingStartedAt),
PROGRESS_FADE_MS,
);
}
}
if (fadedAnyProgress) {
ensureAnimationLoop();
refreshNodeDecorations();
}
}
/**
* Return one cached-node marker when it belongs to the prompt.
* @param {string} nodeIdValue
* @param {string} promptId
* @returns {{ promptId: string, cachedAt: number } | null}
*/
function nodeCachedState(nodeIdValue, promptId) {
const cachedState = modalNodeCachedStates.get(String(nodeIdValue)) ?? null;
return cachedState?.promptId === promptId ? cachedState : null;
}
/**
* Mark one node and its visible ancestors as restored from the remote Modal node-output cache.
* @param {string} nodeIdValue
* @param {string} promptId
*/
function markNodeCached(nodeIdValue, promptId) {
const progressNodeIds = [String(nodeIdValue), ...ancestorNodeIds(nodeIdValue)];
for (const progressNodeId of progressNodeIds) {
if (!shouldApplyPromptState(progressNodeId, promptId)) {
continue;
}
modalNodeCachedStates.set(progressNodeId, {
promptId,
cachedAt: nowMs(),
});
}
ensureAnimationLoop();
refreshNodeDecorations();
}
/**
* Remove one cached-node marker from a node and its visible ancestors.
* @param {string} nodeIdValue
* @param {string | undefined} promptId
*/
function clearNodeCached(nodeIdValue, promptId) {
const progressNodeIds = [String(nodeIdValue), ...ancestorNodeIds(nodeIdValue)];
for (const progressNodeId of progressNodeIds) {
const cachedState = modalNodeCachedStates.get(progressNodeId);
if (!cachedState) {
continue;
}
if (promptId && cachedState.promptId !== promptId) {
continue;
}
modalNodeCachedStates.delete(progressNodeId);
}
stopAnimationLoopIfIdle();
}
/**
* 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();
refreshNodeDecorations();
}
/**
* Record numeric progress for one node and its visible ancestors.
* @param {string} nodeIdValue
* @param {string} promptId
* @param {number} value
* @param {number} maxValue
* @param {Record<string, unknown>} metadata
*/
function setNodeProgress(nodeIdValue, promptId, value, maxValue, metadata = {}) {
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)];
const updatedAt = progressEventTimestampMs();
for (const progressNodeId of progressNodeIds) {
if (!shouldApplyPromptState(progressNodeId, promptId)) {
continue;
}
const existingProgress = modalNodeProgress.get(progressNodeId);
const reportedRate = Number(metadata.tokens_per_second);
modalNodeProgress.set(progressNodeId, {
promptId,
value: safeValue,
max: safeMaxValue,
updatedAt,
stage: String(metadata.stage ?? ""),
message: String(metadata.message ?? ""),
unit: String(metadata.unit ?? ""),
provider: String(metadata.execution_provider ?? existingProgress?.provider ?? ""),
indeterminate: Boolean(metadata.indeterminate),
preGpu: Boolean(metadata.pre_gpu),
elapsedSeconds: Number.isFinite(Number(metadata.elapsed_seconds))
? Number(metadata.elapsed_seconds)
: null,
timeToFirstTokenSeconds: Number.isFinite(Number(metadata.time_to_first_token_seconds))
? Number(metadata.time_to_first_token_seconds)
: null,
iterationRate: Number.isFinite(reportedRate) && reportedRate >= 0
? reportedRate
: progressIterationRate(
existingProgress?.promptId === promptId ? existingProgress : null,
safeValue,
safeMaxValue,
updatedAt,
),
});
}
ensureAnimationLoop();
refreshNodeDecorations();
}
/**
* 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
* @param {boolean} setupOnly
* @param {Record<string, unknown>} metadata
*/
function setNodeProgressLane(
nodeIdValue,
promptId,
laneId,
value,
maxValue,
itemIndex,
setupOnly = false,
metadata = {},
) {
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 updatedAt = progressEventTimestampMs();
const promptState = ensurePromptState(promptId);
if (!promptState) {
return;
}
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(),
};
const existingLaneProgress = laneState.lanes.get(safeLaneId);
laneState.lanes.set(safeLaneId, {
laneId: safeLaneId,
value: safeValue,
max: safeMaxValue,
itemIndex: Number.isFinite(Number(itemIndex)) ? Number(itemIndex) : null,
updatedAt,
setupOnly: Boolean(setupOnly),
unit: String(metadata.unit ?? existingLaneProgress?.unit ?? ""),
provider: String(
metadata.execution_provider ?? existingLaneProgress?.provider ?? "",
),
iterationRate: setupOnly
? null
: progressIterationRate(
existingLaneProgress?.setupOnly ? null : existingLaneProgress,
safeValue,
safeMaxValue,
updatedAt,
),
});
modalNodeProgressLanes.set(progressNodeId, laneState);
}
ensureAnimationLoop();
refreshNodeDecorations();
}
/**
* 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);
stopAnimationLoopIfIdle();
refreshNodeDecorations();
}
/**
* 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 (serializedRemoteFlag(node)) {
remoteNodeIds.push(String(node.id));
}
}
return remoteNodeIds;
}
/** Return remote ids used for immediate UI state, including automatic placement. */
function effectiveRemoteNodeIds(prompt, workflow) {
const preferences = selectedExecutionPreferences(workflow);
if (preferences.autoPlace) {
return Object.keys(prompt ?? {});
}
return extractRemoteNodeIds(workflow);
}
/**
* Return the user-visible Modal toggle value from a serialized workflow node.
* @param {object | undefined} node
* @returns {boolean}
*/
function serializedRemoteFlag(node) {
const namedWidgetValue =
node?.widgets_values_named?.[REMOTE_WIDGET_NAME]
?? node?.widgets_values_named?.[LEGACY_REMOTE_WIDGET_NAME];
if (typeof namedWidgetValue === "boolean") {
return namedWidgetValue;
}
return Boolean(node?.properties?.[REMOTE_PROPERTY]);
}
/** Return whether the live workflow uses connected configuration capacity. */
function usesRemoteExecutionConfigurator() {
return Boolean(
findSomethingInAllSubgraphs((graph) =>
(graph?.nodes ?? []).find(
(node) => String(node?.comfyClass ?? node?.type ?? "") === "RemoteExecutionConfigurator",
),
),
);
}
/**
* Return the root workflow graph, including subgraphs when available.
* @returns {LGraph | null}
*/
function rootGraph() {
return app.rootGraph ?? app.graph?.rootGraph ?? app.graph ?? null;
}
/** Return generic execution preferences persisted in the workflow graph. */
function selectedExecutionPreferences(workflow = null) {
const workflowContainer = workflow ?? rootGraph();
const remoteConfig = workflowContainer?.extra?.[WORKFLOW_REMOTE_CONFIG_KEY] ?? {};
const policy = EXECUTION_POLICIES.some(([value]) => value === remoteConfig[WORKFLOW_REMOTE_POLICY_KEY])
? remoteConfig[WORKFLOW_REMOTE_POLICY_KEY]
: "modal";
return {
policy,
autoPlace: Boolean(remoteConfig[WORKFLOW_REMOTE_AUTO_PLACE_KEY]),
};
}
/** Persist one provider policy or automatic-placement change on the root graph. */
function setExecutionPreferences(updates) {
const graph = rootGraph();
if (!graph) {
throw new Error("The workflow graph is unavailable.");
}
const current = selectedExecutionPreferences(graph);
const next = { ...current, ...updates };
graph.extra ||= {};
graph.extra[WORKFLOW_REMOTE_CONFIG_KEY] ||= {};
graph.extra[WORKFLOW_REMOTE_CONFIG_KEY][WORKFLOW_REMOTE_POLICY_KEY] = next.policy;
graph.extra[WORKFLOW_REMOTE_CONFIG_KEY][WORKFLOW_REMOTE_AUTO_PLACE_KEY] = next.autoPlace;
graph.change?.();
app.graph?.setDirtyCanvas?.(true, true);
const label = EXECUTION_POLICIES.find(([value]) => value === next.policy)?.[1] ?? next.policy;
notifyModal(`Remote execution policy: ${label}; automatic placement ${next.autoPlace ? "on" : "off"}.`);
}
/**
* Return the workflow-level Modal GPU selection, installing the default when absent.
* @param {object | null | undefined} workflow
* @returns {string}
*/
function selectedModalGpu(workflow = null) {
const workflowContainer = workflow ?? rootGraph();
const savedGpu = workflowContainer?.extra?.[WORKFLOW_MODAL_CONFIG_KEY]?.[WORKFLOW_MODAL_GPU_KEY];
if (MODAL_GPU_TYPES.includes(savedGpu)) {
return savedGpu;
}
if (workflowContainer) {
workflowContainer.extra ||= {};
workflowContainer.extra[WORKFLOW_MODAL_CONFIG_KEY] ||= {};
workflowContainer.extra[WORKFLOW_MODAL_CONFIG_KEY][WORKFLOW_MODAL_GPU_KEY] = DEFAULT_MODAL_GPU;
}
return DEFAULT_MODAL_GPU;
}
/**
* Save one Modal GPU selection on the root graph so normal workflow serialization preserves it.
* @param {string} modalGpu
*/
function setSelectedModalGpu(modalGpu) {
if (!MODAL_GPU_TYPES.includes(modalGpu)) {
throw new Error(`Unsupported Modal GPU selection: ${String(modalGpu)}`);
}
const graph = rootGraph();
if (!graph) {
throw new Error("The workflow graph is unavailable.");
}
const previousGpu = selectedModalGpu(graph);
graph.extra[WORKFLOW_MODAL_CONFIG_KEY][WORKFLOW_MODAL_GPU_KEY] = modalGpu;
if (previousGpu !== modalGpu) {
graph.change?.();
app.graph?.setDirtyCanvas?.(true, true);
}
notifyModal(
`Modal GPU set to ${modalGpu}. The next remote run will rebuild the Modal app if needed.`,
);
}
/**
* Copy the live graph GPU selection onto one serialized workflow snapshot.
* @param {object | null | undefined} workflow
*/
function stampModalGpuOnWorkflow(workflow) {
if (!workflow || typeof workflow !== "object") {
return;
}
workflow.extra ||= {};
workflow.extra[WORKFLOW_MODAL_CONFIG_KEY] ||= {};
workflow.extra[WORKFLOW_MODAL_CONFIG_KEY][WORKFLOW_MODAL_GPU_KEY] = selectedModalGpu();
}
/**
* 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 visitedGraphs = new Set();
const visitGraph = (candidateGraph) => {
if (!candidateGraph || visitedGraphs.has(candidateGraph)) {
return null;
}
visitedGraphs.add(candidateGraph);
const match = matcher(candidateGraph);
if (match) {
return match;
}
for (const node of candidateGraph.nodes ?? []) {
const nestedMatch = visitGraph(node?.subgraph);
if (nestedMatch) {
return nestedMatch;
}
}
if (candidateGraph.subgraphs?.values) {
for (const subgraph of candidateGraph.subgraphs.values()) {
const nestedMatch = visitGraph(subgraph);
if (nestedMatch) {
return nestedMatch;
}
}
}
return null;
};
return visitGraph(graph);
}
/**
* Return all live workflow nodes across the root graph and any nested subgraphs.
* @returns {LGraphNode[]}
*/
function allWorkflowNodes() {
const graph = rootGraph();
if (!graph) {
return [];
}
const visitedGraphs = new Set();
const nodes = [];
const visitGraph = (candidateGraph) => {
if (!candidateGraph || visitedGraphs.has(candidateGraph)) {
return;
}
visitedGraphs.add(candidateGraph);
for (const node of candidateGraph.nodes ?? []) {
nodes.push(node);
visitGraph(node?.subgraph);
}
if (candidateGraph.subgraphs?.values) {
for (const subgraph of candidateGraph.subgraphs.values()) {
visitGraph(subgraph);
}
}
};
visitGraph(graph);
return nodes;
}
/**
* Return whether one live graph node is the workflow capacity configurator.
* @param {LGraphNode | null | undefined} node
* @returns {boolean}
*/
function isRemoteExecutionConfiguratorNode(node) {
return String(node?.comfyClass ?? node?.type ?? "") === "RemoteExecutionConfigurator";
}
/**
* Return the configurator node id from one serialized API prompt.
* @param {Record<string, any>} prompt
* @returns {string | null}
*/
function serializedRemoteConfiguratorNodeId(prompt) {
const matches = Object.entries(prompt ?? {}).filter(
([, promptNode]) => promptNode?.class_type === "RemoteExecutionConfigurator",
);
return matches.length === 1 ? String(matches[0][0]) : null;
}
/**
* Resolve and lazily mount the configurator panel for one serialized node id.
* @param {string | null | undefined} configuratorNodeId
* @returns {Record<string, any> | null}
*/
function remoteConfiguratorPanel(configuratorNodeId) {
const safeNodeId = String(configuratorNodeId ?? "");
if (!safeNodeId) {
return null;
}
const existing = remoteConfiguratorPanels.get(safeNodeId);
if (existing) {
return existing;
}
const node = allWorkflowNodes().find(
(candidate) => nodeId(candidate) === safeNodeId && isRemoteExecutionConfiguratorNode(candidate),
);
if (!node) {
return null;
}
mountRemoteExecutionConfiguratorPanel(node);
return remoteConfiguratorPanels.get(safeNodeId) ?? null;
}
/**
* Return a compact status sentence for the configurator panel.
* @param {Record<string, any>} state
* @returns {string}
*/
function remoteConfiguratorStatusMessage(state) {
if (state?.statusMessage) {
return String(state.statusMessage);
}
if (state?.phase === STATE_SETUP) {
return "Preparing remote workflow";
}
if (state?.phase === STATE_STARTING) {
return "Starting remote capacity";
}
if (state?.phase === STATE_WAITING) {
return "Waiting for remote capacity";
}
if (state?.phase === EXECUTION_PHASE) {
return "Remote workflow is running";
}
if (state?.phase === STATE_FINALIZING) {
return "Receiving remote outputs";
}
if (state?.phase === STATE_CANCELLING) {
return "Cancelling remote workflow";
}
if (state?.phase === STATE_ERROR) {
return "Remote workflow failed";
}
if (state?.phase === STATE_COMPLETE) {
return "Remote workflow complete";
}
return "Ready to plan remote execution";
}
/**
* Update one mounted configurator's status without changing its placement table.
* @param {Record<string, any>} panel
* @param {Record<string, any>} state
*/
function renderRemoteConfiguratorStatus(panel, state) {
if (!panel) {
return;
}
const phase = String(state?.phase ?? "idle");
panel.root.dataset.phase = phase;
panel.statusText.textContent = remoteConfiguratorStatusMessage(state);
const total = Math.max(0, Number(state?.statusTotal ?? state?.batchMax ?? 0));
const current = Math.max(
0,
Math.min(total, Number(state?.statusCurrent ?? state?.batchValue ?? 0)),
);
panel.progress.hidden = total <= 1;
panel.progressFill.style.width = total > 1 ? `${(current / total) * 100}%` : "0%";
panel.progressValue.textContent = total > 1 ? `${Math.round(current)}/${Math.round(total)}` : "";
}
/**
* Return one stable row per concrete environment in a placement plan.
* @param {Record<string, Record<string, any>>} assignments
* @param {Array<Record<string, any>>} configurations
* @returns {Array<{ environmentId: string, label: string, initialState: Record<string, any> }>}
*/
function remoteConfiguratorEnvironmentEntries(assignments, configurations) {
const configurationById = new Map(
(configurations ?? []).map((configuration) => [
String(configuration?.configuration_id ?? ""),
configuration,
]),
);
const entries = new Map();
for (const assignment of Object.values(assignments ?? {})) {
const environmentId = String(assignment?.environment_id ?? "").trim();
if (!environmentId || entries.has(environmentId)) {
continue;
}
const configurationId = String(assignment?.configuration_id ?? "");
const configuration = configurationById.get(configurationId);
const configurationName = String(configuration?.display_name ?? configurationId).trim();
const awaitingCapacity =
assignment?.provider === "vast" && environmentId === `vast:${configurationId}`;
const environmentLabel = remoteExecutionEnvironmentLabel(
assignment,
configurationById,
configuration?.gpu_type ?? null,
);
entries.set(environmentId, {
environmentId,
label: configurationName
? `${configurationName} · ${environmentLabel}`
: environmentLabel,
initialState: awaitingCapacity
? {
phase: STATE_SETUP,
statusMessage: "Waiting for Vast.ai setup",
}
: {
phase: STATE_SETUP,
statusMessage: "Waiting for remote preparation",
},
});
}
return Array.from(entries.values());
}
/**
* Create one environment-local status row for the Configurator panel.
* @param {string} environmentId
* @param {string} label
* @returns {Record<string, any>}
*/
function createRemoteConfiguratorEnvironmentRow(environmentId, label) {
const root = document.createElement("div");
root.className = "remote-configurator-environment";
const heading = document.createElement("div");
heading.className = "remote-configurator-environment-heading";
const dot = document.createElement("span");
dot.className = "remote-configurator-environment-dot";
const labelElement = document.createElement("span");
labelElement.className = "remote-configurator-environment-label";
labelElement.textContent = label || environmentId;
const statusText = document.createElement("span");
statusText.className = "remote-configurator-environment-status";
statusText.textContent = "Waiting";
heading.append(dot, labelElement, statusText);
const progress = document.createElement("div");
progress.className = "remote-configurator-environment-progress";
const progressFill = document.createElement("span");
progressFill.className = "remote-configurator-environment-progress-fill";
const progressValue = document.createElement("span");
progressValue.className = "remote-configurator-environment-progress-value";
progress.append(progressFill, progressValue);
root.append(heading, progress);
return {
environmentId,
root,
label: labelElement,
statusText,
progress,
progressFill,
progressValue,
};
}
/**
* Render status for one remote environment without affecting another lane.
* @param {Record<string, any>} row
* @param {Record<string, any>} state
*/
function renderRemoteConfiguratorEnvironmentStatus(row, state) {
if (!row) {
return;
}
const phase = String(state?.phase ?? "idle");
row.root.dataset.phase = phase;
row.statusText.textContent = remoteConfiguratorStatusMessage(state);
const total = Math.max(0, Number(state?.statusTotal ?? 0));
const current = Math.max(0, Math.min(total, Number(state?.statusCurrent ?? 0)));
row.progress.hidden = total <= 1;
row.progressFill.style.width = total > 1 ? `${(current / total) * 100}%` : "0%";
row.progressValue.textContent = total > 1
? `${Math.round(current)}/${Math.round(total)}`
: "";
}
/**
* Rebuild the independent environment rows for a placement plan.
* @param {Record<string, any>} panel
* @param {Record<string, Record<string, any>>} assignments
* @param {Array<Record<string, any>>} configurations
*/
function renderRemoteConfiguratorEnvironments(panel, assignments, configurations) {
if (!panel) {
return;
}
const promptState = modalPromptStates.get(String(panel.promptId ?? ""));
panel.environmentRows.clear();
panel.environments.replaceChildren();
for (const entry of remoteConfiguratorEnvironmentEntries(assignments, configurations)) {
const row = createRemoteConfiguratorEnvironmentRow(entry.environmentId, entry.label);
panel.environmentRows.set(entry.environmentId, row);
panel.environments.appendChild(row.root);
renderRemoteConfiguratorEnvironmentStatus(
row,
promptState?.remoteEnvironmentStatuses.get(entry.environmentId) ?? entry.initialState,
);
}
panel.environments.hidden = panel.environmentRows.size === 0;
}
/** Return a readable storage provider label. */
function remoteStorageProviderLabel(provider) {
if (provider === "cloudflare_r2") {
return "Cloudflare R2";
}
return String(provider || "Storage backend")
.replaceAll("_", " ")
.replace(/\b\w/g, (character) => character.toUpperCase());
}
/** Return a readable R2 jurisdiction label. */
function remoteStorageJurisdictionLabel(jurisdiction) {
const labels = {
default: "Default",
eu: "European Union (EU)",
fedramp: "FedRAMP",
us: "United States (US)",
};
const normalized = String(jurisdiction || "default").toLowerCase();
return labels[normalized] ?? normalized.toUpperCase();
}
/** Return a readable backing-cache policy label. */
function remoteStorageWriteBackLabel(mode) {
const labels = {
async: "Async write-back",
sync: "Synchronous write-back",
off: "Read-only",
};
const normalized = String(mode || "").toLowerCase();
return labels[normalized] ?? (normalized || "Configured");
}
/** Return a compact binary byte-size label for storage usage. */
function remoteStorageSizeLabel(value) {
const sizeBytes = Number(value);
if (!Number.isFinite(sizeBytes) || sizeBytes < 0) {
return "Unavailable";
}
const units = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"];
let size = sizeBytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex += 1;
}
const fractionDigits = unitIndex === 0 || size >= 100 ? 0 : size >= 10 ? 1 : 2;
return `${size.toFixed(fractionDigits)} ${units[unitIndex]}`;
}
/** Return bucket usage with an optional object count. */
function remoteStorageUsageLabel(configuration) {
const sizeLabel = remoteStorageSizeLabel(configuration?.storage_usage_bytes);
const objectCount = Number(configuration?.storage_object_count);
if (
sizeLabel === "Unavailable" ||
!Number.isInteger(objectCount) ||
objectCount < 0
) {
return sizeLabel;
}
return `${sizeLabel} · ${objectCount.toLocaleString()} ${
objectCount === 1 ? "object" : "objects"
}`;
}
/**
* Normalize safe storage configuration metadata for the Configurator panel.
* @param {Array<Record<string, any>>} configurations
* @returns {Array<{ configurationId: string, storageProvider: string, name: string, provider: string, details: Array<{ label: string, value: string }> }>}
*/
function remoteConfiguratorStorageEntries(configurations) {
return (configurations ?? [])
.filter(
(configuration) =>
configuration?.configuration_kind === "storage" || configuration?.storage_provider,
)
.map((configuration) => {
const provider = String(configuration?.storage_provider ?? "");
const details = [];
if (provider === "cloudflare_r2") {
details.push(
{ label: "Bucket", value: String(configuration?.bucket ?? "—") },
{
label: "Storage used",
value: remoteStorageUsageLabel(configuration),
},
{
label: "Jurisdiction",
value: remoteStorageJurisdictionLabel(configuration?.jurisdiction),
},
{
label: "Cache policy",
value: remoteStorageWriteBackLabel(configuration?.write_back_mode),
},
);
const keyPrefix = String(configuration?.key_prefix ?? "").trim();
if (keyPrefix) {
details.push({ label: "Key prefix", value: keyPrefix });
}
}
return {
configurationId: String(configuration?.configuration_id ?? ""),
storageProvider: provider,
name: String(configuration?.display_name ?? "Storage backend").trim(),
provider: remoteStorageProviderLabel(provider),
details,
};
});
}
/** Return one live storage-configuration node by its serialized configuration id. */
function remoteStorageConfigurationNode(configurationId) {
return allWorkflowNodes().find(
(node) =>
nodeId(node) === String(configurationId) &&
String(node?.comfyClass ?? "") === "R2StorageBackingConfiguration",
) ?? null;
}
/** Return one serialized widget value from a live graph node. */
function remoteStorageWidgetValue(node, name, fallback = "") {
const target = (node?.widgets ?? []).find((candidate) => candidate?.name === name);
return String(target?.value ?? fallback).trim();
}
/** Request fresh safe state for one configured R2 bucket. */
async function requestRemoteStorageRefresh(configuration) {
const configurationId = String(configuration?.configuration_id ?? "");
const node = remoteStorageConfigurationNode(configurationId);
const credentialId = remoteStorageWidgetValue(node, "credential_id");
if (!node || !credentialId) {
throw new Error("The connected R2 configuration has no local credentials.");
}
const response = await api.fetchApi(R2_STORAGE_USAGE_ROUTE, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
configuration_id: configurationId,
display_name: String(configuration?.display_name ?? "R2 storage"),
account_id: String(configuration?.account_id ?? ""),
bucket: String(configuration?.bucket ?? ""),
jurisdiction: String(configuration?.jurisdiction ?? "default"),
key_prefix: String(configuration?.key_prefix ?? ""),
write_back_mode: String(configuration?.write_back_mode ?? "async"),
credential_id: credentialId,
}),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
const error = new Error(
payload.error || `${response.status} ${response.statusText}`,
);
error.code = String(payload.code ?? "");
throw error;
}
return payload;
}
/** Show or hide the Configurator's explicit macOS keychain recovery action. */
function setRemoteConfiguratorKeychainUnlockRequired(panel, required) {
if (!panel?.keychainUnlock) {
return;
}
const nextHeight = required ? 28 : 0;
const heightDelta = nextHeight - Number(panel.keychainUnlockHeight ?? 0);
panel.keychainUnlockHeight = nextHeight;
panel.keychainUnlock.hidden = !required;
if (heightDelta > 0) {
panel.minHeight += heightDelta;
panel.node.setSize?.([
Number(panel.node.size?.[0]) || 500,
Math.max(Number(panel.node.size?.[1]) || 0, panel.minHeight),
]);
}
panel.node.graph?.setDirtyCanvas?.(true, true);
}
/** Retain or clear a storage credential recovery code with the displayed plan. */
function setRemoteConfiguratorStorageCredentialError(panel, code) {
if (!panel) {
return;
}
const updatedConfigurations = (panel.configurations ?? []).map((configuration) => {
if (configuration?.storage_provider !== "cloudflare_r2") {
return configuration;
}
const updated = { ...configuration };
if (code) {
updated.credential_error_code = code;
} else {
delete updated.credential_error_code;
}
return updated;
});
panel.configurations = updatedConfigurations;
const promptState = modalPromptStates.get(String(panel.promptId ?? ""));
if (promptState) {
promptState.remoteExecutionConfigurations = updatedConfigurations;
}
}
/** Ask macOS to unlock its login keychain, then retry the R2 refresh. */
async function unlockRemoteConfiguratorKeychain(panel) {
if (!panel || panel.keychainUnlockInFlight) {
return;
}
panel.keychainUnlockInFlight = true;
panel.keychainUnlockButton.disabled = true;
panel.keychainUnlockButton.textContent = "Unlocking…";
panel.keychainUnlockStatus.hidden = false;
panel.keychainUnlockStatus.dataset.state = "loading";
panel.keychainUnlockStatus.textContent = "Complete the macOS prompt";
panel.keychainUnlockStatus.title = "";
try {
const response = await api.fetchApi(R2_KEYCHAIN_UNLOCK_ROUTE, {
method: "POST",
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(payload.error || `${response.status} ${response.statusText}`);
}
setRemoteConfiguratorStorageCredentialError(panel, null);
setRemoteConfiguratorKeychainUnlockRequired(panel, false);
await refreshRemoteConfiguratorStorage(panel);
} catch (error) {
setRemoteConfiguratorKeychainUnlockRequired(panel, true);
panel.keychainUnlockStatus.hidden = false;
panel.keychainUnlockStatus.dataset.state = "error";
panel.keychainUnlockStatus.textContent = "Unlock failed";
panel.keychainUnlockStatus.title = String(error?.message ?? error);
} finally {
panel.keychainUnlockInFlight = false;
panel.keychainUnlockButton.disabled = false;
panel.keychainUnlockButton.textContent = "Unlock Keychain";
}
}
/** Reload every supported storage backend shown by one Configurator panel. */
async function refreshRemoteConfiguratorStorage(panel) {
const configurations = panel?.configurations ?? [];
const refreshable = configurations.filter(
(configuration) => configuration?.storage_provider === "cloudflare_r2",
);
if (!panel || refreshable.length === 0 || panel.storageRefreshInFlight) {
return;
}
panel.storageRefreshInFlight = true;
panel.storageReload.disabled = true;
panel.storageReload.textContent = "Reloading…";
panel.storageRefreshStatus.hidden = false;
panel.storageRefreshStatus.dataset.state = "loading";
panel.storageRefreshStatus.textContent = "Refreshing storage state";
panel.storageRefreshStatus.title = "";
try {
const refreshed = await Promise.all(
refreshable.map((configuration) => requestRemoteStorageRefresh(configuration)),
);
const refreshedById = new Map(
refreshed.map((state) => [String(state?.configuration_id ?? ""), state]),
);
const updatedConfigurations = configurations.map((configuration) => {
const state = refreshedById.get(String(configuration?.configuration_id ?? ""));
if (!state) {
return configuration;
}
const updated = {
...configuration,
storage_usage_bytes: state.storage_usage_bytes,
storage_object_count: state.storage_object_count,
};
delete updated.credential_error_code;
return updated;
});
panel.configurations = updatedConfigurations;
const promptState = modalPromptStates.get(String(panel.promptId ?? ""));
if (promptState) {
promptState.remoteExecutionConfigurations = updatedConfigurations;
}
renderRemoteConfiguratorPlan(panel, panel.assignments, updatedConfigurations);
setRemoteConfiguratorKeychainUnlockRequired(panel, false);
panel.storageRefreshStatus.dataset.state = "success";
panel.storageRefreshStatus.textContent = "Updated";
panel.storageRefreshStatus.title = "";
} catch (error) {
if (error?.code === R2_KEYCHAIN_UNLOCK_REQUIRED_CODE) {
setRemoteConfiguratorStorageCredentialError(panel, error.code);
setRemoteConfiguratorKeychainUnlockRequired(panel, true);
}
panel.storageRefreshStatus.dataset.state = "error";
panel.storageRefreshStatus.textContent = "Refresh failed";
panel.storageRefreshStatus.title = String(error?.message ?? error);
} finally {
panel.storageRefreshInFlight = false;
panel.storageReload.disabled = false;
panel.storageReload.textContent = "Reload";
}
}
/** Render connected storage backends beneath the execution plan. */
function renderRemoteConfiguratorStorage(panel, configurations) {
const entries = remoteConfiguratorStorageEntries(configurations);
setRemoteConfiguratorKeychainUnlockRequired(
panel,
(configurations ?? []).some(
(configuration) =>
configuration?.credential_error_code === R2_KEYCHAIN_UNLOCK_REQUIRED_CODE,
),
);
panel.storageReload.hidden = !entries.some(
(entry) => entry.storageProvider === "cloudflare_r2",
);
if (!panel.storageRefreshInFlight) {
panel.storageRefreshStatus.hidden = true;
panel.storageRefreshStatus.textContent = "";
panel.storageRefreshStatus.title = "";
panel.storageRefreshStatus.dataset.state = "idle";
}
panel.storageList.replaceChildren();
for (const entry of entries) {
const card = document.createElement("div");
card.className = "remote-configurator-storage-card";
const heading = document.createElement("div");
heading.className = "remote-configurator-storage-heading";
const name = document.createElement("span");
name.className = "remote-configurator-storage-name";
name.textContent = entry.name;
const provider = document.createElement("span");
provider.className = "remote-configurator-storage-provider";
provider.textContent = entry.provider;
heading.append(name, provider);
card.appendChild(heading);
if (entry.details.length > 0) {
const details = document.createElement("dl");
details.className = "remote-configurator-storage-details";
for (const detail of entry.details) {
const label = document.createElement("dt");
label.textContent = detail.label;
const value = document.createElement("dd");
value.textContent = detail.value;
details.append(label, value);
}
card.appendChild(details);
}
panel.storageList.appendChild(card);
}
panel.storage.hidden = entries.length === 0;
panel.storageHeight = entries.length === 0
? 0
: 24 + entries.reduce((height, entry) => height + 34 + entry.details.length * 17, 0);
}
/** Return safe, provider-neutral rows for managed Vast leases and Modal containers. */
function remoteManagedCapacityEntries(vastPayload, modalPayload) {
const vastEntries = (Array.isArray(vastPayload?.leases) ? vastPayload.leases : []).map(
(lease) => ({
provider: "vast",
resourceId: String(lease?.instance_id ?? ""),
title: String(lease?.profile_name ?? "Vast.ai lease"),
subtitle: `Instance ${lease?.instance_id ?? "unknown"}`,
state: String(lease?.actual_status ?? "unknown"),
active: Math.max(0, Number(lease?.active_invocations ?? 0)),
details: [
`${Math.max(1, Number(lease?.gpu_count ?? 1))} × ${String(lease?.gpu_name ?? "GPU")}`,
Number(lease?.gpu_ram_mb) > 0
? `${remoteStorageSizeLabel(Number(lease.gpu_ram_mb) * 1024 ** 2)} VRAM`
: null,
Number(lease?.cpu_ram_mb) > 0
? `${remoteStorageSizeLabel(Number(lease.cpu_ram_mb) * 1024 ** 2)} RAM`
: null,
Number.isFinite(Number(lease?.hourly_cost_usd))
? `$${Number(lease.hourly_cost_usd).toFixed(3)}/hr`
: null,
].filter(Boolean),
}),
);
const modalEntries = normalizedModalContainerStatuses(modalPayload).map((container) => ({
provider: "modal",
resourceId: container.containerId,
title: container.appName || "Modal container",
subtitle: `Container ${container.containerId}`,
state: container.state,
active: container.state === "running" ? 1 : 0,
details: [
container.modalGpu || "GPU unavailable",
container.estimatedGpuCostPerSecond > 0
? `$${(container.estimatedGpuCostPerSecond * 3600).toFixed(3)}/hr`
: null,
].filter(Boolean),
}));
return [...vastEntries, ...modalEntries].filter((entry) => entry.resourceId);
}
/** Request JSON and convert an unsuccessful API response into a useful error. */
async function requestRemoteCapacityJson(route, options = {}) {
const response = await api.fetchApi(route, options);
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(payload.error || `${response.status} ${response.statusText}`);
}
return payload;
}
/** Clear a Configurator plan that can no longer describe available capacity. */
function invalidateRemoteConfiguratorExecutionPlan(panel) {
if (!panel) {
return;
}
const promptState = modalPromptStates.get(String(panel.promptId ?? ""));
if (promptState) {
promptState.remoteExecutionPlanInvalidated = true;
promptState.remoteExecutionPlanAssignments = {};
promptState.remoteEnvironmentStatuses.clear();
}
renderRemoteConfiguratorPlan(
panel,
{},
promptState?.remoteExecutionConfigurations ?? panel.configurations,
);
}
/** Kill one exact managed resource after explicit destructive confirmation. */
async function killRemoteManagedCapacity(panel, entry, button) {
const providerName = entry.provider === "vast" ? "Vast.ai lease" : "Modal container";
const confirmed = globalThis.confirm?.(
`KILL ${providerName} ${entry.resourceId}?\n\nAny workflow using it will fail. This cannot be undone.`,
);
if (!confirmed || panel.capacityKillInFlight) {
return;
}
panel.capacityKillInFlight = `${entry.provider}:${entry.resourceId}`;
button.disabled = true;
button.textContent = "KILLING…";
panel.capacityRefreshStatus.hidden = false;
panel.capacityRefreshStatus.dataset.state = "loading";
panel.capacityRefreshStatus.textContent = `Stopping ${entry.resourceId}`;
panel.capacityRefreshStatus.title = "";
try {
const isVast = entry.provider === "vast";
await requestRemoteCapacityJson(
isVast ? VAST_DESTROY_ROUTE : MODAL_CONTAINER_STOP_ROUTE,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
isVast
? { instance_id: Number(entry.resourceId), force: true }
: { container_id: entry.resourceId },
),
},
);
if (isVast) {
invalidateRemoteConfiguratorExecutionPlan(panel);
}
panel.capacityRefreshStatus.dataset.state = "success";
panel.capacityRefreshStatus.textContent = `${entry.resourceId} stopped`;
await refreshRemoteManagedCapacity(panel, { preserveStatus: true });
} catch (error) {
panel.capacityRefreshStatus.dataset.state = "error";
panel.capacityRefreshStatus.textContent = "Kill failed";
panel.capacityRefreshStatus.title = String(error?.message ?? error);
button.disabled = false;
button.textContent = "KILL";
} finally {
panel.capacityKillInFlight = null;
}
}
/** Render every currently managed cloud resource with an exact kill control. */
function renderRemoteManagedCapacity(panel, entries) {
panel.capacityList.replaceChildren();
for (const entry of entries) {
const card = document.createElement("div");
card.className = "remote-configurator-capacity-card";
const icon = document.createElement("img");
icon.className = "remote-configurator-capacity-icon";
icon.alt = entry.provider === "vast" ? "Vast.ai" : "Modal";
icon.src = REMOTE_LOCATION_ICON_SOURCES[entry.provider];
const identity = document.createElement("div");
identity.className = "remote-configurator-capacity-identity";
const name = document.createElement("span");
name.className = "remote-configurator-capacity-name";
name.textContent = entry.title;
const subtitle = document.createElement("span");
subtitle.className = "remote-configurator-capacity-subtitle";
subtitle.textContent = entry.subtitle;
identity.append(name, subtitle);
const summary = document.createElement("span");
summary.className = "remote-configurator-capacity-summary";
const activity = entry.active > 0 ? ` · ${entry.active} active` : " · idle";
summary.textContent = `${entry.state}${activity} · ${entry.details.join(" · ")}`;
const button = document.createElement("button");
button.className = "remote-configurator-capacity-kill";
button.type = "button";
button.textContent = "KILL";
button.title = `Permanently stop ${entry.subtitle}`;
button.addEventListener("click", () => {
void killRemoteManagedCapacity(panel, entry, button);
});
card.append(icon, identity, summary, button);
panel.capacityList.appendChild(card);
}
panel.capacityEmpty.hidden = entries.length > 0;
panel.capacityHeight = 29 + Math.max(20, entries.length * 49);
}
/** Reload all managed Vast leases and Modal containers shown by one panel. */
async function refreshRemoteManagedCapacity(panel, { preserveStatus = false } = {}) {
if (
!panel ||
panel.capacityRefreshInFlight ||
typeof api.fetchApi !== "function"
) {
return;
}
panel.capacityRefreshInFlight = true;
panel.capacityReload.disabled = true;
panel.capacityReload.textContent = "Reloading…";
if (!preserveStatus) {
panel.capacityRefreshStatus.hidden = false;
panel.capacityRefreshStatus.dataset.state = "loading";
panel.capacityRefreshStatus.textContent = "Refreshing capacity";
panel.capacityRefreshStatus.title = "";
}
const results = await Promise.allSettled([
requestRemoteCapacityJson(VAST_STATUS_ROUTE),
requestRemoteCapacityJson(`${MODAL_CONTAINER_STATUS_ROUTE}?include_billing=false`),
]);
const errors = results
.filter((result) => result.status === "rejected")
.map((result) => String(result.reason?.message ?? result.reason));
const entries = remoteManagedCapacityEntries(
results[0].status === "fulfilled" ? results[0].value : {},
results[1].status === "fulfilled" ? results[1].value : {},
);
renderRemoteManagedCapacity(panel, entries);
if (errors.length > 0) {
panel.capacityRefreshStatus.hidden = false;
panel.capacityRefreshStatus.dataset.state = "error";
panel.capacityRefreshStatus.textContent = entries.length > 0 ? "Partially refreshed" : "Refresh failed";
panel.capacityRefreshStatus.title = errors.join("\n");
} else if (!preserveStatus) {
panel.capacityRefreshStatus.hidden = false;
panel.capacityRefreshStatus.dataset.state = "success";
panel.capacityRefreshStatus.textContent = "Updated";
panel.capacityRefreshStatus.title = "";
}
panel.capacityRefreshInFlight = false;
panel.capacityReload.disabled = false;
panel.capacityReload.textContent = "Reload";
renderRemoteConfiguratorPlan(panel, panel.assignments, panel.configurations);
}
/** Return compact GPU-memory and RAM capacity labels for one target. */
function remoteConfiguratorHardwareLabels(hardware) {
const gpuCount = Math.max(0, Number(hardware?.gpu_count ?? 0));
const perDeviceBytes = Math.max(
0,
Number(hardware?.gpu_memory_bytes_per_device ?? 0),
);
const totalGpuBytes = Math.max(
0,
Number(hardware?.gpu_memory_bytes_total ?? 0),
);
const memoryKind = String(hardware?.gpu_memory_kind ?? "GPU memory");
const labels = [];
if (perDeviceBytes > 0) {
const perDevice = remoteStorageSizeLabel(perDeviceBytes);
if (gpuCount > 1) {
const total = remoteStorageSizeLabel(totalGpuBytes || perDeviceBytes * gpuCount);
labels.push(`${gpuCount} × ${perDevice} ${memoryKind} · ${total} total`);
} else {
labels.push(`${perDevice} ${memoryKind}`);
}
} else {
labels.push("GPU memory unavailable");
}
const ramBytes = Math.max(0, Number(hardware?.ram_bytes ?? 0));
const ramCapacityLabel = String(hardware?.ram_capacity_label ?? "").trim();
labels.push(
ramBytes > 0
? `${remoteStorageSizeLabel(ramBytes)} RAM`
: `RAM ${ramCapacityLabel || "unavailable"}`,
);
return labels;
}
/** Return target groups with stable component labels for a placement plan. */
function remoteConfiguratorTargetGroups(assignments, configurations) {
const configurationById = new Map(
(configurations ?? []).map((configuration) => [
String(configuration?.configuration_id ?? ""),
configuration,
]),
);
const sortedAssignments = Object.entries(assignments ?? {}).sort(([left], [right]) =>
String(left).localeCompare(String(right), undefined, { numeric: true }),
);
const groups = new Map();
sortedAssignments.forEach(([representativeNodeId, assignment], index) => {
const configurationId = String(assignment?.configuration_id ?? "");
const configuration = configurationById.get(configurationId);
const environmentId = String(assignment?.environment_id ?? "").trim();
const environmentLabel = remoteExecutionEnvironmentLabel(
assignment,
configurationById,
configuration?.gpu_type ?? null,
);
const targetKey = environmentId || `${assignment?.provider ?? "remote"}:${configurationId}`;
let group = groups.get(targetKey);
if (!group) {
const configurationName = String(
configuration?.display_name ?? configurationId,
).trim();
group = {
targetKey,
targetName: configurationName || environmentLabel,
environmentLabel,
hardware: assignment?.hardware ?? null,
components: [],
};
groups.set(targetKey, group);
} else if (!group.hardware && assignment?.hardware) {
group.hardware = assignment.hardware;
}
const nodeIds = Array.isArray(assignment?.node_ids)
? assignment.node_ids.map((value) => String(value))
: [String(representativeNodeId)];
group.components.push({
representativeNodeId: String(representativeNodeId),
label: `Component ${index + 1}`,
nodeIds,
assignment,
});
});
return Array.from(groups.values()).sort((left, right) =>
`${left.targetName}:${left.targetKey}`.localeCompare(
`${right.targetName}:${right.targetKey}`,
undefined,
{ numeric: true },
),
);
}
/** Return a compact time and cost estimate for one component assignment. */
function remoteConfiguratorComponentEstimate(assignment) {
const predictedCost = assignment?.predicted_cost_usd == null
? Number.NaN
: Number(assignment.predicted_cost_usd);
const predictedSeconds = Number(assignment?.predicted_completion_seconds);
const parts = [];
if (Number.isFinite(predictedSeconds) && predictedSeconds > 0) {
parts.push(`${Math.round(predictedSeconds)}s`);
}
if (Number.isFinite(predictedCost) && predictedCost >= 0) {
parts.push(`$${predictedCost.toFixed(4)}`);
}
return parts.join(" · ") || "—";
}
/** Render one target section and its assigned components. */
function createRemoteConfiguratorTargetSection(group) {
const section = document.createElement("section");
section.className = "remote-configurator-target";
const heading = document.createElement("div");
heading.className = "remote-configurator-target-heading";
const title = document.createElement("span");
title.className = "remote-configurator-target-name";
title.textContent = group.targetName;
const machine = document.createElement("span");
machine.className = "remote-configurator-target-machine";
machine.textContent = String(group.hardware?.machine_type ?? "Unknown GPU");
heading.append(title, machine);
const location = document.createElement("div");
location.className = "remote-configurator-target-location";
location.textContent = group.environmentLabel;
const capacities = document.createElement("div");
capacities.className = "remote-configurator-target-capacities";
for (const capacityLabel of remoteConfiguratorHardwareLabels(group.hardware)) {
const capacity = document.createElement("span");
capacity.textContent = capacityLabel;
capacities.appendChild(capacity);
}
const componentList = document.createElement("div");
componentList.className = "remote-configurator-components";
for (const component of group.components) {
const row = document.createElement("div");
row.className = "remote-configurator-component";
const identity = document.createElement("div");
identity.className = "remote-configurator-component-identity";
const label = document.createElement("span");
label.className = "remote-configurator-component-label";
label.textContent = component.label;
const nodes = document.createElement("span");
nodes.className = "remote-configurator-component-nodes";
nodes.textContent = `Nodes ${component.nodeIds.map((nodeIdValue) => `#${nodeIdValue}`).join(", ")}`;
nodes.title = nodes.textContent;
identity.append(label, nodes);
const estimate = document.createElement("span");
estimate.className = "remote-configurator-component-estimate";
estimate.textContent = remoteConfiguratorComponentEstimate(component.assignment);
row.append(identity, estimate);
componentList.appendChild(row);
}
section.append(heading, location, capacities, componentList);
return section;
}
/**
* Render the completed provider-neutral placement plan grouped by target.
* @param {Record<string, any>} panel
* @param {Record<string, Record<string, any>>} assignments
* @param {Array<Record<string, any>>} configurations
*/
function renderRemoteConfiguratorPlan(panel, assignments, configurations) {
if (!panel) {
return;
}
panel.assignments = assignments ?? {};
panel.configurations = configurations ?? [];
const groups = remoteConfiguratorTargetGroups(assignments, configurations);
panel.targets.replaceChildren();
renderRemoteConfiguratorEnvironments(panel, assignments, configurations);
renderRemoteConfiguratorStorage(panel, configurations);
for (const group of groups) {
panel.targets.appendChild(createRemoteConfiguratorTargetSection(group));
}
panel.emptyText.hidden = groups.length > 0;
panel.targets.hidden = groups.length === 0;
const componentCount = groups.reduce(
(count, group) => count + group.components.length,
0,
);
const planHeight = groups.length * 78 + componentCount * 35;
panel.minHeight =
118 +
Math.max(31, planHeight) +
panel.environmentRows.size * 52 +
panel.storageHeight +
panel.capacityHeight +
panel.keychainUnlockHeight;
const currentWidth = Number(panel.node.size?.[0]) || 0;
const currentHeight = Number(panel.node.size?.[1]) || 0;
panel.node.setSize?.([
Math.max(currentWidth, 540),
Math.max(currentHeight, panel.minHeight),
]);
panel.node.graph?.setDirtyCanvas?.(true, true);
}
/**
* Associate one prompt with its configurator and reset the out-of-band display.
* @param {string} promptId
* @param {string | null | undefined} configuratorNodeId
*/
function registerPromptConfigurator(promptId, configuratorNodeId) {
const safeNodeId = String(configuratorNodeId ?? "");
const promptState = ensurePromptState(promptId);
if (!promptState || !safeNodeId) {
return;
}
promptState.configuratorNodeId = safeNodeId;
const panel = remoteConfiguratorPanel(safeNodeId);
if (!panel) {
return;
}
const currentPromptState = modalPromptStates.get(String(panel.promptId ?? ""));
if (
panel.promptId &&
panel.promptId !== promptId &&
Number(currentPromptState?.startedAt ?? 0) > Number(promptState.startedAt ?? 0)
) {
return;
}
const isNewPrompt = panel.promptId !== promptId;
panel.promptId = promptId;
if (isNewPrompt) {
renderRemoteConfiguratorPlan(
panel,
promptState.remoteExecutionPlanAssignments,
promptState.remoteExecutionConfigurations,
);
renderRemoteConfiguratorStatus(panel, {
phase: STATE_SETUP,
statusMessage: "Preparing remote workflow",
});
}
}
/**
* Retain and render one provider-neutral plan independently of queue completion.
* @param {string} promptId
* @param {Record<string, Record<string, any>>} assignments
* @param {Array<Record<string, any>>} configurations
*/
function registerRemoteConfiguratorPlan(promptId, assignments, configurations) {
const promptState = ensurePromptState(promptId);
if (!promptState || promptState.remoteExecutionPlanInvalidated) {
return;
}
promptState.remoteExecutionPlanAssignments = { ...(assignments ?? {}) };
promptState.remoteExecutionConfigurations = [...(configurations ?? [])];
registerPromptExecutionAssignments(promptId, assignments ?? {});
const panel = remoteConfiguratorPanel(promptState.configuratorNodeId);
if (!panel || panel.promptId !== promptId) {
return;
}
renderRemoteConfiguratorPlan(
panel,
promptState.remoteExecutionPlanAssignments,
promptState.remoteExecutionConfigurations,
);
}
/**
* Replace one provisional Vast configuration lane with its concrete lease lane.
* @param {Record<string, any>} panel
* @param {Record<string, any>} promptState
* @param {string} concreteEnvironmentId
* @returns {boolean}
*/
function promoteProvisionalVastEnvironment(
panel,
promptState,
concreteEnvironmentId,
) {
const provisionalEnvironmentIds = Object.values(panel?.assignments ?? {})
.filter((assignment) => assignment?.provider === "vast")
.map((assignment) => `vast:${String(assignment?.configuration_id ?? "")}`)
.filter(
(environmentId) =>
panel.environmentRows.has(environmentId) &&
/^\d+$/.test(concreteEnvironmentId.slice(environmentId.length + 1)) &&
concreteEnvironmentId.startsWith(`${environmentId}:`),
)
.sort((left, right) => right.length - left.length);
const provisionalEnvironmentId = provisionalEnvironmentIds[0];
if (!provisionalEnvironmentId) {
return false;
}
const assignments = Object.fromEntries(
Object.entries(promptState.remoteExecutionPlanAssignments).map(([componentId, assignment]) => [
componentId,
assignment?.environment_id === provisionalEnvironmentId
? { ...assignment, environment_id: concreteEnvironmentId }
: assignment,
]),
);
const provisionalState = promptState.remoteEnvironmentStatuses.get(
provisionalEnvironmentId,
);
promptState.remoteEnvironmentStatuses.delete(provisionalEnvironmentId);
if (provisionalState && !promptState.remoteEnvironmentStatuses.has(concreteEnvironmentId)) {
promptState.remoteEnvironmentStatuses.set(concreteEnvironmentId, provisionalState);
}
promptState.remoteExecutionPlanAssignments = assignments;
renderRemoteConfiguratorPlan(panel, assignments, promptState.remoteExecutionConfigurations);
return true;
}
/**
* Store and render one environment's latest status independently of its peers.
* @param {string} promptId
* @param {Record<string, any>} detail
*/
function updateRemoteConfiguratorEnvironmentStatus(promptId, detail) {
const environmentId = String(detail?.execution_environment_id ?? "").trim();
const promptState = modalPromptStates.get(promptId);
if (!environmentId || !promptState || promptState.remoteExecutionPlanInvalidated) {
return;
}
const panel = remoteConfiguratorPanel(promptState.configuratorNodeId);
if (panel?.promptId === promptId && !panel.environmentRows.has(environmentId)) {
promoteProvisionalVastEnvironment(panel, promptState, environmentId);
}
const existing = promptState.remoteEnvironmentStatuses.get(environmentId) ?? {};
const state = {
phase: String(detail?.phase ?? existing.phase ?? STATE_SETUP),
statusMessage:
detail?.status_message ?? detail?.message ?? detail?.stage ?? existing.statusMessage ?? null,
statusCurrent:
detail?.status_current ?? detail?.value ?? existing.statusCurrent ?? null,
statusTotal: detail?.status_total ?? detail?.max ?? existing.statusTotal ?? null,
updatedAt: nowMs(),
};
promptState.remoteEnvironmentStatuses.set(environmentId, state);
if (!panel || panel.promptId !== promptId) {
return;
}
let row = panel.environmentRows.get(environmentId);
if (!row) {
const location = String(detail?.execution_location ?? "").trim();
row = createRemoteConfiguratorEnvironmentRow(
environmentId,
location ? `${environmentId} · ${location}` : environmentId,
);
panel.environmentRows.set(environmentId, row);
panel.environments.appendChild(row.root);
panel.environments.hidden = false;
panel.minHeight += 52;
panel.node.setSize?.([
Math.max(Number(panel.node.size?.[0]) || 0, 500),
Math.max(Number(panel.node.size?.[1]) || 0, panel.minHeight),
]);
}
renderRemoteConfiguratorEnvironmentStatus(row, state);
}
/** Restore the newest retained prompt onto a newly mounted configurator panel. */
function restoreRemoteConfiguratorPanel(panel) {
const nodeIdentifier = nodeId(panel?.node);
const candidates = Array.from(modalPromptStates.entries())
.filter(([, state]) => state?.configuratorNodeId === nodeIdentifier)
.sort(([, left], [, right]) => Number(right?.startedAt ?? 0) - Number(left?.startedAt ?? 0));
const newest = candidates[0];
if (!newest) {
return;
}
const [promptId, promptState] = newest;
panel.promptId = promptId;
renderRemoteConfiguratorPlan(
panel,
promptState.remoteExecutionPlanAssignments,
promptState.remoteExecutionConfigurations,
);
renderRemoteConfiguratorStatus(panel, modalGlobalStatusStates.get(promptId) ?? {});
}
/**
* Refresh the node-local status from the same durable state used by the global pill.
* @param {string} promptId
*/
function refreshRemoteConfiguratorPanelForPrompt(promptId) {
const promptState = modalPromptStates.get(promptId);
const panel = remoteConfiguratorPanel(promptState?.configuratorNodeId);
if (!panel || panel.promptId !== promptId) {
return;
}
renderRemoteConfiguratorStatus(panel, modalGlobalStatusStates.get(promptId) ?? {});
}
/**
* Preserve a terminal outcome on the configurator after prompt state is cleaned up.
* @param {string} promptId
* @param {string} phase
* @param {string | null} message
*/
function setRemoteConfiguratorTerminalStatus(promptId, phase, message = null) {
const promptState = modalPromptStates.get(promptId);
const candidatePanel =
remoteConfiguratorPanel(promptState?.configuratorNodeId) ??
Array.from(remoteConfiguratorPanels.values()).find(
(candidate) => candidate?.promptId === promptId,
);
const panel = candidatePanel?.promptId === promptId ? candidatePanel : null;
if (!promptState && !panel) {
return;
}
const terminalState = {
phase,
statusMessage: message,
statusCurrent: null,
statusTotal: null,
updatedAt: nowMs(),
};
const environmentIds = new Set([
...(promptState?.remoteEnvironmentStatuses.keys() ?? []),
...remoteConfiguratorEnvironmentEntries(
promptState?.remoteExecutionPlanAssignments ?? {},
promptState?.remoteExecutionConfigurations ?? [],
).map((entry) => entry.environmentId),
...(panel?.environmentRows.keys() ?? []),
]);
for (const environmentId of environmentIds) {
promptState?.remoteEnvironmentStatuses.set(environmentId, {
...(promptState.remoteEnvironmentStatuses.get(environmentId) ?? {}),
...terminalState,
});
}
if (!panel) {
return;
}
renderRemoteConfiguratorStatus(panel, terminalState);
for (const [environmentId, row] of panel.environmentRows.entries()) {
renderRemoteConfiguratorEnvironmentStatus(
row,
promptState?.remoteEnvironmentStatuses.get(environmentId) ?? terminalState,
);
}
}
/**
* Remove Configurator DOM widgets left behind by an older extension module.
* @param {LGraphNode} node
*/
function removeStaleRemoteConfiguratorWidgets(node) {
const staleWidgets = (node.widgets ?? []).filter(
(widget) => String(widget?.name ?? "") === "remote_execution_plan",
);
for (const widget of staleWidgets) {
widget?.element?.remove?.();
if (typeof node.removeWidget === "function") {
node.removeWidget(widget);
continue;
}
widget?.onRemove?.();
const widgetIndex = node.widgets?.indexOf(widget) ?? -1;
if (widgetIndex >= 0) {
node.widgets.splice(widgetIndex, 1);
}
}
}
/**
* Keep a Configurator DOM widget on ComfyUI's node-width layout path.
* @param {object | null | undefined} widget
*/
function makeRemoteConfiguratorWidgetResponsive(widget) {
if (!widget || widget[REMOTE_CONFIGURATOR_WIDGET_WIDTH_GUARD]) {
return;
}
try {
widget[REMOTE_CONFIGURATOR_WIDGET_WIDTH_VALUE] = undefined;
const setResponsiveWidgetWidth = function setResponsiveWidgetWidth(value) {
if (globalThis.LiteGraph?.vueNodesMode) {
this[REMOTE_CONFIGURATOR_WIDGET_WIDTH_VALUE] = value;
}
};
Object.defineProperty(widget, "width", {
configurable: true,
enumerable: true,
get() {
return this[REMOTE_CONFIGURATOR_WIDGET_WIDTH_VALUE];
},
set: setResponsiveWidgetWidth,
});
Object.defineProperty(widget, REMOTE_CONFIGURATOR_WIDGET_WIDTH_GUARD, {
configurable: true,
value: true,
});
} catch (error) {
widget.width = undefined;
console.warn("Unable to guard the Configurator widget width.", error);
}
}
/**
* Mount the non-serialized planning and status surface on the configurator node.
* @param {LGraphNode} node
*/
function mountRemoteExecutionConfiguratorPanel(node) {
const safeNodeId = nodeId(node);
if (
!isRemoteExecutionConfiguratorNode(node) ||
(node.__remoteConfiguratorPanelMounted && remoteConfiguratorPanels.has(safeNodeId)) ||
typeof node.addDOMWidget !== "function" ||
typeof document === "undefined"
) {
return;
}
removeStaleRemoteConfiguratorWidgets(node);
const root = document.createElement("div");
root.className = "comfy-remote-configurator-panel";
root.innerHTML = `
<div class="remote-configurator-status">
<span class="remote-configurator-dot"></span>
<span class="remote-configurator-status-text">Ready to plan remote execution</span>
</div>
<div class="remote-configurator-progress" hidden>
<span class="remote-configurator-progress-fill"></span>
<span class="remote-configurator-progress-value"></span>
</div>
<div class="remote-configurator-environments" hidden></div>
<div class="remote-configurator-plan-title">Execution plan</div>
<div class="remote-configurator-empty">The selected environments will appear here after planning.</div>
<div class="remote-configurator-targets" hidden></div>
<div class="remote-configurator-capacity">
<div class="remote-configurator-capacity-header">
<div class="remote-configurator-capacity-title">Managed capacity</div>
<div class="remote-configurator-capacity-refresh">
<span class="remote-configurator-capacity-refresh-status" hidden></span>
<button class="remote-configurator-capacity-reload" type="button">Reload</button>
</div>
</div>
<div class="remote-configurator-capacity-empty">No managed Vast.ai leases or Modal containers.</div>
<div class="remote-configurator-capacity-list"></div>
</div>
<div class="remote-configurator-storage" hidden>
<div class="remote-configurator-storage-header">
<div class="remote-configurator-storage-title">Storage backends</div>
<div class="remote-configurator-storage-refresh">
<span class="remote-configurator-storage-refresh-status" hidden></span>
<button class="remote-configurator-storage-reload" type="button">Reload</button>
</div>
</div>
<div class="remote-configurator-storage-list"></div>
</div>
<div class="remote-configurator-keychain" hidden>
<button class="remote-configurator-keychain-unlock" type="button">Unlock Keychain</button>
<span class="remote-configurator-keychain-status" hidden></span>
</div>`;
const panel = {
node,
root,
promptId: null,
minHeight: 198,
storageHeight: 0,
capacityHeight: 49,
keychainUnlockHeight: 0,
storageRefreshInFlight: false,
keychainUnlockInFlight: false,
capacityRefreshInFlight: false,
capacityKillInFlight: null,
assignments: {},
configurations: [],
statusText: root.querySelector(".remote-configurator-status-text"),
progress: root.querySelector(".remote-configurator-progress"),
progressFill: root.querySelector(".remote-configurator-progress-fill"),
progressValue: root.querySelector(".remote-configurator-progress-value"),
environments: root.querySelector(".remote-configurator-environments"),
environmentRows: new Map(),
emptyText: root.querySelector(".remote-configurator-empty"),
targets: root.querySelector(".remote-configurator-targets"),
capacity: root.querySelector(".remote-configurator-capacity"),
capacityList: root.querySelector(".remote-configurator-capacity-list"),
capacityEmpty: root.querySelector(".remote-configurator-capacity-empty"),
capacityReload: root.querySelector(".remote-configurator-capacity-reload"),
capacityRefreshStatus: root.querySelector(
".remote-configurator-capacity-refresh-status",
),
storage: root.querySelector(".remote-configurator-storage"),
storageList: root.querySelector(".remote-configurator-storage-list"),
storageReload: root.querySelector(".remote-configurator-storage-reload"),
storageRefreshStatus: root.querySelector(
".remote-configurator-storage-refresh-status",
),
keychainUnlock: root.querySelector(".remote-configurator-keychain"),
keychainUnlockButton: root.querySelector(
".remote-configurator-keychain-unlock",
),
keychainUnlockStatus: root.querySelector(
".remote-configurator-keychain-status",
),
};
for (const eventName of ["pointerdown", "pointermove", "pointerup", "wheel"]) {
root.addEventListener(eventName, (event) => {
if (event.target?.closest?.("button")) {
event.stopPropagation();
}
});
}
panel.storageReload.addEventListener("click", () => {
void refreshRemoteConfiguratorStorage(panel);
});
panel.capacityReload.addEventListener("click", () => {
void refreshRemoteManagedCapacity(panel);
});
panel.keychainUnlockButton.addEventListener("click", () => {
void unlockRemoteConfiguratorKeychain(panel);
});
let widget;
try {
widget = node.addDOMWidget(
"remote_execution_plan",
"remote-execution-plan",
root,
{
serialize: false,
hideOnZoom: false,
getMinHeight: () => panel.minHeight,
},
);
} catch (error) {
node.__remoteConfiguratorPanelMounted = false;
console.warn("Unable to mount the remote execution configurator panel.", error);
return;
}
if (widget) {
widget.serialize = false;
makeRemoteConfiguratorWidgetResponsive(widget);
}
node.setSize?.([
Math.max(Number(node.size?.[0]) || 0, 500),
Math.max(Number(node.size?.[1]) || 0, panel.minHeight),
]);
remoteConfiguratorPanels.set(safeNodeId, panel);
node.__remoteConfiguratorPanelMounted = true;
restoreRemoteConfiguratorPanel(panel);
void refreshRemoteManagedCapacity(panel);
const originalOnRemoved = node.onRemoved;
node.onRemoved = function onRemoved() {
remoteConfiguratorPanels.delete(nodeId(this));
return originalOnRemoved?.apply(this, arguments);
};
}
/**
* 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(":");
}
/**
* Rebuild the workflow-level index of Modal-enabled leaves beneath each subgraph node.
*/
function rebuildRemoteDescendantIndex() {
modalRemoteDescendantNodeIdsByAncestor.clear();
for (const node of allWorkflowNodes()) {
if (!isRemoteNode(node)) {
continue;
}
const remoteNodePath = workflowNodePath(node);
for (const ancestorNodeId of ancestorNodeIds(remoteNodePath)) {
if (!modalRemoteDescendantNodeIdsByAncestor.has(ancestorNodeId)) {
modalRemoteDescendantNodeIdsByAncestor.set(ancestorNodeId, new Set());
}
modalRemoteDescendantNodeIdsByAncestor.get(ancestorNodeId).add(remoteNodePath);
}
}
}
/**
* Rebuild descendant containment after ComfyUI completes a graph mutation.
*/
function scheduleRemoteDescendantIndexRebuild() {
if (remoteDescendantIndexRebuildScheduled) {
return;
}
remoteDescendantIndexRebuildScheduled = true;
setTimeout(() => {
remoteDescendantIndexRebuildScheduled = false;
rebuildRemoteDescendantIndex();
refreshNodeDecorations();
}, 0);
}
/**
* 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,
});
}
/**
* Request one local Modal maintenance action from the backend.
* @param {string} route
* @param {string} successMessage
*/
async function requestModalMaintenance(route, successMessage) {
if (typeof api.fetchApi !== "function") {
throw new Error("ComfyUI API fetch is unavailable.");
}
const response = await api.fetchApi(route, { method: "POST" });
if (response.status !== 200) {
throw new PromptExecutionError(await response.json());
}
notifyModal(successMessage);
}
/**
* Apply the remote marker value to the workflow nodes named by composed workflow paths.
* @param {string[]} workflowNodePaths
* @param {boolean} value
* @returns {number}
*/
function setWorkflowNodePathsRemote(workflowNodePaths, value) {
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, value);
appliedCount += 1;
}
return appliedCount;
}
/**
* Set Modal on every currently eligible node in the live workflow.
* @param {boolean} value
* @returns {number}
*/
function setAllEligibleWorkflowNodesRemote(value) {
let appliedCount = 0;
for (const node of allWorkflowNodes()) {
if (!isEligibleNode(node)) {
continue;
}
setRemoteFlag(node, value);
appliedCount += 1;
}
refreshNodeDecorations();
const actionLabel = value ? "Enabled" : "Disabled";
notifyModal(
appliedCount > 0
? `${actionLabel} Modal on ${appliedCount} node${appliedCount === 1 ? "" : "s"}.`
: "No Modal-eligible nodes were found in the workflow.",
);
return appliedCount;
}
/**
* Request required upstream nodes from the backend and set their Modal state in the UI.
* @param {LGraphNode} node
* @param {boolean} value
*/
async function analyzeAndSetUpstreamRemoteNodes(node, value) {
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 = setWorkflowNodePathsRemote(resolvedWorkflowNodePaths, value);
setSandwichedLocalNodeIds(value ? (result.sandwiched_local_node_ids ?? []) : []);
refreshNodeDecorations();
if (!value) {
notifyModal(
appliedCount > 0
? `Disabled Modal on ${appliedCount} upstream node${appliedCount === 1 ? "" : "s"}.`
: "Modal analysis finished, but no matching live nodes were found to update.",
);
return;
}
if (addedWorkflowNodePaths.length > 0) {
notifyModal(`Enabled Modal on ${addedWorkflowNodePaths.length} upstream node${addedWorkflowNodePaths.length === 1 ? "" : "s"}.`);
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) &&
!LOCAL_MODAL_NODE_IDS.has(String(nodeData.name))
);
}
/**
* 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 enableUpstreamMenuItemLabel =
selectedNodePaths.length > 1
? "Enable on Upstream Nodes for Selection"
: "Enable on Upstream Nodes";
const disableUpstreamMenuItemLabel =
selectedNodePaths.length > 1
? "Disable on Upstream Nodes for Selection"
: "Disable on Upstream Nodes";
if (!targetOptions.some((option) => option?.content === "Remote Execution")) {
const executionPreferences = selectedExecutionPreferences();
const configuredCapacity = usesRemoteExecutionConfigurator();
const executionPolicyLabel =
EXECUTION_POLICIES.find(([value]) => value === executionPreferences.policy)?.[1]
?? executionPreferences.policy;
targetOptions.push(null, {
content: "Remote Execution",
has_submenu: true,
submenu: {
options: [
...(configuredCapacity
? [{
content: "Providers and capacity come from Remote Execution Configurator",
disabled: true,
}]
: [
{
content: `Legacy provider policy (${executionPolicyLabel})`,
disabled: true,
},
...EXECUTION_POLICIES.map(([value, label]) => ({
content: label,
checked: executionPreferences.policy === value,
callback: () => setExecutionPreferences({ policy: value }),
})),
]),
null,
{
content: "Automatically place eligible nodes",
checked: executionPreferences.autoPlace,
callback: () => setExecutionPreferences({
autoPlace: !selectedExecutionPreferences().autoPlace,
}),
},
null,
{
content: "Manage SSH hosts…",
callback: () => {
void openRemoteEnvironmentManager().catch((error) => {
console.error("Remote environment manager failed.", error);
notifyModal(`Remote environment manager failed: ${String(error?.message ?? error)}`);
});
},
},
],
},
});
}
if (!targetOptions.some((option) => option?.content === "Remote Execution Tools")) {
const currentModalGpu = selectedModalGpu();
const configuredCapacity = usesRemoteExecutionConfigurator();
targetOptions.push(null, {
content: "Remote Execution Tools",
has_submenu: true,
submenu: {
options: [
...(configuredCapacity
? []
: [
{
content: `Legacy Modal GPU (${currentModalGpu})`,
disabled: true,
},
...MODAL_GPU_TYPES.map((modalGpu) => ({
content: modalGpu,
checked: modalGpu === currentModalGpu,
callback: () => setSelectedModalGpu(modalGpu),
})),
null,
]),
{
content: enableUpstreamMenuItemLabel,
callback: () => {
void analyzeAndSetUpstreamRemoteNodes(this, true).catch((error) => {
console.error("Modal remote-node analysis failed.", error);
notifyModal(`Modal remote-node analysis failed: ${String(error?.message ?? error)}`);
});
},
},
{
content: disableUpstreamMenuItemLabel,
callback: () => {
void analyzeAndSetUpstreamRemoteNodes(this, false).catch((error) => {
console.error("Modal remote-node analysis failed.", error);
notifyModal(`Modal remote-node analysis failed: ${String(error?.message ?? error)}`);
});
},
},
null,
{
content: "Enable All Nodes",
callback: () => {
setAllEligibleWorkflowNodesRemote(true);
},
},
{
content: "Disable All Nodes",
callback: () => {
setAllEligibleWorkflowNodesRemote(false);
},
},
{
content: "Delete Modal Caches",
callback: () => {
void requestModalMaintenance(
MODAL_DELETE_CACHES_ROUTE,
"Deleted Modal caches.",
).catch((error) => {
console.error("Modal cache deletion failed.", error);
notifyModal(`Modal cache deletion failed: ${String(error?.message ?? error)}`);
});
},
},
{
content: "Delete Modal Volume",
callback: () => {
if (typeof window !== "undefined" && typeof window.confirm === "function") {
const confirmed = window.confirm("Delete the configured Modal volume?");
if (!confirmed) {
return;
}
}
void requestModalMaintenance(
MODAL_DELETE_VOLUME_ROUTE,
"Deleted Modal volume.",
).catch((error) => {
console.error("Modal volume deletion failed.", error);
notifyModal(`Modal volume deletion 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 ||= {};
const enabled = Boolean(value);
node.properties[REMOTE_PROPERTY] = enabled;
if (node.__modalToggleWidget) {
node.__modalToggleWidget.value = enabled;
}
rebuildRemoteDescendantIndex();
clearSandwichedLocalNodeWarnings();
refreshNodeDecorations();
}
/**
* Reconcile a restored node property with the toggle value displayed to the user.
* @param {LGraphNode} node
*/
function synchronizeRemoteFlagFromWidget(node) {
if (!isEligibleNode(node) || !node.__modalToggleWidget) {
return;
}
const enabled = Boolean(node.__modalToggleWidget.value);
node.properties ||= {};
if (node.properties[REMOTE_PROPERTY] === enabled) {
return;
}
node.properties[REMOTE_PROPERTY] = enabled;
rebuildRemoteDescendantIndex();
clearSandwichedLocalNodeWarnings();
refreshNodeDecorations();
}
/**
* Return the current remote visual state for a node.
* @param {LGraphNode} node
* @returns {{ phase: string, promptId: string } | null}
*/
function getRemoteVisualState(node) {
const visualNodeId = workflowNodePath(node) || nodeId(node);
const storedState = modalNodeStates.get(visualNodeId) ?? null;
const promptContainerState = storedState?.promptId
? remoteContainerVisualState(storedState.promptId, visualNodeId, storedState.errorMessage)
: null;
const staticRemoteDescendantCount = hasRemoteDescendants(node)
? (modalRemoteDescendantNodeIdsByAncestor.get(visualNodeId)?.size ?? 0)
: 0;
const state =
promptContainerState ??
storedState ??
(staticRemoteDescendantCount > 0
? {
phase: "idle",
promptId: null,
isRemoteContainer: true,
isMixedRemoteContainer: false,
phaseCounts: {},
remoteDescendantCount: staticRemoteDescendantCount,
}
: null);
if (!state?.promptId) {
return state;
}
const promptState = modalPromptStates.get(state.promptId);
const progressState = nodeProgressState(visualNodeId, state.promptId);
const progressLanes = nodeProgressLanes(visualNodeId, state.promptId);
const cachedState = nodeCachedState(visualNodeId, state.promptId);
const batchProgressState = modalNodeBatchProgress.get(visualNodeId) ?? null;
const hasLiveProgress = hasLiveNodeProgress(visualNodeId, state.promptId);
const executionAssignment =
promptState?.executionAssignmentByMember.get(visualNodeId) ?? null;
return {
...state,
phase: deriveRemoteNodePhase(state.phase, hasLiveProgress),
isActiveRemoteNode: hasLiveProgress || promptState?.activeNodeId === visualNodeId,
isActiveComponentMember: isNodeInActiveComponent(state.promptId, visualNodeId),
isCachedRemoteNode: Boolean(cachedState),
componentLabel: state.isRemoteContainer
? null
: (promptState?.componentLabelByMember.get(visualNodeId) ?? null),
cachedAt: cachedState?.cachedAt ?? null,
progress: progressState,
batchProgress: batchProgressState?.promptId === state.promptId ? batchProgressState : null,
progressLanes,
executionProvider:
executionAssignment?.provider ??
progressState?.provider ??
progressLanes.find((laneProgress) => laneProgress.provider)?.provider ??
null,
scheduledEnvironmentCount: promptState?.scheduledEnvironmentCount ?? 0,
executionLocation: executionAssignment?.location
? {
provider: executionAssignment.provider,
label: executionAssignment.location,
}
: null,
};
}
/**
* Return the remote node ids that currently have live numeric or lane progress.
* @param {string} promptId
* @returns {string[]}
*/
function activeProgressNodeIds(promptId) {
const promptState = modalPromptStates.get(promptId);
if (!promptState) {
return [];
}
return promptState.remoteNodeIds.filter((candidateNodeId) =>
hasLiveNodeProgress(candidateNodeId, promptId),
);
}
/**
* Return whether one visible node belongs to any component that is currently executing.
* @param {string} promptId
* @param {string} nodeIdValue
* @returns {boolean}
*/
function isNodeInActiveComponent(promptId, nodeIdValue) {
const promptState = modalPromptStates.get(promptId);
if (!promptState) {
return false;
}
const activeComponentNodeIds = new Set();
const promptActiveNodeId = promptState.activeNodeId;
if (promptActiveNodeId) {
const promptActiveComponentNodeIds = resolveComponentNodeIds(promptId, promptActiveNodeId);
if (promptActiveComponentNodeIds?.length) {
for (const componentNodeId of promptActiveComponentNodeIds) {
activeComponentNodeIds.add(String(componentNodeId));
}
} else {
activeComponentNodeIds.add(String(promptActiveNodeId));
}
}
for (const liveProgressNodeId of activeProgressNodeIds(promptId)) {
const liveProgressComponentNodeIds = resolveComponentNodeIds(promptId, liveProgressNodeId);
if (liveProgressComponentNodeIds?.length) {
for (const componentNodeId of liveProgressComponentNodeIds) {
activeComponentNodeIds.add(String(componentNodeId));
}
} else {
activeComponentNodeIds.add(String(liveProgressNodeId));
}
}
if (activeComponentNodeIds.size === 0) {
return false;
}
const safeNodeIdValue = String(nodeIdValue);
if (activeComponentNodeIds.has(safeNodeIdValue)) {
return true;
}
const descendantNodeIds = promptState.descendantNodeIdsByAncestor.get(safeNodeIdValue);
if (!descendantNodeIds || descendantNodeIds.size === 0) {
return false;
}
return Array.from(activeComponentNodeIds).some((componentNodeId) => descendantNodeIds.has(componentNodeId));
}
/**
* Return preparation-phase colors for one remote visual state.
* @param {Record<string, any> | null} state
* @param {number} elapsed
* @returns {{ borderColor: string, shadowColor: string, fillColor: string | null } | null}
*/
function remotePreparationPalette(state, elapsed) {
let borderColor;
let shadowColor;
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_STARTING) {
const pulse = (Math.sin(elapsed * 8) + 1) / 2;
borderColor = `${STARTING_BORDER_COLOR}${Math.round((0.58 + pulse * 0.42) * 255)
.toString(16)
.padStart(2, "0")}`;
shadowColor = `rgba(234, 179, 8, ${0.2 + pulse * 0.42})`;
fillColor = `rgba(250, 204, 21, ${0.08 + pulse * 0.08})`;
} else if (state?.phase === STATE_READY) {
const pulseRate = state?.isCachedRemoteNode ? 2 : 6;
const pulse = (Math.sin(elapsed * pulseRate) + 1) / 2;
borderColor = state?.isActiveComponentMember
? READY_ACTIVE_COMPONENT_BORDER_COLOR
: READY_INACTIVE_COMPONENT_BORDER_COLOR;
shadowColor = state?.isActiveComponentMember
? `rgba(34, 197, 94, ${0.24 + pulse * 0.18})`
: `rgba(22, 101, 52, ${0.18 + pulse * 0.12})`;
fillColor = state?.isActiveComponentMember
? `rgba(134, 239, 172, ${0.1 + pulse * 0.07})`
: `rgba(74, 222, 128, ${0.06 + pulse * 0.04})`;
} else {
return null;
}
return { borderColor, shadowColor, fillColor };
}
/**
* Return execution-phase colors for one remote visual state.
* @param {Record<string, any> | null} state
* @param {number} elapsed
* @returns {{ borderColor: string, shadowColor: string, fillColor: string | null } | null}
*/
function remoteExecutionPalette(state, elapsed) {
let borderColor;
let shadowColor;
let fillColor = null;
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(0, 79, 164, 0.28)";
fillColor = `${COMPLETE_FILL_COLOR}33`;
} else if (state?.phase === STATE_FINALIZING) {
const pulse = (Math.sin(elapsed * 5) + 1) / 2;
borderColor = FINALIZING_NODE_BORDER_COLOR;
shadowColor = `rgba(0, 53, 138, ${0.2 + pulse * 0.26})`;
fillColor = `${COMPLETE_FILL_COLOR}${Math.round((0.16 + pulse * 0.1) * 255)
.toString(16)
.padStart(2, "0")}`;
} else if (state?.phase === STATE_CANCELLING) {
const pulse = (Math.sin(elapsed * 9) + 1) / 2;
borderColor = `${CANCELLING_BORDER_COLOR}${Math.round((0.64 + pulse * 0.36) * 255)
.toString(16)
.padStart(2, "0")}`;
shadowColor = `rgba(251, 113, 133, ${0.24 + pulse * 0.36})`;
fillColor = `rgba(251, 113, 133, ${0.1 + pulse * 0.08})`;
} 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})`;
} else {
return null;
}
return { borderColor, shadowColor, fillColor };
}
/**
* Return the border, glow, and fill colors for one remote visual state.
* @param {Record<string, any> | null} state
* @param {number} elapsed
* @returns {{ borderColor: string, shadowColor: string, fillColor: string | null }}
*/
function remoteDecorationPalette(state, elapsed) {
return (
remotePreparationPalette(state, elapsed) ??
remoteExecutionPalette(state, elapsed) ?? {
borderColor: IDLE_BORDER_COLOR,
shadowColor: "rgba(29, 155, 240, 0.35)",
fillColor: null,
}
);
}
/**
* Return the visible LiteGraph node matching a Nodes 2.0 DOM element.
* @param {HTMLElement} nodeElement
* @returns {LGraphNode | null}
*/
function vueNodeForElement(nodeElement) {
const graph = app.canvas?.graph;
const graphNodes = graph?.nodes ?? graph?._nodes ?? [];
const elementNodeId = String(nodeElement.dataset.nodeId ?? "");
return graphNodes.find((node) => nodeId(node) === elementNodeId) ?? null;
}
/**
* Remove Modal's Nodes 2.0 decoration from one DOM node.
* @param {HTMLElement} nodeElement
*/
function clearVueNodeDecoration(nodeElement) {
nodeElement.classList.remove("comfy-modal-vue-node");
delete nodeElement.dataset.modalPhase;
delete nodeElement.dataset.modalContainer;
nodeElement.querySelector(":scope > .comfy-modal-vue-node-decoration")?.remove();
}
/**
* Describe the remote work summarized by a subgraph container.
* @param {Record<string, any> | null | undefined} state
* @returns {string}
*/
function remoteContainerTooltip(state) {
const descendantCount = Number(state?.remoteDescendantCount ?? 0);
const phaseCounts = state?.phaseCounts ?? {};
const phaseSummary = [
STATE_ERROR,
STATE_CANCELLING,
STATE_ACTIVE,
STATE_STARTING,
STATE_SETUP,
STATE_READY,
STATE_FINALIZING,
STATE_COMPLETE,
]
.filter((phase) => (phaseCounts[phase] ?? 0) > 0)
.map((phase) => `${phaseCounts[phase]} ${phase}`)
.join(", ");
const nodeLabel = descendantCount === 1 ? "node" : "nodes";
if (!phaseSummary) {
return `${descendantCount} descendant ${nodeLabel} set to execute on Modal.`;
}
return `${descendantCount} Modal descendant ${nodeLabel}: ${phaseSummary}.`;
}
/**
* Return the decoration element for one Nodes 2.0 node, creating it when needed.
* @param {HTMLElement} nodeElement
* @returns {HTMLDivElement}
*/
function ensureVueNodeDecoration(nodeElement) {
let decoration = nodeElement.querySelector(":scope > .comfy-modal-vue-node-decoration");
if (!decoration) {
decoration = document.createElement("div");
decoration.className = "comfy-modal-vue-node-decoration";
decoration.setAttribute("aria-hidden", "true");
const badge = document.createElement("span");
badge.className = "comfy-modal-vue-node-badge";
decoration.appendChild(badge);
nodeElement.appendChild(decoration);
}
if (decoration.querySelector(".comfy-modal-vue-execution-location")) {
return decoration;
}
const executionLocation = document.createElement("div");
executionLocation.className = "comfy-modal-vue-execution-location";
executionLocation.hidden = true;
const executionLocationIcon = document.createElement("img");
executionLocationIcon.className = "comfy-modal-vue-execution-location-icon";
executionLocationIcon.alt = "";
executionLocationIcon.setAttribute("aria-hidden", "true");
const executionLocationLabel = document.createElement("span");
executionLocationLabel.className = "comfy-modal-vue-execution-location-label";
executionLocation.append(executionLocationIcon, executionLocationLabel);
decoration.appendChild(executionLocation);
return decoration;
}
/**
* Update the execution-location badge rendered by the Nodes 2.0 DOM layer.
* @param {HTMLDivElement} decoration
* @param {Record<string, any> | null} state
*/
function updateVueExecutionLocation(decoration, state) {
const executionLocation = decoration.querySelector(
".comfy-modal-vue-execution-location",
);
if (!executionLocation) {
return;
}
const location =
Number(state?.scheduledEnvironmentCount ?? 0) > 1 &&
(state?.isActiveRemoteNode || state?.isActiveComponentMember)
? state?.executionLocation
: null;
const label = String(location?.label ?? "");
const provider = String(location?.provider ?? "");
const iconSource = REMOTE_LOCATION_ICON_SOURCES[provider] ?? "";
executionLocation.hidden = !label;
if (!label) {
executionLocation.removeAttribute("title");
return;
}
executionLocation.dataset.provider = provider;
executionLocation.title = label;
const icon = executionLocation.querySelector(".comfy-modal-vue-execution-location-icon");
const labelElement = executionLocation.querySelector(".comfy-modal-vue-execution-location-label");
if (icon) {
icon.hidden = !iconSource;
if (iconSource && icon.getAttribute("src") !== iconSource) {
icon.setAttribute("src", iconSource);
}
}
if (labelElement) {
labelElement.textContent = label;
}
}
/**
* Apply Modal's current visual state to one Nodes 2.0 DOM node.
* @param {HTMLElement} nodeElement
* @param {LGraphNode} node
* @param {number} timestamp
*/
function updateVueNodeDecoration(nodeElement, node, timestamp) {
const localBottleneck = isSandwichedLocalNode(node);
const state = getRemoteVisualState(node);
if (!isRemoteNode(node) && !state?.isRemoteContainer && !localBottleneck) {
clearVueNodeDecoration(nodeElement);
return;
}
const palette = localBottleneck ? null : remoteDecorationPalette(state, timestamp / 1000);
const decoration = ensureVueNodeDecoration(nodeElement);
const innerWrapper = nodeElement.querySelector(':scope > [data-testid="node-inner-wrapper"]');
const borderRadius =
innerWrapper && typeof getComputedStyle === "function"
? getComputedStyle(innerWrapper).borderRadius
: "12px";
decoration.style.borderColor = palette?.borderColor ?? "transparent";
decoration.style.backgroundColor = palette?.fillColor ?? "transparent";
decoration.style.borderRadius = borderRadius || "12px";
decoration.style.boxShadow = palette ? `0 0 8px ${palette.shadowColor}` : "none";
const phase = localBottleneck ? "local-bottleneck" : (state?.phase ?? "idle");
nodeElement.classList.add("comfy-modal-vue-node");
nodeElement.dataset.modalPhase = phase;
if (state?.isRemoteContainer) {
nodeElement.dataset.modalContainer = "true";
} else {
delete nodeElement.dataset.modalContainer;
}
const badge = decoration.querySelector(".comfy-modal-vue-node-badge");
const badgeText = localBottleneck
? "!"
: (state?.isRemoteContainer ? "Σ" : String(state?.componentLabel ?? ""));
badge.textContent = badgeText;
badge.hidden = !badgeText;
badge.title = localBottleneck
? LOCAL_BOTTLENECK_TOOLTIP
: (state?.isRemoteContainer ? remoteContainerTooltip(state) : "");
badge.style.borderColor = localBottleneck
? LOCAL_BOTTLENECK_BADGE_BORDER_COLOR
: palette.borderColor;
badge.style.boxShadow = localBottleneck ? "none" : `0 0 8px ${palette.shadowColor}`;
updateVueExecutionLocation(decoration, state);
}
/**
* Synchronize Modal decorations onto all currently rendered Nodes 2.0 nodes.
* @param {number | undefined} timestamp
*/
function syncVueNodeDecorations(timestamp = performance.now()) {
if (typeof document === "undefined") {
return;
}
for (const nodeElement of document.querySelectorAll(".lg-node[data-node-id]")) {
const node = vueNodeForElement(nodeElement);
if (node) {
updateVueNodeDecoration(nodeElement, node, timestamp);
} else {
clearVueNodeDecoration(nodeElement);
}
}
}
/**
* Schedule one Nodes 2.0 DOM synchronization after Vue has rendered.
*/
function queueVueNodeDecorationSync() {
if (typeof document === "undefined" || vueNodeSyncScheduled) {
return;
}
vueNodeSyncScheduled = true;
const callback = (timestamp) => {
vueNodeSyncScheduled = false;
syncVueNodeDecorations(timestamp);
};
if (typeof requestAnimationFrame === "function") {
requestAnimationFrame(callback);
} else {
setTimeout(() => callback(performance.now()), 0);
}
}
/**
* Redraw both the legacy canvas and the Nodes 2.0 DOM layer.
*/
function refreshNodeDecorations() {
app.graph?.setDirtyCanvas(true, true);
queueVueNodeDecorationSync();
}
/**
* Watch for Vue nodes mounted after workflow loads or renderer switches.
*/
function installVueNodeDecorationObserver() {
if (vueNodeObserver || typeof MutationObserver === "undefined" || !document.body) {
return;
}
vueNodeObserver = new MutationObserver((records) => {
const addedVueNode = records.some((record) =>
Array.from(record.addedNodes).some(
(addedNode) =>
addedNode.nodeType === 1 &&
(addedNode.matches?.(".lg-node[data-node-id]") ||
addedNode.querySelector?.(".lg-node[data-node-id]")),
),
);
if (addedVueNode) {
queueVueNodeDecorationSync();
}
});
vueNodeObserver.observe(document.body, { childList: true, subtree: true });
queueVueNodeDecorationSync();
}
/**
* Trace one rounded-rectangle path, using the native canvas helper when available.
* @param {CanvasRenderingContext2D} ctx
* @param {number} x
* @param {number} y
* @param {number} width
* @param {number} height
* @param {number} radius
*/
function traceRoundedRectPath(ctx, x, y, width, height, radius) {
const safeRadius = Math.max(0, Math.min(radius, width / 2, height / 2));
ctx.beginPath();
if (typeof ctx.roundRect === "function") {
ctx.roundRect(x, y, width, height, safeRadius);
return;
}
ctx.moveTo(x + safeRadius, y);
ctx.lineTo(x + width - safeRadius, y);
ctx.arcTo(x + width, y, x + width, y + safeRadius, safeRadius);
ctx.lineTo(x + width, y + height - safeRadius);
ctx.arcTo(x + width, y + height, x + width - safeRadius, y + height, safeRadius);
ctx.lineTo(x + safeRadius, y + height);
ctx.arcTo(x, y + height, x, y + height - safeRadius, safeRadius);
ctx.lineTo(x, y + safeRadius);
ctx.arcTo(x, y, x + safeRadius, y, safeRadius);
ctx.closePath();
}
/**
* Return whether a node-local pointer position is over the local-warning badge.
* @param {LGraphNode} node
* @param {number[] | undefined} localPosition
* @param {number} scale
* @returns {boolean}
*/
function localBottleneckBadgeContainsPoint(node, localPosition, scale = 1) {
if (!Array.isArray(localPosition) || localPosition.length < 2) {
return false;
}
const safeScale = Number.isFinite(scale) && scale > 0 ? scale : 1;
const titleHeight = node.constructor?.title_height ?? LiteGraph.NODE_TITLE_HEIGHT ?? 24;
const badgeRadius = 10 / safeScale;
const badgeX = 10 / safeScale;
const badgeY = -titleHeight + 10 / safeScale;
const deltaX = Number(localPosition[0]) - badgeX;
const deltaY = Number(localPosition[1]) - badgeY;
return deltaX * deltaX + deltaY * deltaY <= badgeRadius * badgeRadius;
}
/**
* Set or clear native hover text for a legacy canvas Modal badge.
* @param {LGraphCanvas | undefined} graphCanvas
* @param {string} tooltip
*/
function updateLegacyModalTooltip(graphCanvas, tooltip) {
const canvasElement = graphCanvas?.canvas ?? app.canvas?.canvas;
if (!canvasElement) {
return;
}
if (tooltip) {
canvasElement.title = tooltip;
} else if (
canvasElement.title === LOCAL_BOTTLENECK_TOOLTIP ||
canvasElement.title?.includes("Modal descendant") ||
canvasElement.title?.includes("descendant node")
) {
canvasElement.removeAttribute("title");
}
}
/**
* Draw a clipped provider icon and runtime identity beneath node progress bars.
* @param {CanvasRenderingContext2D} ctx
* @param {{ provider?: string, label?: string }} location
* @param {number} rowY
* @param {number} rowHeight
* @param {number} nodeWidth
* @param {number} paddingX
* @param {number} scale
*/
function drawRemoteExecutionLocation(
ctx,
location,
rowY,
rowHeight,
nodeWidth,
paddingX,
scale,
) {
const provider = String(location?.provider ?? "");
const label = String(location?.label ?? "");
if (!label) {
return;
}
const icon = remoteLocationIcon(provider);
const iconHeight = 10 / scale;
const iconWidth = (provider === "modal" ? 19 : 10) / scale;
const iconX = paddingX;
const iconY = rowY + (rowHeight - iconHeight) / 2;
if (icon?.complete && Number(icon.naturalWidth) > 0 && typeof ctx.drawImage === "function") {
ctx.drawImage(icon, iconX, iconY, iconWidth, iconHeight);
}
const textX = iconX + iconWidth + 5 / scale;
const availableTextWidth = Math.max(0, nodeWidth - paddingX - textX);
ctx.fillStyle = "#cbd5e1";
ctx.font = `${Math.max(8 / scale, 6.5)}px ui-sans-serif, system-ui, sans-serif`;
ctx.textAlign = "left";
ctx.textBaseline = "middle";
ctx.fillText(
fitCanvasText(ctx, label, availableTextWidth),
textX,
rowY + rowHeight / 2,
);
}
/**
* Draw the Modal execution decoration or neutral local-bottleneck badge for a node.
* @param {LGraphNode} node
* @param {CanvasRenderingContext2D} ctx
*/
function drawModalNodeDecoration(node, ctx) {
const localBottleneck = isSandwichedLocalNode(node);
const state = getRemoteVisualState(node);
if (!isRemoteNode(node) && !state?.isRemoteContainer && !localBottleneck) {
return;
}
const titleHeight = node.constructor?.title_height ?? LiteGraph.NODE_TITLE_HEIGHT ?? 24;
const scale = app.canvas?.ds?.scale ?? 1;
const borderWidth = 3 / scale;
const cornerRadius = 12 / scale;
const elapsed = performance.now() / 1000;
const palette = localBottleneck ? null : remoteDecorationPalette(state, elapsed);
if (palette) {
ctx.save();
if (palette.fillColor) {
ctx.fillStyle = palette.fillColor;
traceRoundedRectPath(
ctx,
borderWidth,
-titleHeight + borderWidth,
Math.max(0, node.size[0] - borderWidth * 2),
Math.max(0, node.size[1] + titleHeight - borderWidth * 2),
Math.max(0, cornerRadius - borderWidth),
);
ctx.fill();
}
ctx.strokeStyle = palette.borderColor;
ctx.lineWidth = borderWidth;
ctx.shadowColor = palette.shadowColor;
ctx.shadowBlur = 8 / scale;
traceRoundedRectPath(
ctx,
-borderWidth,
-titleHeight,
node.size[0] + borderWidth * 2,
node.size[1] + titleHeight + borderWidth,
cornerRadius,
);
ctx.stroke();
ctx.restore();
}
const nodeBadgeText = localBottleneck
? "!"
: (state?.isRemoteContainer ? "Σ" : state?.componentLabel);
if (nodeBadgeText) {
ctx.save();
const badgeRadius = 10 / scale;
const badgeX = 10 / scale;
const badgeY = -titleHeight + 10 / scale;
ctx.fillStyle = "rgba(15, 23, 42, 0.92)";
ctx.strokeStyle = localBottleneck
? LOCAL_BOTTLENECK_BADGE_BORDER_COLOR
: palette.borderColor;
ctx.lineWidth = 1.5 / scale;
ctx.shadowColor = localBottleneck ? "transparent" : palette.shadowColor;
ctx.shadowBlur = localBottleneck ? 0 : 8 / scale;
ctx.beginPath();
ctx.arc(badgeX, badgeY, badgeRadius, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.shadowBlur = 0;
ctx.fillStyle = "#f8fafc";
ctx.font = `${Math.max(10 / scale, 8)}px ui-sans-serif, system-ui, sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(String(nodeBadgeText), badgeX, badgeY + 0.5 / scale);
ctx.restore();
}
const progressLanes = Array.isArray(state?.progressLanes) ? state.progressLanes : [];
const setupProgressLanes = progressLanes.filter((laneProgress) => laneProgress.setupOnly);
const activeProgressLanes = progressLanes.filter((laneProgress) => !laneProgress.setupOnly);
const visibleActiveProgressLanes = activeProgressLanes.filter(
(laneProgress) => progressVisualOpacity(laneProgress) > 0,
);
const batchProgress = state?.batchProgress ?? null;
const progressOpacity = progressVisualOpacity(state?.progress);
const batchOpacity = progressVisualOpacity(batchProgress);
const laneOpacity = Math.max(
0,
...visibleActiveProgressLanes.map((laneProgress) => progressVisualOpacity(laneProgress)),
...setupProgressLanes.map((laneProgress) => progressVisualOpacity(laneProgress)),
);
const progressPanelOpacity = Math.max(progressOpacity, batchOpacity, laneOpacity);
const hasAggregateProgress = Boolean(
state?.progress &&
progressOpacity > 0 &&
(
[STATE_ACTIVE, STATE_COMPLETE].includes(state.phase) ||
(state.phase === STATE_STARTING && state.progress.preGpu)
),
);
const hasSetupLaneProgress = setupProgressLanes.length > 0;
const hasLaneProgress =
visibleActiveProgressLanes.length > 0 && [STATE_ACTIVE, STATE_COMPLETE].includes(state?.phase);
const hasBatchBadge = Boolean(
batchProgress &&
batchOpacity > 0 &&
[STATE_ACTIVE, STATE_COMPLETE].includes(state?.phase) &&
!hasAggregateProgress,
);
const executionLocation =
Number(state?.scheduledEnvironmentCount ?? 0) > 1 &&
(state?.isActiveRemoteNode || state?.isActiveComponentMember)
? state?.executionLocation
: null;
const hasExecutionLocation = Boolean(executionLocation?.label);
if (
!hasAggregateProgress &&
!hasLaneProgress &&
!hasSetupLaneProgress &&
!hasBatchBadge &&
!hasExecutionLocation
) {
return;
}
ctx.save();
ctx.globalAlpha *= Math.max(progressPanelOpacity, hasExecutionLocation ? 1 : 0);
const barWidth = node.size[0] + borderWidth * 2;
const aggregateHeight = 12 / scale;
const laneHeight = 12 / scale;
const laneGap = 3 / scale;
const panelY = node.size[1] + 6 / scale;
const panelPaddingX = 6 / scale;
const panelPaddingY = 6 / scale;
const visibleLaneProgress = hasLaneProgress ? visibleActiveProgressLanes : setupProgressLanes;
const hasVisibleLaneProgress = visibleLaneProgress.length > 0;
const hasProgressContent =
hasAggregateProgress || hasVisibleLaneProgress || hasBatchBadge;
const headerHeight = hasProgressContent ? 16 / scale : 0;
const hasIterationRateLabels = hasAggregateProgress || hasLaneProgress;
const aggregateProvider = state?.progress?.provider || state?.executionProvider || null;
const hasProgressProviderIcon = [
aggregateProvider,
...visibleLaneProgress.map(
(laneProgress) => laneProgress.provider || state?.executionProvider || null,
),
].some((provider) => Boolean(REMOTE_LOCATION_ICON_SOURCES[String(provider ?? "")]));
const progressIconColumnWidth = hasProgressProviderIcon ? 24 / scale : 0;
const progressBarX = -borderWidth + progressIconColumnWidth;
const progressBarWidth = Math.max(0, barWidth - progressIconColumnWidth);
const laneBlockHeight = hasVisibleLaneProgress
? visibleLaneProgress.length * laneHeight + (visibleLaneProgress.length - 1) * laneGap
: 0;
const progressBodyHeight =
(hasVisibleLaneProgress ? laneBlockHeight + laneGap : 0) + (hasAggregateProgress ? aggregateHeight : 0);
const progressTopGap = progressBodyHeight > 0 ? laneGap : 0;
const locationRowHeight = 13 / scale;
const locationTopGap = hasExecutionLocation && hasProgressContent ? 5 / scale : 0;
const panelHeight =
panelPaddingY * 2 +
headerHeight +
progressTopGap +
progressBodyHeight +
locationTopGap +
(hasExecutionLocation ? locationRowHeight : 0);
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 = palette?.shadowColor ?? LOCAL_BOTTLENECK_BADGE_BORDER_COLOR;
ctx.lineWidth = 1 / scale;
ctx.stroke();
const aggregateProgress = hasAggregateProgress ? state.progress : null;
const progressPercent = aggregateProgress
? Math.round((aggregateProgress.value / aggregateProgress.max) * 100)
: null;
const llmTimingLabel = aggregateProgress?.timeToFirstTokenSeconds != null
? `${aggregateProgress.stage === "complete" ? "Done" : "Gen"} • TTFT ${aggregateProgress.timeToFirstTokenSeconds.toFixed(1)}s`
: null;
const headerText = llmTimingLabel
? llmTimingLabel
: aggregateProgress?.message
? aggregateProgress.message
: aggregateProgress
? `${progressPercent}%`
: hasVisibleLaneProgress
? `${visibleLaneProgress.length}x`
: null;
const progressUnit = aggregateProgress?.unit === "tokens"
? "tok"
: aggregateProgress?.unit || "";
const headerMetric = aggregateProgress && !aggregateProgress.indeterminate
? BYTE_PROGRESS_UNITS.has(String(aggregateProgress.unit ?? "").trim().toLowerCase())
? formatByteProgress(aggregateProgress.value, aggregateProgress.max)
: `${Math.round(aggregateProgress.value)}/${Math.round(aggregateProgress.max)}${progressUnit ? ` ${progressUnit}` : ""}`
: 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";
const metricWidth = headerMetric ? ctx.measureText(headerMetric).width + panelPaddingX : 0;
const availableLabelWidth = Math.max(0, barWidth - panelPaddingX * 2 - metricWidth);
ctx.fillText(
fitCanvasText(ctx, headerText, availableLabelWidth),
panelPaddingX,
headerBaselineY,
);
if (headerMetric) {
ctx.textAlign = "right";
ctx.fillStyle = "#cbd5e1";
ctx.fillText(headerMetric, node.size[0] - 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 + progressTopGap;
if (hasIterationRateLabels) {
ctx.font = `${Math.max(9 / scale, 7)}px ui-sans-serif, system-ui, sans-serif`;
ctx.textAlign = "right";
ctx.textBaseline = "middle";
}
if (hasVisibleLaneProgress) {
let laneY = barY;
const elapsedPulse = (Math.sin(elapsed * 6) + 1) / 2;
for (const [laneIndex, laneProgress] of visibleLaneProgress.entries()) {
const laneRatio = laneProgress.setupOnly
? 1
: Math.max(0, Math.min(1, laneProgress.value / laneProgress.max));
const laneWidth = Math.max(0, progressBarWidth * laneRatio);
ctx.fillStyle = "rgba(15, 23, 42, 0.66)";
ctx.fillRect(progressBarX, laneY, progressBarWidth, laneHeight);
const laneColor = laneColors[laneIndex % laneColors.length];
if (laneProgress.setupOnly) {
ctx.fillStyle = laneColor.replace("0.94)", `${0.28 + elapsedPulse * 0.22})`);
} else {
ctx.fillStyle = laneColor;
}
ctx.fillRect(progressBarX, laneY, laneWidth, laneHeight);
drawProgressProviderIcon(
ctx,
laneProgress.provider || state?.executionProvider,
-borderWidth,
laneY + laneHeight / 2,
progressIconColumnWidth,
laneHeight,
scale,
);
if (!laneProgress.setupOnly) {
drawIterationRateOverlay(
ctx,
laneProgress.iterationRate,
node.size[0] + borderWidth,
laneY + laneHeight / 2,
laneHeight,
scale,
laneProgress.unit,
);
}
laneY += laneHeight + laneGap;
}
}
if (hasAggregateProgress) {
const progressRatio = Math.max(0, Math.min(1, state.progress.value / state.progress.max));
const progressWidth = Math.max(0, progressBarWidth * progressRatio);
const aggregateY = hasVisibleLaneProgress ? barY + laneBlockHeight + laneGap : barY;
ctx.fillStyle = "rgba(15, 23, 42, 0.72)";
ctx.fillRect(progressBarX, aggregateY, progressBarWidth, aggregateHeight);
ctx.fillStyle = "rgba(216, 180, 254, 0.92)";
if (state.progress.indeterminate) {
const pulseWidth = Math.max(progressBarWidth * 0.18, 12 / scale);
const travelWidth = Math.max(0, progressBarWidth - pulseWidth);
const pulseX = progressBarX + ((elapsed * 0.65) % 1) * travelWidth;
ctx.fillRect(pulseX, aggregateY, pulseWidth, aggregateHeight);
} else {
ctx.fillRect(progressBarX, aggregateY, progressWidth, aggregateHeight);
}
drawProgressProviderIcon(
ctx,
aggregateProvider,
-borderWidth,
aggregateY + aggregateHeight / 2,
progressIconColumnWidth,
aggregateHeight,
scale,
);
drawIterationRateOverlay(
ctx,
state.progress.iterationRate,
node.size[0] + borderWidth,
aggregateY + aggregateHeight / 2,
aggregateHeight,
scale,
state.progress.unit,
);
}
if (hasExecutionLocation) {
const locationY =
panelY +
panelPaddingY +
headerHeight +
progressTopGap +
progressBodyHeight +
locationTopGap;
drawRemoteExecutionLocation(
ctx,
executionLocation,
locationY,
locationRowHeight,
node.size[0],
panelPaddingX,
scale,
);
}
ctx.restore();
}
/**
* Install renderer and lifecycle hooks needed by Modal decorations on any graph node.
* @param {LGraphNode} node
*/
function installNodeDecorationHooks(node) {
if (node.__modalDecorationInjected) {
return;
}
const originalDrawForeground = node.onDrawForeground;
node.onDrawForeground = function onDrawForeground(ctx) {
originalDrawForeground?.apply(this, arguments);
drawModalNodeDecoration(this, ctx);
};
const originalOnMouseMove = node.onMouseMove;
node.onMouseMove = function onMouseMove(event, localPosition, graphCanvas) {
const result = originalOnMouseMove?.apply(this, arguments);
const scale = graphCanvas?.ds?.scale ?? app.canvas?.ds?.scale ?? 1;
const state = getRemoteVisualState(this);
const badgeHovered = localBottleneckBadgeContainsPoint(this, localPosition, scale);
const tooltip = !badgeHovered
? ""
: isSandwichedLocalNode(this)
? LOCAL_BOTTLENECK_TOOLTIP
: (state?.isRemoteContainer ? remoteContainerTooltip(state) : "");
updateLegacyModalTooltip(graphCanvas, tooltip);
return result;
};
const originalOnMouseLeave = node.onMouseLeave;
node.onMouseLeave = function onMouseLeave(event, graphCanvas) {
const result = originalOnMouseLeave?.apply(this, arguments);
updateLegacyModalTooltip(graphCanvas, "");
return result;
};
const originalOnRemoved = node.onRemoved;
node.onRemoved = function onRemoved() {
const result = originalOnRemoved?.apply(this, arguments);
scheduleRemoteDescendantIndexRebuild();
return result;
};
node.__modalDecorationInjected = true;
}
/**
* Inject the Modal decoration hooks and, where eligible, the remote toggle widget.
* @param {LGraphNode} node
*/
function decorateNode(node) {
installNodeDecorationHooks(node);
if (!isEligibleNode(node) || node.__modalToggleInjected) {
scheduleRemoteDescendantIndexRebuild();
return;
}
node.properties ||= {};
node.properties[REMOTE_PROPERTY] = Boolean(node.properties[REMOTE_PROPERTY]);
const widget = node.addWidget(
"toggle",
REMOTE_WIDGET_NAME,
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;
scheduleRemoteDescendantIndexRebuild();
queueVueNodeDecorationSync();
}
/**
* 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;
}
if (isPromptCancelling(promptId) && ![STATE_ERROR, "execution_interrupted"].includes(detail.phase)) {
return;
}
if (isPromptTerminal(promptId) && detail.phase !== STATE_SETUP) {
return;
}
const nodeIds = (detail.node_ids ?? []).map((value) => String(value));
const components = detail.components ?? [];
if (nodeIds.length === 0 && components.length === 0) {
return;
}
if (components.length > 0 || nodeIds.length > 0) {
registerPromptComponents(promptId, nodeIds, components);
}
const promptState = ensurePromptState(promptId);
if (!promptState) {
return;
}
if (detail.configurator_node_id) {
registerPromptConfigurator(promptId, detail.configurator_node_id);
}
if (detail.remote_execution_assignments) {
registerRemoteConfiguratorPlan(
promptId,
detail.remote_execution_assignments,
detail.remote_execution_configurations ?? [],
);
}
updateRemoteConfiguratorEnvironmentStatus(promptId, detail);
const executionProvider = String(detail.execution_provider ?? "").trim();
const modalGpu =
(!executionProvider || executionProvider === "modal") &&
MODAL_GPU_TYPES.includes(detail.modal_gpu)
? detail.modal_gpu
: null;
if (modalGpu) {
promptState.modalGpu = modalGpu;
}
if (detail.phase === STATE_SETUP) {
if (isPromptQueuedBehindActiveModal(promptId)) {
return;
}
beginSyntheticExecutionUi(promptId, nodeIds, modalGpu);
setGlobalStatusPhase(promptId, STATE_SETUP, nodeIds.length, {
message: detail.status_message ?? null,
current: detail.status_current ?? null,
total: detail.status_total ?? null,
modalGpu,
});
setPromptActiveNode(promptId, null);
setNodesPhase(nodeIds, STATE_SETUP, promptId);
return;
}
if (["llm_staging", "llm_staged"].includes(detail.phase)) {
if (isPromptQueuedBehindActiveModal(promptId)) {
return;
}
setGlobalStatusPhase(promptId, STATE_STARTING, nodeIds.length, {
message: detail.status_message ?? "Staging LLM on CPU",
current: detail.status_current ?? null,
total: detail.status_total ?? null,
modalGpu,
});
setPromptActiveNode(promptId, null);
setNodesPhase(nodeIds, STATE_STARTING, promptId);
return;
}
if (detail.phase === STATE_WAITING) {
if (isPromptQueuedBehindActiveModal(promptId)) {
return;
}
setGlobalStatusPhase(promptId, STATE_WAITING, nodeIds.length, {
message: detail.status_message ?? null,
current: detail.status_current ?? null,
total: detail.status_total ?? null,
modalGpu,
});
setPromptActiveNode(promptId, null);
return;
}
if (detail.phase === STATE_STARTING) {
if (isPromptQueuedBehindActiveModal(promptId)) {
return;
}
setGlobalStatusPhase(promptId, STATE_STARTING, nodeIds.length, {
message:
detail.status_message ??
(executionProvider === "vast"
? "Starting Vast.ai component"
: executionProvider === "ssh_docker"
? "Starting self-hosted component"
: executionProvider === "modal"
? "Starting Modal component"
: "Starting remote component"),
current: detail.status_current ?? null,
total: detail.status_total ?? null,
modalGpu,
});
setPromptActiveNode(promptId, null);
setNodesPhase(nodeIds, STATE_STARTING, promptId);
return;
}
if (detail.phase === STATE_FINALIZING) {
clearPromptQueued(promptId);
setGlobalStatusPhase(promptId, STATE_FINALIZING, nodeIds.length, {
message: detail.status_message ?? null,
current: detail.status_current ?? null,
total: detail.status_total ?? null,
modalGpu,
});
setPromptActiveNode(promptId, null);
setNodesPhase(nodeIds, STATE_FINALIZING, promptId);
return;
}
if (detail.phase === STATE_ERROR) {
const errorMessage = String(
detail.error_message ?? detail.status_message ?? "Remote workflow failed",
);
clearPromptQueued(promptId);
endSyntheticExecutionUi(promptId, true, errorMessage);
setGlobalStatusPhase(promptId, STATE_ERROR, nodeIds.length, {
message: errorMessage,
modalGpu,
});
setRemoteConfiguratorTerminalStatus(promptId, STATE_ERROR, errorMessage);
setTimeout(() => clearGlobalStatusPhase(promptId), ERROR_CLEAR_DELAY_MS);
clearPromptRemoteNodeVisuals(promptId);
markPromptTerminal(promptId, STATE_ERROR);
return;
}
if (detail.phase === "execution_interrupted") {
markPromptTerminal(promptId, "execution_interrupted");
clearPromptQueued(promptId);
endSyntheticExecutionUi(promptId);
handlePromptInterruption(promptId);
return;
}
if (detail.phase === EXECUTION_PHASE) {
clearPromptQueued(promptId);
endSyntheticExecutionUi(promptId);
const nextActiveNodeId =
detail.active_node_id != null ? String(detail.active_node_id) : null;
const previousActiveNodeId = promptState.activeNodeId;
promptState.hasStreamedProgress = true;
if (nextActiveNodeId) {
promptState.hasRemoteExecutionStarted = true;
setGlobalStatusPhase(promptId, EXECUTION_PHASE, nodeIds.length, { modalGpu });
} else {
setGlobalStatusPhase(promptId, STATE_WAITING, nodeIds.length, {
message:
detail.status_message ??
(executionProvider === "vast"
? "Waiting for Vast.ai capacity"
: executionProvider === "ssh_docker"
? "Waiting for self-hosted capacity"
: executionProvider === "modal"
? "Waiting for Modal container"
: "Waiting for remote capacity"),
modalGpu,
});
}
setNodesPhase(nodeIds, STATE_READY, promptId);
if (
previousActiveNodeId &&
previousActiveNodeId !== nextActiveNodeId &&
nodesShareRemoteComponent(promptId, previousActiveNodeId, nextActiveNodeId)
) {
fadeNodeProgress(previousActiveNodeId, promptId);
setNodesPhase([previousActiveNodeId], STATE_COMPLETE, promptId);
}
completeRemoteAncestorsBeforeActiveNode(
promptId,
detail.completed_ancestor_node_ids,
nextActiveNodeId,
);
if (nextActiveNodeId) {
updateNodeExecutionLocation(promptId, nextActiveNodeId, detail);
setNodesPhase([nextActiveNodeId], STATE_ACTIVE, promptId);
}
setPromptActiveNode(promptId, nextActiveNodeId);
return;
}
if (detail.phase === "execution_success") {
clearPromptQueued(promptId);
promptState.hasStreamedProgress = true;
promptState.hasRemoteExecutionStarted = true;
if (promptState.activeNodeId && nodeIds.includes(promptState.activeNodeId)) {
fadeNodeProgress(promptState.activeNodeId, promptId);
setNodesPhase([promptState.activeNodeId], STATE_COMPLETE, promptId);
setPromptActiveNode(promptId, null);
}
setNodesPhase(nodeIds, STATE_COMPLETE, promptId);
return;
}
}
/**
* Clear one interrupted prompt's temporary Modal UI state.
* @param {string} promptId
*/
function handlePromptInterruption(promptId) {
if (!promptId) {
return;
}
modalCancellingPromptIds.delete(promptId);
markPromptTerminal(promptId, "execution_interrupted");
clearGlobalStatusPhase(promptId);
clearPromptRemoteStates(promptId);
}
/**
* 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;
}
if (isPromptCancelling(promptId)) {
return;
}
if (isPromptTerminal(promptId)) {
return;
}
clearPromptQueued(promptId);
endSyntheticExecutionUi(promptId);
const promptState = ensurePromptState(promptId);
if (!promptState) {
return;
}
if (!detail.cached_hit && !detail.pre_gpu) {
promptState.hasRemoteExecutionStarted = true;
}
updateNodeExecutionLocation(promptId, progressNodeId, detail);
updateRemoteConfiguratorEnvironmentStatus(promptId, {
...detail,
phase: detail.pre_gpu ? STATE_STARTING : EXECUTION_PHASE,
status_message: detail.message ?? detail.stage ?? "Remote component is running",
status_current: detail.value,
status_total: detail.max,
});
const componentNodeIds = resolveComponentNodeIds(promptId, progressNodeId);
const readyNodeIds = (componentNodeIds ?? []).filter((nodeIdValue) => nodeIdValue !== progressNodeId);
promptState.hasStreamedProgress = true;
if (detail.pre_gpu) {
setGlobalStatusPhase(promptId, STATE_STARTING, promptState.remoteNodeIds.length || 1, {
message: detail.message ? String(detail.message) : "Staging LLM on CPU",
});
setPromptActiveNode(promptId, null);
setNodesPhase(componentNodeIds ?? [progressNodeId], STATE_STARTING, promptId);
setNodeProgress(
progressNodeId,
promptId,
Number(detail.value ?? 0),
Number(detail.max ?? 1),
detail,
);
return;
}
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;
}
if (detail.cached_hit) {
const cachedNodeIds = new Set(
[detail.node_id, detail.display_node_id, detail.real_node_id]
.filter((nodeIdValue) => nodeIdValue != null)
.map((nodeIdValue) => String(nodeIdValue)),
);
if (cachedNodeIds.size === 0) {
cachedNodeIds.add(progressNodeId);
}
for (const cachedNodeId of cachedNodeIds) {
markNodeCached(cachedNodeId, promptId);
}
setGlobalStatusPhase(promptId, STATE_WAITING, promptState.remoteNodeIds.length || 1, {
message: "Restoring Modal cache",
});
return;
}
setGlobalStatusPhase(promptId, EXECUTION_PHASE, promptState.remoteNodeIds.length || 1, {
message: detail.message ? String(detail.message) : undefined,
});
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,
Boolean(detail.setup_only),
detail,
);
return;
}
if (detail.clear) {
clearNodeProgress(progressNodeId, promptId);
return;
}
if (readyNodeIds.length > 0) {
setNodesPhase(readyNodeIds, STATE_READY, promptId);
}
const previousActiveNodeId = promptState.activeNodeId;
if (
previousActiveNodeId &&
previousActiveNodeId !== progressNodeId &&
nodesShareRemoteComponent(promptId, previousActiveNodeId, progressNodeId)
) {
fadeNodeProgress(previousActiveNodeId, promptId);
setNodesPhase([previousActiveNodeId], STATE_COMPLETE, promptId);
}
completeRemoteAncestorsBeforeActiveNode(
promptId,
detail.completed_ancestor_node_ids,
progressNodeId,
);
setPromptActiveNode(promptId, progressNodeId);
setNodesPhase([progressNodeId], STATE_ACTIVE, promptId);
setNodeProgress(
progressNodeId,
promptId,
Number(detail.value ?? 0),
Number(detail.max ?? 1),
detail,
);
}
/**
* 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;
}
if (isPromptCancelling(promptId) && phase !== STATE_ERROR) {
return;
}
if (isPromptTerminal(promptId)) {
return;
}
clearPromptQueued(promptId);
const componentNodeIds = resolveComponentNodeIds(promptId, representativeNodeId);
if (!componentNodeIds) {
return;
}
const promptState = ensurePromptState(promptId);
if (promptState.hasStreamedProgress && phase === EXECUTION_PHASE) {
return;
}
if (phase === EXECUTION_PHASE) {
const plannedProviders = Array.from(
promptState.executionAssignmentsByRepresentative.values(),
(assignment) => assignment?.provider,
).filter(Boolean);
setGlobalStatusPhase(
promptId,
promptState.hasRemoteExecutionStarted ? EXECUTION_PHASE : STATE_WAITING,
componentNodeIds.length,
promptState.hasRemoteExecutionStarted
? null
: { message: remoteCapacityWaitingMessage(plannedProviders) },
);
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);
clearPromptRemoteNodeVisuals(promptId);
return;
}
if (phase === STATE_COMPLETE) {
setPromptActiveNode(promptId, null);
for (const nodeIdValue of componentNodeIds) {
fadeNodeProgress(nodeIdValue, promptId);
}
setNodesPhase(componentNodeIds, STATE_COMPLETE, promptId, detail.exception_message);
reconcilePromptGlobalStatus(promptId);
}
}
/**
* Clear all temporary remote execution visuals for a completed prompt.
* @param {string} promptId
*/
function clearPromptRemoteStates(promptId) {
const promptState = modalPromptStates.get(promptId);
if (!promptState) {
clearPromptProgressStates(promptId);
for (const [nodeIdValue, state] of Array.from(modalNodeStates.entries())) {
if (state?.promptId === promptId) {
clearNodeTimer(nodeIdValue);
modalNodeStates.delete(nodeIdValue);
}
}
pruneGlobalStatusStates();
refreshGlobalStatusElement();
stopAnimationLoopIfIdle();
refreshNodeDecorations();
return;
}
clearPromptProgressStates(promptId);
for (const remoteNodeId of promptState.remoteNodeIds) {
clearNodeTimer(remoteNodeId);
clearNodeProgress(remoteNodeId, promptId);
clearNodeCached(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);
clearNodeCached(ancestorNodeId, promptId);
const currentState = modalNodeStates.get(ancestorNodeId);
if (currentState?.promptId === promptId) {
modalNodeStates.delete(ancestorNodeId);
}
}
modalPromptStates.delete(promptId);
clearPromptQueued(promptId);
pruneGlobalStatusStates();
refreshGlobalStatusElement();
stopAnimationLoopIfIdle();
refreshNodeDecorations();
}
/**
* Clear prompt-scoped remote node visuals while leaving the global status entry intact.
* @param {string} promptId
*/
function clearPromptRemoteNodeVisuals(promptId) {
const promptState = modalPromptStates.get(promptId);
const visualNodeIds = new Set();
if (promptState) {
for (const remoteNodeId of promptState.remoteNodeIds) {
visualNodeIds.add(String(remoteNodeId));
}
for (const ancestorNodeId of promptState.descendantNodeIdsByAncestor.keys()) {
visualNodeIds.add(String(ancestorNodeId));
}
}
for (const [nodeIdValue, state] of modalNodeStates.entries()) {
if (state?.promptId === promptId) {
visualNodeIds.add(String(nodeIdValue));
}
}
for (const nodeIdValue of visualNodeIds) {
clearNodeTimer(nodeIdValue);
clearNodeProgress(nodeIdValue, promptId);
clearNodeCached(nodeIdValue, promptId);
const currentState = modalNodeStates.get(nodeIdValue);
if (currentState?.promptId === promptId) {
modalNodeStates.delete(nodeIdValue);
}
}
setPromptActiveNode(promptId, null);
stopAnimationLoopIfIdle();
refreshNodeDecorations();
}
/**
* 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, queueErrorMessage(error));
}
/**
* Preserve an actionable server queue failure when PromptExecutionError is generic.
* @param {Error | any} error
* @returns {string}
*/
function queueErrorMessage(error) {
const responsePayload = error?.modalQueueResponse;
const serverError = responsePayload?.error;
if (typeof serverError === "string" && serverError.trim()) {
return serverError.trim();
}
if (typeof serverError?.message === "string" && serverError.message.trim()) {
return serverError.message.trim();
}
return String(error?.message ?? error);
}
/**
* Preserve remote setup failure state after ComfyUI has accepted the local job record.
* @param {string[]} remoteNodeIds
* @param {string} promptId
* @param {Error | any} error
*/
function reportBackgroundQueueFailure(remoteNodeIds, promptId, error) {
if (error?.modalQueueResponse?.cancelled === true) {
clearPromptQueued(promptId);
endSyntheticExecutionUi(promptId);
handlePromptInterruption(promptId);
console.info(`Remote queue preparation cancelled for prompt ${promptId}.`);
return;
}
const message = queueErrorMessage(error);
clearPromptQueued(promptId);
endSyntheticExecutionUi(promptId, true, message);
markQueueFailure(remoteNodeIds, promptId, error);
setRemoteConfiguratorTerminalStatus(promptId, STATE_ERROR, message);
console.error(`Remote queue submission failed for prompt ${promptId}.`, 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 queue state while the Modal route is still preparing.
*
* Preparation is not node execution. Native `execution_start` and `executing`
* events start ComfyUI's execution watchdog and can make a long asset download
* look like a stalled or failed node. The backend queue bridge exposes the
* prompt through `/queue`; the status event asks ComfyUI to refresh that view.
* @param {string} promptId
* @param {string[]} remoteNodeIds
* @param {string | null} modalGpu
*/
function beginSyntheticExecutionUi(promptId, remoteNodeIds, modalGpu = null) {
if (remoteNodeIds.length === 0 || syntheticPromptUiStates.has(promptId)) {
return;
}
clearPromptTerminal(promptId);
syntheticPromptUiStates.set(promptId, {});
setGlobalStatusPhase(promptId, STATE_SETUP, remoteNodeIds.length, { modalGpu });
dispatchSyntheticApiEvent("status", statusPayload(1));
dispatchSyntheticApiEvent("notification", {
id: promptId,
value: "Waiting for remote capacity.",
});
}
/**
* End a synthetic running state after real queue/execution events take over or the request fails.
* @param {string} promptId
* @param {boolean} failed
* @param {string | null} failureMessage
*/
function endSyntheticExecutionUi(promptId, failed = false, failureMessage = null) {
if (!syntheticPromptUiStates.has(promptId)) {
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:
failed && failureMessage
? failureMessage
: failed
? "Remote setup failed."
: "Remote setup finished.",
});
dispatchSyntheticApiEvent("status", statusPayload(0));
}
/**
* 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) => {
const detail = eventDetail(event);
const promptId = String(detail.prompt_id ?? "");
setRemoteConfiguratorTerminalStatus(
promptId,
STATE_ERROR,
detail.exception_message ?? "Remote workflow failed",
);
endSyntheticExecutionUi(promptId, true);
handleExecutionPhase(event, STATE_ERROR);
markPromptTerminal(promptId, STATE_ERROR);
});
api.addEventListener("execution_interrupted", (event) => {
const promptId = String(eventDetail(event).prompt_id ?? "");
setRemoteConfiguratorTerminalStatus(
promptId,
STATE_ERROR,
"Remote workflow interrupted",
);
markPromptTerminal(promptId, "execution_interrupted");
endSyntheticExecutionUi(promptId);
handlePromptInterruption(promptId);
});
api.addEventListener("execution_success", (event) => {
const detail = eventDetail(event);
const promptId = String(detail.prompt_id ?? "");
if (!promptId) {
return;
}
setRemoteConfiguratorTerminalStatus(promptId, STATE_COMPLETE);
markPromptTerminal(promptId, "execution_success");
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;
stampModalGpuOnWorkflow(workflow);
const modalGpu = selectedModalGpu(workflow);
const configuratorNodeId = serializedRemoteConfiguratorNodeId(prompt);
const usesConfiguredCapacity = Boolean(configuratorNodeId);
const initialModalGpu = usesConfiguredCapacity ? null : modalGpu;
const promptId = createPromptId();
clearPromptTerminal(promptId);
clearSupersededCancellingPrompts(promptId);
const remoteNodeIds = effectiveRemoteNodeIds(prompt, workflow);
registerPromptComponents(promptId, remoteNodeIds, []);
if (configuratorNodeId) {
registerPromptConfigurator(promptId, configuratorNodeId);
}
const queuedBehindActiveModal =
remoteNodeIds.length > 0 && markPromptQueuedBehindActiveModal(promptId);
if (remoteNodeIds.length > 0) {
if (!queuedBehindActiveModal) {
setNodesPhase(remoteNodeIds, STATE_SETUP, promptId);
beginSyntheticExecutionUi(promptId, remoteNodeIds, initialModalGpu);
}
}
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;
}
const queueSubmission = (async () => {
const response = await this.fetchApi(MODAL_ROUTE, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (response.status !== 200) {
const responsePayload = await response.json();
const promptError = new PromptExecutionError(responsePayload);
promptError.modalQueueResponse = responsePayload;
throw promptError;
}
const responsePayload = await response.json();
const acceptedConfiguratorNodeId = String(
responsePayload.remote_execution_configurator_node_id ?? configuratorNodeId ?? "",
);
if (acceptedConfiguratorNodeId) {
registerPromptConfigurator(promptId, acceptedConfiguratorNodeId);
}
const sandwichedLocalNodeIds = Array.isArray(
responsePayload.modal_sandwiched_local_node_ids,
)
? responsePayload.modal_sandwiched_local_node_ids
: [];
setSandwichedLocalNodeIds(sandwichedLocalNodeIds);
if (remoteNodeIds.length > 0) {
const executionAssignmentsByRepresentative =
responsePayload.remote_execution_assignments ?? {};
const executionAssignments = Object.values(executionAssignmentsByRepresentative);
const selectedModalGpus = Array.isArray(
responsePayload.remote_execution_modal_gpus,
)
? responsePayload.remote_execution_modal_gpus.filter((value) =>
MODAL_GPU_TYPES.includes(value),
)
: [];
const configurationsById = new Map(
(responsePayload.remote_execution_configurations ?? []).map(
(configuration) => [String(configuration?.configuration_id ?? ""), configuration],
),
);
const usesModalExecution = executionAssignments.some(
(assignment) => assignment?.provider === "modal",
);
const acceptedModalGpu = usesModalExecution
? selectedModalGpus.length === 1
? selectedModalGpus[0]
: MODAL_GPU_TYPES.includes(responsePayload.modal_gpu)
? responsePayload.modal_gpu
: usesConfiguredCapacity
? null
: modalGpu
: null;
const selectedProviders = new Set(
executionAssignments
.map((assignment) => String(assignment?.provider ?? ""))
.filter(Boolean),
);
const executionEnvironmentLabels = Array.from(
new Set(
executionAssignments.map((assignment) =>
remoteExecutionEnvironmentLabel(
assignment,
configurationsById,
acceptedModalGpu,
),
),
),
);
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);
}
registerRemoteConfiguratorPlan(
promptId,
executionAssignmentsByRepresentative,
responsePayload.remote_execution_configurations ?? [],
);
const promptState = ensurePromptState(promptId);
if (!promptState) {
return responsePayload;
}
promptState.modalGpu = acceptedModalGpu;
promptState.executionLabel = executionEnvironmentLabels.join(" + ") || null;
const acceptedRemoteNodeIds =
promptState.remoteNodeIds.length > 0 ? promptState.remoteNodeIds : remoteNodeIds;
if (!isPromptQueuedBehindActiveModal(promptId)) {
endSyntheticExecutionUi(promptId);
setGlobalStatusPhase(promptId, STATE_WAITING, acceptedRemoteNodeIds.length, {
message: remoteCapacityWaitingMessage(selectedProviders),
modalGpu: usesModalExecution ? acceptedModalGpu : null,
});
setNodesPhase(acceptedRemoteNodeIds, STATE_READY, promptId);
}
}
return responsePayload;
})().catch((error) => {
reportBackgroundQueueFailure(remoteNodeIds, promptId, error);
throw error;
});
if (remoteNodeIds.length === 0) {
return queueSubmission;
}
void queueSubmission.catch(() => {});
return {
prompt_id: promptId,
number,
node_errors: {},
};
};
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; }
}
#comfy-modal-global-status .modal-status-copy {
display: flex;
min-width: 0;
flex-direction: column;
align-items: flex-start;
line-height: 1.15;
}
#comfy-modal-global-status .modal-status-gpu {
margin-top: 3px;
color: rgba(226, 232, 240, 0.76);
font-size: 10px;
font-weight: 500;
letter-spacing: 0.04em;
line-height: 1;
}
#comfy-modal-global-status .modal-status-gpu[hidden] {
display: none;
}
#comfy-modal-global-status .modal-status-cost {
margin-top: 4px;
color: rgba(191, 219, 254, 0.9);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.01em;
line-height: 1.1;
white-space: nowrap;
}
#comfy-modal-global-status .modal-status-cost[hidden] {
display: none;
}
#comfy-modal-global-status .modal-status-billing {
margin-top: 3px;
color: rgba(196, 181, 253, 0.92);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.01em;
line-height: 1.1;
white-space: nowrap;
}
#comfy-modal-global-status .modal-status-billing[hidden] {
display: none;
}
#comfy-modal-global-status .modal-status-containers {
display: flex;
min-width: 210px;
margin-top: 7px;
padding-top: 6px;
flex-direction: column;
gap: 4px;
border-top: 1px solid rgba(148, 163, 184, 0.18);
}
#comfy-modal-global-status .modal-status-containers[hidden] {
display: none;
}
#comfy-modal-global-status .modal-status-container {
color: rgba(226, 232, 240, 0.82);
font-size: 10px;
font-weight: 500;
letter-spacing: 0.01em;
line-height: 1.2;
white-space: nowrap;
}
#comfy-modal-global-status .modal-status-container-running {
color: rgba(187, 247, 208, 0.92);
}
#comfy-modal-global-status .modal-status-container-starting {
color: rgba(254, 240, 138, 0.92);
}
#comfy-modal-global-status .modal-status-container-error {
color: rgba(254, 202, 202, 0.88);
}
.comfy-modal-vue-node-decoration {
position: absolute;
inset: -3px;
z-index: 30;
box-sizing: border-box;
border: 3px solid transparent;
pointer-events: none;
}
.comfy-modal-vue-node-badge {
position: absolute;
top: 7px;
left: 7px;
display: grid;
width: 20px;
height: 20px;
place-items: center;
box-sizing: border-box;
border: 1.5px solid transparent;
border-radius: 9999px;
background: rgba(15, 23, 42, 0.92);
color: #f8fafc;
font: 10px/1 ui-sans-serif, system-ui, sans-serif;
}
.comfy-modal-vue-node[data-modal-phase="local-bottleneck"] .comfy-modal-vue-node-badge,
.comfy-modal-vue-node[data-modal-container="true"] .comfy-modal-vue-node-badge {
pointer-events: auto;
cursor: help;
}
.comfy-modal-vue-node-badge[hidden] {
display: none;
}
.comfy-modal-vue-execution-location {
position: absolute;
top: calc(100% + 6px);
left: 6px;
right: 6px;
display: flex;
min-width: 0;
height: 24px;
align-items: center;
gap: 6px;
box-sizing: border-box;
overflow: hidden;
padding: 5px 7px;
border: 1px solid rgba(148, 163, 184, 0.34);
border-radius: 8px;
background: rgba(15, 23, 42, 0.94);
box-shadow: 0 3px 10px rgba(2, 6, 23, 0.42);
color: #cbd5e1;
font: 500 10px/1 ui-sans-serif, system-ui, sans-serif;
letter-spacing: 0.01em;
white-space: nowrap;
}
.comfy-modal-vue-execution-location[hidden] {
display: none;
}
.comfy-modal-vue-execution-location-icon {
width: 12px;
height: 12px;
flex: 0 0 auto;
object-fit: contain;
}
.comfy-modal-vue-execution-location[data-provider="modal"]
.comfy-modal-vue-execution-location-icon {
width: 22px;
}
.comfy-modal-vue-execution-location-icon[hidden] {
display: none;
}
.comfy-modal-vue-execution-location-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.comfy-remote-configurator-panel {
display: flex;
width: 100%;
max-width: 100%;
min-width: 0;
align-self: stretch;
flex-direction: column;
gap: 8px;
box-sizing: border-box;
padding: 9px 10px 11px;
border: 1px solid rgba(148, 163, 184, 0.28);
border-radius: 9px;
background: rgba(15, 23, 42, 0.9);
color: #e2e8f0;
font: 11px/1.3 ui-sans-serif, system-ui, sans-serif;
pointer-events: none;
}
.remote-configurator-status {
display: flex;
min-width: 0;
align-items: center;
gap: 7px;
font-weight: 650;
}
.remote-configurator-dot {
width: 8px;
height: 8px;
flex: 0 0 auto;
border-radius: 999px;
background: #1d9bf0;
box-shadow: 0 0 0 4px rgba(29, 155, 240, 0.14);
}
.comfy-remote-configurator-panel[data-phase="setup"] .remote-configurator-dot,
.comfy-remote-configurator-panel[data-phase="waiting"] .remote-configurator-dot {
background: #f59e0b;
box-shadow: 0 0 0 4px rgba(245, 158, 11, 0.16);
}
.comfy-remote-configurator-panel[data-phase="starting"] .remote-configurator-dot {
background: #eab308;
box-shadow: 0 0 0 4px rgba(234, 179, 8, 0.16);
}
.comfy-remote-configurator-panel[data-phase="executing"] .remote-configurator-dot {
background: #a855f7;
box-shadow: 0 0 0 4px rgba(168, 85, 247, 0.16);
}
.comfy-remote-configurator-panel[data-phase="complete"] .remote-configurator-dot {
background: #22c55e;
box-shadow: 0 0 0 4px rgba(34, 197, 94, 0.16);
}
.comfy-remote-configurator-panel[data-phase="error"] .remote-configurator-dot,
.comfy-remote-configurator-panel[data-phase="cancelling"] .remote-configurator-dot {
background: #ef4444;
box-shadow: 0 0 0 4px rgba(239, 68, 68, 0.16);
}
.remote-configurator-status-text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.remote-configurator-progress {
position: relative;
height: 12px;
overflow: hidden;
border-radius: 6px;
background: rgba(2, 6, 23, 0.78);
}
.remote-configurator-progress-fill {
position: absolute;
inset: 0 auto 0 0;
width: 0;
background: linear-gradient(90deg, #f59e0b, #eab308);
transition: width 160ms ease-out;
}
.remote-configurator-progress-value {
position: absolute;
inset: 0 5px 0 auto;
color: #f8fafc;
font: 650 9px/12px ui-monospace, SFMono-Regular, monospace;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
}
.remote-configurator-environments {
display: flex;
flex-direction: column;
gap: 7px;
}
.remote-configurator-environment {
display: flex;
min-width: 0;
flex-direction: column;
gap: 4px;
padding: 6px 7px;
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 7px;
background: rgba(2, 6, 23, 0.42);
}
.remote-configurator-environment-heading {
display: flex;
min-width: 0;
align-items: center;
gap: 6px;
}
.remote-configurator-environment-dot {
width: 6px;
height: 6px;
flex: 0 0 auto;
border-radius: 999px;
background: #64748b;
}
.remote-configurator-environment[data-phase="setup"] .remote-configurator-environment-dot,
.remote-configurator-environment[data-phase="waiting"] .remote-configurator-environment-dot {
background: #f59e0b;
}
.remote-configurator-environment[data-phase="starting"] .remote-configurator-environment-dot {
background: #eab308;
}
.remote-configurator-environment[data-phase="executing"] .remote-configurator-environment-dot {
background: #a855f7;
}
.remote-configurator-environment[data-phase="complete"] .remote-configurator-environment-dot {
background: #22c55e;
}
.remote-configurator-environment[data-phase="error"] .remote-configurator-environment-dot,
.remote-configurator-environment[data-phase="cancelling"] .remote-configurator-environment-dot {
background: #ef4444;
}
.remote-configurator-environment-label {
min-width: 0;
flex: 1 1 auto;
overflow: hidden;
color: #e2e8f0;
font-weight: 650;
text-overflow: ellipsis;
white-space: nowrap;
}
.remote-configurator-environment-status {
max-width: 55%;
overflow: hidden;
color: #94a3b8;
font-size: 9px;
text-overflow: ellipsis;
white-space: nowrap;
}
.remote-configurator-environment-progress {
position: relative;
height: 10px;
overflow: hidden;
border-radius: 5px;
background: rgba(15, 23, 42, 0.94);
}
.remote-configurator-environment-progress-fill {
position: absolute;
inset: 0 auto 0 0;
width: 0;
background: linear-gradient(90deg, #3b82f6, #a855f7);
transition: width 160ms ease-out;
}
.remote-configurator-environment-progress-value {
position: absolute;
inset: 0 4px 0 auto;
color: #f8fafc;
font: 650 8px/10px ui-monospace, SFMono-Regular, monospace;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
}
.remote-configurator-plan-title {
color: #94a3b8;
font-size: 9px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.remote-configurator-empty {
color: #94a3b8;
font-size: 10px;
}
.remote-configurator-targets {
display: flex;
min-width: 0;
flex-direction: column;
gap: 7px;
}
.remote-configurator-targets[hidden] {
display: none;
}
.remote-configurator-target {
display: flex;
min-width: 0;
flex-direction: column;
gap: 5px;
padding: 7px;
border: 1px solid rgba(96, 165, 250, 0.28);
border-radius: 8px;
background: rgba(15, 23, 42, 0.64);
}
.remote-configurator-target-heading {
display: flex;
min-width: 0;
align-items: center;
gap: 6px;
}
.remote-configurator-target-name {
min-width: 0;
overflow: hidden;
color: #e2e8f0;
font-size: 11px;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.remote-configurator-target-machine {
flex: 0 1 auto;
overflow: hidden;
padding: 2px 5px;
border: 1px solid rgba(96, 165, 250, 0.3);
border-radius: 999px;
color: #bfdbfe;
background: rgba(30, 64, 175, 0.2);
font: 650 9px/1.2 ui-monospace, SFMono-Regular, monospace;
text-overflow: ellipsis;
white-space: nowrap;
}
.remote-configurator-target-location {
overflow: hidden;
color: #94a3b8;
font-size: 9px;
text-overflow: ellipsis;
white-space: nowrap;
}
.remote-configurator-target-capacities {
display: flex;
min-width: 0;
flex-wrap: wrap;
gap: 4px;
}
.remote-configurator-target-capacities span {
padding: 2px 5px;
border-radius: 4px;
color: #cbd5e1;
background: rgba(51, 65, 85, 0.62);
font: 600 9px/1.25 ui-monospace, SFMono-Regular, monospace;
}
.remote-configurator-components {
display: flex;
min-width: 0;
flex-direction: column;
border-top: 1px solid rgba(148, 163, 184, 0.14);
}
.remote-configurator-component {
display: flex;
min-width: 0;
align-items: center;
gap: 8px;
padding: 5px 1px 1px;
}
.remote-configurator-component-identity {
display: flex;
min-width: 0;
flex: 1 1 auto;
align-items: baseline;
gap: 6px;
}
.remote-configurator-component-label {
flex: 0 0 auto;
color: #e2e8f0;
font-size: 10px;
font-weight: 650;
}
.remote-configurator-component-nodes {
min-width: 0;
overflow: hidden;
color: #94a3b8;
font: 500 9px/1.25 ui-monospace, SFMono-Regular, monospace;
text-overflow: ellipsis;
white-space: nowrap;
}
.remote-configurator-component-estimate {
flex: 0 0 auto;
color: #cbd5e1;
font: 600 9px/1.25 ui-monospace, SFMono-Regular, monospace;
white-space: nowrap;
}
.remote-configurator-capacity,
.remote-configurator-storage {
display: flex;
min-width: 0;
flex-direction: column;
gap: 6px;
padding-top: 2px;
}
.remote-configurator-capacity-title,
.remote-configurator-storage-title {
color: #94a3b8;
font-size: 9px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.remote-configurator-capacity-header,
.remote-configurator-capacity-refresh,
.remote-configurator-storage-header,
.remote-configurator-storage-refresh {
display: flex;
min-width: 0;
align-items: center;
gap: 7px;
}
.remote-configurator-capacity-header,
.remote-configurator-storage-header {
justify-content: space-between;
}
.remote-configurator-capacity-refresh-status,
.remote-configurator-storage-refresh-status {
overflow: hidden;
color: #64748b;
font-size: 9px;
text-overflow: ellipsis;
white-space: nowrap;
}
.remote-configurator-capacity-refresh-status[data-state="error"],
.remote-configurator-storage-refresh-status[data-state="error"] {
color: #f87171;
}
.remote-configurator-capacity-refresh-status[data-state="success"],
.remote-configurator-storage-refresh-status[data-state="success"] {
color: #4ade80;
}
.remote-configurator-capacity-reload,
.remote-configurator-storage-reload {
padding: 2px 7px;
border: 1px solid rgba(56, 189, 248, 0.28);
border-radius: 5px;
background: rgba(14, 116, 144, 0.12);
color: #7dd3fc;
cursor: pointer;
font: 600 9px/1.35 ui-sans-serif, system-ui, sans-serif;
pointer-events: auto;
touch-action: manipulation;
}
.remote-configurator-capacity-reload:hover:not(:disabled),
.remote-configurator-storage-reload:hover:not(:disabled) {
border-color: rgba(56, 189, 248, 0.55);
background: rgba(14, 116, 144, 0.24);
}
.remote-configurator-capacity-reload:active:not(:disabled),
.remote-configurator-storage-reload:active:not(:disabled) {
background: rgba(14, 116, 144, 0.42);
transform: translateY(1px);
}
.remote-configurator-capacity-reload:disabled,
.remote-configurator-storage-reload:disabled {
cursor: wait;
opacity: 0.65;
}
.remote-configurator-keychain {
display: flex;
min-width: 0;
align-items: center;
justify-content: flex-start;
gap: 8px;
padding-top: 2px;
}
.remote-configurator-keychain[hidden] {
display: none;
}
.remote-configurator-keychain-unlock {
padding: 4px 9px;
border: 1px solid rgba(251, 146, 60, 0.62);
border-radius: 5px;
background: rgba(154, 52, 18, 0.32);
color: #fdba74;
cursor: pointer;
font: 700 9px/1.35 ui-sans-serif, system-ui, sans-serif;
pointer-events: auto;
touch-action: manipulation;
}
.remote-configurator-keychain-unlock:hover:not(:disabled) {
border-color: #fb923c;
background: rgba(194, 65, 12, 0.48);
color: #ffedd5;
}
.remote-configurator-keychain-unlock:active:not(:disabled) {
background: rgba(234, 88, 12, 0.62);
transform: translateY(1px);
}
.remote-configurator-keychain-unlock:disabled {
cursor: wait;
opacity: 0.7;
}
.remote-configurator-keychain-status {
min-width: 0;
overflow: hidden;
color: #94a3b8;
font-size: 9px;
text-overflow: ellipsis;
white-space: nowrap;
}
.remote-configurator-keychain-status[data-state="error"] {
color: #f87171;
}
.remote-configurator-capacity-empty {
color: #64748b;
font-size: 9px;
}
.remote-configurator-capacity-list {
display: flex;
min-width: 0;
flex-direction: column;
gap: 5px;
}
.remote-configurator-capacity-card {
display: grid;
min-width: 0;
grid-template-columns: 20px minmax(100px, 0.8fr) minmax(140px, 1.2fr) max-content;
align-items: center;
gap: 7px;
padding: 6px 7px;
border: 1px solid rgba(148, 163, 184, 0.2);
border-radius: 7px;
background: rgba(15, 23, 42, 0.54);
}
.remote-configurator-capacity-icon {
width: 18px;
height: 18px;
object-fit: contain;
}
.remote-configurator-capacity-identity {
display: flex;
min-width: 0;
flex-direction: column;
gap: 1px;
}
.remote-configurator-capacity-name,
.remote-configurator-capacity-subtitle,
.remote-configurator-capacity-summary {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.remote-configurator-capacity-name {
color: #e2e8f0;
font-size: 10px;
font-weight: 650;
}
.remote-configurator-capacity-subtitle,
.remote-configurator-capacity-summary {
color: #94a3b8;
font: 8.5px/1.3 ui-monospace, SFMono-Regular, monospace;
}
.remote-configurator-capacity-kill {
padding: 3px 7px;
border: 1px solid rgba(248, 113, 113, 0.55);
border-radius: 5px;
background: rgba(127, 29, 29, 0.26);
color: #fca5a5;
cursor: pointer;
font: 750 9px/1.3 ui-monospace, SFMono-Regular, monospace;
pointer-events: auto;
touch-action: manipulation;
}
.remote-configurator-capacity-kill:hover:not(:disabled) {
border-color: #ef4444;
background: rgba(185, 28, 28, 0.42);
color: #fee2e2;
}
.remote-configurator-capacity-kill:active:not(:disabled) {
background: rgba(220, 38, 38, 0.6);
transform: translateY(1px);
}
.remote-configurator-capacity-kill:disabled {
cursor: wait;
opacity: 0.65;
}
.remote-configurator-storage[hidden] {
display: none;
}
.remote-configurator-storage-list {
display: flex;
min-width: 0;
flex-direction: column;
gap: 6px;
}
.remote-configurator-storage-card {
display: flex;
min-width: 0;
flex-direction: column;
gap: 6px;
padding: 7px 8px;
border: 1px solid rgba(56, 189, 248, 0.22);
border-radius: 7px;
background: rgba(14, 116, 144, 0.09);
}
.remote-configurator-storage-heading {
display: flex;
min-width: 0;
align-items: baseline;
gap: 7px;
}
.remote-configurator-storage-name {
min-width: 0;
flex: 1 1 auto;
overflow: hidden;
color: #e2e8f0;
font-weight: 650;
text-overflow: ellipsis;
white-space: nowrap;
}
.remote-configurator-storage-provider {
flex: 0 0 auto;
color: #38bdf8;
font-size: 9px;
}
.remote-configurator-storage-details {
display: grid;
min-width: 0;
grid-template-columns: max-content minmax(0, 1fr);
gap: 3px 9px;
margin: 0;
}
.remote-configurator-storage-details dt {
color: #64748b;
font-size: 9px;
}
.remote-configurator-storage-details dd {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
color: #cbd5e1;
font: 9px/1.35 ui-monospace, SFMono-Regular, monospace;
}
`;
document.head.appendChild(style);
}
app.registerExtension({
name: "Comfy.ModalSync.Toggle",
async init() {
installGlobalStatusStyles();
installVueNodeDecorationObserver();
patchInterruptFeedback();
patchQueuePrompt();
registerExecutionListeners();
},
async beforeRegisterNodeDef(nodeType, nodeData) {
installModalContextMenu(nodeType, nodeData);
},
async nodeCreated(node) {
decorateNode(node);
mountRemoteExecutionConfiguratorPanel(node);
},
async loadedGraphNode(node) {
synchronizeRemoteFlagFromWidget(node);
},
async afterConfigureGraph() {
for (const node of allWorkflowNodes()) {
synchronizeRemoteFlagFromWidget(node);
mountRemoteExecutionConfiguratorPanel(node);
}
rebuildRemoteDescendantIndex();
refreshNodeDecorations();
selectedModalGpu();
refreshModalUiAfterVisibilityChange();
},
});