Let the AI run a workflow card's parameter sets by name

A workflow card's saved parameter sets (方案) were the user's alone. Now
list_workflows lists each card's sets as "workflow / set" (those unlike
the card's own values, as the Workflows page lists them), and
run_workflow(workflow="workflow / set") opens the workflow and applies
the set with the page's own Apply code before filling in the blanks; the
tab is marked with the set too, so another set opens afresh. No new
argument: the tool list stays within its budget.

The Anima skill points at the sets: the daily run is the user's saved
workflow or one of its sets.

Checked on an isolated ComfyUI: a card built from the verified two-pass
Anima graph with its own values as the plain route and two sets (upscale
on another model, model switch mid-way); run_workflow on the second set
queued exactly its values and made pictures pixel-identical to that
route's verified ones. Full suite and structure check pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DemonGatanjieu
2026-11-02 17:48:27 +08:00
co-authored by Claude Opus 5.5
parent c495482a85
commit 3d720ef38d
9 changed files with 112 additions and 31 deletions
+4 -2
View File
@@ -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
+2
View File
@@ -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.
+1 -1
View File
@@ -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."},
+21 -13
View File
@@ -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 {})}
+23 -6
View File
@@ -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,
+1 -1
View File
@@ -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."
+2 -2
View File
@@ -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 节) |
+44 -4
View File
@@ -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()
+14 -2
View File
@@ -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;