- Each run keeps its shape read from the prompt ComfyUI queued (ai_run_map.py): boxes by role with their settings, typed links, plumbing wired through, and what changed since the task's run before. The run step shows a one-line summary, the changes as chips and the boxes (top down, fitted to the column, each locating its node); show_run_map puts it on the stage. - By default the AI works in a tab of the task's own (ai_task_canvas.py, mcp_task_tab.js): the open workflow is copied before its first change, the tab is brought back (a click on ComfyUI's tab) before later changes, reads and runs, and the user's tab stays untouched. Per-task switch in the header, default in the Gates window; the AI is told once. - Output pictures can be handed to the task an AI is on (gallery card menu, the inspector's ✦), with the user's words: the AI gets them attached with what made them (ai_pictures.py). search_images(with_pictures) attaches the first six. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
325 lines
16 KiB
JavaScript
325 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 { 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') continue;
|
|
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,
|
|
};
|
|
}
|
|
|
|
function describeCanvas() {
|
|
const g = currentGraph();
|
|
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
|
|
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));
|
|
}
|