Every block in api/blocks names what it gives a block after it ("gives": its sampler and its
output node; {} for one that changes the step it is put on) and its own model loader
("own_model"). api/block_chain.py builds a list of steps on a new tab: the picture first, each
next block anchored to what the step before gave, by near_node_id and a new page-only
save_node_id (canvas_roles.js findRoles pins the output node), so a canvas with several
samplers and outputs is no guess. add_nodes' answer names the block it built.
Redraw with another model and face/hand detailing now take the step before's picture
("$save.images") instead of the sampler's decode, which after an enlargement is the older
picture. The two-model routes are built as two-step chains; routes.json no longer names the
loaders.
Checked through the real page on the test ComfyUI: the Tile route on two Anima models (base on
Harem 1.5, Tile redraw on Kirazuri v4, from the base's picture and prompts), then face and hand
detailing after it took the Tile step's colour-matched picture.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
359 lines
20 KiB
JavaScript
359 lines
20 KiB
JavaScript
/**
|
|
* The page side of the MCP tools that build a workflow (api/mcp_canvas_edit.py): add a node or a
|
|
* group of them, wire an output to an input, set a node's values, remove nodes, and open a new workflow tab,
|
|
* blank or built from a workflow in ComfyUI's API form. Each runs through mcp_bridge.js's
|
|
* `recorded`, so it is one Ctrl+Z step, lands in the activity log as the AI's and shows under
|
|
* its step in the AI workbench. Everything is checked before the canvas changes; answers give
|
|
* the node ids, inputs and outputs the next call needs, and the inputs still unwired with what
|
|
* fits them (mcp_canvas_brief.js). canvas_build.js does the arithmetic. fill_slots puts
|
|
* run_workflow's blanks in (mcp_fill.js), on a tab open_workflow opened from that saved workflow.
|
|
*/
|
|
|
|
import { app } from '../../../scripts/app.js';
|
|
import { recordCanvasStep } from './canvas_history.js';
|
|
import { apiLinks, freeSpot, layered, placedBlock, typesFit, valuePlan } from './canvas_build.js';
|
|
import { fedBy, linkOf, sockets, unwired } from './mcp_canvas_brief.js';
|
|
import { fillPlan, valuesPlan } from './mcp_fill.js';
|
|
import { parsePngMetadataFromUrl } from './png_metadata.js';
|
|
import { freshId } from './detached_graph.js';
|
|
import { applyRecipeParametersToCanvas } from './recipe_actions.js';
|
|
import { translate as t } from './locales.js';
|
|
import { setWidgetValue } from './ui_node_model_picker.js';
|
|
import { showWorkbenchToast } from './ui_prompt_toast.js';
|
|
import { addWires, applyWires, groupWires, pairWires, reachesOutput, validateWires } from './canvas_wiring.js';
|
|
import { workflowKey } from './canvas_undo.js';
|
|
import { findRoles, placeRoles } from './canvas_roles.js';
|
|
|
|
const BLANK = { last_node_id: 0, last_link_id: 0, nodes: [], links: [], groups: [], config: {}, extra: {}, version: 0.4 };
|
|
const SAME_SETTING = { seed: 'noise_seed', noise_seed: 'seed' };
|
|
|
|
/** Socket details when a node was added without wiring; wired receipts already say what changed. */
|
|
function shape(g, node) {
|
|
return {
|
|
inputs: sockets(node).map(input => ({
|
|
name: input.name, type: String(input.type), ...(input.link != null ? { from_node: linkOf(g, input.link)?.origin_id } : {}),
|
|
})),
|
|
outputs: (node.outputs || []).map((output, slot) => ({ slot, name: output.name, type: String(output.type) })),
|
|
};
|
|
}
|
|
|
|
function newNode(type) {
|
|
if (!LiteGraph.registered_node_types?.[type]) throw new Error(`ComfyUI has no node type ${type}; find_nodes looks them up.`);
|
|
const node = LiteGraph.createNode(type);
|
|
if (!node) throw new Error(`ComfyUI could not make a ${type} node.`);
|
|
return node;
|
|
}
|
|
|
|
/** Writes checked values the way the user would, so ComfyUI and other extensions notice. */
|
|
function apply(node, plan) {
|
|
for (const [widget, value] of plan) {
|
|
const old = widget.value;
|
|
setWidgetValue(node, widget, value);
|
|
widget.callback?.(value, app.canvas, node, app.canvas?.graph_mouse, null);
|
|
node.onWidgetChanged?.(widget.name, value, old, widget);
|
|
}
|
|
}
|
|
|
|
function inputSlot(node, input) {
|
|
const slot = (node.inputs || []).findIndex(item => item.name === input);
|
|
if (slot < 0) throw new Error(`Node ${node.id} (${node.type}) has no input ${input}; it has: ${sockets(node).map(item => item.name).join(', ') || 'none'}.`);
|
|
return slot;
|
|
}
|
|
|
|
function wire(g, from, slot, to, target) {
|
|
if (from === to || fedBy(g, from, to.id)) {
|
|
throw new Error(`Node ${to.id} already feeds node ${from.id}; wiring it back would make a loop ComfyUI cannot run.`);
|
|
}
|
|
const given = from.outputs[slot].type;
|
|
const wanted = to.inputs[target].type;
|
|
if (!typesFit(given, wanted)) {
|
|
throw new Error(`Output ${from.outputs[slot].name} of node ${from.id} gives ${given}; input ${to.inputs[target].name} of node ${to.id} takes ${wanted}.`);
|
|
}
|
|
if (!from.connect(slot, to, target)) throw new Error(`ComfyUI refused to wire node ${from.id} to node ${to.id}.`);
|
|
}
|
|
|
|
/** A new tab's name: the AI's words and the time, so it never opens a saved workflow of that name. */
|
|
export function tabName(name) {
|
|
const words = String(name || '').replace(/[\\/:*?"<>|]+/g, ' ').trim().slice(0, 60) || 'AI workflow';
|
|
const now = new Date();
|
|
return `${words} ${[now.getHours(), now.getMinutes(), now.getSeconds()].map(n => String(n).padStart(2, '0')).join('')}`;
|
|
}
|
|
|
|
/** The nodes of an API-form workflow, made and checked off the canvas: {key: node}, plans and links. */
|
|
function prepare(nodes) {
|
|
const made = {};
|
|
const plans = {};
|
|
const links = apiLinks(nodes);
|
|
for (const [key, spec] of Object.entries(nodes)) {
|
|
const node = newNode(String(spec?.class_type || ''));
|
|
if (spec?._meta?.title) node.title = String(spec._meta.title).slice(0, 120);
|
|
const values = Object.fromEntries(Object.entries(spec?.inputs || {}).filter(([, value]) => !Array.isArray(value)));
|
|
try {
|
|
plans[key] = valuePlan(node, values);
|
|
} catch (error) {
|
|
throw new Error(`Node ${key}: ${error.message}`);
|
|
}
|
|
// Settings it takes from the node it is like (canvas_roles.js), where it has them and the value fits;
|
|
// a sampler's seed is noise_seed on KSampler Advanced and the other way round.
|
|
for (const [carried, value] of Object.entries(spec?.carry || {})) {
|
|
const name = [carried, SAME_SETTING[carried]].find(item => item && (node.widgets || []).some(widget => widget.name === item));
|
|
if (!name || name in values) continue;
|
|
try { plans[key].push(...valuePlan(node, { [name]: value })); } catch { /* not a setting of this node */ }
|
|
}
|
|
made[key] = node;
|
|
}
|
|
for (const link of links) {
|
|
const from = made[link.from];
|
|
const to = made[link.to];
|
|
if (!from.outputs?.[link.slot]) throw new Error(`Node ${link.from} (${from.type}) has no output ${link.slot}.`);
|
|
const target = (to.inputs || []).findIndex(item => item.name === link.input);
|
|
if (target < 0) throw new Error(`Node ${link.to} (${to.type}) has no input ${link.input}.`);
|
|
if (!typesFit(from.outputs[link.slot].type, to.inputs[target].type)) {
|
|
throw new Error(`Node ${link.from}'s output ${link.slot} (${from.outputs[link.slot].type}) cannot feed ${link.input} of node ${link.to} (${to.inputs[target].type}).`);
|
|
}
|
|
}
|
|
return { made, plans, links };
|
|
}
|
|
|
|
/**
|
|
* `make()` again after ComfyUI's lists were read anew, when a value was not among a drop-down's
|
|
* choices: a file that has just appeared (a picture Anomalous put in the input folder to repaint).
|
|
*/
|
|
async function withFreshChoices(make) {
|
|
try {
|
|
return make();
|
|
} catch (error) {
|
|
if (!/has no choice/.test(error.message) || typeof app.refreshComboInNodes !== 'function') throw error;
|
|
await app.refreshComboInNodes();
|
|
return make();
|
|
}
|
|
}
|
|
|
|
/** The canvas-building actions, given mcp_bridge.js's own helpers. */
|
|
export function canvasEditActions({ recorded, nodeById }) {
|
|
function addNode({ type, title, values, near_node_id: nearId, replace = false, ...wiring }, getOwner) {
|
|
return recorded(getOwner, 'mcpDidNode', async (g) => {
|
|
const key = workflowKey(app), before = JSON.stringify(g.serialize());
|
|
const { node, plan } = await withFreshChoices(() => {
|
|
const made = newNode(String(type));
|
|
return { node: made, plan: valuePlan(made, values) };
|
|
});
|
|
if ((app.canvas?.graph || app.graph) !== g || workflowKey(app) !== key || JSON.stringify(g.serialize()) !== before) {
|
|
throw new Error('The canvas changed while preparing the node. Nothing added; read get_situation again.');
|
|
}
|
|
if (title) node.title = String(title).slice(0, 120);
|
|
const nearBy = nearId ?? wiring.insert_after ?? wiring.wire_from?.[0] ?? wiring.wire_to?.[0];
|
|
const near = nearBy != null ? nodeById(nearBy) : null;
|
|
const wires = validateWires(g, addWires(g, node, wiring), replace);
|
|
let connections;
|
|
g.beforeChange?.();
|
|
try {
|
|
node.pos = freeSpot(node, (g._nodes || []), near);
|
|
connections = applyWires(g, wires, node, () => apply(node, plan));
|
|
} finally { g.afterChange?.(); }
|
|
g.setDirtyCanvas?.(true, true);
|
|
recordCanvasStep(app);
|
|
return { node_id: node.id, type: node.type, connections, reaches_output: reachesOutput(g, node),
|
|
...unwired(g), ...(!connections.length ? shape(g, node) : {}),
|
|
toast: { name: node.title || node.type } };
|
|
});
|
|
}
|
|
|
|
/** A group of new nodes in API form, wired among themselves, to the canvas ("#12", or a block's roles: canvas_roles.js) and by wire_to, as one block and one Ctrl+Z step. */
|
|
function addNodes({ nodes: asked, near_node_id: nearId, save_node_id: saveId = null, replace = false, roles = {} }, getOwner) {
|
|
return recorded(getOwner, 'mcpDidNodes', async (g) => {
|
|
const nodes = placeRoles(g, asked, nearId, saveId);
|
|
// A block's settings on the canvas nodes it goes with (the sampler's denoise), in the same Ctrl+Z step.
|
|
const found = Object.keys(roles).length ? findRoles(g, nearId, saveId) : {};
|
|
const settings = Object.entries(roles).filter(([role]) => found[role]).map(([role, values]) => [found[role], valuePlan(found[role], values)]);
|
|
const key = workflowKey(app), before = JSON.stringify(g.serialize());
|
|
const built = await withFreshChoices(() => prepare(nodes));
|
|
if ((app.canvas?.graph || app.graph) !== g || workflowKey(app) !== key || JSON.stringify(g.serialize()) !== before) {
|
|
throw new Error('The canvas changed while preparing the nodes. Nothing added; read get_situation again.');
|
|
}
|
|
const wires = validateWires(g, groupWires(g, built.made, nodes), replace);
|
|
const keyOf = new Map(Object.entries(built.made).map(([name, node]) => [node, name]));
|
|
const inside = wires.filter(p => keyOf.has(p.from) && keyOf.has(p.to)).map(p => ({ from: keyOf.get(p.from), to: keyOf.get(p.to) }));
|
|
const spots = placedBlock(built.made, inside, g._nodes || [], nearId != null ? nodeById(nearId) : null);
|
|
const gone = Object.entries(nodes).filter(([, spec]) => spec.replaces != null).map(([name, spec]) => [name, g.getNodeById(spec.replaces)]);
|
|
for (const [name, old] of gone) spots[name] = [...old.pos];
|
|
const added = Object.values(built.made);
|
|
let connections;
|
|
g.beforeChange?.();
|
|
try {
|
|
for (const [name, node] of Object.entries(built.made)) node.pos = spots[name];
|
|
connections = applyWires(g, wires, added, () => Object.entries(built.made).forEach(([name, node]) => apply(node, built.plans[name])));
|
|
settings.forEach(([node, plan]) => apply(node, plan));
|
|
for (const [, old] of gone) g.remove(old);
|
|
} finally { g.afterChange?.(); }
|
|
g.setDirtyCanvas?.(true, true);
|
|
recordCanvasStep(app);
|
|
const spliced = connections.filter(link => link.replaced_from != null);
|
|
return {
|
|
nodes: Object.fromEntries(Object.entries(built.made).map(([name, node]) => [name, node.id])), wired: connections.length,
|
|
...(spliced.length ? { spliced } : {}), ...(gone.length ? { replaced: gone.map(([, old]) => old.id) } : {}), reaches_output: added.every(node => reachesOutput(g, node)),
|
|
...unwired(g), toast: { count: added.length },
|
|
};
|
|
});
|
|
}
|
|
|
|
function connectNodes({ from_node_id: fromId, output, to_node_id: toId, input, replace }, getOwner) {
|
|
return recorded(getOwner, 'mcpDidWire', (g) => {
|
|
const from = nodeById(fromId);
|
|
const to = nodeById(toId);
|
|
const plan = validateWires(g, pairWires(from, to, { output, input }), replace ?? (input != null && output != null));
|
|
let connections;
|
|
g.beforeChange?.();
|
|
try {
|
|
connections = applyWires(g, plan);
|
|
} finally {
|
|
g.afterChange?.();
|
|
}
|
|
g.setDirtyCanvas?.(true, true);
|
|
recordCanvasStep(app);
|
|
return {
|
|
connections, reaches_output: reachesOutput(g, from),
|
|
...unwired(g), toast: { from: from.title || from.type, to: to.title || to.type },
|
|
};
|
|
});
|
|
}
|
|
|
|
/** One node's settings (node_id), or settings by name for whichever nodes have them (mcp_fill.js valuesPlan). */
|
|
function setValues({ node_id: nodeId, values }, getOwner) {
|
|
return recorded(getOwner, 'mcpDidValues', async (g) => {
|
|
const plans = await withFreshChoices(() => (nodeId != null
|
|
? [{ node: nodeById(nodeId), plan: valuePlan(nodeById(nodeId), values) }] : valuesPlan(g, values)));
|
|
const count = plans.reduce((sum, { plan }) => sum + plan.length, 0);
|
|
if (!count) throw new Error('values is empty: {setting name: value}.');
|
|
g.beforeChange?.();
|
|
for (const { node, plan } of plans) {
|
|
apply(node, plan);
|
|
if (app.lastNodeErrors?.[node.id]) delete app.lastNodeErrors[node.id];
|
|
}
|
|
g.afterChange?.();
|
|
g.setDirtyCanvas?.(true, true);
|
|
recordCanvasStep(app);
|
|
return {
|
|
set: Object.fromEntries(plans.map(({ node, plan }) => [node.id, Object.fromEntries(plan.map(([widget]) => [widget.name, widget.value]))])),
|
|
toast: { count },
|
|
};
|
|
});
|
|
}
|
|
|
|
function removeNodes({ node_ids: ids }, getOwner) {
|
|
return recorded(getOwner, 'mcpDidRemove', (g) => {
|
|
const nodes = [...new Set(ids)].map(nodeById);
|
|
g.beforeChange?.();
|
|
for (const node of nodes) g.remove(node);
|
|
g.afterChange?.();
|
|
g.setDirtyCanvas?.(true, true);
|
|
recordCanvasStep(app);
|
|
return { removed: nodes.map(node => ({ node_id: node.id, type: node.type })), ...unwired(g), toast: { count: nodes.length } };
|
|
});
|
|
}
|
|
|
|
async function newWorkflow({ name, nodes }, getOwner) {
|
|
const built = nodes ? await withFreshChoices(() => prepare(nodes)) : null; // nothing opens unless all of it fits
|
|
const tab = tabName(name);
|
|
getOwner()?.flushCanvasActivity?.(); // what was pending belongs to the tab being left
|
|
await app.loadGraphData(structuredClone(BLANK), true, true, tab);
|
|
if (!built) {
|
|
showWorkbenchToast(t('mcpDidWorkflow', { name: tab }));
|
|
return { opened: tab, nodes: {}, note: 'A blank workflow tab; add_node and connect_nodes build it.' };
|
|
}
|
|
return recorded(getOwner, 'mcpDidWorkflow', (g) => {
|
|
const spots = layered(built.made, built.links);
|
|
g.beforeChange?.();
|
|
for (const [key, node] of Object.entries(built.made)) {
|
|
node.pos = spots[key];
|
|
g.add(node);
|
|
apply(node, built.plans[key]);
|
|
}
|
|
for (const link of built.links) {
|
|
const to = built.made[link.to];
|
|
wire(g, built.made[link.from], link.slot, to, inputSlot(to, link.input));
|
|
}
|
|
g.afterChange?.();
|
|
g.setDirtyCanvas?.(true, true);
|
|
recordCanvasStep(app);
|
|
return { opened: tab, nodes: Object.fromEntries(Object.entries(built.made).map(([key, node]) => [key, node.id])), toast: { name: tab } };
|
|
});
|
|
}
|
|
|
|
/** Saved workflow `filename` (a recipe) or an example ("example:<id>"). */
|
|
async function savedWorkflow(filename, name) {
|
|
const example = String(filename || '').match(/^example:(.+)$/);
|
|
const response = await fetch(example ? `/anomalous/example_workflows?id=${encodeURIComponent(example[1])}`
|
|
: `/anomalous/recipe_full?filename=${encodeURIComponent(filename || '')}`);
|
|
const payload = await response.json().catch(() => ({}));
|
|
if (!response.ok || !payload.data?.workflow) throw new Error(`The saved workflow ${name} could not be read.`);
|
|
return structuredClone(payload.data.workflow);
|
|
}
|
|
|
|
/**
|
|
* The workflow gallery picture `picture` ({subfolder, filename}, found in the output folder by
|
|
* api/mcp_fill.py) carries, as a workflow of its own: a new id (the picture keeps the id of the
|
|
* tab it ran on, which may still be open, and ComfyUI would show that tab instead), and without
|
|
* that tab's marks: the saved workflow it was opened from (run_workflow would take this tab for
|
|
* it) and its group of saves.
|
|
*/
|
|
async function pictureWorkflow({ subfolder, filename }, name) {
|
|
const query = new URLSearchParams({ filename, subfolder: subfolder || '', type: 'output' });
|
|
const workflow = await parsePngMetadataFromUrl(`/view?${query}`);
|
|
if (!Array.isArray(workflow?.nodes)) throw new Error(`${name} carries no workflow to open.`);
|
|
const { anomalous_saved: saved, anomalous_saves: saves, ...extra } = workflow.extra || {};
|
|
return { ...workflow, id: freshId(), extra };
|
|
}
|
|
|
|
/**
|
|
* A saved workflow, an example or the workflow a gallery picture carries (`picture`), in a new tab named
|
|
* after it; the open one stays. The tab is marked with what it opened (api/mcp_fill.py opening), so
|
|
* run_workflow with the same name reuses it: in the workflow before it loads, as ComfyUI, finishing the
|
|
* new tab a moment later, may lay that workflow on the canvas again and a mark set afterwards would go.
|
|
*/
|
|
async function openWorkflow({ filename, name, plan, picture }, getOwner) {
|
|
const workflow = picture ? await pictureWorkflow(picture, name) : await savedWorkflow(filename, name);
|
|
const mark = picture ? `picture:${[picture.subfolder, picture.filename].filter(Boolean).join('/')}` : plan ? `${filename}#${plan}` : filename;
|
|
workflow.extra = { ...(workflow.extra || {}), anomalous_saved: mark };
|
|
const tab = tabName(name);
|
|
getOwner()?.flushCanvasActivity?.();
|
|
await app.loadGraphData(workflow, true, true, tab);
|
|
if (plan) await applyPlan(filename, plan, name);
|
|
showWorkbenchToast(t('mcpDidOpenWorkflow', { name }));
|
|
const g = app.canvas?.graph || app.graph;
|
|
if (g) g.extra = { ...(g.extra || {}), anomalous_saved: mark };
|
|
return { opened: tab, node_count: (g?._nodes || []).length, note: 'describe_canvas shows its nodes; add_node, connect_nodes and set_values change it.' };
|
|
}
|
|
|
|
/** A 方案 of saved workflow `filename` (its parameter set `plan`) onto the tab just opened, as its Apply button writes it. */
|
|
async function applyPlan(filename, plan, name) {
|
|
const response = await fetch(`/anomalous/parameters?recipe_filename=${encodeURIComponent(filename)}`);
|
|
const payload = await response.json().catch(() => ({}));
|
|
const set = (payload.notebooks || []).find(item => item.filename === plan);
|
|
if (!response.ok || !set) throw new Error(`The plan ${name} could not be read.`);
|
|
applyRecipeParametersToCanvas(set.data);
|
|
}
|
|
|
|
/** run_workflow's blanks (api/mcp_fill.py); `needs_open` when the canvas is not saved workflow `filename`. */
|
|
async function fillSlots({ filename, ...wanted }, getOwner) {
|
|
const g = app.canvas?.graph || app.graph;
|
|
if (!g) throw new Error('No workflow is open in ComfyUI.');
|
|
if (filename && g.extra?.anomalous_saved !== filename) return { needs_open: true };
|
|
const { plans, set } = await withFreshChoices(() => fillPlan(g, wanted));
|
|
if (!plans.length) return { set };
|
|
return recorded(getOwner, 'mcpDidFill', (graph) => {
|
|
graph.beforeChange?.();
|
|
for (const { node, plan } of plans) apply(node, plan);
|
|
graph.afterChange?.();
|
|
graph.setDirtyCanvas?.(true, true);
|
|
recordCanvasStep(app);
|
|
return { set, toast: { count: plans.length } };
|
|
});
|
|
}
|
|
|
|
return {
|
|
add_node: addNode, add_nodes: addNodes, connect_nodes: connectNodes, set_values: setValues, remove_nodes: removeNodes,
|
|
new_workflow: newWorkflow, open_workflow: openWorkflow, fill_slots: fillSlots,
|
|
};
|
|
}
|