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

444 lines
23 KiB
JavaScript

/**
* The page side of the MCP canvas actions (api/mcp_bridge.py). The server sends
* {id, action, args} as the "anomalous.mcp" event; one open page claims it (a hidden tab
* waits a moment so a visible one goes first), does it with Anomalous's own canvas code and
* posts the outcome back. Every change is one Ctrl+Z step, lands in the activity log marked
* as the AI's (with its undo), and shows a toast. Errors are written for the AI to read. A
* canvas action happens on the task's own tab when the task has one (mcp_task_tab.js).
*/
import { app } from '../../../scripts/app.js';
import { api } from '../../../scripts/api.js';
import { queueTracked } from './mcp_run.js';
import { baseOnly, onKeptBase } from './base_only.js';
import { translate as t } from './locales.js';
import { diffSnapshots, snapshotGraph } from './activity_diff.js';
import { postCanvasActivity } from './activity_log.js';
import { keepUndo, workflowKey } from './canvas_undo.js';
import { canvasBrief, line as briefLine } from './mcp_canvas_brief.js';
import { canvasEditActions } from './mcp_canvas_edit.js';
import { findRoles } from './canvas_roles.js';
import { onTaskTab } from './mcp_task_tab.js';
import { recordCanvasStep } from './canvas_history.js';
import { chainEnd, loraChain, mainModelLoaders, modelLineStart, planLoraInsertion, spliceLora } from './graph_splice.js';
import { checkWorkflowModels, fixWorkflowModels, isProblem } from './model_check.js';
import { downloadFrom, downloadMissing } from './model_download.js';
import { promptBoxes } from './prompt_boxes.js';
import { findModelComboWidget, getNativeWidgetValues, setWidgetValue } from './ui_node_model_picker.js';
import { showWorkbenchToast } from './ui_prompt_toast.js';
import { openListedModel } from './ui_scan_lists.js';
import { freshId } from './detached_graph.js';
const HIDDEN_PAGE_DELAY_MS = 1500;
const MAX_NODES = 150;
// What an AI on an empty canvas needs instead of "add a SaveImage" (api/mcp_situation.py says the same).
const EMPTY_CANVAS = 'The canvas is empty. run_workflow(workflow=<name>) opens a saved workflow and runs it in one call; '
+ "open_workflow opens one to change first, or a gallery picture's own by its file name (to change that picture); "
+ 'list_workflows names them and the examples.';
// UTF-8 bytes, as api/mcp_fit.py: past about 4,000 an AI app files the answer away and the AI reads nothing.
const CANVAS_BUDGET = 3800;
const MAX_VALUE = 400;
const MAX_LOGGED = 60;
const graph = () => app.canvas?.graph || app.graph || null;
const workflowName = () => {
const workflow = app.extensionManager?.workflow?.activeWorkflow;
return String(workflow?.filename || workflow?.path || '');
};
const short = (text) => (String(text).length > MAX_VALUE ? `${String(text).slice(0, MAX_VALUE)}…` : String(text));
const samePath = (a, b) => String(a).replace(/\\/g, '/') === String(b).replace(/\\/g, '/');
function currentGraph() {
const g = graph();
if (!g) throw new Error('No workflow is open in ComfyUI.');
return g;
}
function nodeById(id) {
const node = currentGraph().getNodeById?.(Number(id)) ?? currentGraph().getNodeById?.(id);
if (!node) throw new Error(`There is no node ${id} on the canvas; describe_canvas lists them.`);
return node;
}
/** The drop-down value of `node`'s model widget naming `path` (relative to its models folder). */
function modelChoice(node, widget, path) {
return getNativeWidgetValues(node, widget).find(value => samePath(value, path)) || null;
}
/** Sets a widget the way the user would, so ComfyUI and other extensions notice. */
function writeWidget(node, widget, value) {
const old = widget.value;
app.graph?.beforeChange?.(node);
setWidgetValue(node, widget, value);
widget.callback?.(value, app.canvas, node, app.canvas?.graph_mouse, null);
node.onWidgetChanged?.(widget.name, value, old, widget); // clears ComfyUI's missing-model mark
app.graph?.afterChange?.(node);
if (app.lastNodeErrors?.[node.id]) delete app.lastNodeErrors[node.id];
app.graph?.setDirtyCanvas?.(true, true);
recordCanvasStep(app);
return old;
}
/** Runs a canvas change and logs what it changed as the AI's, with its undo. */
async function recorded(getOwner, toastKey, change) {
getOwner()?.flushCanvasActivity?.(); // what was pending before is the user's own
const g = currentGraph();
const before = snapshotGraph(g);
const workflow = workflowKey(app);
const value = await change(g);
const after = snapshotGraph(g);
const changes = diffSnapshots(before, after);
if (changes.length) {
// Waited for, so the AI workbench's step knows its log entry (and with it the undo).
try {
const data = await postCanvasActivity({ changes: changes.slice(0, MAX_LOGGED), total: changes.length, workflow: workflowName(), via: 'mcp' });
keepUndo(data?.entry?.id, workflow, before, after);
if (data?.entry?.id && value && typeof value === 'object') value._activity = data.entry.id;
getOwner()?.onActivityRecorded?.();
} catch (error) {
console.warn('[AMB] Activity log: the AI change was not recorded.', error);
}
}
showWorkbenchToast(t(toastKey, value?.toast || {}));
delete value?.toast;
return value;
}
function describeNode(g, node) {
const settings = {};
for (const widget of node.widgets || []) {
const value = widget.value;
if (!widget.name || widget.type === 'button' || widget.name.startsWith('$$')) continue; // "$$…": the page's own, not settings
if (typeof value === 'string') settings[widget.name] = short(value);
else if (typeof value === 'number' || typeof value === 'boolean') settings[widget.name] = value;
}
const inputs = {};
for (const input of node.inputs || []) {
if (input.link == null) continue;
const link = g.getLink?.(input.link) ?? g.links?.[input.link];
if (link) inputs[input.name] = link.origin_id;
}
const boxes = promptBoxes(node);
return {
id: node.id,
type: node.type,
title: node.title && node.title !== node.type ? node.title : undefined,
bypassed: node.mode === 4 || undefined,
muted: node.mode === 2 || undefined,
settings,
inputs_from_nodes: Object.keys(inputs).length ? inputs : undefined,
prompt_boxes: boxes.length ? boxes.map(box => ({ setting: box.name, role: box.role || 'unknown' })) : undefined,
};
}
/** The files ComfyUI's page marks missing (its missingMedia store; none on a frontend without one). */
function missingMedia() {
const store = document.querySelector('#vue-app')?.__vue_app__?.config?.globalProperties?.$pinia?._s?.get('missingMedia');
return (store?.missingMediaCandidates || []).filter(item => item?.isMissing !== false);
}
/** The open workflow's nodes with their settings; `brief`: a line a node and what is unwired (get_situation). */
function describeCanvas({ brief } = {}) {
const g = currentGraph();
if (brief) return { workflow: workflowName(), ...canvasBrief(g, app.lastNodeErrors, missingMedia()) };
const nodes = g._nodes || [];
return fitCanvas(g, {
workflow: workflowName(),
node_count: nodes.length,
...(nodes.length ? {} : { note: EMPTY_CANVAS }),
selected_node_ids: Object.keys(app.canvas?.selected_nodes || {}).map(Number),
nodes: nodes.slice(0, MAX_NODES).map(node => describeNode(g, node)),
});
}
/**
* `answer` within CANVAS_BUDGET, losing as little as it can, a step at a time until it fits: the
* titles (mostly the page's own name of each node); then nodes without settings as a line each
* (id, type, what feeds each input: nothing lost); then from the end a line each with its settings
* after "||"; last, from the end, lines without their settings.
*/
function fitCanvas(g, answer) {
const bytes = value => new TextEncoder().encode(JSON.stringify(value)).length;
if (bytes(answer) <= CANVAS_BUDGET) return answer;
const order = new Map(answer.nodes.map((item, at) => [item.id, at]));
const settingsOf = item => Object.entries(item.settings || {})
.map(([key, value]) => `${key}=${typeof value === 'string' ? JSON.stringify(value) : value}`).join(' ');
const full = answer.nodes.map(({ title, ...item }) => item);
const lines = []; // [item, with its settings]
let bare = 0;
const fitted = () => {
if (!lines.length) return { ...answer, nodes: full };
const sorted = [...lines].sort((a, b) => order.get(a[0].id) - order.get(b[0].id));
const in_brief = sorted.map(([item, keep]) => {
const live = g.getNodeById?.(item.id);
const head = live ? briefLine(g, live).replace(/^(#\S+ \S+) "[^"]*"/, '$1') : `#${item.id} ${item.type}`; // no title here either
const settings = keep ? settingsOf(item) : '';
return settings ? `${head} || ${settings}` : head;
});
const note = `Too big to read whole in one answer: ${lines.length} nodes are in in_brief, a line each (id, type, what feeds `
+ 'each input, then its settings after "||")'
+ (bare ? `; ${bare} of them without their settings: find_nodes(types=[their type]) lists a type's settings` : '')
+ '. set_values and connect_nodes take their ids.';
return { ...answer, nodes: full, in_brief, note };
};
const fits = () => bytes(fitted()) <= CANVAS_BUDGET;
for (let at = full.length - 1; at >= 0 && !fits(); at--) {
if (!Object.keys(full[at].settings || {}).length) lines.push([full.splice(at, 1)[0], false]);
}
while (!fits() && full.length) lines.push([full.pop(), true]);
const kept = [...lines].sort((a, b) => order.get(b[0].id) - order.get(a[0].id)).filter(line => line[1]);
for (const line of kept) {
if (fits()) break;
line[1] = false;
bare += 1;
}
return fitted();
}
function findPromptBox(role, nodeId) {
if (nodeId != null) {
const node = nodeById(nodeId);
const boxes = promptBoxes(node);
const box = boxes.find(item => item.role === role) || (boxes.length === 1 ? boxes[0] : null);
if (!box) throw new Error(`Node ${nodeId} (${node.type}) has no ${role} prompt box.`);
return { node, box };
}
const found = (currentGraph()._nodes || []).flatMap(node => promptBoxes(node)
.filter(box => box.role === role).map(box => ({ node, box })));
if (!found.length) throw new Error(`No ${role} prompt box is wired on the canvas; give node_id.`);
if (found.length > 1) throw new Error(`Several ${role} prompt boxes (nodes ${found.map(item => item.node.id).join(', ')}); give node_id.`);
return found[0];
}
function setPrompt({ text, role, node_id: nodeId, mode }, getOwner) {
return recorded(getOwner, role === 'negative' ? 'mcpDidNegative' : 'mcpDidPositive', () => {
const { node, box } = findPromptBox(role, nodeId);
const old = String(box.widget.value || '');
const value = mode === 'append' && old.trim() ? `${old.replace(/[\s,]+$/, '')}, ${text}` : text;
writeWidget(node, box.widget, value);
return { node_id: node.id, role: box.role || role, before: short(old), after: short(value) };
});
}
/** The MODEL line through `node` by its drop-down values: its loader's and its LoRAs', for the server's base-model check (model_advice.py). */
function modelLine(g, node) {
const start = modelLineStart(g, node);
const value = item => findModelComboWidget(item)?.value || '';
return { model: value(start), loras: loraChain(g, start, 'MODEL').map(value).filter(Boolean) };
}
function setModel({ node_id: nodeId, model }, getOwner) {
return recorded(getOwner, 'mcpDidModel', (g) => {
const node = nodeById(nodeId);
const widget = findModelComboWidget(node);
if (!widget) throw new Error(`Node ${nodeId} (${node.type}) has no model drop-down.`);
const value = modelChoice(node, widget, model.path);
if (!value) throw new Error(`${node.type} cannot load ${model.path} (a ${model.type} model), or ComfyUI has not listed it yet.`);
const old = writeWidget(node, widget, value);
delete node.color;
delete node.bgcolor;
return { node_id: node.id, before: old, after: value, line: modelLine(g, node), toast: { name: value.split(/[\\/]/).pop() } };
});
}
/** The workflow's one main model loader, past the LoRAs already chained to it. */
function defaultModelEnd(g) {
const loaders = mainModelLoaders(g);
if (loaders.length !== 1) throw new Error(loaders.length ? 'Several model loaders; give after_node_id.' : 'No model loader on the canvas; give after_node_id.');
return chainEnd(g, loaders[0], 'MODEL');
}
/**
* A LoRA goes after the model line's end (the main loader or the last LoRA on it), as
* graph_splice.js planLoraInsertion places it: the CLIP line goes through it only when the
* file has text-encoder weights (`textEncoder`, read from its header by the server).
*/
function addLora({ model, text_encoder: textEncoder, after_node_id: afterId, strength_model: strengthModel, strength_clip: strengthClip }, getOwner) {
return recorded(getOwner, 'mcpDidLora', (g) => {
const modelEnd = afterId != null ? nodeById(afterId) : defaultModelEnd(g);
const plan = planLoraInsertion(g, modelEnd, 'after', { textEncoder });
if (!plan.supported) throw new Error(`Node ${modelEnd.id} (${modelEnd.type}) has no MODEL output.`);
const clipEnd = plan.outputs.find(output => output.type === 'CLIP')?.node || null;
const node = LiteGraph.createNode(plan.clip ? 'LoraLoader' : 'LoraLoaderModelOnly');
const widget = node && findModelComboWidget(node);
const value = widget && modelChoice(node, widget, model.path);
if (!value) throw new Error(`ComfyUI does not list ${model.path} as a LoRA yet; refresh ComfyUI's model lists (R).`);
setWidgetValue(node, widget, value);
for (const [name, strength] of [['strength_model', strengthModel], ['strength_clip', strengthClip]]) {
const item = node.widgets?.find(w => w.name === name);
if (item && Number.isFinite(strength)) setWidgetValue(node, item, strength);
}
spliceLora({ graph: g, plan, anchorNode: modelEnd, insertedNode: node });
recordCanvasStep(app);
return {
node_id: node.id, node_type: node.type, lora: value, model_from: modelEnd.id, ...(clipEnd ? { clip_from: clipEnd.id } : {}),
line: modelLine(g, node),
...(textEncoder && !clipEnd ? { note: 'The LoRA has text-encoder weights but no single CLIP source was found; only the model is patched.' } : {}),
toast: { name: value.split(/[\\/]/).pop() },
};
});
}
async function placeCombo({ name }, getOwner) {
const data = await (await fetch('/anomalous/notebooks')).json();
const combos = data?.notebooks || [];
const combo = combos.find(item => item.name === name)
|| combos.find(item => String(item.name).toLowerCase() === String(name).toLowerCase());
if (!combo) throw new Error(`No combo called ${name}; list_combos names them.`);
if (!combo.data?.mainModel && combo.data?.kind !== 'nodes') throw new Error(`The combo ${combo.name} has no main model yet.`);
const owner = getOwner();
if (!owner) throw new Error('Anomalous is not ready on this page yet.');
return recorded(getOwner, 'mcpDidCombo', (g) => {
const nodes = g._nodes || [];
const right = nodes.reduce((max, node) => Math.max(max, (node.pos?.[0] || 0) + (node.size?.[0] || 0)), 0);
const top = nodes.length ? Math.min(...nodes.map(node => node.pos?.[1] || 0)) : 100;
const count = nodes.length;
owner.currentNotebook = combo;
owner.sendNotebookToCanvas([right + 120, top]);
return { placed: combo.name, new_nodes: (g._nodes || []).length - count, toast: { name: combo.name } };
});
}
function entryView(entry) {
return {
node_id: entry.node.id, node_type: entry.node.type, setting: entry.widget.name, model: entry.value,
state: entry.state, ...(entry.target ? { can_put_back: entry.target, found_by: entry.via } : {}),
};
}
const CHECK_MEANING = 'ready: present; changed: present but a different file than when saved; fixable: found for sure (fix_workflow_models puts it back); '
+ 'candidate: a likely file, for the user to confirm in Model Check; ambiguous / conflict / missing: not found.';
async function checkModels() {
const entries = await checkWorkflowModels({ refresh: false });
return { models: entries.map(entryView), problems: entries.filter(isProblem).length, meaning: CHECK_MEANING };
}
async function fixModels(_args, getOwner) {
const entries = await checkWorkflowModels({ refresh: false });
const fixable = entries.filter(entry => entry.state === 'fixable').map(entryView);
if (!fixable.length) return { fixed: 0, still_missing: entries.filter(isProblem).map(entryView) };
return recorded(getOwner, 'mcpDidFix', () => {
const fixed = fixWorkflowModels(entries);
return { fixed, put_back: fixable, still_missing: entries.filter(entry => isProblem(entry) && entry.state !== 'fixable').map(entryView), toast: { count: fixed } };
});
}
/** Downloads run on the server; each finished file is put into its nodes like a press in Model Check. */
async function downloadMissingModels({ names }, getOwner) {
currentGraph();
const result = await downloadMissing(getOwner(), names);
return { ...result, note: 'Downloads run one at a time in the background; download_status follows them. Each finished file '
+ 'is checked against its fingerprint and put into the nodes that use it.' };
}
async function downloadModel(args, getOwner) {
return { ...await downloadFrom(getOwner(), args), note: 'download_status follows it.' };
}
/**
* run_workflow: `base_only` queues the first pass alone as previews (base_only.js), checked before anything is
* queued; `hold` is the base the user picked (api/ai_bases.py): its seed goes on its sampler, held "fixed"
* and kept under every try, unless that sampler is not on this canvas; where the rest takes the base's
* picture, it takes the kept picture instead and the base is not drawn again (`held.kept`).
*/
async function runWorkflow({ batch, base_only: firstPass, hold }, getOwner) {
const g = currentGraph();
if (!(g._nodes || []).length) throw new Error(`Nothing was run. ${EMPTY_CANVAS}`);
const held = hold ? await holdBase(g, hold, getOwner) : null;
const base = firstPass ? baseOnly((await app.graphToPrompt()).output) : null;
if (held?.held && hold.kept_input) held.kept = Boolean(onKeptBase((await app.graphToPrompt()).output, hold.kept_input));
const cut = base ? output => baseOnly(output).output : held?.kept ? output => onKeptBase(output, hold.kept_input) || output : null;
const result = await queueTracked(app, api, batch || 1, { cut, keep: held?.held ? [String(hold.node)] : [] });
if (result.queued) showWorkbenchToast(t('mcpDidRun'));
return { ...result, ...(base ? { base: { bases: base.bases, shows: base.shows, saved: base.saved, prefix: base.prefix } } : {}), ...(held ? { held } : {}) };
}
async function holdBase(g, { node: id, type, names, seed }, getOwner) {
const node = (g._nodes || []).find(item => String(item.id) === String(id));
if (!node || node.type !== type) return { held: false, reason: `the sampler #${id} the bases came from is not on this canvas` };
const has = name => (node.widgets || []).some(widget => widget?.name === name);
const settings = Object.fromEntries((names || []).filter(has).map(name => [name, seed]));
if (has('control_after_generate')) settings.control_after_generate = 'fixed';
await ACTIONS.fill_slots({ values: { [String(id)]: settings } }, getOwner);
return { held: true };
}
async function openModel({ type, path_idx: pathIdx, rel }, getOwner) {
const owner = getOwner();
if (!owner) throw new Error('Anomalous is not ready on this page yet.');
owner.show();
await openListedModel(owner, { type, path_idx: pathIdx, rel, filename: rel.split('/').pop() }, () => owner.goTo('models'));
return { opened: rel };
}
/** The model line of the sampler a block goes with (canvas_roles.js): the server picks a ControlNet that fits it. */
function blockModel({ near_node_id: nearId }) {
const g = currentGraph();
return modelLine(g, findRoles(g, nearId).sampler);
}
const ACTIONS = {
describe_canvas: describeCanvas,
block_model: blockModel,
set_prompt: setPrompt,
set_model: setModel,
add_lora: addLora,
place_combo: placeCombo,
check_models: checkModels,
fix_models: fixModels,
download_missing: downloadMissingModels,
download_model: downloadModel,
run_workflow: runWorkflow,
open_model: openModel,
...canvasEditActions({ recorded, nodeById }),
};
/** This page's id in its claims, kept over a reload of the tab: the server gives an action to the page that did the last (api/mcp_bridge.py). */
function pageId() {
try {
const kept = sessionStorage.getItem('anomalous.mcpPage');
if (kept) return kept;
const made = freshId();
sessionStorage.setItem('anomalous.mcpPage', made);
return made;
} catch {
return freshId();
}
}
const PAGE = pageId();
const post = body => fetch('/anomalous/mcp/bridge', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
}).then(response => response.json());
async function handle(detail, getOwner) {
const { id, action, args } = detail || {};
const run = ACTIONS[action];
if (typeof id !== 'string' || !run) return;
if (document.visibilityState !== 'visible') await new Promise(resolve => setTimeout(resolve, HIDDEN_PAGE_DELAY_MS));
const ask = () => post({ id, claim: true, capabilities: ['prompt_ids'], page: PAGE }).catch(() => null);
let claim = await ask();
if (claim?.retry_ms) { // another tab did the last action: it goes first, this one only when that tab is gone
await new Promise(resolve => setTimeout(resolve, claim.retry_ms));
claim = await ask();
}
if (!claim?.granted) return; // another tab took it
const { _task_canvas: taskCanvas, ...given } = args || {};
let outcome;
let tab = null; // a tab made or opened for the task is kept even when the action then fails
getOwner()?.flushCanvasActivity?.(); // the user's own changes so far, before the task's tab may come to the front
try {
outcome = { ok: true, value: (await onTaskTab(action, taskCanvas, () => run(given, getOwner), (made) => { tab = made; })) ?? null };
} catch (error) {
outcome = { ok: false, error: String(error?.message || error) };
}
if (tab) outcome.tab = tab;
await post({ id, outcome }).catch(error => console.warn('[AMB] MCP: could not send the result back.', error));
}
/** Listens for MCP canvas actions; `getOwner()` gives the browser (created on demand). */
export function watchMcpActions(getOwner) {
api.addEventListener('anomalous.mcp', event => void handle(event.detail, getOwner));
}