Files
Ewald ALOEBOETOE d0ab7d594e Resolve remaining ComfyUI integration conflicts
- Move the read-only OpenAPI compat surface from /api/v2/assets to
  /mjr/am/compat/assets so it never squats ComfyUI core's /api namespace;
  the legacy alias is opt-in via MJR_AM_LEGACY_API_V2_ASSETS=1.
- Allow Python 3.14 (requires-python <3.15) and add 3.14 to CI and tox.
- Stop wrapping api.queuePrompt: progress tracking now resolves the
  running prompt from the core Jobs API (getJobDetail), then /queue and
  /history, driven by websocket events. Remove wrapHostQueuePrompt.
- Add opt-in Majoor.Integration.HideNativeAssetsTab setting that hides
  ComfyUI's native Assets sidebar tab through the official
  getSidebarTabs/unregisterSidebarTab API and restores it when disabled.
- Fix all 913 type errors in tsconfig.test.json and type-check tests in
  CI; drop a duplicated `stubs` key in asset_card_inner test; make
  addToastHistory's type argument optional for the object form.
- Update audit report, changelog, API reference and env var docs.
2026-11-03 21:16:34 +00:00

645 lines
23 KiB
TypeScript

import {
centerHostCanvasNodeById,
getRawHostApi,
getRawHostApp,
waitForRawHostApi,
} from "../../app/hostAdapter.js";
import { findGraphNodeById, getHostRootGraph } from "../../app/graphTraversal.js";
const PROGRESS_UPDATE_EVENT = "progress-update";
const GLOBAL_SERVICE_KEY = "__MJR_MFV_PROGRESS_SERVICE__";
function getGlobalHost() {
if (typeof globalThis !== "undefined") return globalThis;
if (typeof window !== "undefined") return window;
return {};
}
function makeCustomEvent(type: any, detail: any) {
if (typeof CustomEvent === "function") {
return new CustomEvent(type, { detail });
}
const event: any = typeof Event === "function" ? new Event(type) : { type };
event.detail = detail;
return event;
}
export class FloatingViewerPromptExecution {
[key: string]: any;
constructor(id: any, getApp = () => getRawHostApp()) {
this.id = String(id || "");
this.promptApi = null;
this.executedNodeIds = [];
this.totalNodes = 0;
this.currentlyExecuting = null;
this.errorDetails = null;
this._getApp = typeof getApp === "function" ? getApp : () => null;
}
setPrompt(prompt: any) {
const output = prompt && typeof prompt === "object" ? prompt.output : null;
this.promptApi = output && typeof output === "object" ? output : null;
this.totalNodes = this.promptApi ? Object.keys(this.promptApi).length : 0;
}
getApiNode(nodeId: any) {
return this.promptApi?.[String(nodeId)] || null;
}
getNodeLabel(nodeId: any) {
const apiNode = this.getApiNode(nodeId);
let label = apiNode?._meta?.title || apiNode?.class_type || "";
if (!label) {
const app = this._getApp?.();
const graphNode = findGraphNodeById(getHostRootGraph(app), nodeId);
label = graphNode?.title || graphNode?.type || "";
}
return String(label || "").trim();
}
markExecuted(nodeId: any) {
const safeNodeId = String(nodeId || "").trim();
if (!safeNodeId) return;
if (!this.executedNodeIds.includes(safeNodeId)) {
this.executedNodeIds.push(safeNodeId);
}
}
executing(nodeId: any, step: any, maxSteps: any) {
if (nodeId == null) {
this.currentlyExecuting = null;
return;
}
const nextNodeId = String(nodeId || "").trim();
if (!nextNodeId) return;
if (this.currentlyExecuting?.nodeId !== nextNodeId) {
if (this.currentlyExecuting != null) {
this.markExecuted(this.currentlyExecuting.nodeId);
}
this.currentlyExecuting = {
nodeId: nextNodeId,
nodeLabel: this.getNodeLabel(nextNodeId),
pass: 0,
};
const apiNode = this.getApiNode(nextNodeId);
if (!this.currentlyExecuting.nodeLabel) {
this.currentlyExecuting.nodeLabel = this.getNodeLabel(nextNodeId);
}
if (apiNode?.class_type === "UltimateSDUpscale") {
this.currentlyExecuting.pass -= 1;
this.currentlyExecuting.maxPasses = -1;
} else if (apiNode?.class_type === "IterativeImageUpscale") {
this.currentlyExecuting.maxPasses = Number(apiNode?.inputs?.steps) || -1;
}
}
if (step != null) {
const numericStep = Number(step);
const numericMax = Number(maxSteps);
if (!Number.isFinite(numericStep)) return;
if (
!this.currentlyExecuting.step ||
numericStep < Number(this.currentlyExecuting.step)
) {
this.currentlyExecuting.pass += 1;
}
this.currentlyExecuting.step = numericStep;
this.currentlyExecuting.maxSteps = Number.isFinite(numericMax) ? numericMax : null;
}
}
error(details: any) {
this.errorDetails = details || null;
}
}
export class FloatingViewerProgressService extends EventTarget {
[key: string]: any;
constructor({
getApi = (app: any) => getRawHostApi(app),
getApp = () => getRawHostApp(),
waitForApi = (options: any) => waitForRawHostApi(options),
} = {}) {
super();
this._getApi = getApi;
this._getApp = getApp;
this._waitForApi = waitForApi;
this.promptsMap = new Map();
this.currentExecution = null;
this.lastQueueRemaining = 0;
this._api = null;
this._listenerEntries = [];
this._initPromise = null;
this._hydrations = new Map();
}
getSnapshot() {
return {
queue: this.lastQueueRemaining,
prompt: this.currentExecution,
};
}
getCurrentNodeId() {
const prompt = this.currentExecution;
return String(
prompt?.errorDetails?.node_id || prompt?.currentlyExecuting?.nodeId || "",
).trim();
}
getOrMakePrompt(id: any) {
const safeId = String(id || "").trim() || "unknown";
let prompt = this.promptsMap.get(safeId);
if (!prompt) {
prompt = new FloatingViewerPromptExecution(safeId, this._getApp);
this.promptsMap.set(safeId, prompt);
}
return prompt;
}
async ensureInitialized({ api = null, app = null, timeoutMs = 0 } = {}) {
if (api && this._api === api) return api;
if (!api && this._api) return this._api;
if (this._initPromise) return this._initPromise;
this._initPromise = this._ensureInitializedInternal({ api, app, timeoutMs }).finally(() => {
this._initPromise = null;
});
return this._initPromise;
}
async _ensureInitializedInternal({ api = null, app = null, timeoutMs = 0 } = {}) {
let resolvedApi = api || this._getApi?.(app) || null;
if (!resolvedApi && timeoutMs > 0 && typeof this._waitForApi === "function") {
try {
resolvedApi = await this._waitForApi({ app, timeoutMs });
} catch (e: any) {
console.debug?.(e);
}
}
if (!resolvedApi) return null;
this._attachApi(resolvedApi);
return resolvedApi;
}
_attachApi(api: any) {
if (!api || typeof api.addEventListener !== "function") return;
if (this._api === api) return;
this.dispose({ keepState: true });
this._api = api;
this._attachApiListeners(api);
}
/**
* Resolve the API-format prompt (node ids -> class_type/_meta) for a
* prompt that the server just started, using ComfyUI's own read-only
* endpoints instead of intercepting `api.queuePrompt`. Wrapping
* queuePrompt is fragile: other extensions wrap it too and the order is
* not guaranteed.
*
* Order: core Jobs API (`api.getJobDetail` -> `GET /api/jobs/{id}`, whose
* `workflow` carries `{ prompt, extra_data }`), then `/queue` for older
* cores, then `/history/{id}` for prompts that already finished.
*/
async _hydratePrompt(api: any, promptExecution: any) {
const promptId = String(promptExecution?.id || "").trim();
if (!promptId || promptId === "unknown" || promptExecution.promptApi) return;
if (!api) return;
if (this._hydrations.has(promptId)) return this._hydrations.get(promptId);
const task = (async () => {
const output =
(await this._findPromptInJob(api, promptId)) ||
(await this._findPromptInQueue(api, promptId)) ||
(await this._findPromptInHistory(api, promptId));
if (output && !promptExecution.promptApi) {
promptExecution.setPrompt({ output });
const current = promptExecution.currentlyExecuting;
if (current?.nodeId) {
current.nodeLabel =
promptExecution.getNodeLabel(current.nodeId) || current.nodeLabel;
}
if (this.promptsMap.get(promptId) === promptExecution) {
this.dispatchProgressUpdate();
}
}
})()
.catch((e: any) => console.debug?.(e))
.finally(() => this._hydrations.delete(promptId));
this._hydrations.set(promptId, task);
return task;
}
async _findPromptInJob(api: any, promptId: string) {
if (typeof api.getJobDetail !== "function") return null;
try {
const job = await api.getJobDetail(promptId);
const prompt = job?.workflow?.prompt;
return prompt && typeof prompt === "object" && !Array.isArray(prompt) ? prompt : null;
} catch (e: any) {
console.debug?.(e);
return null;
}
}
async _fetchJson(api: any, route: string) {
if (typeof api.fetchApi !== "function") return null;
const resp = await api.fetchApi(route, { cache: "no-store" });
if (!resp?.ok || typeof resp.json !== "function") return null;
return resp.json();
}
async _findPromptInQueue(api: any, promptId: string) {
const data = await this._fetchJson(api, "/queue");
const items = [
...(Array.isArray(data?.queue_running) ? data.queue_running : []),
...(Array.isArray(data?.queue_pending) ? data.queue_pending : []),
];
for (const item of items) {
// Queue entries are [number, prompt_id, prompt, extra_data, outputs_to_execute, ...]
if (Array.isArray(item) && String(item[1] || "") === promptId) {
return item[2] && typeof item[2] === "object" ? item[2] : null;
}
}
return null;
}
async _findPromptInHistory(api: any, promptId: string) {
const data = await this._fetchJson(api, `/history/${encodeURIComponent(promptId)}`);
const prompt = data?.[promptId]?.prompt;
return Array.isArray(prompt) && prompt[2] && typeof prompt[2] === "object" ? prompt[2] : null;
}
_attachApiListeners(api: any) {
this._attachListener(api, "status", (event: any) => {
if (!event?.detail?.exec_info) return;
this.lastQueueRemaining = Number(event.detail.exec_info.queue_remaining) || 0;
this.dispatchProgressUpdate();
});
this._attachListener(api, "execution_start", (event: any) => {
const promptId = String(
event?.detail?.prompt_id || event?.detail?.promptId || "",
).trim();
if (!promptId) return;
this.currentExecution = this.getOrMakePrompt(promptId);
this.dispatchProgressUpdate();
void this._hydratePrompt(api, this.currentExecution);
});
this._attachListener(api, "executing", (event: any) => {
if (!this.currentExecution) {
this.currentExecution = this.getOrMakePrompt("unknown");
}
this.currentExecution.executing(event?.detail);
if (event?.detail == null) {
this.currentExecution = null;
}
this.dispatchProgressUpdate();
});
this._attachListener(api, "progress", (event: any) => {
const detail = event?.detail || {};
if (!this.currentExecution) {
this.currentExecution = this.getOrMakePrompt(detail.prompt_id || detail.promptId);
}
this.currentExecution.executing(detail.node, detail.value, detail.max);
this.dispatchProgressUpdate();
});
this._attachListener(api, "execution_cached", (event: any) => {
const detail = event?.detail || {};
if (!this.currentExecution) {
this.currentExecution = this.getOrMakePrompt(detail.prompt_id || detail.promptId);
}
for (const cached of Array.isArray(detail.nodes) ? detail.nodes : []) {
this.currentExecution.markExecuted(cached);
}
this.dispatchProgressUpdate();
});
this._attachListener(api, "executed", (event: any) => {
const detail = event?.detail || {};
if (!this.currentExecution) {
const promptId = String(detail.prompt_id || detail.promptId || "").trim();
if (promptId) {
this.currentExecution = this.getOrMakePrompt(promptId);
}
}
});
this._attachListener(api, "execution_error", (event: any) => {
const detail = event?.detail || {};
if (!this.currentExecution) {
this.currentExecution = this.getOrMakePrompt(detail.prompt_id || detail.promptId);
}
this.currentExecution?.error(detail);
this.dispatchProgressUpdate();
});
const clearCurrentExecution = () => {
if (this.currentExecution) {
this.currentExecution.executing(null);
}
this.currentExecution = null;
this.dispatchProgressUpdate();
};
this._attachListener(api, "execution_success", clearCurrentExecution);
this._attachListener(api, "execution_interrupted", clearCurrentExecution);
}
_attachListener(api: any, type: any, handler: any) {
api.addEventListener(type, handler);
this._listenerEntries.push({ api, type, handler });
}
dispatchProgressUpdate() {
this.dispatchEvent(makeCustomEvent(PROGRESS_UPDATE_EVENT, this.getSnapshot()));
}
dispose({ keepState = false }: { keepState?: boolean } = {}) {
for (const { api, type, handler } of this._listenerEntries.splice(0)) {
try {
api?.removeEventListener?.(type, handler);
} catch (e: any) {
console.debug?.(e);
}
}
this._api = null;
if (!keepState) {
this.promptsMap.clear();
this.currentExecution = null;
this.lastQueueRemaining = 0;
}
}
}
const globalHost = getGlobalHost() as Record<string, any>;
export const floatingViewerProgressService =
globalHost[GLOBAL_SERVICE_KEY] || new FloatingViewerProgressService();
if (!globalHost[GLOBAL_SERVICE_KEY]) {
globalHost[GLOBAL_SERVICE_KEY] = floatingViewerProgressService;
}
export function ensureFloatingViewerProgressTracking(options: Record<string, any> = {}): Promise<any> {
return floatingViewerProgressService.ensureInitialized(options);
}
function formatProgressText(detail: any) {
const queue = Math.max(0, Number(detail?.queue) || 0);
const prompt = detail?.prompt || null;
if (prompt?.errorDetails) {
const exceptionType = String(
prompt.errorDetails?.exception_type || "Execution error",
).trim();
const nodeId = String(prompt.errorDetails?.node_id || "").trim();
const nodeType = String(prompt.errorDetails?.node_type || "").trim();
return [exceptionType, nodeId, nodeType].filter(Boolean).join(" ");
}
if (prompt?.currentlyExecuting) {
const current = prompt.currentlyExecuting;
let progressText = `(${queue}) `;
if (!prompt.totalNodes) {
progressText += "??%";
} else {
const percent = (prompt.executedNodeIds.length / prompt.totalNodes) * 100;
progressText += `${Math.round(percent)}%`;
}
let stepsLabel = "";
if (current.step != null && current.maxSteps) {
const stepPercent = (Number(current.step) / Number(current.maxSteps)) * 100;
if (current.pass > 1 || current.maxPasses != null) {
stepsLabel += `#${current.pass}`;
if (current.maxPasses && current.maxPasses > 0) {
stepsLabel += `/${current.maxPasses}`;
}
stepsLabel += " - ";
}
stepsLabel += `${Math.round(stepPercent)}%`;
}
const nodeLabel = String(current.nodeLabel || "").trim();
if (nodeLabel || stepsLabel) {
progressText += ` - ${nodeLabel || "Unknown node"}${stepsLabel ? ` (${stepsLabel})` : ""}`;
}
return progressText;
}
if (queue > 0) {
return `(${queue}) Running... in another tab`;
}
return "Idle";
}
export function formatFloatingViewerMediaProgressText(detail: any): string {
const prompt = detail?.prompt || null;
if (prompt?.errorDetails) {
const errorDetails = prompt?.errorDetails || {};
const nodeLabel = String(
prompt?.currentlyExecuting?.nodeLabel ||
errorDetails?.node_type ||
errorDetails?.node_id ||
"Execution",
).trim();
const rawMessage =
errorDetails?.exception_message ??
errorDetails?.error ??
errorDetails?.message ??
errorDetails?.detail ??
errorDetails?.reason ??
"";
const normalizedMessage = Array.isArray(rawMessage)
? rawMessage
.map((item: any) => String(item || "").trim())
.filter(Boolean)
.join(" | ")
: String(rawMessage || "").trim();
const compactMessage = normalizedMessage.replace(/\s+/g, " ").trim();
if (compactMessage) {
return `${nodeLabel} - ${compactMessage}`;
}
return `${nodeLabel} - Error`;
}
const current = prompt?.currentlyExecuting || null;
if (!current) return "";
const nodeLabel = String(current.nodeLabel || current.nodeId || "Node").trim();
const step = Number(current.step);
const maxSteps = Number(current.maxSteps);
if (Number.isFinite(step) && Number.isFinite(maxSteps) && maxSteps > 0) {
if (current.pass > 1) {
return `${nodeLabel} #${current.pass} - ${step}/${maxSteps}`;
}
return `${nodeLabel} - ${step}/${maxSteps}`;
}
return nodeLabel;
}
function applyFloatingViewerProgressState(viewer: any, detail: any) {
if (!viewer?._progressEl && !viewer?._mediaProgressEl) return;
const prompt = detail?.prompt || null;
const currentNodeId = String(
prompt?.errorDetails?.node_id || prompt?.currentlyExecuting?.nodeId || "",
).trim();
let nodesWidth = "0%";
let stepsWidth = "0%";
const isError = !!prompt?.errorDetails;
if (prompt?.currentlyExecuting) {
if (prompt.totalNodes > 0) {
const percent = (prompt.executedNodeIds.length / prompt.totalNodes) * 100;
nodesWidth = `${Math.max(2, Math.round(percent * 100) / 100)}%`;
}
const step = Number(prompt.currentlyExecuting?.step);
const maxSteps = Number(prompt.currentlyExecuting?.maxSteps);
if (Number.isFinite(step) && Number.isFinite(maxSteps) && maxSteps > 0) {
stepsWidth = `${Math.max(0, Math.min(100, (step / maxSteps) * 100))}%`;
}
} else if (isError) {
nodesWidth = "100%";
stepsWidth = "100%";
}
viewer._progressCurrentNodeId = currentNodeId || null;
if (viewer._progressEl) {
viewer._progressNodesEl.style.width = nodesWidth;
viewer._progressStepsEl.style.width = stepsWidth;
viewer._progressTextEl.textContent = formatProgressText(detail);
viewer._progressEl.classList.toggle("is-error", isError);
viewer._progressEl.classList.toggle("is-clickable", Boolean(currentNodeId));
viewer._progressEl.title = currentNodeId
? "Execution progress - click to center active node"
: "Execution progress";
}
if (viewer._mediaProgressEl) {
const mediaLabel = formatFloatingViewerMediaProgressText(detail);
viewer._mediaProgressTextEl.textContent = mediaLabel;
viewer._mediaProgressEl.title = mediaLabel || "";
viewer._mediaProgressEl.classList.toggle("is-error", isError);
viewer._mediaProgressEl.classList.toggle("is-visible", Boolean(mediaLabel));
}
}
function centerFloatingViewerProgressNode(viewer: any, nodeId: any) {
const safeNodeId = String(nodeId || "").trim();
if (!safeNodeId) return false;
try {
return centerHostCanvasNodeById(safeNodeId, getRawHostApp());
} catch (e: any) {
console.debug?.(e);
return false;
}
}
export function buildFloatingViewerProgressBar(viewer: any): HTMLElement {
const root = document.createElement("div");
root.className = "mjr-mfv-progress";
root.setAttribute("role", "status");
root.setAttribute("aria-live", "polite");
const nodesBar = document.createElement("div");
nodesBar.className = "mjr-mfv-progress-bar mjr-mfv-progress-bar--nodes";
const stepsBar = document.createElement("div");
stepsBar.className = "mjr-mfv-progress-bar mjr-mfv-progress-bar--steps";
const overlay = document.createElement("div");
overlay.className = "mjr-mfv-progress-overlay";
overlay.setAttribute("aria-hidden", "true");
const text = document.createElement("span");
text.className = "mjr-mfv-progress-text";
text.textContent = "Idle";
root.appendChild(nodesBar);
root.appendChild(stepsBar);
root.appendChild(overlay);
root.appendChild(text);
root.addEventListener("pointerdown", (event: any) => {
if (event.button !== 0) return;
if (!centerFloatingViewerProgressNode(viewer, viewer._progressCurrentNodeId)) return;
event.preventDefault();
event.stopPropagation();
});
viewer._progressEl = root;
viewer._progressNodesEl = nodesBar;
viewer._progressStepsEl = stepsBar;
viewer._progressTextEl = text;
if (viewer._progressUpdateHandler) {
floatingViewerProgressService.removeEventListener(
PROGRESS_UPDATE_EVENT,
viewer._progressUpdateHandler,
);
}
viewer._progressUpdateHandler = (event: any) => {
applyFloatingViewerProgressState(
viewer,
event?.detail || floatingViewerProgressService.getSnapshot(),
);
};
floatingViewerProgressService.addEventListener(
PROGRESS_UPDATE_EVENT,
viewer._progressUpdateHandler,
);
void ensureFloatingViewerProgressTracking({ timeoutMs: 4000 }).catch((e: any) => {
console.debug?.(e);
});
applyFloatingViewerProgressState(viewer, floatingViewerProgressService.getSnapshot());
return root;
}
export function buildFloatingViewerMediaProgressOverlay(viewer: any) {
const root = document.createElement("div");
root.className = "mjr-mfv-media-progress";
root.setAttribute("aria-hidden", "true");
const text = document.createElement("span");
text.className = "mjr-mfv-media-progress-text";
root.appendChild(text);
viewer._mediaProgressEl = root;
viewer._mediaProgressTextEl = text;
applyFloatingViewerProgressState(viewer, floatingViewerProgressService.getSnapshot());
return root;
}
export function disposeFloatingViewerProgressBar(viewer: any) {
if (viewer?._progressUpdateHandler) {
try {
floatingViewerProgressService.removeEventListener(
PROGRESS_UPDATE_EVENT,
viewer._progressUpdateHandler,
);
} catch (e: any) {
console.debug?.(e);
}
}
viewer._progressUpdateHandler = null;
viewer._progressCurrentNodeId = null;
viewer._progressEl = null;
viewer._progressNodesEl = null;
viewer._progressStepsEl = null;
viewer._progressTextEl = null;
viewer._mediaProgressEl = null;
viewer._mediaProgressTextEl = null;
}