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),
370 lines • 15.6 kB
JavaScript
import { buildNodeLabel, wrapNodeLabel, escapeForMermaid, extractConnections, guessOutputType, isConnection, DISPLAY_VALUES, } from "./mermaid-converter.js";
// Section shapes for the overview diagram
const SECTION_SHAPES = {
sampling: (id, label) => `${id}{{"${label}"}}`,
conditioning: (id, label) => `${id}(["${label}"])`,
loading: (id, label) => `${id}[["${label}"]]`,
output: (id, label) => `${id}((("${label}")))`,
image: (id, label) => `${id}("${label}")`,
};
// Sanitize section name for use as a mermaid node ID (alphanumeric + underscore only)
function sectionId(name) {
return "sec_" + name.replace(/[^a-zA-Z0-9]/g, "_");
}
function sectionShape(sectionName, id, label) {
const shaper = SECTION_SHAPES[sectionName];
if (shaper)
return shaper(id, label);
return `${id}["${label}"]`;
}
export function generateOverview(workflow, sections, options = {}) {
const { direction = "TB" } = options;
const lines = [`flowchart ${direction}`];
// Emit section nodes
for (const [name, section] of sections) {
const nodeCount = section.nodeIds.size;
const keyTypesStr = section.keyTypes.join(", ");
const label = escapeForMermaid(`${section.label} (${nodeCount} nodes)<br/>${keyTypesStr}`);
lines.push(` ${sectionShape(name, sectionId(name), label)}`);
}
// Emit cross-section edges
// Deduplicate: collect all unique from->to pairs with their data types
const edgePairs = new Map();
for (const [name, section] of sections) {
for (const outEdge of section.outEdges) {
const key = `${sectionId(name)}->${sectionId(outEdge.toSection)}`;
if (!edgePairs.has(key))
edgePairs.set(key, new Set());
for (const dt of outEdge.dataTypes) {
edgePairs.get(key).add(dt);
}
}
}
for (const [key, dataTypes] of edgePairs) {
const [from, to] = key.split("->");
const typeLabel = [...dataTypes].slice(0, 4).join(", ");
if (typeLabel) {
lines.push(` ${from} -->|${escapeForMermaid(typeLabel)}| ${to}`);
}
else {
lines.push(` ${from} --> ${to}`);
}
}
// Style classes
lines.push("");
lines.push(" classDef sampling fill:#ff9,stroke:#f90,stroke-width:2px");
lines.push(" classDef loading fill:#9cf,stroke:#36f,stroke-width:2px");
lines.push(" classDef conditioning fill:#fc9,stroke:#f60,stroke-width:2px");
lines.push(" classDef output fill:#9f9,stroke:#0a0,stroke-width:2px");
lines.push(" classDef image fill:#c9f,stroke:#60f,stroke-width:2px");
// Apply classes to section nodes
for (const [name] of sections) {
const cls = ["sampling", "loading", "conditioning", "output", "image"].includes(name)
? name
: undefined;
if (cls) {
lines.push(` class ${sectionId(name)} ${cls}`);
}
}
return lines.join("\n");
}
export function generateSectionDetail(workflow, sections, sectionName, options = {}) {
const { showValues = true, direction = "LR" } = options;
const section = sections.get(sectionName);
if (!section) {
const available = [...sections.keys()].join(", ");
throw new Error(`Section "${sectionName}" not found. Available sections: ${available}`);
}
const lines = [`flowchart ${direction}`];
const nodeIds = section.nodeIds;
// Categorize each node using a simple heuristic for shape wrapping
function simpleCategory(classType) {
if (classType.startsWith("KSampler") || classType === "SamplerCustom")
return "sampling";
if (classType === "CLIPTextEncode" ||
classType.startsWith("Conditioning"))
return "conditioning";
if (classType.startsWith("CheckpointLoader") ||
classType.startsWith("CLIPLoader") ||
classType.startsWith("VAELoader") ||
classType.startsWith("LoraLoader"))
return "loading";
if (classType === "SaveImage" || classType === "PreviewImage")
return "output";
if (classType === "VAEDecode" ||
classType === "VAEEncode" ||
classType === "LoadImage" ||
classType.startsWith("ImageScale"))
return "image";
return "utility";
}
// Emit main section nodes
lines.push(` subgraph ${section.label}`);
for (const id of nodeIds) {
const node = workflow[id];
if (!node)
continue;
const label = escapeForMermaid(buildNodeLabel(node, showValues));
const cat = simpleCategory(node.class_type);
lines.push(` ${wrapNodeLabel(id, label, cat)}`);
}
lines.push(" end");
// Find boundary nodes: nodes in other sections that connect to/from this section
const allEdges = extractConnections(workflow);
const boundaryNodes = new Map();
for (const edge of allEdges) {
const sourceInSection = nodeIds.has(edge.sourceId);
const targetInSection = nodeIds.has(edge.targetId);
if (sourceInSection && !targetInSection) {
// Outgoing edge: target is a boundary node
const targetNode = workflow[edge.targetId];
if (targetNode && !boundaryNodes.has(edge.targetId)) {
// Find which section the target belongs to
let targetSectionName = "?";
for (const [sName, sInfo] of sections) {
if (sInfo.nodeIds.has(edge.targetId)) {
targetSectionName = sInfo.label;
break;
}
}
boundaryNodes.set(edge.targetId, {
label: `${targetNode._meta?.title ?? targetNode.class_type} (${targetSectionName})`,
direction: "out",
sectionName: targetSectionName,
});
}
}
else if (!sourceInSection && targetInSection) {
// Incoming edge: source is a boundary node
const sourceNode = workflow[edge.sourceId];
if (sourceNode && !boundaryNodes.has(edge.sourceId)) {
let sourceSectionName = "?";
for (const [sName, sInfo] of sections) {
if (sInfo.nodeIds.has(edge.sourceId)) {
sourceSectionName = sInfo.label;
break;
}
}
boundaryNodes.set(edge.sourceId, {
label: `${sourceNode._meta?.title ?? sourceNode.class_type} (${sourceSectionName})`,
direction: "in",
sectionName: sourceSectionName,
});
}
}
}
// Emit boundary nodes
if (boundaryNodes.size > 0) {
lines.push(` subgraph External`);
for (const [id, info] of boundaryNodes) {
const escapedLabel = escapeForMermaid(info.label);
lines.push(` ${id}:::boundary["${escapedLabel}"]`);
}
lines.push(" end");
}
// Emit edges (only those where at least one endpoint is in this section)
for (const edge of allEdges) {
const sourceInSection = nodeIds.has(edge.sourceId);
const targetInSection = nodeIds.has(edge.targetId);
const sourceIsBoundary = boundaryNodes.has(edge.sourceId);
const targetIsBoundary = boundaryNodes.has(edge.targetId);
// Require at least one endpoint actually in the section (not just boundary)
const touchesSection = sourceInSection || targetInSection;
const bothVisible = (sourceInSection || sourceIsBoundary) &&
(targetInSection || targetIsBoundary);
if (touchesSection && bothVisible) {
const sourceNode = workflow[edge.sourceId];
const dataType = sourceNode
? guessOutputType(sourceNode.class_type, edge.outputIndex)
: "";
const labelPart = dataType ? `|${dataType}|` : "";
if (labelPart) {
lines.push(` ${edge.sourceId} -->${labelPart} ${edge.targetId}`);
}
else {
lines.push(` ${edge.sourceId} --> ${edge.targetId}`);
}
}
}
// Boundary styling
lines.push("");
lines.push(" classDef boundary fill:#eee,stroke:#999,stroke-width:1px,stroke-dasharray: 5 5");
return lines.join("\n");
}
export function listSections(workflow, sections) {
const lines = [];
const totalNodes = Object.keys(workflow).length;
lines.push(`## Workflow Overview: ${totalNodes} total nodes, ${sections.size} sections\n`);
for (const [name, section] of sections) {
lines.push(`### ${section.label} (${section.nodeIds.size} nodes)`);
lines.push(`Key types: ${section.keyTypes.join(", ")}`);
if (section.inEdges.length > 0) {
const inSummary = section.inEdges
.map((e) => `${e.fromSection} (${[...e.dataTypes].join(", ")})`)
.join("; ");
lines.push(`Receives from: ${inSummary}`);
}
if (section.outEdges.length > 0) {
const outSummary = section.outEdges
.map((e) => `${e.toSection} (${[...e.dataTypes].join(", ")})`)
.join("; ");
lines.push(`Sends to: ${outSummary}`);
}
lines.push("");
}
return lines.join("\n");
}
// Key widget values to show in AI summary (superset of DISPLAY_VALUES)
const SUMMARY_VALUES = new Set([
...DISPLAY_VALUES,
"add_noise",
"start_at_step",
"end_at_step",
"return_with_leftover_noise",
"noise_seed",
"lora_01",
"strength_01",
"lora_02",
"strength_02",
"clip_name",
"unet_name",
"vae_name",
"type",
"weight_dtype",
"model_name",
"shift",
"length",
"batch_size",
"generation_mode",
"positive_prompt",
"negative_prompt",
"crop",
"num_frames",
]);
function formatNodeSummary(id, node) {
const title = node._meta?.title;
const classType = node.class_type;
const nameStr = title && title !== classType ? `${classType} "${title}"` : classType;
// Collect key settings
const settings = [];
for (const [key, value] of Object.entries(node.inputs)) {
if (isConnection(value))
continue;
if (!SUMMARY_VALUES.has(key))
continue;
let display;
if (typeof value === "string") {
// Truncate long strings
display = value.length > 50 ? value.slice(0, 47) + "..." : value;
}
else {
display = String(value);
}
settings.push(`${key}=${display}`);
}
const settingsStr = settings.length > 0 ? ` — ${settings.join(", ")}` : "";
return ` ${id}: ${nameStr}${settingsStr}`;
}
export function generateSummary(workflow, sections, objectInfo, virtualEdges, nodeToSection, getSetNodeIds) {
const lines = [];
const totalNodes = Object.keys(workflow).length;
const getSetCount = getSetNodeIds.size;
lines.push(`# Workflow: ${totalNodes} nodes (${totalNodes - getSetCount} real + ${getSetCount} Get/Set), ${sections.size} sections`);
lines.push("");
// Sections with node details
for (const [, section] of sections) {
lines.push(`## ${section.label} (${section.nodeIds.size} nodes)`);
// Node list with IDs and key settings
for (const id of section.nodeIds) {
const node = workflow[id];
if (!node)
continue;
lines.push(formatNodeSummary(id, node));
}
// Cross-section data flow using real output types from object_info
const receives = [];
const sends = [];
for (const inEdge of section.inEdges) {
const types = [...inEdge.dataTypes].join(", ");
receives.push(`← ${inEdge.fromSection}: ${types}`);
}
for (const outEdge of section.outEdges) {
const types = [...outEdge.dataTypes].join(", ");
sends.push(`→ ${outEdge.toSection}: ${types}`);
}
if (receives.length > 0 || sends.length > 0) {
lines.push(" Data flow:");
for (const r of receives)
lines.push(` ${r}`);
for (const s of sends)
lines.push(` ${s}`);
}
lines.push("");
}
// Virtual wires (Get/Set)
if (virtualEdges.length > 0) {
lines.push("## Virtual Wires (Get/Set)");
// Group by key
const wiresByKey = new Map();
for (const ve of virtualEdges) {
if (!wiresByKey.has(ve.key)) {
wiresByKey.set(ve.key, { setterId: ve.sourceNodeId, getterIds: [] });
}
wiresByKey.get(ve.key).getterIds.push(ve.targetNodeId);
}
for (const [key, wire] of wiresByKey) {
// Find what feeds into the SetNode
const setNode = workflow[wire.setterId];
let feederInfo = "";
if (setNode) {
for (const [, value] of Object.entries(setNode.inputs)) {
if (isConnection(value)) {
const feederId = value[0];
const feederNode = workflow[feederId];
if (feederNode) {
const feederSection = nodeToSection.get(feederId) ?? "?";
feederInfo = ` fed by ${feederId}:${feederNode._meta?.title ?? feederNode.class_type} [${feederSection}]`;
}
break;
}
}
}
// Find what consumes from each GetNode
const consumers = [];
for (const getterId of wire.getterIds) {
for (const [nodeId, node] of Object.entries(workflow)) {
for (const [inputName, value] of Object.entries(node.inputs)) {
if (isConnection(value) && value[0] === getterId) {
const consumerSection = nodeToSection.get(nodeId) ?? "?";
consumers.push(`${nodeId}:${node._meta?.title ?? node.class_type}.${inputName} [${consumerSection}]`);
}
}
}
}
lines.push(` "${key}": Set(${wire.setterId})${feederInfo} → Get(${wire.getterIds.join(", ")})`);
if (consumers.length > 0) {
for (const c of consumers) {
lines.push(` → ${c}`);
}
}
}
lines.push("");
}
// Connection graph: list all direct node-to-node connections for reference
lines.push("## Connection Graph");
const allEdges = extractConnections(workflow);
for (const edge of allEdges) {
const sourceNode = workflow[edge.sourceId];
const targetNode = workflow[edge.targetId];
if (!sourceNode || !targetNode)
continue;
// Get real output type from object_info
const def = objectInfo[sourceNode.class_type];
const outputType = def?.output?.[edge.outputIndex] ??
guessOutputType(sourceNode.class_type, edge.outputIndex) ??
"?";
lines.push(` ${edge.sourceId}:${sourceNode.class_type}[${edge.outputIndex}] --${outputType}--> ${edge.targetId}:${targetNode.class_type}.${edge.inputName}`);
}
return lines.join("\n");
}
//# sourceMappingURL=hierarchical-mermaid.js.map