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

289 lines • 11.6 kB
/** * #1877 / #1921 — panel_create_group auto-bounds from live `pos`/`size` can * miss a requested node whose origin is already inside the box. * * #1877: a collapsed node often has `size[1] === 0` (title-chip only), so the * box is ~100px tall around the origin. Membership then tests the CENTRE of a * boundingRect rebuilt with the panel's `finiteExtent` fallback (0 is not a * usable height, so the body becomes 100px plus a 30px title). That centre * sits a few pixels below the box, so the group is empty even though the * node's coordinates are inside. * * #1921: SaveImage / other DOM-preview nodes start compact (`size[1] === 58`) * and rehydrate to ~326px after the image widget mounts (save/reopen). The * compact auto-box still reports them as members; after rehydrate their centre * drops below the persisted bottom and they fall out. * * These helpers expand a created group's bounds just enough to contain every * membership-plausible centre of a requested node whose origin is already in * the box — including the stable full-height centre of a DOM-preview node — * then the caller writes those bounds with graph_edit_group. * * Constants match comfyui-mcp-panel `web/js/lib/group-geometry.js` * (`COLLAPSED_TITLE_HEIGHT`, `COLLAPSED_PILL_WIDTH`, `finiteExtent` fallbacks) * and LiteGraph's containsCentre rule (min edge inclusive, max edge exclusive). */ export const NODE_TITLE_HEIGHT = 30; export const COLLAPSED_PILL_WIDTH = 80; export const DEFAULT_NODE_WIDTH = 200; export const DEFAULT_NODE_BODY_HEIGHT = 100; /** Compact SaveImage body is ~58px; after the image-preview DOM widget mounts it is 326px (#1921). */ export const DOM_PREVIEW_NODE_MIN_BODY_HEIGHT = 326; /** Node types whose LiteGraph size grows when a DOM/preview widget rehydrates. */ export const DOM_PREVIEW_NODE_TYPES = new Set([ "SaveImage", "PreviewImage", "LoadImage", "LoadImageMask", "SaveVideo", "VHS_LoadVideo", "VHS_VideoCombine", ]); function finiteExtent(value, fallback) { const n = Number(value); return Number.isFinite(n) && n > 0 ? n : fallback; } function finiteQuad(v) { if (!Array.isArray(v) || v.length !== 4) return null; const q = [Number(v[0]), Number(v[1]), Number(v[2]), Number(v[3])]; return q.every(Number.isFinite) ? q : null; } export function nodePosInsideGroupBounds(pos, bounds) { const [gx, gy, gw, gh] = bounds; const [x, y] = pos; // Inclusive on every edge: the reporter's "bounds cover the node" is a // top-left origin sitting on or inside the box, not a centre test. return x >= gx && x <= gx + gw && y >= gy && y <= gy + gh; } export function isDomPreviewNodeType(type) { return typeof type === "string" && DOM_PREVIEW_NODE_TYPES.has(type); } /** * Centres LiteGraph / the panel might use for containsCentre, given only the * geometry panel_query_graph actually returns (pos, size, type, collapsed, full_height). */ export function membershipCandidateCenters(node) { const x = node.pos[0]; const y = node.pos[1]; const pillW = finiteExtent(node.size[0], COLLAPSED_PILL_WIDTH); const fullW = finiteExtent(node.size[0], DEFAULT_NODE_WIDTH); const fullH = finiteExtent(node.size[1], DEFAULT_NODE_BODY_HEIGHT); const chipH = finiteExtent(node.full_height, NODE_TITLE_HEIGHT); const points = [ // Collapsed title-chip (syncNodeArea without forceCollapsed). { x: x + pillW / 2, y: y - NODE_TITLE_HEIGHT + chipH / 2 }, // forceCollapsed wantedNodeArea: size[1]===0 falls back to DEFAULT_NODE_BODY_HEIGHT. { x: x + fullW / 2, y: y - NODE_TITLE_HEIGHT + (fullH + NODE_TITLE_HEIGHT) / 2 }, // Raw pos + stored size (degenerate when size[1] is 0 — origin itself). { x: x + fullW / 2, y: y + (node.size[1] > 0 ? node.size[1] : 0) / 2 }, ]; // #1921 — expanded DOM-preview body after widget rehydrate. Collapsed chips stay chips. if (!node.collapsed && isDomPreviewNodeType(node.type)) { const previewH = Math.max(fullH, DOM_PREVIEW_NODE_MIN_BODY_HEIGHT); if (previewH > fullH) { points.push({ x: x + fullW / 2, y: y + previewH / 2 }); points.push({ x: x + fullW / 2, y: y - NODE_TITLE_HEIGHT + (previewH + NODE_TITLE_HEIGHT) / 2 }); } } return points; } /** Grow `bounds` so every point is a containsCentre member (max edge exclusive). */ export function expandBoundsToContainCenters(bounds, points) { let [x, y, w, h] = bounds; let x2 = x + w; let y2 = y + h; for (const p of points) { if (!Number.isFinite(p.x) || !Number.isFinite(p.y)) continue; if (p.x < x) x = p.x; if (p.y < y) y = p.y; if (p.x >= x2) x2 = p.x + 1; if (p.y >= y2) y2 = p.y + 1; } return [x, y, x2 - x, y2 - y]; } export function expandGroupBoundsForMembership(bounds, nodes) { const points = []; for (const n of nodes) { if (!nodePosInsideGroupBounds(n.pos, bounds)) continue; points.push(...membershipCandidateCenters(n)); } return points.length ? expandBoundsToContainCenters(bounds, points) : bounds; } function idKey(id) { return String(id); } export function classifyRequestedMembership(requested, members) { const reqKeys = new Set(requested.map(idKey)); const memberKeys = new Set(members.map(idKey)); return { extra: members.filter((id) => !reqKeys.has(idKey(id))), missing: requested.filter((id) => !memberKeys.has(idKey(id))), }; } function asIdList(v) { if (!Array.isArray(v)) return []; return v.filter((id) => typeof id === "number" || typeof id === "string"); } function parseToolJson(res) { if (!res || 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 parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null; } catch { return null; } } function withPayload(res, payload) { const first = res.content[0]; if (!first || first.type !== "text") return res; return { ...res, content: [{ type: "text", text: JSON.stringify(payload, null, 2) }, ...res.content.slice(1)], }; } function groupFromCreateReply(payload) { const inner = payload.group; if (inner && typeof inner === "object" && !Array.isArray(inner)) return inner; if ("bounding" in payload || "node_ids" in payload) return payload; return null; } export function parseDetailNodesFromQueryText(text) { if (typeof text !== "string") return []; const out = []; for (const line of text.split("\n")) { const s = line.trim(); if (!s.startsWith("{")) continue; try { const row = JSON.parse(s); const id = row.id; if (typeof id !== "number" && typeof id !== "string") continue; const pos = row.pos; const size = row.size; if (!Array.isArray(pos) || pos.length < 2 || !Array.isArray(size) || size.length < 2) continue; const x = Number(pos[0]); const y = Number(pos[1]); const w = Number(size[0]); const h = Number(size[1]); if (![x, y, w, h].every(Number.isFinite)) continue; const geom = { id, pos: [x, y], size: [w, h] }; if (typeof row.type === "string" && row.type) geom.type = row.type; if (row.collapsed === true) geom.collapsed = true; if (typeof row.full_height === "number" && Number.isFinite(row.full_height)) { geom.full_height = row.full_height; } out.push(geom); } catch { /* skip a non-JSON detail line */ } } return out; } function honestyWarning(extra, missing) { return ("group membership is geometric: the box that wraps the requested nodes " + `also captures ${extra} unrelated node(s) (their centre falls inside)` + (missing ? ` and misses ${missing} requested node(s)` : "") + ". Move the intended nodes into a contiguous region (panel_edit_node / " + "panel_auto_layout) before grouping, or edit the group bounds, to get an exact set."); } function attachHonesty(group, requested) { const live = asIdList(group.node_ids); const { extra, missing } = classifyRequestedMembership(requested, live); const out = { ...group, requested_node_ids: requested }; delete out.extra_node_ids; delete out.missing_node_ids; delete out.warning; if (extra.length || missing.length) { if (extra.length) out.extra_node_ids = extra; if (missing.length) out.missing_node_ids = missing; out.warning = honestyWarning(extra.length, missing.length); } return out; } function quadsEqual(a, b) { return a[0] === b[0] && a[1] === b[1] && a[2] === b[2] && a[3] === b[3]; } /** * After graph_create_group, query the requested nodes and expand the box so * every origin-inside node stays a containsCentre member — including collapsed * fallback centres (#1877) and DOM-preview full height after rehydrate (#1921). * Never throws: a failed repair returns the original create result. */ export async function includeRequestedCreateGroupMembers(created, requestedIds, call) { try { if (created.isError) return created; const requested = asIdList(requestedIds); if (!requested.length) return created; const payload = parseToolJson(created); if (!payload) return created; const group = groupFromCreateReply(payload); if (!group) return created; const bounds = finiteQuad(group.bounding); if (!bounds) return created; const query = await call({ cmd: "graph_query", ids: requested, fields: "detail", limit: Math.min(Math.max(requested.length, 1), 200), }, 8000); if (query.isError) return created; const qPayload = parseToolJson(query); const nodes = parseDetailNodesFromQueryText(qPayload?.text).filter((n) => requested.some((id) => idKey(id) === idKey(n.id))); const repairable = nodes.filter((n) => nodePosInsideGroupBounds(n.pos, bounds)); if (!repairable.length) return created; const expanded = expandGroupBoundsForMembership(bounds, repairable); if (quadsEqual(expanded, bounds)) return created; const groupId = group.id; if (typeof groupId !== "number" && typeof groupId !== "string") return created; const edited = await call({ cmd: "graph_edit_group", group_id: groupId, bounds: expanded }, 15000); if (edited.isError) return created; const ePayload = parseToolJson(edited); if (!ePayload) return created; const editedGroup = groupFromCreateReply(ePayload); if (!editedGroup) return created; return withPayload(created, { ...ePayload, group: attachHonesty(editedGroup, requested) }); } catch { return created; } } //# sourceMappingURL=create-group-membership.js.map