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),
464 lines • 18 kB
JavaScript
import { ValidationError } from "../utils/errors.js";
import { logger } from "../utils/logger.js";
/**
* Parse a mermaid flowchart string into structured nodes and edges.
*/
export function parseMermaid(mermaidText) {
// Strip code fence if present
let text = mermaidText.trim();
if (text.startsWith("```")) {
text = text.replace(/^```(?:mermaid)?\s*\n?/, "").replace(/\n?```\s*$/, "");
}
const lines = text.split("\n").map((l) => l.trim());
const nodes = new Map();
const edges = [];
let direction = "LR";
for (const line of lines) {
// Parse direction from flowchart declaration
const dirMatch = line.match(/^flowchart\s+(LR|TB|TD|RL)$/);
if (dirMatch) {
direction = dirMatch[1] === "TD" ? "TB" : dirMatch[1];
continue;
}
// Skip subgraph/end/style/linkStyle lines
if (line.startsWith("subgraph ") ||
line === "end" ||
line.startsWith("style ") ||
line.startsWith("linkStyle ") ||
line === "") {
continue;
}
// Try to parse as edge: sourceId -->|TYPE| targetId OR sourceId --> targetId
const edgeMatch = line.match(/^(\S+)\s+--+>(?:\|([^|]*)\|)?\s*(\S+)$/);
if (edgeMatch) {
edges.push({
sourceId: edgeMatch[1],
targetId: edgeMatch[3],
dataType: edgeMatch[2] || undefined,
});
continue;
}
// Try to parse as node definition with various shapes
const nodeResult = parseNodeLine(line);
if (nodeResult) {
nodes.set(nodeResult.id, nodeResult);
}
}
if (nodes.size === 0) {
throw new ValidationError("Could not parse any nodes from mermaid text. Expected format: flowchart LR/TB with node definitions.");
}
return { nodes, edges, direction };
}
/**
* Parse a single node definition line.
* Handles all shape variants:
* id["label"] — rectangle (loading, utility)
* id(["label"]) — stadium (conditioning)
* id{{"label"}} — hexagon (sampling)
* id("label") — rounded (image)
* id((("label"))) — triple circle (output)
* id(("label")) — double circle
*/
function parseNodeLine(line) {
// Match: id followed by shape-wrapped label
// The id is one or more non-whitespace chars at the start (before the shape opener)
const patterns = [
// Triple circle: id((("label")))
/^(\S+?)\(\(\("(.+?)"\)\)\)$/,
// Double circle: id(("label"))
/^(\S+?)\(\("(.+?)"\)\)$/,
// Hexagon: id{{"label"}}
/^(\S+?)\{\{"(.+?)"\}\}$/,
// Stadium: id(["label"])
/^(\S+?)\(\["(.+?)"\]\)$/,
// Rounded: id("label")
/^(\S+?)\("(.+?)"\)$/,
// Rectangle: id["label"]
/^(\S+?)\["(.+?)"\]$/,
];
for (const pattern of patterns) {
const match = line.match(pattern);
if (match) {
const id = match[1];
const rawLabel = match[2];
return parseLabel(id, rawLabel);
}
}
return null;
}
/**
* Parse the inner label text.
* Format: "ClassName\nkey:value key2:value2"
* or: "Title\nkey:value key2:value2"
*/
function parseLabel(id, rawLabel) {
// Unescape mermaid HTML entities
const label = rawLabel.replace(/#quot;/g, '"');
// Split on literal \n (the escaped newline in mermaid syntax)
const parts = label.split("\\n");
const title = parts[0].trim();
const values = {};
if (parts.length > 1) {
// The rest is space-separated key:value pairs
const valuesStr = parts.slice(1).join(" ");
// Parse key:value pairs. Values can contain colons (filenames like model.safetensors),
// so we match key up to first colon, then value up to next key or end.
// Pattern: word chars + colon + value (up to next space-followed-by-word-colon, or end)
const kvRegex = /(\w+):(.+?)(?=\s+\w+:|$)/g;
let kvMatch;
while ((kvMatch = kvRegex.exec(valuesStr)) !== null) {
values[kvMatch[1]] = kvMatch[2].trim();
}
}
return { id, label: title, values };
}
/**
* Resolve a parsed mermaid graph into a valid ComfyUI workflow JSON
* using node definitions from /object_info.
*/
export function resolveWorkflow(parsed, objectInfo) {
const workflow = {};
const warnings = [];
// Build lookup: display_name → class_type
const displayNameMap = new Map();
for (const [className, def] of Object.entries(objectInfo)) {
if (def.display_name) {
displayNameMap.set(def.display_name.toLowerCase(), className);
}
// Also map the class name itself (case-insensitive)
displayNameMap.set(className.toLowerCase(), className);
}
// Phase 1: Resolve each node's class_type
const nodeClassTypes = new Map();
for (const [id, node] of parsed.nodes) {
const resolved = resolveClassType(node.label, objectInfo, displayNameMap);
if (resolved) {
nodeClassTypes.set(id, resolved);
}
else {
// Try inferring from connection data type signature
const inferred = inferClassTypeFromConnections(id, parsed.edges, objectInfo);
if (inferred) {
nodeClassTypes.set(id, inferred);
logger.info(`Node ${id}: Inferred class_type "${inferred}" from connection signature for label "${node.label}"`);
}
else {
warnings.push(`Node ${id}: Could not resolve "${node.label}" to a known ComfyUI node type. Using label as class_type.`);
nodeClassTypes.set(id, node.label);
}
}
}
// Phase 2: Create workflow nodes with empty inputs
for (const [id, node] of parsed.nodes) {
const classType = nodeClassTypes.get(id);
workflow[id] = {
class_type: classType,
inputs: {},
};
// If the label differs from class_type, preserve it as _meta.title
if (node.label !== classType) {
workflow[id]._meta = { title: node.label };
}
}
// Phase 3: Wire connections using edge data types + object_info
for (const edge of parsed.edges) {
const sourceClassType = nodeClassTypes.get(edge.sourceId);
const targetClassType = nodeClassTypes.get(edge.targetId);
if (!sourceClassType || !targetClassType) {
warnings.push(`Edge ${edge.sourceId} → ${edge.targetId}: source or target node not found, skipping.`);
continue;
}
const sourceDef = objectInfo[sourceClassType];
const targetDef = objectInfo[targetClassType];
if (!sourceDef || !targetDef) {
warnings.push(`Edge ${edge.sourceId} → ${edge.targetId}: missing object_info for ${!sourceDef ? sourceClassType : targetClassType}. Using best guess.`);
// Best effort: put connection as first matching input
if (edge.dataType) {
const inputName = guessInputNameByType(edge.dataType, targetClassType);
if (inputName) {
workflow[edge.targetId].inputs[inputName] = [edge.sourceId, 0];
}
}
continue;
}
// Find the output index on the source that produces this data type
let outputIndex = 0;
if (edge.dataType && sourceDef.output) {
const idx = sourceDef.output.indexOf(edge.dataType);
if (idx >= 0) {
outputIndex = idx;
}
else {
warnings.push(`Edge ${edge.sourceId} → ${edge.targetId}: data type "${edge.dataType}" not found in ${sourceClassType} outputs [${sourceDef.output.join(", ")}]. Using index 0.`);
}
}
// Find the input name on the target that accepts this data type.
// Use the source node's title as a hint for disambiguation
// (e.g., "Positive Prompt" → positive, "Negative Prompt" → negative).
const sourceTitle = parsed.nodes.get(edge.sourceId)?.label ?? "";
const inputName = findInputForType(targetDef, edge.dataType, workflow[edge.targetId].inputs, sourceTitle);
if (inputName) {
workflow[edge.targetId].inputs[inputName] = [edge.sourceId, outputIndex];
}
else {
warnings.push(`Edge ${edge.sourceId} → ${edge.targetId}: could not find input on ${targetClassType} that accepts "${edge.dataType}".`);
}
}
// Phase 4: Fill widget values from parsed labels
for (const [id, node] of parsed.nodes) {
const classType = nodeClassTypes.get(id);
const def = objectInfo[classType];
if (!def)
continue;
for (const [key, rawValue] of Object.entries(node.values)) {
// Don't overwrite connections
if (isConnectionValue(workflow[id].inputs[key]))
continue;
// Coerce value to the correct type based on objectInfo
const coerced = coerceValue(key, rawValue, def);
if (coerced !== undefined) {
workflow[id].inputs[key] = coerced;
}
else {
// Fall back to string
workflow[id].inputs[key] = rawValue;
}
}
}
// Phase 5: Fill remaining required inputs with defaults from objectInfo
for (const [id] of parsed.nodes) {
const classType = nodeClassTypes.get(id);
const def = objectInfo[classType];
if (!def)
continue;
fillDefaults(workflow[id], def, warnings, id);
}
return { workflow, warnings };
}
/**
* Infer class_type by matching a node's connection signature against all known node types.
* E.g., a node that receives CLIP and outputs CONDITIONING is very likely CLIPTextEncode.
*/
function inferClassTypeFromConnections(nodeId, edges, objectInfo) {
// Collect incoming and outgoing data types for this node
const incomingTypes = new Set();
const outgoingTypes = new Set();
for (const edge of edges) {
if (edge.targetId === nodeId && edge.dataType) {
incomingTypes.add(edge.dataType);
}
if (edge.sourceId === nodeId && edge.dataType) {
outgoingTypes.add(edge.dataType);
}
}
if (incomingTypes.size === 0 && outgoingTypes.size === 0)
return null;
// Score each node type by how well it matches the signature
let bestMatch = null;
let bestScore = 0;
for (const [className, def] of Object.entries(objectInfo)) {
let score = 0;
// Check outgoing types match outputs
if (outgoingTypes.size > 0) {
const outputSet = new Set(def.output);
for (const t of outgoingTypes) {
if (outputSet.has(t))
score += 2;
}
}
// Check incoming types match inputs
if (incomingTypes.size > 0) {
const allInputs = {
...def.input.required,
...def.input.optional,
};
const inputTypes = new Set(Object.values(allInputs).map((spec) => typeof spec[0] === "string" ? spec[0] : ""));
for (const t of incomingTypes) {
if (inputTypes.has(t))
score += 1;
}
}
if (score > bestScore) {
bestScore = score;
bestMatch = className;
}
}
// Require a minimum score to avoid false matches
return bestScore >= 2 ? bestMatch : null;
}
/**
* Resolve a label to a known class_type.
*/
function resolveClassType(label, objectInfo, displayNameMap) {
// Direct match against class names
if (objectInfo[label])
return label;
// Case-insensitive match
const lower = label.toLowerCase();
const fromDisplay = displayNameMap.get(lower);
if (fromDisplay)
return fromDisplay;
// Fuzzy: try removing spaces (e.g., "Checkpoint Loader Simple" → "CheckpointLoaderSimple")
const noSpaces = label.replace(/\s+/g, "");
if (objectInfo[noSpaces])
return noSpaces;
// Try display name without spaces
const fromDisplayNoSpaces = displayNameMap.get(noSpaces.toLowerCase());
if (fromDisplayNoSpaces)
return fromDisplayNoSpaces;
return null;
}
/**
* Find an input on the target node that accepts the given data type.
* Avoids assigning to an input that already has a connection.
* Uses sourceTitle as a hint when multiple inputs accept the same type
* (e.g., KSampler has both "positive" and "negative" CONDITIONING inputs).
*/
function findInputForType(targetDef, dataType, currentInputs, sourceTitle) {
if (!dataType)
return null;
const allInputs = {
...targetDef.input.required,
...targetDef.input.optional,
};
// Collect all matching, unwired inputs
const candidates = [];
for (const [inputName, spec] of Object.entries(allInputs)) {
if (isConnectionValue(currentInputs[inputName]))
continue;
const acceptedType = spec[0];
const matches = (typeof acceptedType === "string" && acceptedType === dataType) ||
(Array.isArray(acceptedType) && acceptedType.includes(dataType));
if (matches) {
candidates.push(inputName);
}
}
if (candidates.length === 0)
return null;
if (candidates.length === 1)
return candidates[0];
// Multiple candidates — use sourceTitle to pick the best match.
// E.g., title "Positive Prompt" should match input named "positive",
// and "Negative Prompt" should match "negative".
if (sourceTitle) {
const titleLower = sourceTitle.toLowerCase();
for (const inputName of candidates) {
if (titleLower.includes(inputName.toLowerCase())) {
return inputName;
}
}
}
// Fall back to first available
return candidates[0];
}
/**
* Fallback: guess input name from data type for common patterns.
*/
function guessInputNameByType(dataType, _targetClassType) {
const typeToInput = {
MODEL: "model",
CLIP: "clip",
VAE: "vae",
CONDITIONING: "positive", // ambiguous but common
LATENT: "latent_image",
IMAGE: "images",
MASK: "mask",
CONTROL_NET: "control_net",
UPSCALE_MODEL: "upscale_model",
};
return typeToInput[dataType] ?? null;
}
/**
* Coerce a string value to the expected type based on objectInfo.
*/
function coerceValue(key, rawValue, def) {
const spec = findInputSpec(key, def);
if (!spec)
return undefined;
const [typeOrEnum, options] = spec;
// Enum type (array of allowed string values)
if (Array.isArray(typeOrEnum)) {
// Return the raw value if it's in the enum, otherwise closest match
if (typeOrEnum.includes(rawValue))
return rawValue;
// Try case-insensitive
const found = typeOrEnum.find((v) => typeof v === "string" && v.toLowerCase() === rawValue.toLowerCase());
return found ?? rawValue;
}
switch (typeOrEnum) {
case "INT": {
const n = Number(rawValue);
return Number.isFinite(n) ? Math.round(n) : undefined;
}
case "FLOAT": {
const n = Number(rawValue);
return Number.isFinite(n) ? n : undefined;
}
case "BOOLEAN":
return rawValue === "true" || rawValue === "1";
case "STRING":
return rawValue;
default:
// Unknown type — might be a connection type like MODEL, CLIP
// Don't set scalar values for connection types
return undefined;
}
}
/**
* Find the input spec for a given key in a node definition.
*/
function findInputSpec(key, def) {
if (def.input.required?.[key])
return def.input.required[key];
if (def.input.optional?.[key])
return def.input.optional[key];
return null;
}
/**
* Fill required inputs that weren't set by connections or parsed values.
*/
function fillDefaults(node, def, warnings, nodeId) {
const required = def.input.required ?? {};
for (const [inputName, spec] of Object.entries(required)) {
// Skip if already set
if (node.inputs[inputName] !== undefined)
continue;
const [typeOrEnum, options] = spec;
// Connection types (MODEL, CLIP, etc.) — can't fill with defaults, skip
if (typeof typeOrEnum === "string" &&
typeOrEnum === typeOrEnum.toUpperCase() &&
typeOrEnum.length > 1 &&
!["INT", "FLOAT", "STRING", "BOOLEAN"].includes(typeOrEnum)) {
continue;
}
// Enum: use first option as default
if (Array.isArray(typeOrEnum) && typeOrEnum.length > 0) {
node.inputs[inputName] = typeOrEnum[0];
continue;
}
// Use explicit default if available
if (options && "default" in options) {
node.inputs[inputName] = options.default;
continue;
}
// No default available
logger.debug(`Node ${nodeId} (${node.class_type}): required input "${inputName}" has no value or default`);
}
// Also fill optional inputs that have defaults (for completeness)
const optional = def.input.optional ?? {};
for (const [inputName, spec] of Object.entries(optional)) {
if (node.inputs[inputName] !== undefined)
continue;
const [, options] = spec;
if (options && "default" in options) {
node.inputs[inputName] = options.default;
}
}
}
function isConnectionValue(value) {
return (Array.isArray(value) &&
value.length === 2 &&
typeof value[0] === "string" &&
typeof value[1] === "number");
}
//# sourceMappingURL=mermaid-parser.js.map