Files
DemonGatanjieuandClaude Opus 5.5 46eca1b594 Run a picture's own workflow, keep a task on one page, read a big canvas whole
- 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>
2026-11-07 01:16:17 +08:00

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,
};
}