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),

227 lines • 8.94 kB
/** * #2553 — last confirmed subgraph viewing, per bound tab. * * `panel_enter_subgraph` / `panel_query_graph` can prove `viewing.scope=subgraph` * and the very next MCP call still land on the root graph. Subgraph-interior * mutations (`unexpose` / `expose` / rail / promote) then refuse with * "must be run INSIDE a subgraph" even though this session already confirmed * the inner scope. The panel canvas is the live view; this store is the last * confirmation the session actually saw, so a later mutation can re-enter that * owner instead of silently targeting root. * * Root after an explicit `panel_exit_subgraph` (or a query that saw root) is * also remembered, so a deliberate leave is not undone. */ /** Bridge commands that the panel refuses unless the active graph IS a subgraph. */ export const SUBGRAPH_INTERIOR_CMDS = new Set([ "graph_unexpose_subgraph_input", "graph_unexpose_subgraph_output", "graph_expose_subgraph_input", "graph_expose_subgraph_output", "graph_move_rail", "graph_promote_widget", ]); const OUTSIDE_SUBGRAPH_RE = /(?:must be run|must run) INSIDE a subgraph/i; const rememberedByTab = new Map(); function asRecord(value) { if (value === null || typeof value !== "object" || Array.isArray(value)) return null; return value; } function toolText(res) { return res.content.map((c) => (c.type === "text" ? (c.text ?? "") : "")).join("\n"); } export function parseJsonPayload(res) { if (res.isError) return null; const text = res.content.find((c) => c.type === "text")?.text; if (typeof text !== "string") return null; try { const parsed = JSON.parse(text); return asRecord(parsed); } catch { return null; } } function canonicalNodeId(value) { if (typeof value === "number") { return Number.isSafeInteger(value) ? String(value) : null; } if (typeof value !== "string" || value.length === 0) return null; if (!/^-?\d+(?::\d+)*$/.test(value)) return null; return value; } function wireNodeId(id) { return /^-?\d+$/.test(id) ? Number.parseInt(id, 10) : id; } export function parseViewingScope(value) { const rec = asRecord(value); if (!rec) return null; const scope = rec.scope; if (scope !== "root" && scope !== "subgraph") return null; const workflowUuid = rec.workflow_uuid; if (workflowUuid !== undefined && (typeof workflowUuid !== "string" || workflowUuid.length === 0)) { return null; } let ownerNodeId = null; if (rec.owner_node_id !== undefined && rec.owner_node_id !== null) { ownerNodeId = canonicalNodeId(rec.owner_node_id); if (!ownerNodeId) return null; } return { scope, ownerNodeId, ...(typeof workflowUuid === "string" ? { workflowUuid } : {}), }; } export function isOutsideSubgraphRefusal(text) { return OUTSIDE_SUBGRAPH_RE.test(text); } export function isSubgraphInteriorCmd(cmd) { return typeof cmd === "string" && SUBGRAPH_INTERIOR_CMDS.has(cmd); } export function rememberedViewingScope(tabId) { return rememberedByTab.get(tabId) ?? null; } export function rememberedSubgraphOwner(tabId) { const rec = rememberedByTab.get(tabId); return rec?.scope === "subgraph" ? rec.ownerNodeId : null; } export function clearRememberedViewingScope(tabId) { if (tabId === undefined) { rememberedByTab.clear(); return; } rememberedByTab.delete(tabId); } /** Drop a remembered subgraph owner. A later live root read or an explicit * current-mode rebind must not keep authorizing the promoted-subgraph path. */ export function clearStaleSubgraphIdentity(tabId) { if (!tabId) return; const rec = rememberedByTab.get(tabId); if (rec?.scope === "subgraph") rememberedByTab.delete(tabId); } /** Record a live root viewing so a prior subgraph confirmation cannot linger. */ export function applyLiveRootViewing(tabId, viewing) { if (!tabId) return false; const parsed = parseViewingScope(viewing); if (parsed?.scope !== "root") return false; rememberedByTab.set(tabId, parsed); return true; } export function noteConfirmedViewing(tabId, payload, extras) { if (!tabId) return null; const rec = asRecord(payload); const viewing = parseViewingScope(rec?.viewing); const entered = canonicalNodeId(extras?.enteredNodeId ?? rec?.entered); let next = null; if (viewing?.scope === "root") { next = viewing; } else if (viewing?.scope === "subgraph") { next = { ...viewing, ownerNodeId: viewing.ownerNodeId ?? entered, }; } else if (entered) { next = { scope: "subgraph", ownerNodeId: entered }; } if (!next) return null; rememberedByTab.set(tabId, next); return next; } export function noteConfirmedViewingFromToolResult(tabId, res, extras) { const payload = parseJsonPayload(res); return payload ? noteConfirmedViewing(tabId, payload, extras) : null; } function withNote(res, note) { if (!note) return res; return { ...res, content: [...res.content, { type: "text", text: note }] }; } function restoreNote(ownerNodeId) { return (`Re-entered subgraph node ${ownerNodeId} from the last confirmed viewing scope ` + `(artokun/comfyui-mcp#2553) — the canvas had returned to root between tool calls.`); } function reenterFailedNote(ownerNodeId, enterText) { return (`The canvas was at root, so this session tried to restore the last confirmed ` + `subgraph (node ${ownerNodeId}) before retrying. panel_enter_subgraph failed: ` + `${enterText} The original refusal stands. Call panel_enter_subgraph yourself ` + `if that is still the graph you mean to edit. (artokun/comfyui-mcp#2553)`); } function missingOwnerNote() { return (`This session last confirmed viewing.scope=subgraph, but no owner_node_id was ` + `published, so the subgraph cannot be re-entered automatically. Call ` + `panel_enter_subgraph with the subgraph node id. (artokun/comfyui-mcp#2553)`); } function workflowMismatchNote(remembered, live) { return (`The last confirmed subgraph scope belonged to workflow ${remembered}, but the ` + `live canvas now reports ${live}. Not re-entering that subgraph. Call ` + `panel_enter_subgraph if the live canvas is the one you mean. (artokun/comfyui-mcp#2553)`); } /** * Remember viewing from a successful graph reply (enter / query / exit / outline). * Failures do not overwrite a prior confirmation. */ export async function callAndRememberViewing(tabId, cmd, call, timeoutMs) { const res = await call(cmd, timeoutMs); if (!res.isError) { noteConfirmedViewingFromToolResult(tabId, res, cmd.cmd === "graph_enter_subgraph" ? { enteredNodeId: cmd.node_id } : undefined); } return res; } /** * Dispatch a subgraph-interior mutation. On the panel's "must be run INSIDE a * subgraph" refusal, re-enter the last confirmed owner for this tab (when the * live canvas is at root) and retry once. A remembered root — an explicit exit * or a query that saw root — is left alone. */ export async function callWithRememberedSubgraph(tabId, cmd, call, timeoutMs) { const first = await call(cmd, timeoutMs); if (!first.isError || !isOutsideSubgraphRefusal(toolText(first))) return first; if (!isSubgraphInteriorCmd(cmd.cmd)) return first; const remembered = rememberedViewingScope(tabId); if (!remembered || remembered.scope !== "subgraph") return first; if (!remembered.ownerNodeId) return withNote(first, missingOwnerNote()); const probe = await call({ cmd: "graph_query", fields: "ids", limit: 1 }, 8000); const live = parseViewingScope(parseJsonPayload(probe)?.viewing); if (remembered.workflowUuid && live?.workflowUuid && remembered.workflowUuid !== live.workflowUuid) { return withNote(first, workflowMismatchNote(remembered.workflowUuid, live.workflowUuid)); } if (live?.scope === "subgraph") { const retried = await call(cmd, timeoutMs); return retried; } const ownerNodeId = remembered.ownerNodeId; const enter = await call({ cmd: "graph_enter_subgraph", node_id: wireNodeId(ownerNodeId) }, 15000); if (enter.isError) { return withNote(first, reenterFailedNote(ownerNodeId, toolText(enter))); } noteConfirmedViewingFromToolResult(tabId, enter, { enteredNodeId: ownerNodeId }); const retried = await call(cmd, timeoutMs); if (!retried.isError) return withNote(retried, restoreNote(ownerNodeId)); return withNote(retried, `Re-entered subgraph node ${ownerNodeId}, but the mutation still failed: ${toolText(retried)}`); } //# sourceMappingURL=subgraph-viewing-scope.js.map