Set any node's settings by name instead of growing the list of blanks
run_workflow's blanks were a fixed list (prompt, seed, size, image). Anything
else meant a set_values call per node, or yet another blank. Now `values`, on
run_workflow and on set_values without a node, sets any setting by its name, a
third-party node's too.
- The one node that has the setting gets it.
- When several nodes have it (a hires fix's two samplers), nothing changes and
the nodes are listed, so the AI names one: {"12": {"denoise": 0.5}}.
- One Ctrl+Z step for all of them.
The remaining blanks are roles (which prompt box, which latent), not setting
names. `prompt` now stops when several positive prompt boxes are combined into
one sampler (regional prompts) instead of giving them all one text. A refiner's
own prompt still gets it.
The scalar check moves from mcp_canvas_edit to mcp_fill (flat_values), which
both use; set_values keeps its argument order.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
4fcceffa1b
commit
9b4e565826
+3
-1
@@ -187,7 +187,9 @@ covered by the `styles.css` manifest.
|
||||
`api/mcp_fill.py` gives run_workflow its blanks, a daily run in one call: a saved workflow
|
||||
opened in the task's tab once and reused (the page marks the tab with the workflow's file),
|
||||
then the prompts, seed, size and the picture a LoadImage reads filled in by the page's
|
||||
`fill_slots`, planned by `web/modules/mcp_fill.js` (pure); with them a run asks as the stricter
|
||||
`fill_slots`, planned by `web/modules/mcp_fill.js` (pure). Any other setting goes by its name
|
||||
(`values`, also set_values without a node): the one node that has it, else the AI names the
|
||||
node; the fixed blanks are roles (which prompt, which latent), not a list that grows. With them a run asks as the stricter
|
||||
of its gate and the canvas's (`action_catalog.ALSO_CANVAS`). `tests/test_mcp_fill.py`,
|
||||
`tests/mcp_fill.mjs`.
|
||||
`api/ai_toolbox.py` keeps what the user hands the AI to work with in a task ("给 AI 的素材",
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
- 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.
|
||||
- AI 加节点时能插在已有的线前面:`wire_to` 指向的输入原来接着线的,改接新节点,原来的来源接进新节点,比如在采样器前加 ControlNet(正向接正向、反向接反向),或在采样器和两个提示词前加 LoRA,一次调用接好。同类型的几个插口名字相同时自动配对;`insert_after` 会把 LoRA 的模型线和 CLIP 线一起接进去。
|
||||
- add_node splices in front of a node: an input `wire_to` feeds that was already wired now goes through the new node, its old source feeding the new node (a ControlNet before a KSampler, positive to positive and negative to negative; a LoRA before the sampler and both prompts). Same-type sockets of the same name pair up, and `insert_after` splices every type a node passes through (a LoRA's MODEL and CLIP).
|
||||
- 改参数不再靠固定的填空:`run_workflow` 和 `set_values` 都能按参数名改任何节点的参数,第三方节点也一样(比如 `{"steps": 30}`)。只有一个节点有这个参数就直接改;几个节点都有时(高清修复的两个采样器)什么都不改,列出是哪几个,让 AI 写上节点号。几个正向提示词框接进同一个采样器(分区提示词)时,`prompt` 也不再把同一句话填进所有框,而是停下来让 AI 分别写。
|
||||
- Settings go by name instead of a growing list of blanks: run_workflow's and set_values' `values` set any node's setting, a third-party node's too ({"steps": 30}). The one node that has it is changed; a name several nodes have changes nothing and lists them, for the AI to name the node ({"12": {"denoise": 0.5}}). `prompt` no longer gives one text to several positive prompt boxes combined into one sampler (regional prompts).
|
||||
|
||||
- 修复任务画布标签关闭或切换失败后误用当前工作流的问题:操作会明确报错,重新打开或明确指定新画布后才能继续。下载缺失模型现在也跟随任务画布;留言里的 `@` 搭配和工作流按完整名称查找,不再因模糊搜索前几项占满而误报缺失。
|
||||
- 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.
|
||||
|
||||
@@ -28,7 +28,7 @@ DAILY = frozenset({
|
||||
})
|
||||
|
||||
# Arguments with which a call also changes the canvas: it then asks as the stricter of its own gate and "canvas".
|
||||
ALSO_CANVAS = {"run_workflow": ("workflow", "prompt", "negative", "seed", "width", "height", "image", "image_node")}
|
||||
ALSO_CANVAS = {"run_workflow": ("workflow", "prompt", "negative", "seed", "width", "height", "image", "image_node", "values")}
|
||||
|
||||
TOOLS = {
|
||||
# reading
|
||||
|
||||
+5
-3
@@ -308,8 +308,9 @@ ACTIONS = dict([
|
||||
"A daily run in one call: workflow (a saved one, list_workflows names them) opens it in the task's tab "
|
||||
"the first time and reuses it after; prompt and negative fill its prompt boxes, seed every sampler's "
|
||||
"seed, width and height its empty latent, image (a picture from search_images, or in the input folder, "
|
||||
"like an inpaint_image) its LoadImage; the rest stays as saved. Without workflow they fill the open "
|
||||
"canvas. No describe_canvas, set_prompt or set_values needed for these.",
|
||||
"like an inpaint_image) its LoadImage; values any other setting by name ({\"steps\": 30}; "
|
||||
"{\"12\": {\"denoise\": 0.5}} when several nodes have it, as set_values); the rest stays as saved. "
|
||||
"Without workflow they fill the open canvas. No describe_canvas, set_prompt or set_values needed for these.",
|
||||
run_workflow, {"batch": {"type": "integer", "minimum": 1, "maximum": 8, "default": 1},
|
||||
"wait_seconds": {"type": "number", "minimum": 0, "maximum": 55, "default": 15,
|
||||
"description": "Wait for execution after submission; returns actual state/results. Never resubmit while running."},
|
||||
@@ -319,7 +320,8 @@ ACTIONS = dict([
|
||||
"width": {"type": "integer", "minimum": 16, "maximum": 16384},
|
||||
"height": {"type": "integer", "minimum": 16, "maximum": 16384},
|
||||
"image": {"type": "string", "description": "subfolder/name.png in the output or input folder."},
|
||||
"image_node": {"type": "integer", "description": "The LoadImage to fill when the workflow has several."}}),
|
||||
"image_node": {"type": "integer", "description": "The LoadImage to fill when the workflow has several."},
|
||||
"values": {"type": "object", "description": "Other settings by name, any node's."}}),
|
||||
_action("open_in_anomalous", "Show a model",
|
||||
"Opens Anomalous Model Browser on this model's page, so the user sees its cover and details.",
|
||||
open_in_anomalous, {"model_id": _MODEL}, ["model_id"]),
|
||||
|
||||
+11
-20
@@ -17,8 +17,6 @@ from .mcp_tools import ToolError
|
||||
MAX_FOUND = 20
|
||||
MAX_SEARCH = 5
|
||||
MAX_CHOICES_NAMED = 60
|
||||
MAX_VALUES = 60
|
||||
MAX_TEXT = 20000
|
||||
MAX_REMOVE = 30
|
||||
MAX_WORKFLOW_NODES = 200
|
||||
MAX_WORKFLOW_BYTES = 1_000_000
|
||||
@@ -143,17 +141,6 @@ def _id(value, what):
|
||||
return value
|
||||
|
||||
|
||||
def _values(values):
|
||||
if values is None:
|
||||
return {}
|
||||
if not isinstance(values, dict) or len(values) > MAX_VALUES:
|
||||
raise ToolError(f"values is an object {{setting name: value}} of up to {MAX_VALUES} settings.")
|
||||
for name, value in values.items():
|
||||
if len(name) > 100 or not (isinstance(value, (bool, int, float)) or (isinstance(value, str) and len(value) <= MAX_TEXT)):
|
||||
raise ToolError(f"The value of {name[:100]} is a number, true/false or text of up to {MAX_TEXT} characters.")
|
||||
return values
|
||||
|
||||
|
||||
def _type(value):
|
||||
if not isinstance(value, str) or not value.strip() or len(value) > 200:
|
||||
raise ToolError("type is a node type, as find_nodes gives it (e.g. KSampler).")
|
||||
@@ -189,7 +176,7 @@ def add_node(ctx, type, values=None, title="", near_node_id=None, insert_after=N
|
||||
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],
|
||||
return _next(_page("add_node", {"type": _type(type), "values": mcp_fill.flat_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"), **wiring}))
|
||||
|
||||
|
||||
@@ -208,10 +195,12 @@ def connect_nodes(ctx, from_node_id, to_node_id, input=None, output=None, replac
|
||||
"to_node_id": _id(to_node_id, "to_node_id"), "input": input, "replace": replace}))
|
||||
|
||||
|
||||
def set_values(ctx, node_id, values):
|
||||
def set_values(ctx, node_id=None, values=None):
|
||||
if node_id is None:
|
||||
return _page("set_values", {"node_id": None, "values": mcp_fill.node_values(values)})
|
||||
if not values:
|
||||
raise ToolError("values is an object {setting name: value}.")
|
||||
return _page("set_values", {"node_id": _id(node_id, "node_id"), "values": _values(values)})
|
||||
return _page("set_values", {"node_id": _id(node_id, "node_id"), "values": mcp_fill.flat_values(values)})
|
||||
|
||||
|
||||
def remove_nodes(ctx, node_ids):
|
||||
@@ -231,7 +220,7 @@ def new_workflow(ctx, name="", nodes=None):
|
||||
or not isinstance(spec.get("inputs", {}), dict)):
|
||||
raise ToolError(f"Node {key[:40]}: each node is {{class_type, inputs: {{name: value or [node id, output slot]}}}}.")
|
||||
_type(spec["class_type"])
|
||||
_values({name: value for name, value in spec.get("inputs", {}).items() if not isinstance(value, list)})
|
||||
mcp_fill.flat_values({name: value for name, value in spec.get("inputs", {}).items() if not isinstance(value, list)})
|
||||
_allowed(ctx, [spec["class_type"] for spec in nodes.values()])
|
||||
return _page("new_workflow", {"name": str(name or "")[:60], "nodes": nodes}, timeout=60)
|
||||
|
||||
@@ -272,9 +261,11 @@ CANVAS_TOOLS = dict([
|
||||
"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.",
|
||||
set_values, {"node_id": _NODE_ID, "values": _VALUES}, ["node_id", "values"], destructive=True),
|
||||
"Sets settings (steps, cfg, denoise, a sampler, a file choice, a third-party node's own…), checked "
|
||||
"against what each takes. values {setting name: value} goes to the one node that has that setting; a "
|
||||
"name several nodes have takes the node: {\"12\": {\"denoise\": 0.5}}. node_id sets that node's values. "
|
||||
"For prompts set_prompt, for models set_model find the right ones. One Ctrl+Z step.",
|
||||
set_values, {"node_id": _NODE_ID, "values": _VALUES}, ["values"], destructive=True),
|
||||
_action("remove_nodes", "Remove nodes",
|
||||
"Removes nodes from the open workflow, with their wires. One Ctrl+Z step.",
|
||||
remove_nodes, {"node_ids": {"type": "array", "items": _NODE_ID, "maxItems": MAX_REMOVE}}, ["node_ids"],
|
||||
|
||||
+35
-2
@@ -5,7 +5,9 @@ reuses that tab after (the page marks a tab open_workflow opened with the workfl
|
||||
the AI fills in goes into it first (web/modules/mcp_canvas_edit.js fill_slots): the positive and
|
||||
negative prompt boxes, the seed of every sampler, the empty latent's width and height, the
|
||||
picture its LoadImage reads (from the input or the output folder, found here, never copied;
|
||||
image_node says which LoadImage when there are several). The rest stays as saved. Without
|
||||
image_node says which LoadImage when there are several), and `values`: any other setting by its
|
||||
name, a third-party node's as well, for the one node that has it ({node id: {...}} when several
|
||||
do; set_values takes the same). No fixed list of blanks grows here. The rest stays as saved. Without
|
||||
`workflow` the blanks go into the canvas the run would run. All of it is checked before anything
|
||||
changes, and it is one Ctrl+Z step logged as the AI's.
|
||||
"""
|
||||
@@ -14,7 +16,36 @@ from . import ai_task_canvas, ai_tasks
|
||||
from .mcp_bridge import PageRefused, PageUnavailable, ask_page
|
||||
from .mcp_tools import ToolError, _resolve_folder_image, list_workflows
|
||||
|
||||
SLOTS = ("prompt", "negative", "seed", "width", "height", "image", "image_node")
|
||||
SLOTS = ("prompt", "negative", "seed", "width", "height", "image", "image_node", "values")
|
||||
MAX_VALUES = 60
|
||||
MAX_TEXT = 20000
|
||||
|
||||
|
||||
def flat_values(values):
|
||||
"""{setting name: value}, checked for shape and size; the page checks what each setting takes."""
|
||||
if values is None:
|
||||
return {}
|
||||
if not isinstance(values, dict) or len(values) > MAX_VALUES:
|
||||
raise ToolError(f"values is an object {{setting name: value}} of up to {MAX_VALUES} settings.")
|
||||
for name, value in values.items():
|
||||
if len(name) > 100 or not (isinstance(value, (bool, int, float)) or (isinstance(value, str) and len(value) <= MAX_TEXT)):
|
||||
raise ToolError(f"The value of {name[:100]} is a number, true/false or text of up to {MAX_TEXT} characters.")
|
||||
return values
|
||||
|
||||
|
||||
def node_values(values):
|
||||
"""Settings by name ({setting name: value}) or for one node ({node id: {setting name: value}})."""
|
||||
if not isinstance(values, dict) or not values:
|
||||
raise ToolError('values is {setting name: value}, or {"<node id>": {setting name: value}} for one node.')
|
||||
nested = {key: value for key, value in values.items() if isinstance(value, dict)}
|
||||
for key, settings in nested.items():
|
||||
if not str(key).isdigit():
|
||||
raise ToolError(f"values.{str(key)[:40]}: only a node id takes an object of settings.")
|
||||
flat_values(settings)
|
||||
flat_values({key: value for key, value in values.items() if key not in nested})
|
||||
if len(values) - len(nested) + sum(len(settings) for settings in nested.values()) > MAX_VALUES:
|
||||
raise ToolError(f"values sets up to {MAX_VALUES} settings at once.")
|
||||
return values
|
||||
|
||||
|
||||
def saved_workflow(name):
|
||||
@@ -55,6 +86,8 @@ def fill(ctx, workflow=None, **slots):
|
||||
return None
|
||||
if "image" in slots:
|
||||
slots["image"] = _picture(slots["image"])
|
||||
if "values" in slots:
|
||||
node_values(slots["values"])
|
||||
filename = name = None
|
||||
if workflow:
|
||||
filename, name = saved_workflow(workflow)
|
||||
|
||||
+1
-1
@@ -116,7 +116,7 @@ claude mcp add --transport http anomalous http://127.0.0.1:8188/anomalous/mcp
|
||||
| move_model | 把放错文件夹的模型挪到它该在的文件夹(封面、备注跟着走),和模型页“整理”里的“挪过去”一样;不会盖掉同名文件。默认先问你 |
|
||||
| list_voices / speak | 列出 Anomalous TTS 的角色;让某个角色读一段台词并保存(最多等两分钟) |
|
||||
| begin_task / report | 在 AI 工作台里起一个任务的标题;给你留言、交上它用别的方式做出来的图、结束任务(见第 5 节) |
|
||||
| run_workflow 的填空 | 日常出图一步完成:带上 `workflow`(存好的工作流名),第一次在任务专用标签页里打开它,之后接着用这个标签页;`prompt` / `negative` 填正、反向提示词,`seed` 填所有采样器的种子,`width` / `height` 填空白潜空间的尺寸,`image` 填“加载图片”读的图(输出或输入文件夹里的,比如标注后的重绘图;有几个“加载图片”时用 `image_node` 指定),其余照存好的样子不动。不带 `workflow` 时填到当前画布上。填空和出图一起算一步,改画布那部分也能 Ctrl+Z;画布关卡比显卡关卡严时,按画布关卡问你 |
|
||||
| run_workflow 的填空 | 日常出图一步完成:带上 `workflow`(存好的工作流名),第一次在任务专用标签页里打开它,之后接着用这个标签页;`prompt` / `negative` 填正、反向提示词,`seed` 填所有采样器的种子,`width` / `height` 填空白潜空间的尺寸,`image` 填“加载图片”读的图(输出或输入文件夹里的,比如标注后的重绘图;有几个“加载图片”时用 `image_node` 指定);其他任何参数用 `values` 按名字改,比如 `{"steps": 30}`,第三方节点的参数也一样:只有一个节点有这个参数就直接改,几个节点都有(比如高清修复的两个采样器都有 `denoise`)就要写节点号 `{"12": {"denoise": 0.5}}`,否则什么都不改并列出是哪几个。几个正向提示词框接进同一个采样器(分区提示词)时,`prompt` 也会停下来,让 AI 用 `values` 分别写。其余照存好的样子不动。不带 `workflow` 时填到当前画布上。填空和出图一起算一步,改画布那部分也能 Ctrl+Z;画布关卡比显卡关卡严时,按画布关卡问你 |
|
||||
| get_situation | 一次拿到任务、流程链、少量节点摘要、缺失输入和可匹配输出、错误及最近运行。每个回复的 `next` 提示下一步。新开对话或拿不准时先调它;简报预算约 3600 字,减少 Antigravity 将大回复转存为文件的情况 |
|
||||
| log_outside | AI 用了 AMB 以外的工具(命令、脚本、读写文件)后,说清做了什么、为什么、动了哪些文件;显示在时间线上(见第 5 节) |
|
||||
| ask_user | 在 AI 工作台里问你一个问题:几个选项(AI 推荐的那个标着“推荐”),加一行“都不是?写下你的想法”;AI 在同一步里等你回答 |
|
||||
|
||||
+29
-1
@@ -1,7 +1,7 @@
|
||||
// node tests/mcp_fill.mjs — run_workflow's blanks planned on the canvas: prompts, seeds, size, the
|
||||
// picture a LoadImage reads; nothing planned when a blank has nowhere to go.
|
||||
import assert from 'node:assert/strict';
|
||||
import { fillPlan } from '../web/modules/mcp_fill.js';
|
||||
import { fillPlan, valuesPlan } from '../web/modules/mcp_fill.js';
|
||||
|
||||
const text = (name, value = '') => ({ name, value });
|
||||
const number = (name, value, min = 0, max = 0xffffffffffffffff) => ({ name, value, options: { min, max } });
|
||||
@@ -40,4 +40,32 @@ assert.throws(() => fillPlan({ _nodes: [sampler] }, { prompt: 'a' }), /No positi
|
||||
assert.throws(() => fillPlan({ _nodes: [prompts] }, { seed: 1 }), /No sampler with a seed/);
|
||||
assert.throws(() => fillPlan({ _nodes: [scale] }, { height: 768 }), /No empty latent/);
|
||||
|
||||
// Any other setting by name, a third-party node's too; a name several nodes have asks for the node.
|
||||
const sharpen = node(10, 'ImageSharpen_ThirdParty', [number('sharpen_radius', 1, 1, 31)]);
|
||||
const second = node(11, 'KSampler', [number('seed', 3), number('steps', 20, 1, 10000)]);
|
||||
assert.deepEqual(fillPlan({ _nodes: [sampler, sharpen] }, { values: { steps: 30, sharpen_radius: 3 } }).set, { values: [2, 10] });
|
||||
assert.throws(() => fillPlan({ _nodes: [sampler, second] }, { values: { steps: 30 } }), /steps is a setting of #2, #11: give the one to change as \{"<node id>"/);
|
||||
const both = valuesPlan({ _nodes: [sampler, second] }, { 11: { steps: 8 } });
|
||||
assert.deepEqual(both.map(({ node: item, plan }) => [item.id, plan.map(([widget, value]) => [widget.name, value])]), [[11, [['steps', 8]]]]);
|
||||
assert.throws(() => valuesPlan({ _nodes: [sampler] }, { cfg: 5 }), /No node on the canvas has a setting cfg/);
|
||||
assert.throws(() => valuesPlan({ _nodes: [sampler] }, { 99: { steps: 5 } }), /No node 99/);
|
||||
assert.throws(() => fillPlan({ _nodes: [sampler] }, { seed: 1, values: { 2: { seed: 2 } } }), /seed is set by a blank and by values/);
|
||||
|
||||
// Two prompts combined into one sampler (regional prompts) are not given one text; a refiner's own prompt is.
|
||||
{
|
||||
const links = { 1: { target_id: 33, target_slot: 0 }, 2: { target_id: 33, target_slot: 1 }, 3: { target_id: 34, target_slot: 0 }, 4: { target_id: 35, target_slot: 0 } };
|
||||
const g2 = { links, getNodeById(id) { return this._nodes.find(item => item.id === id); } };
|
||||
const encoder = (id, link) => node(id, 'CLIPTextEncode', [text('text', '')], { outputs: [{ name: 'CONDITIONING', type: 'CONDITIONING', links: [link] }], required: { text: multiline } });
|
||||
const combine = node(33, 'ConditioningCombine', [], { outputs: [{ name: 'CONDITIONING', type: 'CONDITIONING', links: [3] }],
|
||||
inputs: [{ name: 'conditioning_1', type: 'CONDITIONING' }, { name: 'conditioning_2', type: 'CONDITIONING' }] });
|
||||
const into = id => node(id, 'KSampler', [number('seed', 1)], { inputs: [{ name: 'positive', type: 'CONDITIONING' }] });
|
||||
g2._nodes = [encoder(31, 1), encoder(32, 2), combine, into(34)];
|
||||
g2._nodes.forEach(item => { item.graph = g2; });
|
||||
assert.throws(() => fillPlan(g2, { prompt: 'a castle' }), /Several positive prompt boxes feed one sampler \(#31, #32\)/);
|
||||
g2._nodes = [encoder(31, 1), encoder(36, 4), combine, into(34), into(35)];
|
||||
links[1] = { target_id: 34, target_slot: 0 };
|
||||
g2._nodes.forEach(item => { item.graph = g2; });
|
||||
assert.deepEqual(fillPlan(g2, { prompt: 'a castle' }).set, { prompt: [31, 36] }, 'base and refiner prompts each feed their own sampler');
|
||||
}
|
||||
|
||||
console.log('mcp_fill: ok');
|
||||
|
||||
@@ -9,7 +9,7 @@ from unittest import mock
|
||||
PLUGIN = Path(__file__).resolve().parents[1]
|
||||
sys.path[:0] = [str(PLUGIN), str(PLUGIN.parents[1])]
|
||||
|
||||
from api import action_catalog, mcp_canvas_edit, mcp_server # noqa: E402
|
||||
from api import action_catalog, mcp_canvas_edit, mcp_fill, mcp_server # noqa: E402
|
||||
from api.mcp_tools import ToolError, call_tool # noqa: E402
|
||||
|
||||
INFO = {
|
||||
@@ -107,6 +107,15 @@ class BuildToolTests(unittest.TestCase):
|
||||
self.assertFalse(result.get("isError"), result)
|
||||
self.assertEqual(self.page.call_count, 2)
|
||||
|
||||
def test_settings_by_name_or_for_one_node(self):
|
||||
mcp_canvas_edit.set_values({}, values={"steps": 30, "12": {"denoise": 0.5}})
|
||||
self.assertEqual(self.page.call_args[0], ("set_values", {"node_id": None, "values": {"steps": 30, "12": {"denoise": 0.5}}}))
|
||||
mcp_canvas_edit.set_values({}, 4, {"cfg": 5})
|
||||
self.assertEqual(self.page.call_args[0], ("set_values", {"node_id": 4, "values": {"cfg": 5}}), "the old form")
|
||||
for values in ({"sampler": {"denoise": 0.5}}, {"12": {"denoise": [1]}}, {}, {"12": {f"s{i}": 1 for i in range(61)}}):
|
||||
with self.assertRaises(ToolError, msg=values):
|
||||
mcp_canvas_edit.set_values({}, values=values)
|
||||
|
||||
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"])
|
||||
@@ -123,7 +132,7 @@ class BuildToolTests(unittest.TestCase):
|
||||
bad = [
|
||||
lambda: mcp_canvas_edit.add_node({}, ""),
|
||||
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", {"text": "x" * (mcp_fill.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))),
|
||||
|
||||
@@ -61,6 +61,14 @@ class FillTests(unittest.TestCase):
|
||||
self.assertEqual(mcp_fill.fill(self.ctx, None, width=1024), {"size": [4]})
|
||||
self.assertEqual(self.calls, [("fill_slots", {"filename": None, "width": 1024})])
|
||||
|
||||
def test_other_settings_go_by_name_and_bad_ones_never_reach_the_page(self):
|
||||
self.answers = [{"set": {"values": [3, 12]}}]
|
||||
self.assertEqual(mcp_fill.fill(self.ctx, None, values={"steps": 30, "12": {"denoise": 0.5}}), {"values": [3, 12]})
|
||||
self.assertEqual(self.calls[-1], ("fill_slots", {"filename": None, "values": {"steps": 30, "12": {"denoise": 0.5}}}))
|
||||
with self.assertRaises(mcp_tools.ToolError):
|
||||
mcp_fill.fill(self.ctx, None, values={"sampler": {"denoise": 0.5}})
|
||||
self.assertEqual(len(self.calls), 1)
|
||||
|
||||
def test_the_run_carries_what_was_filled_and_stops_when_it_fails(self):
|
||||
with mock.patch.object(mcp_actions, "ask_page", return_value={"prompt_ids": ["p1"]}) as run:
|
||||
self.answers = [{"opened": "海边"}, {"set": {"prompt": [6]}}]
|
||||
|
||||
@@ -13,7 +13,7 @@ import { app } from '../../../scripts/app.js';
|
||||
import { recordCanvasStep } from './canvas_history.js';
|
||||
import { apiLinks, freeSpot, layered, typesFit, valuePlan } from './canvas_build.js';
|
||||
import { fedBy, linkOf, sockets, unwired } from './mcp_canvas_brief.js';
|
||||
import { fillPlan } from './mcp_fill.js';
|
||||
import { fillPlan, valuesPlan } 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';
|
||||
@@ -168,18 +168,25 @@ export function canvasEditActions({ recorded, nodeById }) {
|
||||
});
|
||||
}
|
||||
|
||||
/** One node's settings (node_id), or settings by name for whichever nodes have them (mcp_fill.js valuesPlan). */
|
||||
function setValues({ node_id: nodeId, values }, getOwner) {
|
||||
return recorded(getOwner, 'mcpDidValues', async (g) => {
|
||||
const node = nodeById(nodeId);
|
||||
const plan = await withFreshChoices(() => valuePlan(node, values));
|
||||
if (!plan.length) throw new Error('values is empty: {setting name: value}.');
|
||||
g.beforeChange?.(node);
|
||||
apply(node, plan);
|
||||
g.afterChange?.(node);
|
||||
if (app.lastNodeErrors?.[node.id]) delete app.lastNodeErrors[node.id];
|
||||
const plans = await withFreshChoices(() => (nodeId != null
|
||||
? [{ node: nodeById(nodeId), plan: valuePlan(nodeById(nodeId), values) }] : valuesPlan(g, values)));
|
||||
const count = plans.reduce((sum, { plan }) => sum + plan.length, 0);
|
||||
if (!count) throw new Error('values is empty: {setting name: value}.');
|
||||
g.beforeChange?.();
|
||||
for (const { node, plan } of plans) {
|
||||
apply(node, plan);
|
||||
if (app.lastNodeErrors?.[node.id]) delete app.lastNodeErrors[node.id];
|
||||
}
|
||||
g.afterChange?.();
|
||||
g.setDirtyCanvas?.(true, true);
|
||||
recordCanvasStep(app);
|
||||
return { node_id: node.id, set: Object.fromEntries(plan.map(([widget]) => [widget.name, widget.value])), toast: { count: plan.length } };
|
||||
return {
|
||||
set: Object.fromEntries(plans.map(({ node, plan }) => [node.id, Object.fromEntries(plan.map(([widget]) => [widget.name, widget.value]))])),
|
||||
toast: { count },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+49
-7
@@ -1,22 +1,52 @@
|
||||
/**
|
||||
* run_workflow's blanks on the canvas (api/mcp_fill.py), as a plan mcp_canvas_edit.js applies:
|
||||
* the positive and negative prompt boxes (prompt_boxes.js), every sampler's seed, the empty
|
||||
* latent's width and height, the picture a LoadImage reads. Checked before anything changes: a
|
||||
* latent's width and height, the picture a LoadImage reads, and any other setting by its name
|
||||
* (`values`, also set_values without a node: valuesPlan). Checked before anything changes: a
|
||||
* blank asked for with nothing on the canvas to take it stops it all, and so do several
|
||||
* LoadImage nodes without image_node. Muted and bypassed nodes are left alone. Pure: the graph
|
||||
* comes in.
|
||||
* LoadImage nodes without image_node, two prompt nodes into one sampler, or a setting name
|
||||
* several nodes have. Muted and bypassed nodes are left alone. Pure: the graph comes in.
|
||||
*/
|
||||
|
||||
import { valuePlan } from './canvas_build.js';
|
||||
import { promptBoxes } from './prompt_boxes.js';
|
||||
import { promptBoxes, wiredRole } from './prompt_boxes.js';
|
||||
|
||||
const SEEDS = ['seed', 'noise_seed'];
|
||||
const off = node => node.mode === 2 || node.mode === 4;
|
||||
const has = (node, name) => (node.widgets || []).some(widget => widget?.name === name);
|
||||
const label = node => `#${node.id}${node.title && node.title !== node.type ? ` "${node.title}"` : ''}`;
|
||||
|
||||
/**
|
||||
* Settings for whichever node has them, a third party's as well as ComfyUI's: {name: value} goes to
|
||||
* the one active node with a setting of that name, {node id: {name: value}} to that node. A name
|
||||
* several nodes have (two samplers' denoise) stops it, naming them: which one is the AI's to say.
|
||||
*/
|
||||
export function valuesPlan(g, values) {
|
||||
const wanted = new Map();
|
||||
const put = (node, name, value) => {
|
||||
const settings = wanted.get(node) || {};
|
||||
if (name in settings) throw new Error(`${name} of ${label(node)} is given twice.`);
|
||||
wanted.set(node, { ...settings, [name]: value });
|
||||
};
|
||||
for (const [key, value] of Object.entries(values || {})) {
|
||||
if (/^\d+$/.test(key)) {
|
||||
const node = (g._nodes || []).find(item => String(item.id) === key);
|
||||
if (!node) throw new Error(`No node ${key} on the canvas; get_situation lists them.`);
|
||||
for (const [name, setting] of Object.entries(value || {})) put(node, name, setting);
|
||||
continue;
|
||||
}
|
||||
const holders = (g._nodes || []).filter(node => !off(node) && has(node, key));
|
||||
if (!holders.length) throw new Error(`No node on the canvas has a setting ${key}.`);
|
||||
if (holders.length > 1) {
|
||||
throw new Error(`${key} is a setting of ${holders.slice(0, 6).map(label).join(', ')}: give the one to change as {"<node id>": {"${key}": …}}. Nothing changed.`);
|
||||
}
|
||||
put(holders[0], key, value);
|
||||
}
|
||||
return [...wanted].map(([node, settings]) => ({ node, plan: valuePlan(node, settings) }));
|
||||
}
|
||||
|
||||
/** `{plans: [{node, plan: [[widget, value]]}], set: {blank: [node ids]}}` for what `wanted` fills in. */
|
||||
export function fillPlan(g, { prompt, negative, seed, width, height, image, image_node: imageNode } = {}) {
|
||||
export function fillPlan(g, { prompt, negative, seed, width, height, image, image_node: imageNode, values } = {}) {
|
||||
const nodes = (g._nodes || []).filter(node => !off(node));
|
||||
const plans = [];
|
||||
const set = {};
|
||||
@@ -27,8 +57,16 @@ export function fillPlan(g, { prompt, negative, seed, width, height, image, imag
|
||||
};
|
||||
for (const [blank, text, role] of [['prompt', prompt, 'positive'], ['negative', negative, 'negative']]) {
|
||||
if (text == null) continue;
|
||||
for (const node of nodes) add(blank, node, promptBoxes(node).filter(box => box.role === role).map(box => [box.widget, String(text)]));
|
||||
if (!set[blank]) throw new Error(`No ${role} prompt box on the canvas; set_prompt with node_id, or set_values.`);
|
||||
const boxes = nodes.map(node => [node, promptBoxes(node).filter(box => box.role === role)]).filter(([, found]) => found.length);
|
||||
if (!boxes.length) throw new Error(`No ${role} prompt box on the canvas; set_prompt with node_id, or set_values.`);
|
||||
// Prompt nodes combined into one sampler (regional prompts) each say something else: not one text for all.
|
||||
const into = new Map();
|
||||
for (const [node] of boxes) for (const sampler of wiredRole(node).samplers) into.set(sampler, [...(into.get(sampler) || []), node]);
|
||||
const shared = [...into.values()].find(list => list.length > 1);
|
||||
if (shared) {
|
||||
throw new Error(`Several ${role} prompt boxes feed one sampler (${shared.map(label).join(', ')}): set each with values {"<node id>": {"<box name>": "…"}}. Nothing changed.`);
|
||||
}
|
||||
for (const [node, found] of boxes) add(blank, node, found.map(box => [box.widget, String(text)]));
|
||||
}
|
||||
if (seed != null) {
|
||||
for (const node of nodes) {
|
||||
@@ -54,5 +92,9 @@ export function fillPlan(g, { prompt, negative, seed, width, height, image, imag
|
||||
if (picked.length > 1) throw new Error(`Several LoadImage nodes (${picked.map(label).join(', ')}): give image_node, the one to fill.`);
|
||||
add('image', picked[0], [[picked[0].widgets.find(widget => widget.name === 'image'), String(image)]]); // any picture: ComfyUI checks the file
|
||||
}
|
||||
if (values) for (const { node, plan } of valuesPlan(g, values)) add('values', node, plan);
|
||||
const widgets = plans.flatMap(({ plan }) => plan.map(([widget]) => widget));
|
||||
const twice = widgets.find((widget, at) => widgets.indexOf(widget) !== at);
|
||||
if (twice) throw new Error(`${twice.name} is set by a blank and by values; give it once.`);
|
||||
return { plans, set };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user