diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b83e93e..639e697 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -238,8 +238,10 @@ covered by the `styles.css` manifest. themselves: Antigravity reads a tool's file before its first call); the module also keeps which list each app fetched last (`anomalous/ai_tool_lists.json`), so a call from an app still holding the list of an older Anomalous is told so and the task header says how to reconnect it. - `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), + `api/mcp_fill.py` gives run_workflow its blanks, a daily run in one call: a saved workflow, or + one of its 方案 ("workflow / plan": a saved parameter set of it, `mcp_tools.plans`, which + list_workflows lists; the page opens the workflow and applies the set as its Apply button does), + opened in the task's tab once and reused (the page marks the tab with the workflow's file, and the set's), 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). 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index df315e0..210370d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## v1.63.0 — unreleased +- AI 能用工作流卡里的 **参数方案** 了:一张工作流卡一套节点,存几个参数方案(比如同一套两段放大,放大时换不换模型、半途换不换模型),`list_workflows` 会列出每张卡的方案,写成“工作流名 / 方案名”;`run_workflow` 带上它,AMB 先打开工作流,再像点“应用到当前画布”一样套上那个方案,然后填题材、种子出图。AI 只要选哪个方案,改数值的事由 AMB 做。 +- AI apps can use a workflow card's **parameter sets** (方案): one card, one set of nodes, several saved sets of values (the same two-pass upscale with or without a model change, say). `list_workflows` lists each card's sets as "workflow / set"; with that name `run_workflow` opens the workflow, applies the set as its Apply button does, then fills in the subject and seed. The AI only picks the set; Anomalous changes the values. - 新增 **Anima Tile 放大细化** 做法:保留底图,以 1.22 倍潜空间放大和 Tile 引导高降噪细化,检查模型架构与必需权重,结果另存供对比;参数来自三组本机对照,不保证修手或复刻原图。配套的按需 skill **Anima 插画·底图与 Tile 细化** 只写怎么判断(先出底图给你看、再用这块做法、你存过这条路线的工作流或搭配就直接用、你没选的画风和 LoRA 不加),具体模型和数值留在做法和你自己的搭配里。 - New **Anima Tile detail upscale** way: retain the base and save a guided 1.22× latent redraw separately, checking the model family and required weight; settings come from three local comparisons and do not guarantee hand repair or reproduction. Its on-demand skill **Anima illustration: base and Tile detail** holds judgment only (show the base first, then this way; a workflow or combo you saved for this route is used as it is; no style or LoRA you did not pick); the models and values stay in the way and in your own combos. diff --git a/api/mcp_actions.py b/api/mcp_actions.py index e22e5ac..8928892 100644 --- a/api/mcp_actions.py +++ b/api/mcp_actions.py @@ -368,7 +368,7 @@ ACTIONS = dict([ run_workflow, {"batch": {"type": "integer", "minimum": 1, "maximum": 8, "default": 1, "description": "Tries, each its own seed."}, "wait_seconds": {"type": "number", "minimum": 0, "maximum": 55, "default": 55}, - "workflow": {"type": "string", "description": "A name from list_workflows."}, + "workflow": {"type": "string", "description": "A name or plan from list_workflows."}, "prompt": _PROMPT, "negative": {"type": "string"}, "prompts": {"type": "array", "items": {"type": "string"}, "maxItems": MAX_PROMPTS, "description": "Instead of prompt: several, queued in order as one run."}, diff --git a/api/mcp_fill.py b/api/mcp_fill.py index 08be3b2..1972ac3 100644 --- a/api/mcp_fill.py +++ b/api/mcp_fill.py @@ -1,6 +1,7 @@ """run_workflow's blanks: a daily run in one call, so a small model need not read and edit the canvas. -With `workflow` (a saved workflow's name) the run opens it in the task's tab the first time and +With `workflow` (a saved workflow's name, or one of its 方案 as "workflow / plan": a parameter set saved +under it, applied as its Apply button does) the run opens it in the task's tab the first time and reuses that tab after (the page marks a tab open_workflow opened with the workflow's file); what 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 @@ -15,7 +16,7 @@ changes, and it is one Ctrl+Z step logged as the AI's. from . import ai_task_canvas, ai_tasks from .ai_pictures import to_input from .mcp_bridge import PageRefused, PageUnavailable, ask_page -from .mcp_tools import ToolError, _resolve_folder_image, list_workflows +from .mcp_tools import ToolError, _resolve_folder_image, plans SLOTS = ("prompt", "negative", "seed", "width", "height", "image", "image_node", "values") MAX_VALUES = 60 @@ -50,15 +51,21 @@ def node_values(values): def saved_workflow(name): - """(file name, name) of the user's saved workflow called `name`.""" + """(file name, name, plan) of the user's saved workflow called `name`, or of its 方案 "workflow / plan" + (plan: the parameter set's file name, else None).""" from .recipe_store import _list_recipes, get_recipes_dir name = str(name or "").strip() - if not name or not any(item["name"] == name for item in list_workflows(name, 50)["workflows"]): - raise ToolError(f"No saved workflow called {name}; list_workflows names them.") - filename = next((recipe["filename"] for recipe in _list_recipes(get_recipes_dir()) - if (recipe["data"].get("name") or recipe["filename"]) == name), None) - return filename, name + files = {} + for recipe in _list_recipes(get_recipes_dir()): + files.setdefault(recipe["data"].get("name") or recipe["filename"], recipe["filename"]) + if name and name in files: + return files[name], name, None + workflow, _, plan = name.rpartition(" / ") + found = next((file for file, plan_name in plans().get(files.get(workflow), []) if plan_name == plan), None) + if not found: + raise ToolError(f"No saved workflow or plan called {name}; list_workflows names them.") + return files[workflow], name, found def _picture(image): @@ -89,15 +96,16 @@ def fill(ctx, workflow=None, **slots): slots["image"] = _picture(slots["image"]) if "values" in slots: node_values(slots["values"]) - filename = name = None + filename = name = plan = None if workflow: - filename, name = saved_workflow(workflow) + filename, name, plan = saved_workflow(workflow) + opened = f"{filename}#{plan}" if plan else filename # what the page marks the tab with task = ai_tasks.get_task(ctx.get("task")) if ctx.get("task") else None no_tab_yet = bool(task) and ai_task_canvas.mode_of(task) == "new" and not (task.get("canvas") or {}).get("tab") - filled = None if workflow and no_tab_yet else _page("fill_slots", {"filename": filename, **slots}) + filled = None if workflow and no_tab_yet else _page("fill_slots", {"filename": opened, **slots}) if workflow and (filled is None or filled.get("needs_open")): - _page("open_workflow", {"filename": filename, "name": name}, timeout=60) - filled = _page("fill_slots", {"filename": filename, **slots}) + _page("open_workflow", {"filename": filename, "name": name, "plan": plan}, timeout=60) + filled = _page("fill_slots", {"filename": opened, **slots}) if filled.get("needs_open"): raise ToolError(f"The saved workflow {name} did not open; nothing was run.") return {**({"workflow": name} if name else {}), **(filled.get("set") or {})} diff --git a/api/mcp_tools.py b/api/mcp_tools.py index 354d762..28d3eac 100644 --- a/api/mcp_tools.py +++ b/api/mcp_tools.py @@ -34,6 +34,7 @@ from .mcp_arguments import prepare_arguments, renaming_note from .model_catalog import _iter_search_models from .model_constants import MEDIA_EXTENSIONS, MODEL_EXTENSIONS, PREVIEW_SUFFIXES from .notebooks import _list_notebooks +from .parameters import _read_parameter_notebooks, get_parameters_dir from .path_utils import model_file_within, resolve_folder_subdir, resolve_within from .recipe_store import _list_recipes, get_recipes_dir from .scan_report import last_scan_path @@ -42,6 +43,7 @@ from .scan_summary import _summarize MAX_LIMIT = 50 MAX_SEARCH_PICTURES = 6 # search_images(with_pictures): the first few attached MAX_TEXT = 1500 +MAX_PLANS = 12 # 方案 listed for one workflow MAX_IMAGE_BYTES = 1_500_000 IMAGE_TYPES = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp"} SOURCES = {"civitai": "civitai", "local": "inferred_from_file", "": "not_scanned"} @@ -429,18 +431,33 @@ def list_combos(query="", limit=20, *, exact_name=False): return {"total_matches": len(found), "combos": found[:_limit(limit)]} +def plans(): + """Each saved workflow's 方案: the parameter sets saved under it with values unlike its own (saving a workflow + keeps one equal to them, which the Workflows page leaves out too): {workflow file name: [(set file name, set name)]}.""" + own = {recipe["filename"]: (recipe["data"].get("parameter_signature") or {}).get("value") for recipe in _list_recipes(get_recipes_dir())} + out = {} + for notebook in _read_parameter_notebooks(get_parameters_dir()): + workflow = notebook["data"].get("recipe_filename") + if workflow in own and not (own[workflow] and (notebook["data"].get("parameter_signature") or {}).get("value") == own[workflow]): + out.setdefault(workflow, []).append((notebook["filename"], notebook["name"])) + return out + + def list_workflows(query="", limit=20, *, exact_name=False): - terms, found = _terms(query), [] + terms, found, saved_plans = _terms(query), [], plans() for recipe in _list_recipes(get_recipes_dir()): data = recipe.get("data") or {} + name = data.get("name") or recipe["filename"] params = json.dumps(data.get("params") or {}, ensure_ascii=False) - if exact_name and (data.get("name") or recipe["filename"]) != query: + own = [f"{name} / {plan}" for _file, plan in saved_plans.get(recipe["filename"], [])][:MAX_PLANS] + if exact_name and name != query: continue - if not exact_name and not _matches(terms, data.get("name"), " ".join(data.get("tags") or []), data.get("notes"), params): + if not exact_name and not _matches(terms, name, " ".join(data.get("tags") or []), data.get("notes"), params, " ".join(own)): continue - found.append({"name": data.get("name") or recipe["filename"], "tags": data.get("tags") or [], + found.append({"name": name, "tags": data.get("tags") or [], "notes": _text(data.get("notes"), 500), "saved": _when((data.get("timestamp") or 0) / 1000), - "settings": params if len(params) <= MAX_TEXT else params[:MAX_TEXT] + "…"}) + "settings": params if len(params) <= MAX_TEXT else params[:MAX_TEXT] + "…", + **({"plans": own} if own else {})}) # run_workflow(workflow=one of them) runs it with that set return {"total_matches": len(found), "workflows": found[:_limit(limit)]} @@ -517,7 +534,7 @@ TOOLS = dict([ "structure\") model nodes and text nodes saved from the canvas with their links, listed by slot.", list_combos, {"query": _QUERY, "limit": _LIMIT}), _tool("list_workflows", "Workflow recipes", - "The user's saved workflow recipes with tags, notes and their main settings.", list_workflows, + "The user's saved workflow recipes with tags, notes, main settings and plans.", list_workflows, {"query": _QUERY, "limit": _LIMIT}), _tool("list_saved_prompts", "Saved prompts", "Prompts the user saved in the Prompt Studio, with positive and negative text.", list_saved_prompts, diff --git a/api/skills/anima_illustration.json b/api/skills/anima_illustration.json index 0353a98..68c700c 100644 --- a/api/skills/anima_illustration.json +++ b/api/skills/anima_illustration.json @@ -10,7 +10,7 @@ }, "text": [ "Use for an Anima illustration the user wants refined in two passes, not as a default for other models or styles. Preserve the user's text, requested character and pose; add a style, artist or LoRA only when the user picked it (a combo or saved prompt of theirs).", - "If the user saved this route as a workflow (list_workflows) or a combo (list_combos), use it: run_workflow with their subject and seed is the daily run. Its models and values are theirs: do not change them.", + "If the user saved this route as a workflow (list_workflows lists it with its plans) or a combo (list_combos), use it: run_workflow(workflow=\"name\" or \"name / plan\") with their subject and seed is the daily run. Its models and values are theirs: do not change them; pick another plan instead when the user wants the other route.", "Run the base first and show it; when the user wants to choose, let them pick it before the second pass. Then add_nodes(block=\"upscale_anima_tile\") on the Anima canvas: the way carries the tested second-pass values and checks the model family and the Tile weight. Report a missing node or model; never swap in a look-alike.", "What to expect: a high-denoise second pass without Tile tends to change the pose and ornaments; with Tile the composition holds better, but faces, hands and small details are still redrawn. Keep the base, compare, and lower the denoise when keeping more matters to the user.", "Changing models between the passes: only within one family sharing the text encoder and VAE; otherwise say it is untried. These findings come from a few single-seed comparisons: say what ran and what changed, and do not promise a reproduction or repaired hands." diff --git a/docs/guides/mcp.md b/docs/guides/mcp.md index 410e084..4e10208 100644 --- a/docs/guides/mcp.md +++ b/docs/guides/mcp.md @@ -90,7 +90,7 @@ claude mcp add --transport http anomalous http://127.0.0.1:8188/anomalous/mcp | scan_report | 哪些模型匹配上了 C 站,哪些没有、原因是什么,哪些还没扫,上次扫描做了什么 | | search_images / get_image_info | 按提示词、模型、参数找出图;看某张图的全部生成参数,可以附上缩略图。`folder=input` 时看 ComfyUI 输入文件夹里的图(比如你标注时 AMB 存的重绘遮罩图):尺寸、要重画的区域在哪、占多大,也能附上图,不用 AI 自己跑命令去看 | | list_combos | 你的搭配(主模型 + LoRA + 提示词) | -| list_workflows | 你存下的工作流,带标签、备注和主要设置 | +| list_workflows | 你存下的工作流,带标签、备注、主要设置和它的参数方案(写成“工作流名 / 方案名”) | | list_saved_prompts | 提示词工坊里存的提示词 | | search_audio | 用 Anomalous TTS 生成过的语音,带台词和角色 | | recent_activity | 插件最近改过什么(操作记录) | @@ -117,7 +117,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` 指定);其他任何参数用 `values` 按名字改,比如 `{"steps": 30}`,第三方节点的参数也一样:只有一个节点有这个参数就直接改,几个节点都有(比如高清修复的两个采样器都有 `denoise`)就要写节点号 `{"12": {"denoise": 0.5}}`,否则什么都不改并列出是哪几个。几个正向提示词框接进同一个采样器(分区提示词)时,`prompt` 也会停下来,让 AI 用 `values` 分别写。其余照存好的样子不动。不带 `workflow` 时填到当前画布上。要一次跑好几条提示词,用 `prompts`(最多 8 条,代替 `prompt`):AMB 按顺序一条条填进去排队,别的填空(比如同一个种子)每条都一样,全部算同一次跑图,图都在时间线的同一步里;跑完画布上留的是最后一条。哪一条排不上就停在那里,前面排上的照常跑完,AI 会知道停在第几条。填空和出图一起算一步,改画布那部分也能 Ctrl+Z;画布关卡比显卡关卡严时,按画布关卡问你 | +| run_workflow 的填空 | 日常出图一步完成:带上 `workflow`(存好的工作流名;也可以写“工作流名 / 方案名”,用你在工作流卡里存的某个参数方案,AMB 先打开工作流,再像点“应用到当前画布”一样套上那个方案),第一次在任务的副本标签页里打开它,之后接着用这个标签页;`prompt` / `negative` 填正、反向提示词,`seed` 填所有采样器的种子,`width` / `height` 填空白潜空间的尺寸,`image` 填“加载图片”读的图(输出或输入文件夹里的,比如标注后的重绘图;有几个“加载图片”时用 `image_node` 指定);其他任何参数用 `values` 按名字改,比如 `{"steps": 30}`,第三方节点的参数也一样:只有一个节点有这个参数就直接改,几个节点都有(比如高清修复的两个采样器都有 `denoise`)就要写节点号 `{"12": {"denoise": 0.5}}`,否则什么都不改并列出是哪几个。几个正向提示词框接进同一个采样器(分区提示词)时,`prompt` 也会停下来,让 AI 用 `values` 分别写。其余照存好的样子不动。不带 `workflow` 时填到当前画布上。要一次跑好几条提示词,用 `prompts`(最多 8 条,代替 `prompt`):AMB 按顺序一条条填进去排队,别的填空(比如同一个种子)每条都一样,全部算同一次跑图,图都在时间线的同一步里;跑完画布上留的是最后一条。哪一条排不上就停在那里,前面排上的照常跑完,AI 会知道停在第几条。填空和出图一起算一步,改画布那部分也能 Ctrl+Z;画布关卡比显卡关卡严时,按画布关卡问你 | | 工具目录 | 不是一个工具:AI 开始任务时(还没有任务时调现状一览也一样),回复里附带一张按“想做的事”排好的工具目录(出图、画布、模型、提示词、图片……每个工具一行标题),AI 看一眼就直接去用对的工具,不用到处试。目录从 AMB 的工具登记表自动生成,只列当前给 AI 的工具,加了新工具会自己出现 | | get_situation | 一次拿到任务、流程链、少量节点摘要、缺失输入和可匹配输出、错误及最近运行。每个回复的 `next` 提示下一步。新开对话或拿不准时先调它;简报预算约 3600 字,减少 Antigravity 将大回复转存为文件的情况 | | log_outside | AI 用了 AMB 以外的工具(命令、脚本、读写文件)后,说清做了什么、为什么、动了哪些文件;显示在时间线上(见第 5 节) | diff --git a/tests/test_mcp_fill.py b/tests/test_mcp_fill.py index 41123db..1291fea 100644 --- a/tests/test_mcp_fill.py +++ b/tests/test_mcp_fill.py @@ -1,6 +1,7 @@ -"""run_workflow's blanks (api/mcp_fill.py): a saved workflow opened for the task once and reused, -the blanks filled before the run, a picture from the input or output folder, and the gate a run -that changes the canvas asks at. The page is stood in for; folders and tasks are temporary.""" +"""run_workflow's blanks (api/mcp_fill.py): a saved workflow, or one of its 方案, opened for the task +once and reused, the blanks filled before the run, a picture from the input or output folder, and +the gate a run that changes the canvas asks at. The page is stood in for; folders and tasks are temporary.""" +import json import os import sys import tempfile @@ -31,7 +32,7 @@ class FillTests(unittest.TestCase): self.calls.append((action, dict(args))) return self.answers.pop(0) for patcher in (mock.patch.object(mcp_fill, "ask_page", side_effect=page), - mock.patch.object(mcp_fill, "saved_workflow", return_value=("海边.json", "海边"))): + mock.patch.object(mcp_fill, "saved_workflow", return_value=("海边.json", "海边", None))): patcher.start() self.addCleanup(patcher.stop) @@ -56,6 +57,13 @@ class FillTests(unittest.TestCase): with self.assertRaises(mcp_tools.ToolError): mcp_fill.fill(self.ctx, "海边") + def test_a_plan_opens_its_workflow_with_that_set_and_has_a_tab_of_its_own(self): + with mock.patch.object(mcp_fill, "saved_workflow", return_value=("海边.json", "海边 / 夕阳", "params_1.json")): + self.answers = [{"opened": "海边 / 夕阳"}, {"set": {}}] + self.assertEqual(mcp_fill.fill(self.ctx, "海边 / 夕阳"), {"workflow": "海边 / 夕阳"}) + self.assertEqual(self.calls[0], ("open_workflow", {"filename": "海边.json", "name": "海边 / 夕阳", "plan": "params_1.json"})) + self.assertEqual(self.calls[1], ("fill_slots", {"filename": "海边.json#params_1.json"}), "the plan's tab, not the workflow's own") + def test_blanks_alone_and_nothing_at_all(self): self.assertIsNone(mcp_fill.fill(self.ctx, None, prompt="")) self.assertEqual(self.calls, []) @@ -120,5 +128,37 @@ class GateTests(unittest.TestCase): self.assertIn("canvas", blocked["content"][0]["text"]) +class SavedTests(unittest.TestCase): + """Saved workflows and their 方案 as list_workflows lists them and run_workflow finds them.""" + + def setUp(self): + user = tempfile.mkdtemp(prefix="amb_fill_saved_") + patcher = mock.patch.object(mcp_tools.folder_paths, "get_user_directory", return_value=user) + patcher.start() + self.addCleanup(patcher.stop) + own = {"parameter_signature": {"value": "own"}} + for folder, name, data in (("anomalous_recipes", "recipe_a.json", {"name": "Anima 两段", "timestamp": 2, **own}), + ("anomalous_recipes", "recipe_b.json", {"name": "海边", "timestamp": 1}), + ("anomalous_parameters", "params_1.json", {"name": "左", "recipe_filename": "recipe_a.json", "timestamp": 1}), + ("anomalous_parameters", "params_2.json", {"name": "中", "recipe_filename": "recipe_a.json", "timestamp": 2}), + ("anomalous_parameters", "params_0.json", {"name": "Anima 两段", "recipe_filename": "recipe_a.json", + "timestamp": 3, **own})): + os.makedirs(os.path.join(user, "workflows", folder), exist_ok=True) + with open(os.path.join(user, "workflows", folder, name), "w", encoding="utf-8") as target: + json.dump(data, target) + + def test_each_workflow_lists_its_plans_and_a_run_finds_them(self): + listed = {item["name"]: item for item in mcp_tools.list_workflows()["workflows"]} + self.assertEqual(listed["Anima 两段"]["plans"], ["Anima 两段 / 中", "Anima 两段 / 左"], + "newest first, named for run_workflow; the set equal to the workflow's own values is left out") + self.assertNotIn("plans", listed["海边"]) + self.assertEqual([item["name"] for item in mcp_tools.list_workflows("左")["workflows"]], ["Anima 两段"], "found by a plan's name") + self.assertEqual(mcp_fill.saved_workflow("海边"), ("recipe_b.json", "海边", None)) + self.assertEqual(mcp_fill.saved_workflow("Anima 两段 / 左"), ("recipe_a.json", "Anima 两段 / 左", "params_1.json")) + for wrong in ("Anima 两段 / 右", "海边 / 左", "nothing", ""): + with self.assertRaises(mcp_tools.ToolError, msg=wrong): + mcp_fill.saved_workflow(wrong) + + if __name__ == "__main__": unittest.main() diff --git a/web/modules/mcp_canvas_edit.js b/web/modules/mcp_canvas_edit.js index 87acbf9..872f2a5 100644 --- a/web/modules/mcp_canvas_edit.js +++ b/web/modules/mcp_canvas_edit.js @@ -14,6 +14,7 @@ import { recordCanvasStep } from './canvas_history.js'; import { apiLinks, freeSpot, layered, placedBlock, typesFit, valuePlan } from './canvas_build.js'; import { fedBy, linkOf, sockets, unwired } from './mcp_canvas_brief.js'; import { fillPlan, valuesPlan } from './mcp_fill.js'; +import { applyRecipeParametersToCanvas } from './recipe_actions.js'; import { translate as t } from './locales.js'; import { setWidgetValue } from './ui_node_model_picker.js'; import { showWorkbenchToast } from './ui_prompt_toast.js'; @@ -275,19 +276,30 @@ export function canvasEditActions({ recorded, nodeById }) { } /** One of the user's saved workflows (a recipe), in a new tab named after it; the open one stays. */ - async function openWorkflow({ filename, name }, getOwner) { + async function openWorkflow({ filename, name, plan }, getOwner) { const response = await fetch(`/anomalous/recipe_full?filename=${encodeURIComponent(filename || '')}`); const payload = await response.json().catch(() => ({})); if (!response.ok || !payload.data?.workflow) throw new Error(`The saved workflow ${name} could not be read.`); const tab = tabName(name); getOwner()?.flushCanvasActivity?.(); await app.loadGraphData(structuredClone(payload.data.workflow), true, true, tab); + if (plan) await applyPlan(filename, plan, name); showWorkbenchToast(t('mcpDidOpenWorkflow', { name })); const g = app.canvas?.graph || app.graph; - if (g) g.extra = { ...(g.extra || {}), anomalous_saved: filename }; // run_workflow with this workflow reuses the tab + // run_workflow with this workflow (or this plan of it) reuses the tab + if (g) g.extra = { ...(g.extra || {}), anomalous_saved: plan ? `${filename}#${plan}` : filename }; return { opened: tab, node_count: (g?._nodes || []).length, note: 'describe_canvas shows its nodes; add_node, connect_nodes and set_values change it.' }; } + /** A 方案 of saved workflow `filename` (its parameter set `plan`) onto the tab just opened, as its Apply button writes it. */ + async function applyPlan(filename, plan, name) { + const response = await fetch(`/anomalous/parameters?recipe_filename=${encodeURIComponent(filename)}`); + const payload = await response.json().catch(() => ({})); + const set = (payload.notebooks || []).find(item => item.filename === plan); + if (!response.ok || !set) throw new Error(`The plan ${name} could not be read.`); + applyRecipeParametersToCanvas(set.data); + } + /** run_workflow's blanks (api/mcp_fill.py); `needs_open` when the canvas is not saved workflow `filename`. */ async function fillSlots({ filename, ...wanted }, getOwner) { const g = app.canvas?.graph || app.graph;