feat(mcp): add typed auto-wiring and compact workflow context
This commit is contained in:
+3
-1
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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 "
|
||||
|
||||
@@ -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."}}),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 任务 →**。
|
||||
|
||||
|
||||
@@ -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.');
|
||||
@@ -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
@@ -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")
|
||||
|
||||
|
||||
@@ -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, {}),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
}));
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 } : {}) };
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user