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

202 lines • 9.36 kB
const DEFAULT_KEEP_BYPASSED = ["TextGenerate"]; const SINK_TYPES = new Set([ "SaveImage", "VHS_VideoCombine", "SaveVideo", "SaveAudio", "PreviewImage", ]); const inBox = (pos, b) => !!pos && !!b && pos[0] >= b[0] && pos[0] <= b[0] + b[2] && pos[1] >= b[1] && pos[1] <= b[1] + b[3]; /** * Slice ONE pipeline out of a toggle-template workflow (the kind built with * rgthree "Fast Groups Bypasser/Muter": one graph with many pipelines, only one * active at a time). Seeds from the output/SaveImage nodes inside the named * groups, takes their backward dependency closure (through real links AND virtual * Set/Get buses), un-bypasses the kept nodes (and the internals of any subgraph * definitions they use), and returns a standalone, activated UI graph carrying * only the subgraph defs it uses. * * `groupSubstrings` are case-insensitive substrings of group titles whose output * nodes seed the slice — shared post-proc (upscale/grain/sharpen) is pulled in * automatically via the closure. The opt-in enhancer LLM (`TextGenerate`, or any * type in `opts.keepBypassed`) is left bypassed. */ export function sliceWorkflow(wf, groupSubstrings, opts = {}) { const g = wf; const want = groupSubstrings.map((s) => s.trim().toLowerCase()).filter(Boolean); if (!want.length) throw new Error("Provide at least one group title substring to slice."); const keepBypassed = new Set(opts.keepBypassed ?? DEFAULT_KEEP_BYPASSED); const allNodes = g.nodes ?? []; const nodes = new Map(allNodes.map((n) => [n.id, n])); const allLinks = (g.links ?? []).filter((l) => Array.isArray(l) && l.length >= 6); const links = new Map(allLinks.map((l) => [l[0], l])); const groups = g.groups ?? []; // #2780 — a node can sit inside several overlapping/nested boxes. `find` // attributed it only to the first, so a SaveImage inside both "Save Group" // and "#Save Draft" was missed when the caller asked for the inner title. const groupsOf = (n) => groups.filter((gr) => inBox(n.pos, gr.bounding)).map((gr) => gr.title ?? ""); const wantNode = (n) => groupsOf(n).some((title) => { const t = title.toLowerCase(); return want.some((w) => t.includes(w)); }); const groupOf = (n) => groupsOf(n) .map((t) => t.trim()) .filter(Boolean) .join(" / "); // Set/Get bus maps (keyed by the bus name in widgets_values[0]). const setByBus = new Map(); const getBus = new Map(); for (const n of allNodes) { if (n.type === "SetNode") setByBus.set(n.widgets_values?.[0], n.id); else if (n.type === "GetNode") getBus.set(n.id, n.widgets_values?.[0]); } const incoming = (id) => { const n = nodes.get(id); const s = new Set(); for (const inp of n?.inputs ?? []) { const l = inp.link != null ? links.get(inp.link) : undefined; if (l) s.add(l[1]); } if (n?.type === "GetNode") { const bus = getBus.get(id); const src = setByBus.get(bus); if (src != null) s.add(src); } return s; }; const closure = (seed) => { const seen = new Set(); const st = [...seed]; while (st.length) { const x = st.pop(); if (seen.has(x)) continue; seen.add(x); for (const s of incoming(x)) st.push(s); } return seen; }; const seeds = allNodes.filter((n) => SINK_TYPES.has(n.type) && wantNode(n)).map((n) => n.id); if (!seeds.length) { // #690(4) — the old message named only what was searched FOR, never what was // there, so six different substrings produced six identical dead ends and the // caller had no way to converge except guessing. Everything needed to explain // the miss is already in hand here. throw new Error(describeSliceMiss({ groupSubstrings, groupTitles: groups.map((gr) => gr.title ?? ""), sinks: allNodes .filter((n) => SINK_TYPES.has(n.type)) .map((n) => ({ id: n.id, type: n.type, group: groupOf(n) })), })); } const keep = closure(seeds); // Subgraph instance nodes have a 36-char UUID type. const isSubgraph = (id) => String(nodes.get(id)?.type ?? "").length === 36; let unbypassed = 0; const unbyp = (n) => { if ((n.mode === 2 || n.mode === 4) && !keepBypassed.has(n.type ?? "")) { n.mode = 0; unbypassed++; } }; const newNodes = [...keep].map((id) => structuredClone(nodes.get(id))); for (const n of newNodes) unbyp(n); const newLinks = allLinks.filter((l) => keep.has(l[1]) && keep.has(l[3])); const usedDefs = new Set([...keep].filter(isSubgraph).map((id) => nodes.get(id)?.type)); const keptDefs = (g.definitions?.subgraphs ?? []) .filter((d) => usedDefs.has(d.id)) .map((d) => structuredClone(d)); for (const d of keptDefs) for (const nd of d.nodes ?? []) unbyp(nd); const newWf = { ...g, nodes: newNodes, links: newLinks, groups: groups.filter((gr) => [...keep].some((id) => inBox(nodes.get(id)?.pos, gr.bounding))), definitions: { subgraphs: keptDefs }, }; const badLinks = newLinks.filter((l) => !keep.has(l[1]) || !keep.has(l[3])).length; const orphanGets = newNodes.filter((n) => { if (n.type !== "GetNode") return false; const bus = getBus.get(n.id); const src = setByBus.get(bus); return !(src != null && keep.has(src)); }).length; return { workflow: newWf, stats: { nodes: newNodes.length, unbypassed, links: newLinks.length, subgraphs: keptDefs.length, seeds: seeds.length, badLinks, orphanGets }, }; } /** Cap the enumerations so a 200-group workflow cannot turn one refusal into a wall * of text. The counts are always exact, so a truncated list still says how much it * is hiding rather than quietly implying that is all there is. */ const SLICE_MISS_LIST_CAP = 25; function capped(items, cap = SLICE_MISS_LIST_CAP) { if (!items.length) return "(none)"; if (items.length <= cap) return items.join(", "); return `${items.slice(0, cap).join(", ")} … and ${items.length - cap} more`; } /** * #690(4) — explain a slice that matched no output node. * * The reporter tried six different `groups` substrings against one workflow and got * the same message every time: it named the substrings they had just supplied and * nothing about the workflow, so there was no way to converge except blind trial and * error. Their workflow DID have an output node (a `SaveVideo`, node 92) — and * `SaveVideo` is in SINK_TYPES, so the sink type was never the problem. What failed * was group matching: a node belongs to a group only by falling inside its bounding * box, so an output node sitting outside every group, or inside one whose title none * of the substrings match, yields no seeds. * * Both facts are known at the throw site, so the refusal now carries them: * • the group titles that actually exist — no more guessing at names; * • every output node found ANYWHERE in the workflow and the group it falls in, * which distinguishes "your substring is wrong" from "this output node is not * inside any group at all" — two different fixes that produced one message. * * Deliberately still a REFUSAL. Falling back to "slice from any output node" would * silently produce a different workflow than the caller asked for, which is worse * than a clear stop. */ export function describeSliceMiss(info) { const { groupSubstrings, groupTitles, sinks } = info; const titles = groupTitles.map((t) => String(t ?? "").trim()).filter(Boolean); const lines = [ `No output node (SaveImage / VHS_VideoCombine / SaveVideo / SaveAudio / PreviewImage) ` + `found in groups matching: ${groupSubstrings.join(", ")}`, `Groups in this workflow (${titles.length}): ${capped(titles)}`, ]; if (!sinks.length) { lines.push("This workflow contains NO output node of any recognized type, so no substring can " + "match — slicing needs one to seed from."); } else { lines.push(`Output nodes present (${sinks.length}): ` + capped(sinks.map((s) => `#${s.id} ${s.type} in ${s.group ? `"${s.group}"` : "NO group"}`))); // The distinction that decides what to do next. A node belongs to a group purely // by geometry, so an output node outside every box can never be matched by any // substring, and telling the caller to try another one would send them in circles. if (sinks.every((s) => !s.group)) { lines.push("None of them is inside ANY group, so no `groups` value can match. Move the output " + "node into the group's bounds on the canvas, or slice a workflow whose pipelines " + "are grouped."); } else { lines.push("Pass a substring of one of the group titles listed above."); } } return lines.join("\n"); } //# sourceMappingURL=workflow-slicer.js.map