Files
DemonGatanjieu-Anomalous_Mo…/web/modules/mcp_bridge.js
T
DemonGatanjieuandClaude Opus 5.5 68342ad61d Give an AI where things stand in one call, and what is still unwired
A small model lost its way wiring the canvas: describe_canvas says what
feeds each input but not what is still free or what fits it, so it
looked up every node type and kept the graph in mind, and long answers
were filed away by Antigravity for the AI to open.

get_situation (mcp_situation.py) gives the task (what the user asked
last, what waits for them, decisions, pictures, steps outside AMB), the
canvas in brief (mcp_canvas_brief.js: a line per node, the inputs a run
needs that nothing feeds yet with the outputs that fit, ComfyUI's
errors), the latest run and the next step, kept under 3,600 characters.
add_node, connect_nodes and remove_nodes answer with what is still
unwired. The connect sentence, the rules and the instructions point a
new chat to get_situation first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 03:37:49 +08:00

329 lines
16 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 { 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 } from './mcp_canvas_brief.js';
import { canvasEditActions } from './mcp_canvas_edit.js';
import { onTaskTab } from './mcp_task_tab.js';
import { recordCanvasStep } from './canvas_history.js';
import { chainEnd, mainModelLoaders, 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';
const HIDDEN_PAGE_DELAY_MS = 1500;
const MAX_NODES = 150;
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 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) };
const nodes = g._nodes || [];
return {
workflow: workflowName(),
node_count: nodes.length,
selected_node_ids: Object.keys(app.canvas?.selected_nodes || {}).map(Number),
nodes: nodes.slice(0, MAX_NODES).map(node => describeNode(g, node)),
};
}
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) };
});
}
function setModel({ node_id: nodeId, model }, getOwner) {
return recorded(getOwner, 'mcpDidModel', () => {
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, 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 } : {}),
...(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.' };
}
async function runWorkflow({ batch }) {
currentGraph();
const result = await queueTracked(app, api, batch || 1);
if (result.queued) showWorkbenchToast(t('mcpDidRun'));
return result;
}
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 };
}
const ACTIONS = {
describe_canvas: describeCanvas,
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 }),
};
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 claim = await post({ id, claim: true, capabilities: ['prompt_ids'] }).catch(() => null);
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));
}