Files
DemonGatanjieu-Anomalous_Mo…/web/modules/mcp_bridge.js
T
DemonGatanjieuandClaude Opus 5.5 cb523930da Guide the AI with fewer detours and guesses
- An AI app calling by an older tool list (tool_help or use_tool not offered,
  use_tool for a tool offered directly, a tool the list lacks) is told so, and
  the task header shows how to reconnect until the app fetches tools/list again.
- add_lora / set_model compare the base models on the model line
  (model_advice.py; the page reports the line) and add base_model_note.
- get_model gives author_examples (sampler, steps, CFG, size, a LoRA's weight
  from the Civitai record's example pictures) and the version name.
- run_workflow says ComfyUI's refusal as which node, what is wrong and the tool
  that fixes it (run_refusal.py; mcp_run.js passes the refusal as data).
- Gates "When I don't answer": wait for me, or go with the recommendation once
  the waiting time runs out (choices with a recommended option only), handed to
  the AI in the same call and shown on the stage and under Decisions.
- A tool budget test (count, size, description length, required arguments) and
  the exam tasks with their scorecard (docs/architecture/tool-exam.md,
  tools/tool_exam.mjs). The tool set's text recommends Full for Antigravity.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 14:26:41 +08:00

337 lines
17 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, 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';
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) };
});
}
/** 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.' };
}
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));
}