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:
co-authored by
Claude Opus 5.5
parent
c495482a85
commit
3d720ef38d
+4
-2
@@ -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
|
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
|
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.
|
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
|
`api/mcp_fill.py` gives run_workflow its blanks, a daily run in one call: a saved workflow, or
|
||||||
opened in the task's tab once and reused (the page marks the tab with the workflow's file),
|
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
|
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
|
`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
|
(`values`, also set_values without a node): the one node that has it, else the AI names the
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
## v1.63.0 — unreleased
|
## 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 不加),具体模型和数值留在做法和你自己的搭配里。
|
- 新增 **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.
|
- 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
@@ -368,7 +368,7 @@ ACTIONS = dict([
|
|||||||
run_workflow, {"batch": {"type": "integer", "minimum": 1, "maximum": 8, "default": 1,
|
run_workflow, {"batch": {"type": "integer", "minimum": 1, "maximum": 8, "default": 1,
|
||||||
"description": "Tries, each its own seed."},
|
"description": "Tries, each its own seed."},
|
||||||
"wait_seconds": {"type": "number", "minimum": 0, "maximum": 55, "default": 55},
|
"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"},
|
"prompt": _PROMPT, "negative": {"type": "string"},
|
||||||
"prompts": {"type": "array", "items": {"type": "string"}, "maxItems": MAX_PROMPTS,
|
"prompts": {"type": "array", "items": {"type": "string"}, "maxItems": MAX_PROMPTS,
|
||||||
"description": "Instead of prompt: several, queued in order as one run."},
|
"description": "Instead of prompt: several, queued in order as one run."},
|
||||||
|
|||||||
+21
-13
@@ -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.
|
"""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
|
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
|
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
|
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 . import ai_task_canvas, ai_tasks
|
||||||
from .ai_pictures import to_input
|
from .ai_pictures import to_input
|
||||||
from .mcp_bridge import PageRefused, PageUnavailable, ask_page
|
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")
|
SLOTS = ("prompt", "negative", "seed", "width", "height", "image", "image_node", "values")
|
||||||
MAX_VALUES = 60
|
MAX_VALUES = 60
|
||||||
@@ -50,15 +51,21 @@ def node_values(values):
|
|||||||
|
|
||||||
|
|
||||||
def saved_workflow(name):
|
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
|
from .recipe_store import _list_recipes, get_recipes_dir
|
||||||
|
|
||||||
name = str(name or "").strip()
|
name = str(name or "").strip()
|
||||||
if not name or not any(item["name"] == name for item in list_workflows(name, 50)["workflows"]):
|
files = {}
|
||||||
raise ToolError(f"No saved workflow called {name}; list_workflows names them.")
|
for recipe in _list_recipes(get_recipes_dir()):
|
||||||
filename = next((recipe["filename"] for recipe in _list_recipes(get_recipes_dir())
|
files.setdefault(recipe["data"].get("name") or recipe["filename"], recipe["filename"])
|
||||||
if (recipe["data"].get("name") or recipe["filename"]) == name), None)
|
if name and name in files:
|
||||||
return filename, name
|
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):
|
def _picture(image):
|
||||||
@@ -89,15 +96,16 @@ def fill(ctx, workflow=None, **slots):
|
|||||||
slots["image"] = _picture(slots["image"])
|
slots["image"] = _picture(slots["image"])
|
||||||
if "values" in slots:
|
if "values" in slots:
|
||||||
node_values(slots["values"])
|
node_values(slots["values"])
|
||||||
filename = name = None
|
filename = name = plan = None
|
||||||
if workflow:
|
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
|
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")
|
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")):
|
if workflow and (filled is None or filled.get("needs_open")):
|
||||||
_page("open_workflow", {"filename": filename, "name": name}, timeout=60)
|
_page("open_workflow", {"filename": filename, "name": name, "plan": plan}, timeout=60)
|
||||||
filled = _page("fill_slots", {"filename": filename, **slots})
|
filled = _page("fill_slots", {"filename": opened, **slots})
|
||||||
if filled.get("needs_open"):
|
if filled.get("needs_open"):
|
||||||
raise ToolError(f"The saved workflow {name} did not open; nothing was run.")
|
raise ToolError(f"The saved workflow {name} did not open; nothing was run.")
|
||||||
return {**({"workflow": name} if name else {}), **(filled.get("set") or {})}
|
return {**({"workflow": name} if name else {}), **(filled.get("set") or {})}
|
||||||
|
|||||||
+23
-6
@@ -34,6 +34,7 @@ from .mcp_arguments import prepare_arguments, renaming_note
|
|||||||
from .model_catalog import _iter_search_models
|
from .model_catalog import _iter_search_models
|
||||||
from .model_constants import MEDIA_EXTENSIONS, MODEL_EXTENSIONS, PREVIEW_SUFFIXES
|
from .model_constants import MEDIA_EXTENSIONS, MODEL_EXTENSIONS, PREVIEW_SUFFIXES
|
||||||
from .notebooks import _list_notebooks
|
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 .path_utils import model_file_within, resolve_folder_subdir, resolve_within
|
||||||
from .recipe_store import _list_recipes, get_recipes_dir
|
from .recipe_store import _list_recipes, get_recipes_dir
|
||||||
from .scan_report import last_scan_path
|
from .scan_report import last_scan_path
|
||||||
@@ -42,6 +43,7 @@ from .scan_summary import _summarize
|
|||||||
MAX_LIMIT = 50
|
MAX_LIMIT = 50
|
||||||
MAX_SEARCH_PICTURES = 6 # search_images(with_pictures): the first few attached
|
MAX_SEARCH_PICTURES = 6 # search_images(with_pictures): the first few attached
|
||||||
MAX_TEXT = 1500
|
MAX_TEXT = 1500
|
||||||
|
MAX_PLANS = 12 # 方案 listed for one workflow
|
||||||
MAX_IMAGE_BYTES = 1_500_000
|
MAX_IMAGE_BYTES = 1_500_000
|
||||||
IMAGE_TYPES = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp"}
|
IMAGE_TYPES = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp"}
|
||||||
SOURCES = {"civitai": "civitai", "local": "inferred_from_file", "": "not_scanned"}
|
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)]}
|
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):
|
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()):
|
for recipe in _list_recipes(get_recipes_dir()):
|
||||||
data = recipe.get("data") or {}
|
data = recipe.get("data") or {}
|
||||||
|
name = data.get("name") or recipe["filename"]
|
||||||
params = json.dumps(data.get("params") or {}, ensure_ascii=False)
|
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
|
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
|
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),
|
"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)]}
|
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,
|
"structure\") model nodes and text nodes saved from the canvas with their links, listed by slot.", list_combos,
|
||||||
{"query": _QUERY, "limit": _LIMIT}),
|
{"query": _QUERY, "limit": _LIMIT}),
|
||||||
_tool("list_workflows", "Workflow recipes",
|
_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}),
|
{"query": _QUERY, "limit": _LIMIT}),
|
||||||
_tool("list_saved_prompts", "Saved prompts",
|
_tool("list_saved_prompts", "Saved prompts",
|
||||||
"Prompts the user saved in the Prompt Studio, with positive and negative text.", list_saved_prompts,
|
"Prompts the user saved in the Prompt Studio, with positive and negative text.", list_saved_prompts,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
},
|
},
|
||||||
"text": [
|
"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).",
|
"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.",
|
"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.",
|
"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."
|
"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
@@ -90,7 +90,7 @@ claude mcp add --transport http anomalous http://127.0.0.1:8188/anomalous/mcp
|
|||||||
| scan_report | 哪些模型匹配上了 C 站,哪些没有、原因是什么,哪些还没扫,上次扫描做了什么 |
|
| scan_report | 哪些模型匹配上了 C 站,哪些没有、原因是什么,哪些还没扫,上次扫描做了什么 |
|
||||||
| search_images / get_image_info | 按提示词、模型、参数找出图;看某张图的全部生成参数,可以附上缩略图。`folder=input` 时看 ComfyUI 输入文件夹里的图(比如你标注时 AMB 存的重绘遮罩图):尺寸、要重画的区域在哪、占多大,也能附上图,不用 AI 自己跑命令去看 |
|
| search_images / get_image_info | 按提示词、模型、参数找出图;看某张图的全部生成参数,可以附上缩略图。`folder=input` 时看 ComfyUI 输入文件夹里的图(比如你标注时 AMB 存的重绘遮罩图):尺寸、要重画的区域在哪、占多大,也能附上图,不用 AI 自己跑命令去看 |
|
||||||
| list_combos | 你的搭配(主模型 + LoRA + 提示词) |
|
| list_combos | 你的搭配(主模型 + LoRA + 提示词) |
|
||||||
| list_workflows | 你存下的工作流,带标签、备注和主要设置 |
|
| list_workflows | 你存下的工作流,带标签、备注、主要设置和它的参数方案(写成“工作流名 / 方案名”) |
|
||||||
| list_saved_prompts | 提示词工坊里存的提示词 |
|
| list_saved_prompts | 提示词工坊里存的提示词 |
|
||||||
| search_audio | 用 Anomalous TTS 生成过的语音,带台词和角色 |
|
| search_audio | 用 Anomalous TTS 生成过的语音,带台词和角色 |
|
||||||
| recent_activity | 插件最近改过什么(操作记录) |
|
| recent_activity | 插件最近改过什么(操作记录) |
|
||||||
@@ -117,7 +117,7 @@ claude mcp add --transport http anomalous http://127.0.0.1:8188/anomalous/mcp
|
|||||||
| move_model | 把放错文件夹的模型挪到它该在的文件夹(封面、备注跟着走),和模型页“整理”里的“挪过去”一样;不会盖掉同名文件。默认先问你 |
|
| move_model | 把放错文件夹的模型挪到它该在的文件夹(封面、备注跟着走),和模型页“整理”里的“挪过去”一样;不会盖掉同名文件。默认先问你 |
|
||||||
| list_voices / speak | 列出 Anomalous TTS 的角色;让某个角色读一段台词并保存(最多等两分钟) |
|
| list_voices / speak | 列出 Anomalous TTS 的角色;让某个角色读一段台词并保存(最多等两分钟) |
|
||||||
| begin_task / report | 在 AI 工作台里起一个任务的标题;给你留言、交上它用别的方式做出来的图、结束任务(见第 5 节) |
|
| 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 的工具,加了新工具会自己出现 |
|
| 工具目录 | 不是一个工具:AI 开始任务时(还没有任务时调现状一览也一样),回复里附带一张按“想做的事”排好的工具目录(出图、画布、模型、提示词、图片……每个工具一行标题),AI 看一眼就直接去用对的工具,不用到处试。目录从 AMB 的工具登记表自动生成,只列当前给 AI 的工具,加了新工具会自己出现 |
|
||||||
| get_situation | 一次拿到任务、流程链、少量节点摘要、缺失输入和可匹配输出、错误及最近运行。每个回复的 `next` 提示下一步。新开对话或拿不准时先调它;简报预算约 3600 字,减少 Antigravity 将大回复转存为文件的情况 |
|
| get_situation | 一次拿到任务、流程链、少量节点摘要、缺失输入和可匹配输出、错误及最近运行。每个回复的 `next` 提示下一步。新开对话或拿不准时先调它;简报预算约 3600 字,减少 Antigravity 将大回复转存为文件的情况 |
|
||||||
| log_outside | AI 用了 AMB 以外的工具(命令、脚本、读写文件)后,说清做了什么、为什么、动了哪些文件;显示在时间线上(见第 5 节) |
|
| log_outside | AI 用了 AMB 以外的工具(命令、脚本、读写文件)后,说清做了什么、为什么、动了哪些文件;显示在时间线上(见第 5 节) |
|
||||||
|
|||||||
+44
-4
@@ -1,6 +1,7 @@
|
|||||||
"""run_workflow's blanks (api/mcp_fill.py): a saved workflow opened for the task once and reused,
|
"""run_workflow's blanks (api/mcp_fill.py): a saved workflow, or one of its 方案, opened for the task
|
||||||
the blanks filled before the run, a picture from the input or output folder, and the gate a run
|
once and reused, the blanks filled before the run, a picture from the input or output folder, and
|
||||||
that changes the canvas asks at. The page is stood in for; folders and tasks are temporary."""
|
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 os
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -31,7 +32,7 @@ class FillTests(unittest.TestCase):
|
|||||||
self.calls.append((action, dict(args)))
|
self.calls.append((action, dict(args)))
|
||||||
return self.answers.pop(0)
|
return self.answers.pop(0)
|
||||||
for patcher in (mock.patch.object(mcp_fill, "ask_page", side_effect=page),
|
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()
|
patcher.start()
|
||||||
self.addCleanup(patcher.stop)
|
self.addCleanup(patcher.stop)
|
||||||
|
|
||||||
@@ -56,6 +57,13 @@ class FillTests(unittest.TestCase):
|
|||||||
with self.assertRaises(mcp_tools.ToolError):
|
with self.assertRaises(mcp_tools.ToolError):
|
||||||
mcp_fill.fill(self.ctx, "海边")
|
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):
|
def test_blanks_alone_and_nothing_at_all(self):
|
||||||
self.assertIsNone(mcp_fill.fill(self.ctx, None, prompt=""))
|
self.assertIsNone(mcp_fill.fill(self.ctx, None, prompt=""))
|
||||||
self.assertEqual(self.calls, [])
|
self.assertEqual(self.calls, [])
|
||||||
@@ -120,5 +128,37 @@ class GateTests(unittest.TestCase):
|
|||||||
self.assertIn("canvas", blocked["content"][0]["text"])
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { recordCanvasStep } from './canvas_history.js';
|
|||||||
import { apiLinks, freeSpot, layered, placedBlock, typesFit, valuePlan } from './canvas_build.js';
|
import { apiLinks, freeSpot, layered, placedBlock, typesFit, valuePlan } from './canvas_build.js';
|
||||||
import { fedBy, linkOf, sockets, unwired } from './mcp_canvas_brief.js';
|
import { fedBy, linkOf, sockets, unwired } from './mcp_canvas_brief.js';
|
||||||
import { fillPlan, valuesPlan } from './mcp_fill.js';
|
import { fillPlan, valuesPlan } from './mcp_fill.js';
|
||||||
|
import { applyRecipeParametersToCanvas } from './recipe_actions.js';
|
||||||
import { translate as t } from './locales.js';
|
import { translate as t } from './locales.js';
|
||||||
import { setWidgetValue } from './ui_node_model_picker.js';
|
import { setWidgetValue } from './ui_node_model_picker.js';
|
||||||
import { showWorkbenchToast } from './ui_prompt_toast.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. */
|
/** 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 response = await fetch(`/anomalous/recipe_full?filename=${encodeURIComponent(filename || '')}`);
|
||||||
const payload = await response.json().catch(() => ({}));
|
const payload = await response.json().catch(() => ({}));
|
||||||
if (!response.ok || !payload.data?.workflow) throw new Error(`The saved workflow ${name} could not be read.`);
|
if (!response.ok || !payload.data?.workflow) throw new Error(`The saved workflow ${name} could not be read.`);
|
||||||
const tab = tabName(name);
|
const tab = tabName(name);
|
||||||
getOwner()?.flushCanvasActivity?.();
|
getOwner()?.flushCanvasActivity?.();
|
||||||
await app.loadGraphData(structuredClone(payload.data.workflow), true, true, tab);
|
await app.loadGraphData(structuredClone(payload.data.workflow), true, true, tab);
|
||||||
|
if (plan) await applyPlan(filename, plan, name);
|
||||||
showWorkbenchToast(t('mcpDidOpenWorkflow', { name }));
|
showWorkbenchToast(t('mcpDidOpenWorkflow', { name }));
|
||||||
const g = app.canvas?.graph || app.graph;
|
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.' };
|
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`. */
|
/** run_workflow's blanks (api/mcp_fill.py); `needs_open` when the canvas is not saved workflow `filename`. */
|
||||||
async function fillSlots({ filename, ...wanted }, getOwner) {
|
async function fillSlots({ filename, ...wanted }, getOwner) {
|
||||||
const g = app.canvas?.graph || app.graph;
|
const g = app.canvas?.graph || app.graph;
|
||||||
|
|||||||
Reference in New Issue
Block a user