feat(mcp): add typed auto-wiring and compact workflow context

This commit is contained in:
DemonGatanjieu
2026-10-24 09:53:22 +08:00
parent 7b43403243
commit 4afee8326c
18 changed files with 585 additions and 87 deletions
+3 -1
View File
@@ -176,7 +176,9 @@ covered by the `styles.css` manifest.
`recorded`, after checking types, settings and choices against the nodes themselves;
`web/modules/canvas_build.js` is its pure part (type fit, value checks, a free spot, the
layered layout). `open_workflow` opens one of the user's saved workflows in a new tab.
`web/modules/mcp_canvas_brief.js` is the canvas in brief for an AI (pure): a line per node,
`web/modules/canvas_wiring.js` plans unique concrete-type connections, selected-branch insertion,
conflict/cycle checks and rollback on application failure (`tests/canvas_wiring.mjs`).
`web/modules/mcp_canvas_brief.js` is the canvas in brief for an AI (pure): bounded path chains and node lines,
the inputs a run needs that nothing feeds yet with the outputs that fit them, ComfyUI's
errors; every canvas change answers with what is still unwired. `api/mcp_situation.py` is
get_situation: the task, that brief (describe_canvas with `brief`), the latest run and the
+4
View File
@@ -2,6 +2,10 @@
## v1.62.0 — unreleased
- AI 搭工作流支持一次添加并自动接线:`insert_after` 接回选中的下游,`wire_from` / `wire_to` 按唯一类型批量连接。正负条件歧义、多分支、已有输入冲突和循环在写入前报明;连接失败恢复原接线。成功回执包含实际连线、缺失输入与输出可达性,支持原生 Ctrl+Z 撤销。
- 现状简报新增有长度限制的流程链;节点搜索只返回少量摘要,具体参数按需读取。日常工具集开放节点搜索、添加、连线、参数修改与新建工作流,减少逐线调用和重复上下文。
- Canvas tools can add and wire a node in one call, safely splice a selected branch, and connect unique concrete types by node IDs. Ambiguity/conflicts/cycles are rejected before mutation; failed connections restore previous wiring. Compact receipts, bounded flow chains and brief node search reduce context, with construction tools available in the daily set.
- 修复任务画布标签关闭或切换失败后误用当前工作流的问题:操作会明确报错,重新打开或明确指定新画布后才能继续。下载缺失模型现在也跟随任务画布;留言里的 `@` 搭配和工作流按完整名称查找,不再因模糊搜索前几项占满而误报缺失。
- Task canvas actions now fail when their recorded tab is closed or cannot be activated instead of using another workflow. Missing-model downloads follow the task tab, and message references resolve exact combo/workflow names before search limits.
+3 -2
View File
@@ -5,7 +5,7 @@ entry here: its kind (the AI workbench's colour, words and counts; None = not a
own), the stage of the work it belongs to (the flow over a task: models, combo, prompt,
canvas, run, pictures, voice, library) and its gate (what it touches or costs, for the user's
rules on what needs their OK: canvas, gpu, download, network, files; None = nothing to guard).
tests/test_action_catalog.py keeps this catalog and the tools in step.
tests/test_ai_tasks.py keeps this catalog and the tools in step.
The user's own changes in Anomalous (activity log actions) get stages too, so the ones made
while an AI works sit in the same flow as its steps (ai_tasks.note_activity).
@@ -19,11 +19,12 @@ STAGES = ("model", "combo", "prompt", "canvas", "run", "picture", "voice", "libr
GATES = ("canvas", "gpu", "download", "network", "files")
# The "daily" set the user can offer AI apps instead of all tools (ai_gates.tool_set): runs of saved
# workflows with their blanks, pictures, models, the task's own; a small model reads half as much.
# workflows with their blanks, pictures, models, the task's own, and compact canvas building.
DAILY = frozenset({
"begin_task", "get_situation", "report", "ask_user", "get_answers", "log_decision", "log_outside",
"list_workflows", "run_workflow", "run_status", "stop_run", "search_images", "get_image_info", "zoom_image",
"search_models", "set_model", "add_lora", "check_workflow_models", "fix_workflow_models",
"find_nodes", "add_node", "connect_nodes", "set_values", "new_workflow",
})
# Arguments with which a call also changes the canvas: it then asks as the stricter of its own gate and "canvas".
+65 -25
View File
@@ -15,7 +15,7 @@ from .mcp_actions import _action, _local, _page
from .mcp_tools import ToolError
MAX_FOUND = 20
MAX_CHOICES = 12
MAX_SEARCH = 5
MAX_CHOICES_NAMED = 60
MAX_VALUES = 60
MAX_TEXT = 20000
@@ -116,14 +116,25 @@ def find_nodes(ctx, query="", types=None):
scored.append((not ai_toolbox.chosen(picked, name, item), 0 if name.lower() == " ".join(words) else 1 if all(w in label for w in words) else 2,
bool(item.get("deprecated")), ai_toolbox.source_of(item) == "api", len(name), name))
scored.sort()
nodes = [_node(name, info[name], MAX_CHOICES) for *_rest, name in scored[:MAX_FOUND]]
# Searching identifies candidates; don't repeat hundreds of model choices/settings for each.
nodes = []
for *_rest, name in scored[:MAX_SEARCH]:
detail = _node(name, info[name], 0)
nodes.append({"type": name, "display_name": str(detail["display_name"])[:80],
"source": str(detail["source"])[:80],
"description": detail["description"][:96],
"inputs": [f"{i['name'][:48]}:{i['type'][:48]}" for i in detail["inputs"][:8]],
"outputs": [f"{i['name'][:48]}:{i['type'][:48]}" for i in detail["outputs"][:8]],
**{k: detail[k] for k in ("paid", "deprecated") if k in detail},
**({"more_inputs": len(detail["inputs"]) - 8} if len(detail["inputs"]) > 8 else {})})
for node in nodes:
if ai_toolbox.chosen(picked, node["type"], info[node["type"]]):
node["picked_by_user"] = True
while len(nodes) > 1 and len(json.dumps(nodes, ensure_ascii=False)) > 3000:
nodes.pop()
return {"nodes": nodes, "total_matches": len(scored),
"note": "Inputs of type choice take one of their choices; others are sockets fed by connect_nodes "
"or settings set by values. types=[...] gives a node's full choice lists. picked_by_user: the user "
"wants these used; paid: a Comfy API node that spends the user's credits, use it only when asked."}
"note": "Brief candidates; types=[chosen type] gives settings/choices and output slots when needed. "
"Narrow query for other matches. picked_by_user: preferred; paid: spends credits, use only when asked."}
def _id(value, what):
@@ -152,25 +163,49 @@ def _type(value):
def _next(answer):
"""The checklist's next step after a change (the page answers with what is still unwired)."""
if isinstance(answer, dict):
answer["next"] = ("connect_nodes: wire each of unwired (fits lists the outputs on the canvas that fit it)."
if answer.get("unwired") else "Nothing a run needs is unwired: run_workflow runs it.")
answer["next"] = ("Connect the new branch to an output; it does not reach one yet."
if answer.get("reaches_output") is False else
"Wire unwired inputs with connect_nodes; unique types need only the two node IDs."
if answer.get("unwired") else "No required input is unwired; run_workflow validates and runs the graph.")
return answer
def add_node(ctx, type, values=None, title="", near_node_id=None):
def _ids(value, label):
if value is None:
return []
items = value if isinstance(value, list) else [value]
if not 1 <= len(items) <= 8:
raise ToolError(f"{label} names 1 to 8 node IDs.")
return [_id(item, label) for item in items]
def add_node(ctx, type, values=None, title="", near_node_id=None, insert_after=None, insert_type=None,
wire_from=None, wire_to=None, replace=False):
if not isinstance(replace, bool):
raise ToolError("replace must be true or false.")
if insert_type is not None and (insert_after is None or not isinstance(insert_type, str) or not 0 < len(insert_type) <= 60):
raise ToolError("insert_type names a through type (e.g. IMAGE) and needs insert_after.")
wiring = {"wire_from": _ids(wire_from, "wire_from"), "wire_to": _ids(wire_to, "wire_to"), "replace": replace}
if insert_after is not None:
wiring.update(insert_after=_id(insert_after, "insert_after"), insert_type=insert_type)
_allowed(ctx, [_type(type)])
return _next(_page("add_node", {"type": _type(type), "values": _values(values), "title": str(title or "")[:120],
"near_node_id": None if near_node_id is None else _id(near_node_id, "near_node_id")}))
"near_node_id": None if near_node_id is None else _id(near_node_id, "near_node_id"), **wiring}))
def connect_nodes(ctx, from_node_id, to_node_id, input, output=0):
if isinstance(output, bool) or not isinstance(output, (int, str)) or len(str(output)) > 100:
def connect_nodes(ctx, from_node_id, to_node_id, input=None, output=None, replace=None):
if output is not None and (isinstance(output, bool) or not isinstance(output, (int, str)) or len(str(output)) > 100):
raise ToolError("output is the output's slot number or its name.")
output = int(output) if isinstance(output, str) and output.strip().isdigit() else output
if not isinstance(input, str) or not input or len(input) > 100:
if input is not None and (not isinstance(input, str) or not input or len(input) > 100):
raise ToolError("input is the input's name, as add_node, find_nodes or describe_canvas give it.")
if replace is not None and not isinstance(replace, bool):
raise ToolError("replace must be true or false.")
# Preserve the old explicit-input form's default output and replacement behaviour.
if input is not None and output is None:
output = 0
return _next(_page("connect_nodes", {"from_node_id": _id(from_node_id, "from_node_id"), "output": output,
"to_node_id": _id(to_node_id, "to_node_id"), "input": input}))
"to_node_id": _id(to_node_id, "to_node_id"), "input": input, "replace": replace}))
def set_values(ctx, node_id, values):
@@ -208,26 +243,31 @@ def open_workflow(ctx, name):
_NODE_ID = {"type": "integer", "description": "A node id from describe_canvas or a tool's answer."}
_VALUES = {"type": "object", "description": "{setting name: value}; a choice takes one of its choices exactly."}
_NEIGHBOURS = {"oneOf": [_NODE_ID, {"type": "array", "items": _NODE_ID, "minItems": 1, "maxItems": 8}]}
CANVAS_TOOLS = dict([
_action("find_nodes", "Look up node types",
"The node types this ComfyUI has (built in and custom nodes): type, category, each input (a socket, or a "
"setting with its choices, default and range) and each output with its slot. query searches names, "
"categories and descriptions; types gives those nodes in full. Look here before add_node or new_workflow.",
"Find built-in and custom node types. query returns up to five brief candidates, without bulky choice "
"lists. types=[chosen type] gives inputs, settings, choices, ranges and output slots when needed. "
"Use add_node with insert_after or wire_from/wire_to to add and wire in one call.",
find_nodes, {"query": {"type": "string"}, "types": {"type": "array", "items": {"type": "string"}, "maxItems": MAX_FOUND}},
read_only=True),
_action("add_node", "Add a node",
"Adds one node to the open workflow, where the user sees it: right of near_node_id, or right of the "
"workflow; nothing it would cover. values sets its settings. The answer gives its node_id, inputs and "
"outputs for connect_nodes. One Ctrl+Z step.",
"Add and wire a node in ONE call. insert_after splices one branch end-to-end; if several branches, "
"wire_to selects the downstream node(s). Or wire_from/wire_to name neighbours; unique concrete types "
"wire automatically (IMAGE+MASK may both connect). Ambiguity changes nothing and lists candidates. "
"replace:true permits replacing occupied inputs. One undo step; returns connections and reaches_output.",
add_node, {"type": {"type": "string", "description": "A node type from find_nodes, e.g. KSampler."},
"values": _VALUES, "title": {"type": "string"}, "near_node_id": _NODE_ID}, ["type"]),
"values": _VALUES, "title": {"type": "string"}, "near_node_id": _NODE_ID,
"insert_after": _NODE_ID, "insert_type": {"type": "string", "description": "Only if needed: through type, e.g. IMAGE."},
"wire_from": _NEIGHBOURS, "wire_to": _NEIGHBOURS, "replace": {"type": "boolean"}}, ["type"]),
_action("connect_nodes", "Wire two nodes",
"Wires output `output` (slot number or name) of from_node_id to input `input` of to_node_id, replacing "
"what fed that input before (the answer says so). Types must fit. One Ctrl+Z step.",
connect_nodes, {"from_node_id": _NODE_ID, "output": {"type": "string", "default": "0", "description": "The output's slot number or name."},
"to_node_id": _NODE_ID, "input": {"type": "string"}},
["from_node_id", "to_node_id", "input"]),
"Wire two nodes by ID: all unique concrete matching types connect in one undo step. No guessing "
"positive/negative or wildcard sockets: ambiguity lists candidates, changes nothing. "
"Optional output/input select sockets explicitly; replace:true permits replacing existing wires in auto mode.",
connect_nodes, {"from_node_id": _NODE_ID, "output": {"type": "string", "description": "Optional output slot number or name."},
"to_node_id": _NODE_ID, "input": {"type": "string"}, "replace": {"type": "boolean"}},
["from_node_id", "to_node_id"]),
_action("set_values", "Change a node's settings",
"Sets settings of one node (steps, cfg, a sampler, a size, a file choice…), checked against what each "
"takes. For prompts set_prompt, for models set_model find the right ones. One Ctrl+Z step.",
+5 -3
View File
@@ -47,9 +47,11 @@ INSTRUCTIONS = (
"Tools over the user's ComfyUI library as Anomalous Model Browser keeps it: model files with "
"their Civitai information, output images with their generation settings, combos (a model, "
"LoRAs and a prompt), workflow recipes, saved prompts, generated audio, the scan status and "
"the activity log; and tools that act on the workflow open in ComfyUI (read it with "
"describe_canvas first) or build one where the user watches (find_nodes to look node types up, "
"new_workflow for a new tab, add_node, connect_nodes, set_values), scan models, or have an "
"the activity log; and tools that act on the workflow open in ComfyUI (get_situation gives flow chains; "
"describe_canvas only for details) or build one where the user watches: find_nodes searches brief types; "
"add_node(insert_after=ID) splices a branch, wire_from/wire_to wire unique types in the same call; "
"connect_nodes needs only two IDs unless sockets are ambiguous. Reuse receipts instead of rereading "
"the graph. new_workflow opens a tab only when needed. Tools also scan models or have an "
"Anomalous TTS voice speak. Canvas changes "
"are single Ctrl+Z steps and are logged as the AI's; nothing deletes files. Ids returned by "
"one tool (model_id, image, node ids) are what the others take. For what no tool does, tell "
+7 -4
View File
@@ -2,7 +2,7 @@
The task (what the user asked last, questions and gates still waiting for them, decisions so
far, pictures made, steps done outside Anomalous), the canvas in brief (web/modules/
mcp_canvas_brief.js: a line per node, the inputs nothing feeds yet and the outputs that fit,
mcp_canvas_brief.js: flow chains, brief node lines, missing inputs and outputs that fit,
ComfyUI's errors), the latest run, and the one thing to do next. For an AI starting a new chat
on a task or unsure where it is: a small model reads this instead of describe_canvas, find_nodes
and the task's history. Unread messages of the user's come with it as with every answer
@@ -73,10 +73,13 @@ def get_situation(ctx, task=None):
canvas = _canvas()
answer.update(run=run, canvas=canvas)
answer["next"] = _next(summary, run, canvas)
# Stay under the budget: the canvas lines go first, then the decisions.
# Trim detail before chains and decisions; preserve actionable missing inputs and errors.
while len(tool_text(answer)) > BUDGET and canvas.get("lines"):
canvas["lines"].pop()
canvas["more_nodes"] = canvas.get("more_nodes", 0) + 1
while len(tool_text(answer)) > BUDGET and canvas.get("chains"):
canvas["chains"].pop()
canvas["more_chains"] = canvas.get("more_chains", 0) + 1
while len(tool_text(answer)) > BUDGET and record and answer["task"]["decisions"]:
answer["task"]["decisions"].pop(0)
return answer
@@ -86,8 +89,8 @@ SITUATION_TOOLS = {
"get_situation": {"run": get_situation, "context": True, "spec": {
"name": "get_situation", "title": "Where things stand",
"description": "One call, the whole picture: the task (what the user asked last, what waits for them, "
"decisions, pictures made), the canvas in brief (a line per node: what feeds it, what it "
"gives; the inputs nothing feeds yet with the outputs that fit them; ComfyUI's errors), the "
"decisions, pictures made), the canvas in brief (bounded flow chains, node lines, "
"missing inputs with matching outputs, ComfyUI's errors), the "
"latest run, and next: the one thing to do now. Call it first in a new chat on a task, and "
"whenever you are unsure, instead of describe_canvas, find_nodes or reading files.",
"inputSchema": _schema({"task": {"type": "string", "description": "Optional task id returned by begin_task."}}),
+11
View File
@@ -346,6 +346,17 @@ server's own `/anomalous/scan_all`, `/anomalous/download/status`, `/anomalous_tt
minutes for the file). Downloads go through the page (`model_download.js` downloadMissing /
downloadFrom), so the placement rules and the put-in after a download are the UI's own. Canvas changes are posted to the activity log with `via: "mcp"`.
Canvas construction is also in the daily tool set. `find_nodes(query=...)` returns up to five
brief candidates within a small text budget; `types=[...]` explicitly requests full settings.
`add_node` accepts `insert_after`, optional `insert_type`, and `wire_from`/`wire_to` (one ID or up
to eight IDs). `connect_nodes` accepts just two node IDs. Both return actual connections,
remaining required inputs and whether the branch reaches an output; reachability is not run
validation. Automatic wiring never replaces occupied inputs without `replace:true`, except
the selected downstream edge of an insertion. The legacy explicit `input` form still defaults
to output slot zero and permits replacement. Ambiguity is an error with bounded candidates.
`get_situation` includes at most six path chains and eight node lines; its overall 3600-character
budget trims these and reports omitted counts. Full canvas details remain explicitly available.
## Backups
`api/backup.py` packs the user's data into one .zip (`POST /anomalous/backup/export`, then
+14
View File
@@ -458,6 +458,20 @@ now redundant and can go as the rules they override are touched.
## Verification
MCP canvas construction uses `canvas_wiring.js` to plan all connections against live sockets
before mutation. Automatic pairs require a unique concrete type on both sides; wildcard,
union and ambiguous sockets require explicit selection. Insertion requires one through type
and an explicit downstream selection when the output branches. Final topology is checked for
cycles after planned replacements. `mcp_canvas_edit.js` brackets application in one native
beforeChange/afterChange transaction. On failure it removes the added node and restores prior
connections; callback failures during restoration are reported as incomplete rollback.
Asynchronous node preparation verifies the active graph, workflow key and serialized content
before writing. Successful receipts report links and output reachability, not execution success.
The activity log's limited undo is separate from ComfyUI's native Ctrl+Z for topology changes.
`mcp_canvas_brief.js` collapses unbranched paths within each flow, grouping IMAGE/LATENT into
the picture spine so auxiliary VAE/MASK inputs do not split it, keeps positive/negative roles,
walks each edge once (including malformed cycles), and bounds the resulting text.
`tests/prompt_tags.mjs` covers splitting, joining, weights and tag edits keeping
separators. The UI fixture simulates DOM and ComfyUI APIs and does not replace
checking the real host, layout, focus, drag gestures and theme rendering.
+21 -2
View File
@@ -100,6 +100,8 @@ claude mcp add --transport http anomalous http://127.0.0.1:8188/anomalous/mcp
| 工具 | 做什么 |
|---|---|
| describe_canvas | 读当前工作流:每个节点的编号、类型、设置,哪个是正向/反向提示词框 |
| find_nodes | 搜索最多五个节点摘要;需要具体参数和选项时,用 `types=[节点类型]` 读取 |
| add_node / connect_nodes | 添加节点并按唯一类型自动连线,或把节点插进已有路径;见下方例子 |
| set_prompt | 改写或追加正向/反向提示词 |
| set_model | 换某个加载器节点里的模型 |
| add_lora | 插入一个 LoRA,默认接在主模型加载器和已有的 LoRA 后面。LoRA 文件里练过文本编码器的(SD1.5、SDXL、Illustrious 的大多是),CLIP 线也接上:从 checkpoint 接,或者从 UNet 工作流里单独的 CLIP 加载器接,正反向提示词一起改接到它后面;没练过的(多数 Flux、Anima 的 LoRA)只接模型线 |
@@ -115,7 +117,7 @@ claude mcp add --transport http anomalous http://127.0.0.1:8188/anomalous/mcp
| list_voices / speak | 列出 Anomalous TTS 的角色;让某个角色读一段台词并保存(最多等两分钟) |
| begin_task / report | 在 AI 工作台里起一个任务的标题;给你留言、交上它用别的方式做出来的图、结束任务(见第 5 节) |
| run_workflow 的填空 | 日常出图一步完成:带上 `workflow`(存好的工作流名),第一次在任务专用标签页里打开它,之后接着用这个标签页;`prompt` / `negative` 填正、反向提示词,`seed` 填所有采样器的种子,`width` / `height` 填空白潜空间的尺寸,`image` 填“加载图片”读的图(输出或输入文件夹里的,比如标注后的重绘图;有几个“加载图片”时用 `image_node` 指定),其余照存好的样子不动。不带 `workflow` 时填到当前画布上。填空和出图一起算一步,改画布那部分也能 Ctrl+Z;画布关卡比显卡关卡严时,按画布关卡问你 |
| get_situation | 每个回复里的 `next` 写着下一步该做什么(出图、看进度、连线、问你之后都有)。现状一览:一次拿到任务(你最后说了什么、有什么在等你、定了哪些决策、出过哪些图)、画布简报(每个节点一行,还缺哪几根线、各要什么、画布上哪些输出能接上,出错的节点)、最近一次出图和下一步该做什么。新开对话或 AI 拿不准时先调它,回复不超过约 3600 字,Antigravity 不会把它存成文件让 AI 去读 |
| get_situation | 一次拿到任务、流程链、少量节点摘要、缺失输入和可匹配输出、错误及最近运行。每个回复的 `next` 提示下一步。新开对话或拿不准时先调它;简报预算约 3600 字,减少 Antigravity 将大回复转存为文件的情况 |
| log_outside | AI 用了 AMB 以外的工具(命令、脚本、读写文件)后,说清做了什么、为什么、动了哪些文件;显示在时间线上(见第 5 节) |
| ask_user | 在 AI 工作台里问你一个问题:几个选项(AI 推荐的那个标着“推荐”),加一行“都不是?写下你的想法”;AI 在同一步里等你回答 |
| show_panel / update_panel / get_answers | 在 AI 工作台里画一个面板(对比图片、模型卡片、表格、按钮和输入框);改它或关掉它的问题;读你的回答(见第 5 节) |
@@ -156,7 +158,24 @@ claude mcp add --transport http anomalous http://127.0.0.1:8188/anomalous/mcp
**运行进度**:AI 运行工作流后,时间线上那一步下面有进度条(排队时前面还有几个;运行时哪个节点、第几步、节点做完几个)。进度条右边是百分比,旁边是红色的 **■ 停止**:一点就停这一次运行(还在排队的移出队列,正在跑的立刻中断,只动这次运行),AI 也会收到“你停止了运行”,不会自己再跑一遍。任务工具栏的运行状态按钮旁边也有一个小 ■。进度条下面写着当前节点 **约剩多久**、每步几秒、**显存剩多少**,再下面一行是 ComfyUI **控制台**最新的那一行(比如 `Model Initializing ...`、`Requested to load …`),点开能看最近几行;这些 AI 也同样看得到。出图刚排上队,AMB 会读这次的工作流算出每个采样器要在多大的图上采样:如果是放大模型放大后又整张重新采样、尺寸超过这张显卡的舒适范围、或者要解码特别大的图,会出现黄色提醒,告诉你可能很慢或爆显存,以及怎么改(只想放大就用纯模型超分,或者先缩小、减步数、分块解码);AI 收到同样的提醒,会被要求先问你要不要停(它也能用 `stop_run` 停自己的运行)。AI 用 `run_status` 问同样的进度,或者等到跑完再继续,不会凭时间猜;运行期间 AI 调用别的工具时,回复里的 `your_run` 也带着一句当前进度,跑完后告诉它一次结果,所以它不用自己去翻输出文件夹或者看屏幕。
**流程图和关卡**:任务标题下面的一排流程按 AI 做事的先后列出它经过的阶段(模型、搭配、提示词、运行、挑图……),点一下跳到那一步。任务列表上方的 **关卡** 里,改画布、用显卡出图配音、下载模型、联网扫描、挪动模型文件五类事各自可以选 **直接做 / 先问我 / 不许做**(默认下载和挪文件先问)。选了“先问我”,AI 的那一步会停在时间线上列出它要做什么,你点 **允许** 才做;点 **不要** 时可以写原因或想怎么改,AI 会收到。AI 最多等 50 秒,等不及的话会先去问你的回答(`get_answers`),你允许后它再做一次,不会问第二遍。关卡窗口最下面的 **给 AI 的工具** 可以选 **日常**:只给 AI 19 个常用工具(照着跑、看图、换模型或 LoRA、查缺的模型、问你、留言、现状一览),要读的说明少一半,适合 Gemini Flash 这类小模型日常跑图;要让 AI 连线搭工作流、画面板、下载,就选 **完整**。AI 软件在一个对话里会记住开头拿到的工具,换了以后要开个新对话。
**流程图和关卡**:任务标题下面的一排流程按 AI 做事的先后列出它经过的阶段(模型、搭配、提示词、运行、挑图……),点一下跳到那一步。任务列表上方的 **关卡** 里,改画布、用显卡出图配音、下载模型、联网扫描、挪动模型文件五类事各自可以选 **直接做 / 先问我 / 不许做**(默认下载和挪文件先问)。选了“先问我”,AI 的那一步会停在时间线上列出它要做什么,你点 **允许** 才做;点 **不要** 时可以写原因或想怎么改,AI 会收到。AI 最多等 50 秒,等不及的话会先去问你的回答(`get_answers`),你允许后它再做一次,不会问第二遍。关卡窗口最下面的 **给 AI 的工具** 可以选 **日常**:常用工具,包括跑图、看图、模型、问你、留言、现状一览,以及节点搜索、自动接线搭建和参数修改;要画面板、下载等其余能力,选 **完整**。AI 软件在一个对话里会记住开头拿到的工具,换了以后要开个新对话。
**少调用几次就接好线**:以下编号只是例子,以当前画布为准。
```text
add_node(type="ImageScale", insert_after=8)
```
原来 `#8 → #9 保存` 会变成 `#8 → 新放大节点 → #9 保存`。如果 #8 同时连到保存和预览,工具会要求用 `wire_to=9` 指定要插入的分支。只有一种明确的贯通类型才自动插入;多个贯通类型时用 `insert_type="IMAGE"` 等指定。没有下游时用 `wire_from=8` 延长末端。
```text
add_node(type="VAEEncodeForInpaint", wire_from=[12, 4], wire_to=3, replace=true)
connect_nodes(from_node_id=12, to_node_id=15)
```
前一个调用按唯一类型连接图片、遮罩、VAE 和采样器的 LATENT;`replace=true` 明确允许替换采样器原有的输入。后一个只报两个节点号,把它们之间唯一匹配的类型一次接好。同类型多插口(如 positive/negative)、通配类型不会猜,工具会要求指定插口;多来源争抢同一输入、循环和未获替换许可的已有连线都会在改动前报错。显式指定插口的旧调用仍保留原有替换行为。
成功后直接返回实际接线、还缺的输入和是否通向输出,通常不用再读整张画布。输出可达只说明存在路径,运行前仍由 ComfyUI 校验。一次成功操作可用原生 Ctrl+Z 撤销;执行中连接失败会恢复原接线,第三方节点回调若妨碍恢复会明确报出,需要检查画布。升级后重启 ComfyUI、刷新页面,并重新连接 MCP;若 AI 软件缓存工具定义,再开一个对话。
**AI 干活时你自己改的东西**:AI 在做一个任务时(10 分钟内调用过),你在 AMB 里做的改动,比如画布上的改动、改模型信息、存搭配或提示词,会以“你在 AMB 里改了”出现在这个任务的时间线上,画布改动带修改前后。AI 下一次调用任何工具时会收到它(并被告知保留你的改动,不要改回去),时间线上写着它是否已经知道。操作记录页里,AI 做的和 AI 干活时你做的记录旁边有 **AI 任务 →**。
+137
View File
@@ -0,0 +1,137 @@
import assert from 'node:assert/strict';
import { addWires, applyWires, pairWires, reachesOutput, validateWires } from '../web/modules/canvas_wiring.js';
class Graph {
constructor() { this._nodes = []; this.links = {}; this.serial = 0; this.nodeSerial = 0; }
getNodeById(id) { return this._nodes.find(n => String(n.id) === String(id)); }
getLink(id) { return this.links[id]; }
add(n) { n.id = ++this.nodeSerial; n.graph = this; this._nodes.push(n); }
remove(n) {
for (const link of Object.values(this.links)) {
if (link.origin_id === n.id || link.target_id === n.id) this.getNodeById(link.target_id).disconnectInput(link.target_slot);
}
this._nodes = this._nodes.filter(item => item !== n);
}
}
function node(g, inputs = [], outputs = [], terminal = false) {
const n = {
inputs: inputs.map(([name, type]) => ({ name, type, link: null })),
outputs: outputs.map(([name, type]) => ({ name, type, links: [] })),
constructor: { nodeData: { output_node: terminal } },
disconnectInput(slot) {
const id = this.inputs[slot].link, link = this.graph.links[id];
if (link) {
const out = this.graph.getNodeById(link.origin_id).outputs[link.origin_slot];
out.links = out.links.filter(i => i !== id);
delete this.graph.links[id];
}
this.inputs[slot].link = null;
},
connect(slot, to, target) {
if (this.fail) { this.fail = false; return null; }
to.disconnectInput(target);
const id = ++this.graph.serial;
const link = { id, origin_id: this.id, origin_slot: slot, target_id: to.id, target_slot: target };
this.graph.links[id] = link;
this.outputs[slot].links.push(id); to.inputs[target].link = id;
return link;
},
};
if (g) g.add(n);
return n;
}
const snapshot = g => Object.values(g.links).map(l => [l.origin_id, l.origin_slot, l.target_id, l.target_slot].join(':')).sort();
const wire = (g, plan, added = null, replace = false) => applyWires(g, validateWires(g, plan, replace), added);
// Insertion keeps the entire selected branch connected and reaches the real output.
{
const g = new Graph(), decode = node(g, [], [['IMAGE','IMAGE']]);
const save = node(g, [['images','IMAGE']], [], true);
decode.connect(0, save, 0);
const scale = node(null, [['image','IMAGE']], [['IMAGE','IMAGE']]);
const result = wire(g, addWires(g, scale, { insert_after: decode.id }), scale);
assert.equal(result.length, 2);
assert.equal(result[1].replaced_from, decode.id);
assert.equal(g.getLink(save.inputs[0].link).origin_id, scale.id);
assert.equal(reachesOutput(g, scale), true);
save.mode = 2;
assert.equal(reachesOutput(g, scale), false, 'muted outputs do not count');
}
// Fan-out requires an explicit branch; the other preview is not rewired.
{
const g = new Graph(), decode = node(g, [], [['IMAGE','IMAGE']]);
const save = node(g, [['image','IMAGE']], [], true), preview = node(g, [['image','IMAGE']], [], true);
decode.connect(0, save, 0); decode.connect(0, preview, 0);
const scale = node(null, [['image','IMAGE']], [['IMAGE','IMAGE']]);
const before = snapshot(g);
assert.throws(() => addWires(g, scale, { insert_after: decode.id }), /branches/);
assert.deepEqual(snapshot(g), before);
wire(g, addWires(g, scale, { insert_after: decode.id, wire_to: [save.id] }), scale);
assert.equal(g.getLink(preview.inputs[0].link).origin_id, decode.id);
}
// Four inpainting wires in one transaction; replacing an occupied latent needs opt-in.
{
const g = new Graph(), load = node(g, [], [['IMAGE','IMAGE'], ['MASK','MASK']]);
const ckpt = node(g, [], [['MODEL','MODEL'], ['VAE','VAE']]);
const empty = node(g, [], [['LATENT','LATENT']]), sampler = node(g, [['latent_image','LATENT']], [['LATENT','LATENT']]);
empty.connect(0, sampler, 0);
const encode = node(null, [['pixels','IMAGE'], ['mask','MASK'], ['vae','VAE']], [['LATENT','LATENT']]);
const plan = addWires(g, encode, { wire_from: [load.id, ckpt.id], wire_to: [sampler.id] });
assert.equal(plan.length, 4);
assert.throws(() => validateWires(g, plan), /replace:true/);
assert.equal(wire(g, plan, encode, true).length, 4);
assert.equal(reachesOutput(g, encode), false);
}
// Ambiguity, wildcard and conflicting sources fail before mutation.
{
const g = new Graph(), text = node(g, [], [['CONDITIONING','CONDITIONING']]);
const sampler = node(g, [['positive','CONDITIONING'], ['negative','CONDITIONING']]);
assert.throws(() => pairWires(text, sampler), /positive.*negative/);
assert.equal(pairWires(text, sampler, { output: 0, input: 'negative' }).length, 1);
const any = node(g, [], [['any','*']]);
assert.throws(() => pairWires(any, sampler), /wildcards/);
assert.equal(pairWires(any, sampler, { output: 0, input: 'negative' }).length, 1);
const other = node(g, [], [['CONDITIONING','CONDITIONING']]);
assert.throws(() => validateWires(g, [...pairWires(text, sampler, { output: 0, input: 'positive' }),
...pairWires(other, sampler, { output: 0, input: 'positive' })]), /Several sources/);
assert.equal(Object.keys(g.links).length, 0);
}
// Cycles in the final planned topology are refused, including a detached new node.
{
const g = new Graph(), a = node(g, [['in','IMAGE']], [['out','IMAGE']]), b = node(g, [['in','IMAGE']], [['out','IMAGE']]);
a.connect(0,b,0);
const added = node(null, [['in','IMAGE']], [['out','IMAGE']]);
assert.throws(() => validateWires(g, addWires(g, added, { wire_from:[b.id], wire_to:[a.id] })), /loop/);
}
// Failure on the second link removes the newly added node and restores the original branch.
{
const g = new Graph(), decode = node(g, [], [['IMAGE','IMAGE']]), save = node(g, [['images','IMAGE']], [], true);
decode.connect(0,save,0);
const before = snapshot(g), added = node(null, [['image','IMAGE']], [['IMAGE','IMAGE']]);
added.fail = true;
assert.throws(() => wire(g, addWires(g, added, { insert_after: decode.id }), added), /Original connections restored/);
assert.deepEqual(snapshot(g), before);
assert.equal(g._nodes.length, 2);
}
// An existing-node batch must restore BOTH replaced inputs if the second connection fails.
{
const g = new Graph(), old = node(g, [], [['IMAGE','IMAGE'], ['MASK','MASK']]);
const next = node(g, [], [['IMAGE','IMAGE'], ['MASK','MASK']]);
const target = node(g, [['pixels','IMAGE'], ['mask','MASK']]);
old.connect(0, target, 0); old.connect(1, target, 1);
const before = snapshot(g), connect = next.connect;
next.connect = function(slot, ...args) { return slot === 1 ? null : connect.call(this, slot, ...args); };
assert.throws(() => wire(g, pairWires(next, target), null, true), /Original connections restored/);
assert.deepEqual(snapshot(g), before);
}
// Widget callbacks that fail before wiring must leave no new node and preserve the old path.
{
const g = new Graph(), a = node(g, [], [['IMAGE','IMAGE']]), b = node(g, [['images','IMAGE']]);
a.connect(0,b,0);
const added = node(null, [['image','IMAGE']], [['IMAGE','IMAGE']]), before = snapshot(g);
const plan = validateWires(g, addWires(g, added, { insert_after: a.id }));
assert.throws(() => applyWires(g, plan, added, () => { throw new Error('widget failed'); }), /Original connections restored/);
assert.equal(g._nodes.length, 2);
assert.deepEqual(snapshot(g), before);
}
console.log('Typed wiring: insertion, fan-out, inpainting, ambiguity, cycles and rollback passed.');
+29 -3
View File
@@ -1,7 +1,7 @@
// node tests/mcp_canvas_brief.mjs — the canvas in brief for an AI: a line per node, what a run still
// needs wired and what on the canvas fits it, ComfyUI's errors.
import assert from 'node:assert/strict';
import { canvasBrief, unwired } from '../web/modules/mcp_canvas_brief.js';
import { canvasBrief, flowChains, unwired } from '../web/modules/mcp_canvas_brief.js';
/** A node as LiteGraph keeps it, with ComfyUI's data on which inputs a run needs. */
function node(id, type, inputs = [], outputs = [], { required = inputs.map(input => input.name), mode = 0, title } = {}) {
@@ -43,11 +43,37 @@ assert.equal(canvasBrief(g).errors, undefined);
const many = graph(Array.from({ length: 35 }, (_, at) => node(at + 1, 'SaveImage', [socket('images', 'IMAGE')], [])), {});
const long = canvasBrief(many);
assert.equal(long.lines.length, 30);
assert.equal(long.more_nodes, 5);
assert.equal(long.lines.length, 8);
assert.equal(long.more_nodes, 27);
assert.equal(long.unwired.length, 8);
assert.equal(long.more_unwired, 27);
assert.equal(long.unwired[0].fits, undefined, 'nothing on the canvas gives an IMAGE');
assert.deepEqual(unwired(graph([], {})), { unwired: [] });
assert(brief.chains.some(chain => chain.includes('CONDITIONING:positive')));
assert(!brief.chains.some(chain => chain.includes('#6 ')), 'bypassed nodes are not active paths');
const serial = graph([
node(1, 'EmptyLatentImage'), node(2, 'KSampler', [socket('latent_image', 'LATENT', 1)]),
node(3, 'VAEDecode', [socket('samples', 'LATENT', 2)]), node(4, 'SaveImage', [socket('images', 'IMAGE', 3)]),
], { 1: { origin_id: 1 }, 2: { origin_id: 2 }, 3: { origin_id: 3 } });
assert.deepEqual(flowChains(serial).chains, [
'#1 EmptyLatentImage --LATENT:latent_image--> #2 KSampler --LATENT:samples--> #3 VAEDecode --IMAGE:images--> #4 SaveImage',
]);
serial._nodes.push(node(5, 'VAELoader'));
serial._nodes[2].inputs.push(socket('vae', 'VAE', 5));
serial.links[5] = { origin_id: 5 };
assert(flowChains(serial).chains[0].includes('#2 KSampler --LATENT:samples--> #3 VAEDecode --IMAGE:images--> #4 SaveImage'),
'auxiliary VAE input must not split the picture spine');
serial._nodes[2].inputs.pop();
delete serial.links[5];
serial._nodes[0].inputs = [socket('loop', 'IMAGE', 4)];
serial.links[4] = { origin_id: 4 };
assert.equal(flowChains(serial).chains.length, 1, 'a malformed cyclic graph still terminates');
assert(flowChains(serial).chains[0].length <= 240);
const branches = graph([node(1, 'Source'), ...Array.from({ length: 20 }, (_, i) =>
node(i + 2, 'Target', [socket(i % 2 ? 'positive' : 'negative', 'CONDITIONING', i + 1)]))],
Object.fromEntries(Array.from({ length: 20 }, (_, i) => [i + 1, { origin_id: 1 }])));
assert.equal(flowChains(branches).chains.length, 6);
assert.equal(flowChains(branches).more_chains, 14);
console.log('mcp_canvas_brief: ok');
+2 -1
View File
@@ -81,7 +81,8 @@ class ProtocolTests(unittest.TestCase):
status, body = post({"jsonrpc": "2.0", "id": 1, "method": "tools/list"}, {"MCP-Protocol-Version": "2025-06-18"})
names = {tool["name"] for tool in body["result"]["tools"]}
self.assertEqual(names, set(action_catalog.DAILY) | set(mcp_apps.APP_TOOLS))
self.assertNotIn("add_node", names)
self.assertTrue({"find_nodes", "add_node", "connect_nodes", "set_values", "new_workflow"} <= names)
self.assertNotIn("download_model", names)
status, body = post({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}, {"MCP-Protocol-Version": "2025-06-18"})
self.assertIn("add_node", {tool["name"] for tool in body["result"]["tools"]}, "full by default")
+45 -6
View File
@@ -31,14 +31,24 @@ class FindNodesTests(unittest.TestCase):
patcher.start()
self.addCleanup(patcher.stop)
def test_search_gives_inputs_outputs_and_a_few_choices(self):
def test_search_gives_compact_candidates_without_choice_lists(self):
found = mcp_canvas_edit.find_nodes({}, query="load checkpoint")
node = found["nodes"][0]
self.assertEqual((node["type"], found["total_matches"]), ("CheckpointLoaderSimple", 1))
choice = node["inputs"][0]
self.assertEqual((choice["type"], len(choice["choices"]), choice["choices_count"]), ("choice", mcp_canvas_edit.MAX_CHOICES, 80))
self.assertEqual([item["name"] for item in node["outputs"]], ["MODEL", "CLIP", "VAE"])
self.assertEqual(node["outputs"][1]["slot"], 1)
self.assertEqual(node["inputs"], ["ckpt_name:choice"])
self.assertEqual(node["outputs"], ["MODEL:MODEL", "CLIP:CLIP", "VAE:VAE"])
self.assertNotIn("m0.safetensors", str(found))
def test_search_is_bounded_even_with_many_large_node_definitions(self):
import json
with mock.patch.object(mcp_canvas_edit, "object_info", return_value={
"Large" + str(i): {"input": {"required": {"input" + str(k): ["STRING"] for k in range(100)}},
"description": "x" * 10000} for i in range(100)
}):
result = mcp_canvas_edit.find_nodes({}, query="Large")
self.assertEqual(result["total_matches"], 100)
self.assertLessEqual(len(result["nodes"]), 5)
self.assertLess(len(json.dumps(result)), 3600)
def test_settings_keep_their_range_and_sockets_say_so(self):
node = mcp_canvas_edit.find_nodes({}, types=["KSampler"])["nodes"][0]
@@ -69,7 +79,8 @@ class BuildToolTests(unittest.TestCase):
def test_the_page_gets_what_it_needs(self):
self.assertIn("run_workflow", mcp_canvas_edit.add_node({}, "KSampler", {"steps": 28}, near_node_id=4)["next"])
self.assertEqual(self.page.call_args[0], ("add_node", {"type": "KSampler", "values": {"steps": 28}, "title": "", "near_node_id": 4}))
self.assertEqual(self.page.call_args[0], ("add_node", {"type": "KSampler", "values": {"steps": 28}, "title": "", "near_node_id": 4,
"wire_from": [], "wire_to": [], "replace": False}))
mcp_canvas_edit.connect_nodes({}, 4, 3, "model", output="0")
self.assertEqual(self.page.call_args[0][1]["output"], 0, "a slot number given as text")
mcp_canvas_edit.connect_nodes({}, 4, 3, "clip", output="CLIP")
@@ -77,6 +88,29 @@ class BuildToolTests(unittest.TestCase):
mcp_canvas_edit.remove_nodes({}, [3, 4])
self.assertEqual(self.page.call_args[0], ("remove_nodes", {"node_ids": [3, 4]}))
def test_automatic_wiring_and_old_explicit_input_form(self):
mcp_canvas_edit.connect_nodes({}, 12, 15)
self.assertEqual(self.page.call_args[0][1], {"from_node_id": 12, "to_node_id": 15,
"input": None, "output": None, "replace": None})
mcp_canvas_edit.connect_nodes({}, 12, 15, input="pixels")
self.assertEqual(self.page.call_args[0][1]["output"], 0)
mcp_canvas_edit.add_node({}, "VAEEncodeForInpaint", wire_from=[12, 4], wire_to=3, replace=True)
payload = self.page.call_args[0][1]
self.assertEqual((payload["wire_from"], payload["wire_to"], payload["replace"]), ([12, 4], [3], True))
mcp_canvas_edit.add_node({}, "ImageScale", insert_after=8, wire_to=9)
self.assertEqual(self.page.call_args[0][1]["insert_after"], 8)
def test_registry_accepts_auto_calls_without_socket_names(self):
for tool, args in (("connect_nodes", {"from_node_id": 1, "to_node_id": 2}),
("add_node", {"type": "ImageScale", "insert_after": 8, "wire_to": [9]})):
result = call_tool(mcp_server.TOOLS, tool, args, {})
self.assertFalse(result.get("isError"), result)
self.assertEqual(self.page.call_count, 2)
def test_receipt_does_not_claim_disconnected_branch_can_run(self):
self.page.return_value = {"reaches_output": False, "unwired": []}
self.assertIn("does not reach", mcp_canvas_edit.add_node({}, "ImageScale")["next"])
def test_a_workflow_in_api_form(self):
nodes = {"1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "a.safetensors"}},
"2": {"class_type": "KSampler", "inputs": {"model": ["1", 0], "steps": 20}}}
@@ -91,6 +125,11 @@ class BuildToolTests(unittest.TestCase):
lambda: mcp_canvas_edit.add_node({}, "KSampler", {"steps": [1, 2]}),
lambda: mcp_canvas_edit.add_node({}, "KSampler", {"text": "x" * (mcp_canvas_edit.MAX_TEXT + 1)}),
lambda: mcp_canvas_edit.add_node({}, "KSampler", near_node_id=True),
lambda: mcp_canvas_edit.add_node({}, "ImageScale", wire_from=[]),
lambda: mcp_canvas_edit.add_node({}, "ImageScale", wire_to=list(range(9))),
lambda: mcp_canvas_edit.add_node({}, "ImageScale", insert_type="IMAGE"),
lambda: mcp_canvas_edit.add_node({}, "ImageScale", replace="yes"),
lambda: mcp_canvas_edit.connect_nodes({}, 1, 2, replace="yes"),
lambda: mcp_canvas_edit.connect_nodes({}, 1, 2, ""),
lambda: mcp_canvas_edit.connect_nodes({}, {"id": 1}, 2, "model"),
lambda: mcp_canvas_edit.set_values({}, 3, {}),
+10
View File
@@ -66,6 +66,16 @@ class SituationTests(unittest.TestCase):
self.assertLessEqual(len(tool_text(got)), mcp_situation.BUDGET)
self.assertEqual(len(got["canvas"]["lines"]) + got["canvas"]["more_nodes"], 60) # what was left out is counted
def test_chain_budget_keeps_missing_inputs_and_counts_omissions(self):
self.asked.return_value = {"node_count": 8, "lines": ["x" * 140] * 8,
"chains": ["x" * 240] * 6, "more_chains": 2,
"unwired": [{"node": 3, "input": "positive", "type": "CONDITIONING",
"fits": ["x" * 100] * 3}] * 8}
got = mcp_situation.get_situation(CTX)
self.assertLessEqual(len(tool_text(got)), mcp_situation.BUDGET)
self.assertEqual(len(got["canvas"]["unwired"]), 8)
self.assertEqual(len(got["canvas"]["chains"]) + got["canvas"]["more_chains"], 8)
if __name__ == "__main__":
unittest.main()
+153
View File
@@ -0,0 +1,153 @@
/** Plans typed wiring without changing the graph, then applies the whole plan with rollback.
* Automatic matching requires a concrete, unique type; explicit sockets retain typesFit.
* Insertion replaces only the selected downstream branch. No browser or app state here. */
import { typesFit } from './canvas_build.js';
import { linkOf } from './mcp_canvas_brief.js';
const concrete = type => typeof type === 'string' && type.trim() && !/[*,]/.test(type);
const label = node => `#${node.id ?? 'new'}`;
const slotLabel = (node, slot, input) => `${label(node)}.${(input ? node.inputs : node.outputs)[slot].name}`;
const same = (a, b) => a.from === b.from && a.to === b.to && a.slot === b.slot && a.target === b.target;
const oldSource = (g, to, target) => {
const link = linkOf(g, to.inputs[target].link);
return link && { from: g.getNodeById(link.origin_id), slot: link.origin_slot, to, target };
};
function selected(slots, selector, input) {
const all = (slots || []).map((value, index) => ({ value, index }));
if (selector == null) return all.filter(({ value }) => !input || !value.widget);
const found = all.filter(({ value, index }) => Number.isInteger(selector) ? index === selector
: String(value.name).toLowerCase() === String(selector).toLowerCase());
if (found.length) return found;
if (!input) return all.filter(({ value }) => String(value.type).toLowerCase() === String(selector).toLowerCase());
return [];
}
/** Every unique concrete type shared by two nodes, or one explicitly selected socket pair. */
export function pairWires(from, to, { output, input } = {}) {
if (from === to) throw new Error('Cannot wire a node to itself.');
const explicit = output != null && input != null;
const pairs = [];
for (const a of selected(from.outputs, output, false)) {
for (const b of selected(to.inputs, input, true)) {
const fits = explicit ? typesFit(a.value.type, b.value.type)
: concrete(a.value.type) && concrete(b.value.type) && a.value.type === b.value.type;
if (fits) pairs.push({ from, slot: a.index, to, target: b.index });
}
}
if (!pairs.length) throw new Error(`No unique concrete type from ${label(from)} to ${label(to)}. Specify output and input; wildcards need explicit sockets.`);
const ambiguous = pairs.some(a => pairs.some(b => a !== b && (a.slot === b.slot || a.target === b.target)));
if (ambiguous || pairs.length > 8) {
const choices = pairs.slice(0, 8).map(p => `${slotLabel(from, p.slot, false)} -> ${slotLabel(to, p.target, true)}`);
throw new Error(`Ambiguous wiring; nothing changed. Choose output/input: ${choices.join('; ')}${pairs.length > 8 ? '; more candidates omitted' : ''}`);
}
return pairs;
}
/** Build a node's insertion or explicit-neighbour plan while the new node is still detached. */
export function addWires(g, node, { insert_after: after, insert_type: type, wire_from: from = [], wire_to: to = [] } = {}) {
const get = id => {
const found = g.getNodeById(id);
if (!found) throw new Error(`No node #${id}; call get_situation.`);
return found;
};
const upstream = from.map(get), downstream = to.map(get);
if (after == null) return [...upstream.flatMap(n => pairWires(n, node)), ...downstream.flatMap(n => pairWires(node, n))];
const anchor = get(after);
const entry = pairWires(anchor, node).filter(p => !type || anchor.outputs[p.slot].type === type);
const through = entry.flatMap(p => (node.outputs || []).flatMap((o, slot) =>
concrete(o.type) && o.type === anchor.outputs[p.slot].type ? [{ entry: p, slot }] : []));
if (through.length !== 1) throw new Error('Insertion needs one unambiguous through type. Give insert_type, or use explicit connections.');
const chosen = through[0];
const links = (anchor.outputs[chosen.entry.slot].links || []).map(id => linkOf(g, id)).filter(Boolean);
if (!links.length) throw new Error('Nothing follows that output; use wire_from to extend its end.');
if (!downstream.length && links.length > 1) {
throw new Error(`Several downstream branches; choose wire_to: ${[...new Set(links.map(l => l.target_id))].slice(0, 8).join(', ')}. Nothing changed.`);
}
const chosenLinks = downstream.length ? links.filter(l => downstream.includes(g.getNodeById(l.target_id))) : links;
if (downstream.some(n => !chosenLinks.some(l => l.target_id === n.id))) throw new Error('wire_to must name existing downstream branches of insert_after.');
return [chosen.entry, ...upstream.flatMap(n => pairWires(n, node)), ...chosenLinks.map(l => ({
from: node, slot: chosen.slot, to: get(l.target_id), target: l.target_slot, replace: true,
}))];
}
/** Validate final topology, including replacements, before the first node/link is touched. */
export function validateWires(g, proposed, replace = false) {
const plan = proposed.filter((p, i) => !proposed.slice(0, i).some(other => same(p, other)));
if (plan.length > 32) throw new Error('Too many links in one change (maximum 32). Select fewer branches.');
const targets = new Map();
for (const p of plan) {
if (!typesFit(p.from.outputs[p.slot]?.type, p.to.inputs[p.target]?.type)) throw new Error('Incompatible sockets.');
if (!targets.has(p.to)) targets.set(p.to, new Set());
if (targets.get(p.to).has(p.target)) throw new Error(`Several sources for ${slotLabel(p.to, p.target, true)}. Choose one source; nothing changed.`);
targets.get(p.to).add(p.target);
const old = oldSource(g, p.to, p.target);
if (old && !same(old, p) && !p.replace && !replace) {
throw new Error(`${slotLabel(p.to, p.target, true)} is already wired from ${label(old.from)}. Use replace:true to replace it; nothing changed.`);
}
}
const edges = new Map();
const edge = (from, to) => { if (!edges.has(from)) edges.set(from, []); edges.get(from).push(to); };
for (const to of g._nodes || []) {
(to.inputs || []).forEach((input, target) => {
if (targets.get(to)?.has(target)) return;
const old = oldSource(g, to, target);
if (old?.from) edge(old.from, to);
});
}
plan.forEach(p => edge(p.from, p.to));
const visited = new Set(), pending = new Set();
function visit(node) {
if (pending.has(node)) throw new Error('These connections would make a loop. Nothing changed.');
if (visited.has(node)) return;
pending.add(node);
for (const next of edges.get(node) || []) visit(next);
pending.delete(node); visited.add(node);
}
for (const node of edges.keys()) visit(node);
return plan;
}
/** The caller brackets this in one beforeChange/afterChange and records only success. */
export function applyWires(g, plan, added = null, prepare = () => {}) {
const old = plan.map(p => oldSource(g, p.to, p.target));
try {
if (added) g.add(added);
prepare();
for (const p of plan) {
const linked = p.from.connect(p.slot, p.to, p.target);
const actual = oldSource(g, p.to, p.target);
if (!linked || !actual || !same(p, actual)) throw new Error('ComfyUI refused a planned connection.');
}
} catch (error) {
const problems = [];
// Remove new connections first, then restore original sources even after a partial failure.
for (const p of plan) {
try { p.to.disconnectInput(p.target); } catch (e) { problems.push(e); }
}
if (added && (g._nodes || []).includes(added)) {
try { g.remove(added); } catch (e) { problems.push(e); }
}
for (const p of old.filter(Boolean)) {
try {
p.from.connect(p.slot, p.to, p.target);
const actual = oldSource(g, p.to, p.target);
if (!actual || !same(actual, p)) throw new Error('Original connection could not be restored.');
} catch (e) { problems.push(e); }
}
if (problems.length) throw new Error(`${error.message} Rollback incomplete: inspect the canvas before retrying.`);
throw new Error(`${error.message} Original connections restored.`);
}
return plan.map((p, i) => ({ from: p.from.id, output: p.from.outputs[p.slot].name, to: p.to.id,
input: p.to.inputs[p.target].name, ...(old[i] && !same(old[i], p) ? { replaced_from: old[i].from.id } : {}) }));
}
export function reachesOutput(g, node, seen = new Set()) {
if (seen.has(node) || node.mode === 2 || node.mode === 4) return false;
seen.add(node);
if (node.constructor?.nodeData?.output_node) return true;
return (node.outputs || []).some(o => (o.links || []).some(id => {
const link = linkOf(g, id), next = link && g.getNodeById(link.target_id);
return next && reachesOutput(g, next, seen);
}));
}
+2 -2
View File
@@ -1293,7 +1293,7 @@ export const i18n = {
aiCanvasTab_new: "新开标签页",
aiCanvasTab_current: "改当前画布",
aiGate_tools: "给 AI 的工具",
aiGateWhy_tools: "日常:只给 19 个常用工具(照着跑、看图、换模型或 LoRA、查缺的模型、现状一览),说明少一半,Gemini Flash 这类小模型不容易绕晕。完整:全部工具,能连线搭工作流、画面板、下载。换了以后在 AI 软件里开个新对话才生效。",
aiGateWhy_tools: "日常:常用工具,包括出图、看图、换模型、查缺失模型、现状一览,以及自动接线搭工作流,减少小模型要读的说明。完整:全部工具,包括画面板和下载。换了以后在 AI 软件里开个新对话才生效。",
aiToolSet_daily: "日常",
aiToolSet_full: "完整",
aiPicturesMenu: "发给 AI",
@@ -4627,7 +4627,7 @@ export const i18n = {
aiCanvasTab_new: "New tab",
aiCanvasTab_current: "Open canvas",
aiGate_tools: "Tools for the AI",
aiGateWhy_tools: "Daily: 19 everyday tools (run a saved workflow with changes, look at pictures, switch a model or LoRA, check missing models, where things stand), half the reading, so a small model such as Gemini Flash keeps its way. Full: every tool, wiring workflows, panels, downloads. A change shows in a new chat in the AI app.",
aiGateWhy_tools: "Daily: everyday tools for running workflows, pictures, models, missing files, task status and automatic workflow wiring, with fewer descriptions to read. Full: every tool, including panels and downloads. A change shows in a new chat in the AI app.",
aiToolSet_daily: "Daily",
aiToolSet_full: "Full",
aiPicturesMenu: "Hand to AI",
+51 -3
View File
@@ -1,6 +1,6 @@
/**
* The canvas in brief for an AI, so a small model need not hold the whole graph in mind:
* a line per node (id, type, title, what feeds each wired input, what it gives), the inputs a run
* bounded flow chains and node lines (id, type, what feeds each input), the inputs a run
* needs that nothing feeds yet with the outputs on the canvas that fit them (`unwired`), and the
* errors ComfyUI gave for the last run it refused. get_situation reads it (mcp_bridge.js
* describe_canvas with brief), and every change of the AI's answers with what is still unwired
@@ -10,7 +10,7 @@
import { typesFit } from './canvas_build.js';
const MAX_LINES = 30;
const MAX_LINES = 8;
const MAX_UNWIRED = 8;
const MAX_FITS = 3;
const MAX_ERRORS = 6;
@@ -81,15 +81,63 @@ function errorsOf(nodeErrors) {
}))).slice(0, MAX_ERRORS);
}
/** The canvas in brief: `{node_count, lines, unwired, errors}` (and how many lines were left out). */
/** The canvas in brief: `{node_count, chains, lines, unwired, errors}` and omitted counts. */
export function canvasBrief(g, nodeErrors = null) {
const nodes = g._nodes || [];
const errors = errorsOf(nodeErrors);
return {
node_count: nodes.length,
...flowChains(g),
lines: nodes.slice(0, MAX_LINES).map(node => line(g, node)),
...(nodes.length > MAX_LINES ? { more_nodes: nodes.length - MAX_LINES } : {}),
...unwired(g),
...(errors.length ? { errors } : {}),
};
}
/** Collapse unbranched paths within a flow (IMAGE/LATENT share the picture flow).
* Auxiliary VAE/MASK inputs don't split the picture spine; positive/negative stay named.
* Walk each edge once, never enumerate all paths through a branching graph. */
export function flowChains(g) {
const edges = [], incoming = new Map(), outgoing = new Map();
const family = type => ['IMAGE', 'LATENT'].includes(type) ? 'picture' : type;
const bucket = (index, node, kind) => index.get(node)?.get(kind) || [];
function put(index, node, edge) {
if (!index.has(node)) index.set(node, new Map());
if (!index.get(node).has(edge.family)) index.get(node).set(edge.family, []);
index.get(node).get(edge.family).push(edge);
}
for (const to of g._nodes || []) {
if (off(to)) continue;
for (const input of to.inputs || []) {
const link = input.link != null && linkOf(g, input.link);
const from = link && g.getNodeById(link.origin_id);
if (!from || off(from)) continue;
const edge = { from, to, type: String(input.type), input: input.name, family: family(String(input.type)) };
edges.push(edge);
put(outgoing, from, edge);
put(incoming, to, edge);
}
}
const seen = new Set(), chains = [];
const name = n => `#${n.id} ${String(n.type).slice(0, 32)}`;
function walk(first) {
if (seen.has(first)) return;
let edge = first, text = name(first.from);
while (edge && !seen.has(edge)) {
seen.add(edge);
text += ` --${edge.type}:${edge.input}--> ${name(edge.to)}`;
const next = bucket(outgoing, edge.to, edge.family);
edge = bucket(incoming, edge.to, edge.family).length === 1 && next.length === 1 ? next[0] : null;
}
chains.push(text.length > 240 ? text.slice(0, 239) + '…' : text);
}
const priority = ['picture', 'MODEL', 'CONDITIONING', 'CLIP', 'VAE', 'MASK'];
const rank = edge => { const n = priority.indexOf(edge.family); return n < 0 ? priority.length : n; };
edges.sort((a, b) => rank(a) - rank(b));
for (const edge of edges) {
if (bucket(incoming, edge.from, edge.family).length !== 1 || bucket(outgoing, edge.from, edge.family).length !== 1) walk(edge);
}
edges.forEach(walk);
return { chains: chains.slice(0, 6), ...(chains.length > 6 ? { more_chains: chains.length - 6 } : {}) };
}
+23 -35
View File
@@ -17,23 +17,18 @@ import { fillPlan } from './mcp_fill.js';
import { translate as t } from './locales.js';
import { setWidgetValue } from './ui_node_model_picker.js';
import { showWorkbenchToast } from './ui_prompt_toast.js';
import { addWires, applyWires, pairWires, reachesOutput, validateWires } from './canvas_wiring.js';
import { workflowKey } from './canvas_undo.js';
const BLANK = { last_node_id: 0, last_link_id: 0, nodes: [], links: [], groups: [], config: {}, extra: {}, version: 0.4 };
/** A node as the AI wires it: its inputs (with what feeds them), outputs and settings. */
/** Socket details when a node was added without wiring; wired receipts already say what changed. */
function shape(g, node) {
const settings = {};
for (const widget of node.widgets || []) {
if (!widget?.name || widget.type === 'button') continue;
settings[widget.name] = typeof widget.value === 'string' ? widget.value.slice(0, 200) : widget.value;
}
return {
node_id: node.id, type: node.type, title: node.title,
inputs: sockets(node).map(input => ({
name: input.name, type: String(input.type), ...(input.link != null ? { from_node: linkOf(g, input.link)?.origin_id } : {}),
})),
outputs: (node.outputs || []).map((output, slot) => ({ slot, name: output.name, type: String(output.type) })),
settings,
};
}
@@ -54,19 +49,6 @@ function apply(node, plan) {
}
}
function outputSlot(node, output) {
const outputs = node.outputs || [];
if (Number.isInteger(output) && outputs[output]) return output;
const name = String(output ?? '').toLowerCase();
let slot = outputs.findIndex(item => String(item.name).toLowerCase() === name);
if (slot < 0) {
const typed = outputs.flatMap((item, index) => (String(item.type).toLowerCase() === name ? [index] : []));
slot = typed.length === 1 ? typed[0] : -1;
}
if (slot < 0) throw new Error(`Node ${node.id} (${node.type}) has no output ${output}; it has: ${outputs.map((item, i) => `${i} ${item.name}`).join(', ') || 'none'}.`);
return slot;
}
function inputSlot(node, input) {
const slot = (node.inputs || []).findIndex(item => item.name === input);
if (slot < 0) throw new Error(`Node ${node.id} (${node.type}) has no input ${input}; it has: ${sockets(node).map(item => item.name).join(', ') || 'none'}.`);
@@ -137,43 +119,49 @@ async function withFreshChoices(make) {
/** The canvas-building actions, given mcp_bridge.js's own helpers. */
export function canvasEditActions({ recorded, nodeById }) {
function addNode({ type, title, values, near_node_id: nearId }, getOwner) {
function addNode({ type, title, values, near_node_id: nearId, replace = false, ...wiring }, getOwner) {
return recorded(getOwner, 'mcpDidNode', async (g) => {
const key = workflowKey(app), before = JSON.stringify(g.serialize());
const { node, plan } = await withFreshChoices(() => {
const made = newNode(String(type));
return { node: made, plan: valuePlan(made, values) };
});
if ((app.canvas?.graph || app.graph) !== g || workflowKey(app) !== key || JSON.stringify(g.serialize()) !== before) {
throw new Error('The canvas changed while preparing the node. Nothing added; read get_situation again.');
}
if (title) node.title = String(title).slice(0, 120);
const near = nearId != null ? nodeById(nearId) : null;
const near = (nearId ?? wiring.insert_after) != null ? nodeById(nearId ?? wiring.insert_after) : null;
const wires = validateWires(g, addWires(g, node, wiring), replace);
let connections;
g.beforeChange?.();
node.pos = freeSpot(node, (g._nodes || []), near);
g.add(node);
apply(node, plan);
g.afterChange?.();
try {
node.pos = freeSpot(node, (g._nodes || []), near);
connections = applyWires(g, wires, node, () => apply(node, plan));
} finally { g.afterChange?.(); }
g.setDirtyCanvas?.(true, true);
recordCanvasStep(app);
return { ...shape(g, node), ...unwired(g), toast: { name: node.title || node.type } };
return { node_id: node.id, type: node.type, connections, reaches_output: reachesOutput(g, node),
...unwired(g), ...(!connections.length ? shape(g, node) : {}),
toast: { name: node.title || node.type } };
});
}
function connectNodes({ from_node_id: fromId, output, to_node_id: toId, input }, getOwner) {
function connectNodes({ from_node_id: fromId, output, to_node_id: toId, input, replace }, getOwner) {
return recorded(getOwner, 'mcpDidWire', (g) => {
const from = nodeById(fromId);
const to = nodeById(toId);
const slot = outputSlot(from, output);
const target = inputSlot(to, input);
const was = to.inputs[target].link != null ? linkOf(g, to.inputs[target].link)?.origin_id : null;
const plan = validateWires(g, pairWires(from, to, { output, input }), replace ?? (input != null && output != null));
let connections;
g.beforeChange?.();
try {
wire(g, from, slot, to, target);
connections = applyWires(g, plan);
} finally {
g.afterChange?.();
}
g.setDirtyCanvas?.(true, true);
recordCanvasStep(app);
return {
from_node: from.id, output: from.outputs[slot].name, to_node: to.id, input: to.inputs[target].name,
...(was != null && was !== from.id ? { replaced_link_from: was } : {}),
connections, reaches_output: reachesOutput(g, from),
...unwired(g), toast: { from: from.title || from.type, to: to.title || to.type },
};
});