A combo can now hold its own node structure: pick nodes on the canvas and New -> From the nodes picked on the canvas keeps the model nodes (a drop-down of model files) and the text nodes (a multiline text box by the node's definition) with the links between them; other picked nodes and links to them are left out, their ports stay open on the canvas. Slots are per box, so a node with several model drop-downs has several; a box fed by a link is no slot. Each model slot's folder is the one whose files hold all its drop-down's model-file options (ComfyUI's /models lists), never guessed from names. Every other box is stored by name and put back untouched, in order (a count box adds its rows first). The save dialog shows what is kept and dropped and lets slots be renamed or unticked. The editor lists the slots: a picker of that folder's models with covers, filtered by base model, and text boxes. Put on canvas makes the nodes (missing node types and their packs are named before anything is placed), fills the slots by name, links by port name and type, as one Ctrl+Z step; a box or link that cannot be restored is reported, never guessed. New -> Same structure as ... starts another combo from a saved structure. The default combo is unchanged. MCP list_combos lists node combos by slot and place_combo places them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
314 lines
15 KiB
JavaScript
314 lines
15 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.
|
|
*/
|
|
|
|
import { app } from '../../../scripts/app.js';
|
|
import { api } from '../../../scripts/api.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 { 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) {
|
|
postCanvasActivity({ changes: changes.slice(0, MAX_LOGGED), total: changes.length, workflow: workflowName(), via: 'mcp' })
|
|
.then((data) => {
|
|
keepUndo(data?.entry?.id, workflow, before, after);
|
|
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();
|
|
await app.queuePrompt(0, batch || 1);
|
|
showWorkbenchToast(t('mcpDidRun'));
|
|
return { queued: batch || 1, note: 'Results land in the output folder; search_images finds them when done.' };
|
|
}
|
|
|
|
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,
|
|
};
|
|
|
|
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 }).catch(() => null);
|
|
if (!claim?.granted) return; // another tab took it
|
|
let outcome;
|
|
try {
|
|
outcome = { ok: true, value: (await run(args || {}, getOwner)) ?? null };
|
|
} catch (error) {
|
|
outcome = { ok: false, error: String(error?.message || error) };
|
|
}
|
|
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));
|
|
}
|