- run_workflow takes a gallery picture as its workflow (mcp_fill.opening says what a name opens, for open_workflow too); a picture's workflow opens with an id of its own, and the tab's mark goes into the workflow before it loads, so ComfyUI finishing the tab does not drop it. - With ComfyUI open in two browser tabs a task's actions were split between them (one opened the task's tab, the other could not find it): the page that did the last action gets the next first now, another only when it is gone (mcp_bridge.py, pages keep an id of their own). - describe_canvas on a big canvas: titles off, then nodes as a line each with their settings, so every value stays readable. - search_models names the models that did not fit; begin_task's tool directory goes by name alone when the answer would be too long. - ask_user refuses a "test" question with made-up options too. - Exam round recorded: question 2 with nothing filed away, a big canvas read right in 3 calls, question 3 in 12 calls. 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, replace = false, roles = {} }, getOwner) {
|
|
return recorded(getOwner, 'mcpDidNodes', async (g) => {
|
|
const nodes = placeRoles(g, asked, nearId);
|
|
// 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) : {};
|
|
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,
|
|
};
|
|
}
|