diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 06994b7..2308709 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 的素材", diff --git a/CHANGELOG.md b/CHANGELOG.md index b1c5df4..bb1ed3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/api/action_catalog.py b/api/action_catalog.py index 03e2e5e..8d0b490 100644 --- a/api/action_catalog.py +++ b/api/action_catalog.py @@ -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 diff --git a/api/mcp_actions.py b/api/mcp_actions.py index 3e8f197..390c9a1 100644 --- a/api/mcp_actions.py +++ b/api/mcp_actions.py @@ -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"]), diff --git a/api/mcp_canvas_edit.py b/api/mcp_canvas_edit.py index e657044..3d2166a 100644 --- a/api/mcp_canvas_edit.py +++ b/api/mcp_canvas_edit.py @@ -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"], diff --git a/api/mcp_fill.py b/api/mcp_fill.py index 89cc642..b99e23d 100644 --- a/api/mcp_fill.py +++ b/api/mcp_fill.py @@ -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 {"": {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) diff --git a/docs/guides/mcp.md b/docs/guides/mcp.md index 2a2c003..543179b 100644 --- a/docs/guides/mcp.md +++ b/docs/guides/mcp.md @@ -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 在同一步里等你回答 | diff --git a/tests/mcp_fill.mjs b/tests/mcp_fill.mjs index 9eb3624..739269f 100644 --- a/tests/mcp_fill.mjs +++ b/tests/mcp_fill.mjs @@ -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 \{""/); +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'); diff --git a/tests/test_mcp_canvas_edit.py b/tests/test_mcp_canvas_edit.py index 1f4433c..9082a5c 100644 --- a/tests/test_mcp_canvas_edit.py +++ b/tests/test_mcp_canvas_edit.py @@ -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))), diff --git a/tests/test_mcp_fill.py b/tests/test_mcp_fill.py index de95664..c7edaf3 100644 --- a/tests/test_mcp_fill.py +++ b/tests/test_mcp_fill.py @@ -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]}}] diff --git a/web/modules/mcp_canvas_edit.js b/web/modules/mcp_canvas_edit.js index 04dbf9a..c2f68ce 100644 --- a/web/modules/mcp_canvas_edit.js +++ b/web/modules/mcp_canvas_edit.js @@ -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 }, + }; }); } diff --git a/web/modules/mcp_fill.js b/web/modules/mcp_fill.js index 34c2e3e..1278eb4 100644 --- a/web/modules/mcp_fill.js +++ b/web/modules/mcp_fill.js @@ -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 {"": {"${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 {"": {"": "…"}}. 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 }; }