- run_workflow takes a gallery picture as its workflow (mcp_fill.opening says what a name opens, for open_workflow too); a picture's workflow opens with an id of its own, and the tab's mark goes into the workflow before it loads, so ComfyUI finishing the tab does not drop it. - With ComfyUI open in two browser tabs a task's actions were split between them (one opened the task's tab, the other could not find it): the page that did the last action gets the next first now, another only when it is gone (mcp_bridge.py, pages keep an id of their own). - describe_canvas on a big canvas: titles off, then nodes as a line each with their settings, so every value stays readable. - search_models names the models that did not fit; begin_task's tool directory goes by name alone when the answer would be too long. - ask_user refuses a "test" question with made-up options too. - Exam round recorded: question 2 with nothing filed away, a big canvas read right in 3 calls, question 3 in 12 calls. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
144 lines
7.4 KiB
Python
144 lines
7.4 KiB
Python
"""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, or one of its 方案 as "workflow / plan": a parameter set saved
|
|
under it, applied as its Apply button does, or a gallery picture, whose own workflow is opened: `opening`)
|
|
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, or "picture:" and the picture); 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
|
|
picture its LoadImage reads (from the input or the output folder, found here, never copied;
|
|
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.
|
|
"""
|
|
|
|
import os
|
|
|
|
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 IMAGE_TYPES, ToolError, _resolve_folder_image, _resolve_image, plans
|
|
from .recipe_images import _embedded_workflow_payload
|
|
|
|
SLOTS = ("prompt", "negative", "seed", "width", "height", "image", "image_node", "values")
|
|
MAX_VALUES = 60
|
|
MAX_TEXT = 20000
|
|
|
|
|
|
def flat_values(values):
|
|
"""{setting name: value}, checked for shape and size; the page checks what each setting takes."""
|
|
if values is None:
|
|
return {}
|
|
if not isinstance(values, dict) or len(values) > MAX_VALUES:
|
|
raise ToolError(f"values is an object {{setting name: value}} of up to {MAX_VALUES} settings.")
|
|
for name, value in values.items():
|
|
if len(name) > 100 or not (isinstance(value, (bool, int, float)) or (isinstance(value, str) and len(value) <= MAX_TEXT)):
|
|
raise ToolError(f"The value of {name[:100]} is a number, true/false or text of up to {MAX_TEXT} characters.")
|
|
return values
|
|
|
|
|
|
def node_values(values):
|
|
"""Settings by name ({setting name: value}) or for one node ({node id: {setting name: value}})."""
|
|
if not isinstance(values, dict) or not values:
|
|
raise ToolError('values is {setting name: value}, or {"<node id>": {setting name: value}} for one node.')
|
|
nested = {key: value for key, value in values.items() if isinstance(value, dict)}
|
|
for key, settings in nested.items():
|
|
if not str(key).isdigit():
|
|
raise ToolError(f"values.{str(key)[:40]}: only a node id takes an object of settings.")
|
|
flat_values(settings)
|
|
flat_values({key: value for key, value in values.items() if key not in nested})
|
|
if len(values) - len(nested) + sum(len(settings) for settings in nested.values()) > MAX_VALUES:
|
|
raise ToolError(f"values sets up to {MAX_VALUES} settings at once.")
|
|
return values
|
|
|
|
|
|
def saved_workflow(name):
|
|
"""(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), or of the example called that."""
|
|
from . import example_workflows
|
|
from .recipe_store import _list_recipes, get_recipes_dir
|
|
|
|
name = str(name or "").strip()
|
|
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
|
|
example = example_workflows.file_of(name)
|
|
if example:
|
|
return example, 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_workflow(name):
|
|
"""{subfolder, filename} of gallery picture `name` (in the output folder) when the name is a
|
|
picture's and it carries a workflow; None when the name is no picture's."""
|
|
if os.path.splitext(str(name or "").strip())[1].lower() not in IMAGE_TYPES:
|
|
return None
|
|
rel, path = _resolve_image(str(name).strip())
|
|
if not (_embedded_workflow_payload(path) or {}).get("workflow"):
|
|
raise ToolError(f"{rel} carries no workflow to open: only the pictures ComfyUI saves with one do.")
|
|
subfolder, _, filename = rel.rpartition("/")
|
|
return {"subfolder": subfolder, "filename": filename}
|
|
|
|
|
|
def opening(name):
|
|
"""(what the page's open_workflow takes, the mark it leaves on the tab) for `name`: a saved workflow,
|
|
one of its plans or an example (saved_workflow), else a gallery picture's own workflow."""
|
|
try:
|
|
filename, shown, plan = saved_workflow(name)
|
|
except ToolError:
|
|
picture = picture_workflow(name) # not a saved one: a picture's own, to change what made it
|
|
if not picture:
|
|
raise
|
|
rel = "/".join(part for part in (picture["subfolder"], picture["filename"]) if part)
|
|
return {"picture": picture, "name": os.path.splitext(picture["filename"])[0]}, f"picture:{rel}"
|
|
return {"filename": filename, "name": shown, "plan": plan}, (f"{filename}#{plan}" if plan else filename)
|
|
|
|
|
|
def _picture(image):
|
|
"""The value a LoadImage takes for picture `image`: in the input folder as it is, from the
|
|
output folder its copy there (ai_pictures.to_input)."""
|
|
for folder in ("input", "output"):
|
|
try:
|
|
rel, _path = _resolve_folder_image(image, folder)
|
|
except ToolError:
|
|
continue
|
|
return rel if folder == "input" else to_input(rel)
|
|
raise ToolError(f"{image} is in neither ComfyUI's input nor its output folder; search_images lists the pictures in the output.")
|
|
|
|
|
|
def _page(action, args, timeout=30):
|
|
try:
|
|
return ask_page(action, args, timeout)
|
|
except (PageUnavailable, PageRefused) as error:
|
|
raise ToolError(str(error))
|
|
|
|
|
|
def fill(ctx, workflow=None, **slots):
|
|
"""Opens `workflow` for the task (once) and fills `slots` in; what was set, or None when nothing was asked."""
|
|
slots = {key: value for key, value in slots.items() if key in SLOTS and value not in (None, "")}
|
|
if not workflow and not slots:
|
|
return None
|
|
if "image" in slots:
|
|
slots["image"] = _picture(slots["image"])
|
|
if "values" in slots:
|
|
node_values(slots["values"])
|
|
args, opened = opening(workflow) if workflow else ({}, None) # opened: what the page marks the tab with
|
|
name = args.get("name")
|
|
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": opened, **slots})
|
|
if workflow and (filled is None or filled.get("needs_open")):
|
|
_page("open_workflow", args, 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 {})}
|