UNPKG

comfyui-mcp

Version:

Local-first, agent-native control plane for ComfyUI — MCP server + autonomous sidebar agent that drives your live graph in natural language on ANY LLM: Claude/ChatGPT/Gemini on your subscription (no API key), free local models via Ollama (fully offline),

662 lines • 24.3 kB
// #1710 — a save-as copy opens onto the right node set, then save refuses // because extra.comfyui_mcp still names the SOURCE, and outline shows empty // promoted widgets the dest file still carries. // // Two levels stay separate: // presentation (order/size/named-vs-positional widgets) is not identity // a NON-EMPTY live widget that disagrees with dest is not dest's graph // (#1639: the previous workflow still answering under the old stamp) export function isPlainObject(v) { return typeof v === "object" && v !== null && !Array.isArray(v); } export function normalizeWorkflowPath(value) { if (typeof value !== "string" || !value) return null; const normalized = value.replace(/\\/g, "/").replace(/^\.\/+/, "").replace(/\/+/g, "/"); if (!normalized || /^(?:tmp:|wf:)/i.test(normalized)) return null; return normalized; } export function workflowFromSerializeReply(reply) { if (!isPlainObject(reply)) return null; if (Array.isArray(reply.nodes)) return reply; const wf = reply.workflow; if (isPlainObject(wf) && Array.isArray(wf.nodes)) return wf; return null; } export function extraWorkflowPath(graph) { if (!isPlainObject(graph)) return null; const extra = isPlainObject(graph.extra) ? graph.extra : null; const meta = extra && isPlainObject(extra.comfyui_mcp) ? extra.comfyui_mcp : null; return typeof meta?.workflow_path === "string" && meta.workflow_path ? meta.workflow_path : null; } export function extraWorkflowUuid(graph) { if (!isPlainObject(graph)) return null; const extra = isPlainObject(graph.extra) ? graph.extra : null; const meta = extra && isPlainObject(extra.comfyui_mcp) ? extra.comfyui_mcp : null; return typeof meta?.workflow_uuid === "string" && meta.workflow_uuid ? meta.workflow_uuid : null; } /** * Exact unsaved routing handle (`tmp:<id>`). OPEN must accept any non-empty * tmp: token panel_list_workflows publishes — not only RFC-uuid suffixes — or an * already-active imported tab cannot rebind (#2503). */ export function unsavedTmpWorkflowKey(value) { if (typeof value !== "string" || !value) return null; return /^tmp:\S+$/.test(value) ? value : null; } /** * Saved-file path encoded in a panel tab id. `wf:<path>` (legacy handle) and * `wf:<tabRouteId>:<path>` (current route) both name dest A. `tmp:` and bare * uuids do not — those tabs have no saved identity (#2503). */ export function savedPathFromTabId(tabId) { if (typeof tabId !== "string" || !tabId.startsWith("wf:")) return null; const rest = tabId.slice(3); if (!rest) return null; const sep = rest.indexOf(":"); const head = sep === -1 ? "" : rest.slice(0, sep); const isRoute = sep > 1 && !/[/\\]/.test(head); const path = isRoute ? rest.slice(sep + 1) : rest; return normalizeWorkflowPath(path); } /** * #2503 — importing/loading onto a new tmp tab must not keep the source graph's * extra.comfyui_mcp.workflow_uuid. Replace it with the tab's assigned uuid. * Leaves workflow_path untouched (#2505). * * Returns a cloned graph when a rewrite is needed; null when dest/graph are * unusable or the stamp already names dest. */ export function bindImportedTmpWorkflowUuid(graph, destUuid) { if (!isPlainObject(graph)) return null; if (typeof destUuid !== "string" || !destUuid) return null; const stamped = extraWorkflowUuid(graph); if (!stamped || stamped === destUuid) return null; const next = structuredClone(graph); const extra = isPlainObject(next.extra) ? { ...next.extra } : {}; const meta = isPlainObject(extra.comfyui_mcp) ? { ...extra.comfyui_mcp } : {}; meta.workflow_uuid = destUuid; extra.comfyui_mcp = meta; next.extra = extra; return next; } /** * #2505 — loading a graph onto another tab must not keep the source * extra.comfyui_mcp.workflow_path. Replace path (and uuid when destUuid is * known) with the active dest identity so a later in-place save is not refused * by the #1667 stale-canvas guard. * * Returns a cloned graph when a rewrite is needed; null when dest/graph are * unusable or the stamp already names dest. */ export function bindLoadedWorkflowIdentity(graph, destPath, destUuid) { if (!isPlainObject(graph)) return null; const dest = normalizeWorkflowPath(destPath); if (!dest) return null; const stampedPath = normalizeWorkflowPath(extraWorkflowPath(graph)); const stampedUuid = extraWorkflowUuid(graph); const pathNeeds = stampedPath !== dest; const uuidNeeds = Boolean(destUuid) && stampedUuid !== destUuid; if (!pathNeeds && !uuidNeeds) return null; const next = structuredClone(graph); const extra = isPlainObject(next.extra) ? { ...next.extra } : {}; const meta = isPlainObject(extra.comfyui_mcp) ? { ...extra.comfyui_mcp } : {}; meta.workflow_path = dest; if (destUuid) meta.workflow_uuid = destUuid; extra.comfyui_mcp = meta; next.extra = extra; return next; } export function extraStampDisagrees(graph, destPath) { const stamped = normalizeWorkflowPath(extraWorkflowPath(graph)); const dest = normalizeWorkflowPath(destPath); if (!stamped || !dest) return false; return stamped !== dest; } export function isEmptyWidgetValue(v) { if (v == null) return true; if (v === "") return true; if (Array.isArray(v)) return v.length === 0 || v.every(isEmptyWidgetValue); if (isPlainObject(v)) { const vals = Object.values(v); return vals.length === 0 || vals.every(isEmptyWidgetValue); } return false; } function namedWidgets(node) { if (isPlainObject(node.widgets_values_named)) return node.widgets_values_named; if (isPlainObject(node.widgets_values)) return node.widgets_values; return null; } function valuesDiffer(a, b) { if (Object.is(a, b)) return false; try { return JSON.stringify(a) !== JSON.stringify(b); } catch { return true; } } function nodeIdentityKey(node) { if (!isPlainObject(node) || node.id == null || typeof node.type !== "string") return null; return `${String(node.id)}\0${node.type}`; } export function nodeIdentitySet(graph) { if (!isPlainObject(graph) || !Array.isArray(graph.nodes)) return null; const keys = new Set(); for (const raw of graph.nodes) { const key = nodeIdentityKey(raw); if (key == null || keys.has(key)) return null; keys.add(key); } return keys; } export function nodeIdentitiesMatch(a, b) { const left = nodeIdentitySet(a); const right = nodeIdentitySet(b); if (!left || !right || left.size !== right.size || left.size === 0) return false; for (const key of left) if (!right.has(key)) return false; return true; } /** True when dest has a NON-EMPTY widget live already holds as a different value. */ export function liveSemanticWidgetsDisagree(live, dest) { if (!isPlainObject(live) || !isPlainObject(dest)) return true; if (!Array.isArray(live.nodes) || !Array.isArray(dest.nodes)) return true; const liveById = new Map(); for (const raw of live.nodes) { if (isPlainObject(raw) && raw.id != null) liveById.set(String(raw.id), raw); } for (const raw of dest.nodes) { if (!isPlainObject(raw) || raw.id == null) continue; const liveNode = liveById.get(String(raw.id)); if (!liveNode) return true; if (nodeWidgetsDisagree(liveNode, raw)) return true; } return false; } function nodeWidgetsDisagree(liveNode, destNode) { const destNamed = namedWidgets(destNode); if (destNamed) { const liveMap = namedWidgets(liveNode) ?? {}; for (const [key, destVal] of Object.entries(destNamed)) { const liveVal = liveMap[key]; if (isEmptyWidgetValue(liveVal)) continue; if (valuesDiffer(liveVal, destVal)) return true; } return false; } if (!Array.isArray(destNode.widgets_values)) return false; const destWv = destNode.widgets_values; const liveWv = Array.isArray(liveNode.widgets_values) ? liveNode.widgets_values : []; for (let i = 0; i < destWv.length; i++) { if (isEmptyWidgetValue(liveWv[i])) continue; if (valuesDiffer(liveWv[i], destWv[i])) return true; } return false; } function nodeNeedsPromotedRestore(liveNode, destNode) { const destNamed = namedWidgets(destNode); if (destNamed) { const liveMap = namedWidgets(liveNode) ?? {}; for (const [key, destVal] of Object.entries(destNamed)) { if (!isEmptyWidgetValue(destVal) && isEmptyWidgetValue(liveMap[key])) return true; } } if (Array.isArray(destNode.widgets_values)) { const liveWv = Array.isArray(liveNode.widgets_values) ? liveNode.widgets_values : []; for (let i = 0; i < destNode.widgets_values.length; i++) { if (!isEmptyWidgetValue(destNode.widgets_values[i]) && isEmptyWidgetValue(liveWv[i])) { return true; } } } return false; } export function destHasEmptyLivePromotedWidgets(live, dest) { if (!isPlainObject(live) || !isPlainObject(dest)) return false; if (!Array.isArray(live.nodes) || !Array.isArray(dest.nodes)) return false; const liveById = new Map(); for (const raw of live.nodes) { if (isPlainObject(raw) && raw.id != null) liveById.set(String(raw.id), raw); } for (const raw of dest.nodes) { if (!isPlainObject(raw) || raw.id == null) continue; const liveNode = liveById.get(String(raw.id)); if (liveNode && nodeNeedsPromotedRestore(liveNode, raw)) return true; } return false; } export function shouldRebindOpenIdentity(input) { if (!nodeIdentitiesMatch(input.live, input.dest)) return false; if (liveSemanticWidgetsDisagree(input.live, input.dest)) return false; return (extraStampDisagrees(input.live, input.destPath) || destHasEmptyLivePromotedWidgets(input.live, input.dest)); } function restoreEmptyPromotedWidgets(liveNode, destNode) { let restored = false; const destNamed = namedWidgets(destNode); if (destNamed) { const liveNamed = { ...(namedWidgets(liveNode) ?? {}) }; let namedChanged = false; for (const [key, destVal] of Object.entries(destNamed)) { if (!isEmptyWidgetValue(destVal) && isEmptyWidgetValue(liveNamed[key])) { liveNamed[key] = destVal; namedChanged = true; } } if (namedChanged) { restored = true; if (isPlainObject(liveNode.widgets_values_named) || isPlainObject(destNode.widgets_values_named)) { liveNode.widgets_values_named = liveNamed; } if (isPlainObject(liveNode.widgets_values) || isPlainObject(destNode.widgets_values)) { liveNode.widgets_values = { ...(isPlainObject(liveNode.widgets_values) ? liveNode.widgets_values : {}), ...liveNamed, }; } } } if (Array.isArray(destNode.widgets_values)) { const destWv = destNode.widgets_values; const liveWv = Array.isArray(liveNode.widgets_values) ? [...liveNode.widgets_values] : []; let positionalChanged = false; for (let i = 0; i < destWv.length; i++) { if (!isEmptyWidgetValue(destWv[i]) && isEmptyWidgetValue(liveWv[i])) { liveWv[i] = destWv[i]; positionalChanged = true; } } if (positionalChanged) { liveNode.widgets_values = liveWv; restored = true; } } return restored; } export function patchOpenIdentity(live, dest, destPath, destUuid) { const graph = structuredClone(live); const extra = isPlainObject(graph.extra) ? { ...graph.extra } : {}; const meta = isPlainObject(extra.comfyui_mcp) ? { ...extra.comfyui_mcp } : {}; meta.workflow_path = destPath; if (destUuid) meta.workflow_uuid = destUuid; extra.comfyui_mcp = meta; graph.extra = extra; const destById = new Map(); if (Array.isArray(dest.nodes)) { for (const raw of dest.nodes) { if (isPlainObject(raw) && raw.id != null) destById.set(String(raw.id), raw); } } let restoredWidgets = 0; if (Array.isArray(graph.nodes)) { for (const raw of graph.nodes) { if (!isPlainObject(raw) || raw.id == null) continue; const destNode = destById.get(String(raw.id)); if (destNode && restoreEmptyPromotedWidgets(raw, destNode)) restoredWidgets += 1; } } return { graph, restoredWidgets }; } export function isStampMismatchSaveRefusal(text) { return /stamped as belonging to/i.test(text) && /extra\.comfyui_mcp\.workflow_path/i.test(text); } /** * #1846 — dest vs live after a load that only rewrote node definitions / widget * schema (unknown placeholders rehydrated after a custom-node install). * * The panel compares the just-loaded state to the live serialize and treats an * unexplained `definitions` difference as unknown. This asks a narrower question: * does the live canvas still hold dest's nodes, connections, and non-empty widget * values? Schema fields (inputs/outputs/properties/size/order) and the * definitions store's widget-schema filling are ignored. Fail closed on anything * else — a rewired link, a missing node, a dest widget value live does not hold. */ function isUnknownPlaceholderType(type) { const t = type.trim().toLowerCase(); return t === "unknown" || t === "unknownnode"; } function typesCompatibleForRehydration(destType, liveType) { return destType === liveType || isUnknownPlaceholderType(destType); } function nodesById(graph) { if (!isPlainObject(graph) || !Array.isArray(graph.nodes)) return null; const byId = new Map(); for (const raw of graph.nodes) { if (!isPlainObject(raw) || raw.id == null) return null; const id = String(raw.id); if (byId.has(id)) return null; byId.set(id, raw); } return byId; } function nodeIdentitiesMatchAllowingRehydration(dest, live) { const destById = nodesById(dest); const liveById = nodesById(live); if (!destById || !liveById || destById.size !== liveById.size) return false; for (const [id, destNode] of destById) { const liveNode = liveById.get(id); if (!liveNode) return false; if (typeof destNode.type !== "string" || typeof liveNode.type !== "string") return false; if (!typesCompatibleForRehydration(destNode.type, liveNode.type)) return false; } return true; } function linkEndpointKey(link) { if (Array.isArray(link) && link.length >= 5) { const origin = link[1]; const target = link[3]; if (origin == null || target == null) return null; return `${String(origin)}\0${String(link[2])}\0${String(target)}\0${String(link[4])}`; } if (!isPlainObject(link)) return null; const origin = link.origin_id ?? link.from; const target = link.target_id ?? link.to; if (origin == null || target == null) return null; const originSlot = link.origin_slot ?? link.from_slot ?? 0; const targetSlot = link.target_slot ?? link.to_slot ?? 0; return `${String(origin)}\0${String(originSlot)}\0${String(target)}\0${String(targetSlot)}`; } function linkTopologySet(graph) { if (!isPlainObject(graph)) return null; if (graph.links == null) return new Set(); if (!Array.isArray(graph.links)) return null; const keys = new Set(); for (const link of graph.links) { const key = linkEndpointKey(link); if (key == null) return null; keys.add(key); } return keys; } function linkTopologiesMatch(dest, live) { const destLinks = linkTopologySet(dest); const liveLinks = linkTopologySet(live); if (!destLinks || !liveLinks || destLinks.size !== liveLinks.size) return false; for (const key of destLinks) if (!liveLinks.has(key)) return false; return true; } function stableWidgetsDisagree(destNode, liveNode) { const destNamed = namedWidgets(destNode); if (destNamed) { const liveMap = namedWidgets(liveNode) ?? {}; for (const [key, destVal] of Object.entries(destNamed)) { if (isEmptyWidgetValue(destVal)) continue; const liveVal = liveMap[key]; if (isEmptyWidgetValue(liveVal) || valuesDiffer(liveVal, destVal)) return true; } return false; } if (!Array.isArray(destNode.widgets_values)) return false; const destWv = destNode.widgets_values; const liveWv = Array.isArray(liveNode.widgets_values) ? liveNode.widgets_values : []; for (let i = 0; i < destWv.length; i++) { if (isEmptyWidgetValue(destWv[i])) continue; if (isEmptyWidgetValue(liveWv[i]) || valuesDiffer(liveWv[i], destWv[i])) return true; } return false; } function graphStableWidgetsDisagree(dest, live) { const destById = nodesById(dest); const liveById = nodesById(live); if (!destById || !liveById) return true; for (const [id, destNode] of destById) { const liveNode = liveById.get(id); if (!liveNode || stableWidgetsDisagree(destNode, liveNode)) return true; } return false; } function subgraphEntries(graph) { if (!isPlainObject(graph)) return null; if (!Object.prototype.hasOwnProperty.call(graph, "definitions")) return []; const defs = graph.definitions; if (defs == null) return []; if (!isPlainObject(defs)) return null; if (!Object.prototype.hasOwnProperty.call(defs, "subgraphs")) return []; const subgraphs = defs.subgraphs; if (subgraphs == null) return []; if (Array.isArray(subgraphs)) return subgraphs; if (isPlainObject(subgraphs)) return Object.values(subgraphs); return null; } function subgraphsById(graph) { const entries = subgraphEntries(graph); if (!entries) return null; const byId = new Map(); for (const raw of entries) { if (!isPlainObject(raw)) return null; if (typeof raw.id !== "string" && typeof raw.id !== "number") return null; const id = String(raw.id); if (byId.has(id)) return null; byId.set(id, raw); } return byId; } export function openLiveMatchesDestContent(live, dest) { if (!nodeIdentitiesMatchAllowingRehydration(dest, live)) return false; if (!linkTopologiesMatch(dest, live)) return false; if (graphStableWidgetsDisagree(dest, live)) return false; const destSgs = subgraphsById(dest); const liveSgs = subgraphsById(live); if (!destSgs || !liveSgs) return false; for (const [id, destSg] of destSgs) { const liveSg = liveSgs.get(id); if (!liveSg) return false; if (!openLiveMatchesDestContent(liveSg, destSg)) return false; } return true; } function asFrontendWidgetValue(value) { if (value === undefined || value === null) return value; if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { return value; } if (Array.isArray(value)) { return value.every((entry) => entry === null || typeof entry === "string" || typeof entry === "number" || typeof entry === "boolean") ? value : undefined; } return isPlainObject(value) ? value : undefined; } function frontendWidgetPrimitive(value) { if (!isPlainObject(value) || !Object.prototype.hasOwnProperty.call(value, "value")) { return asFrontendWidgetValue(value); } for (const key of Object.keys(value)) { if (key !== "name" && key !== "value" && key !== "type" && key !== "label") { return asFrontendWidgetValue(value); } } if (value.name != null && typeof value.name !== "string") return asFrontendWidgetValue(value); if (value.type != null && typeof value.type !== "string") return asFrontendWidgetValue(value); return asFrontendWidgetValue(value.value); } function frontendWidgetValuesEquivalent(a, b) { const left = frontendWidgetPrimitive(a); const right = frontendWidgetPrimitive(b); if (Object.is(left, right)) return true; if ((typeof left === "number" && typeof right === "string") || (typeof left === "string" && typeof right === "number")) { const n = Number(left); const m = Number(right); if (Number.isFinite(n) && Number.isFinite(m) && n === m) return true; } return !valuesDiffer(left, right); } function destAuthoredWidgetSequence(node) { if (Array.isArray(node.widgets_values)) return node.widgets_values; const named = namedWidgets(node); return named ? Object.values(named) : null; } function liveSerializedWidgetSequence(node) { if (Array.isArray(node.widgets_values)) return node.widgets_values; const named = namedWidgets(node); return named ? Object.values(named) : []; } function frontendStableWidgetsDisagree(destNode, liveNode) { const destNamed = namedWidgets(destNode); const liveNamed = namedWidgets(liveNode); if (destNamed && liveNamed && !Array.isArray(destNode.widgets_values)) { for (const [key, destVal] of Object.entries(destNamed)) { if (isEmptyWidgetValue(destVal)) continue; const liveVal = liveNamed[key]; if (isEmptyWidgetValue(liveVal) || !frontendWidgetValuesEquivalent(liveVal, destVal)) { return true; } } return false; } const destWv = destAuthoredWidgetSequence(destNode); if (!destWv) return false; const liveWv = liveSerializedWidgetSequence(liveNode); for (let i = 0; i < destWv.length; i++) { if (isEmptyWidgetValue(destWv[i])) continue; if (isEmptyWidgetValue(liveWv[i]) || !frontendWidgetValuesEquivalent(liveWv[i], destWv[i])) { return true; } } return false; } function graphFrontendStableWidgetsDisagree(dest, live) { const destById = nodesById(dest); const liveById = nodesById(live); if (!destById || !liveById) return true; for (const [id, destNode] of destById) { const liveNode = liveById.get(id); if (!liveNode || frontendStableWidgetsDisagree(destNode, liveNode)) return true; } return false; } export function openLiveMatchesDestAfterReconnect(live, dest) { if (!nodeIdentitiesMatchAllowingRehydration(dest, live)) return false; if (!linkTopologiesMatch(dest, live)) return false; if (graphFrontendStableWidgetsDisagree(dest, live)) return false; const destSgs = subgraphsById(dest); const liveSgs = subgraphsById(live); if (!destSgs || !liveSgs) return false; for (const [id, destSg] of destSgs) { const liveSg = liveSgs.get(id); // Live serialize of an already-open tab can omit nested definitions while // the root node set still matches. Recurse only when the inner graph is // actually present to compare (#2494). if (!liveSg) continue; if (!openLiveMatchesDestAfterReconnect(liveSg, destSg)) return false; } return true; } //# sourceMappingURL=open-identity-normalization.js.map