From six Gemini 3.8 Flash runs of one question (Harem 1.5, then a Kirazuri v4 Tile redraw, then faces and hands), recorded in tool-exam.md: - begin_task's ways and the empty-canvas hint name add_nodes(plan=[...]); a route without its models and "several samplers" say what to do instead. - A plan is two steps or more (no one-step probe card for the user); a model may be named by its name when only one main model has it; a way for a handed-over picture (fix_hands) gives way to its use's way that goes on from the step before. - The user's rule is kept in run_workflow: a plan's first run is three bases, and the whole is refused once until they pick (an AI told "go on without" calls again). - canvas_roles takes a "...model" path past size-bound patches (LLLite, ControlNet, T2I-Adapter): face detailing after a Tile redraw took the LLLite-patched model and crashed ComfyUI. Last run: plan, build, three bases, pick, the whole from the picked base, no crash; 27 calls, 4 failed (each an empty first try). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
147 lines
8.3 KiB
Python
147 lines
8.3 KiB
Python
"""add_nodes(plan=…): the big steps an AI lays out for a picture (make it, redraw it with another model, fix
|
|
faces, enlarge), each a ready-made block (reference_blocks.py), shown to the user on a plan card
|
|
(ai_plans.py) and built in one go on a new tab (block_chain.py) when they say build: the AI wires nothing
|
|
and the user sees the route before it is built. A step is {use, want} as for add_nodes, or {block}, and
|
|
may name a model (the first step's drawing model, else the task's picked one; a later step's for its
|
|
block's own loader). With "build without asking" on, the card shows what was built at once. A card
|
|
answered after the AI stopped waiting is built by add_nodes(route=<its panel id>)."""
|
|
|
|
from . import ai_answer_wait, ai_panels, ai_plans, ai_tasks, ai_toolbox, block_chain, model_advice, reference_blocks
|
|
from .mcp_routes import NEXT, ONE_PASS
|
|
from .mcp_tools import ToolError, _resolve_model, get_metadata, search_models
|
|
|
|
MAX_STEPS = 6
|
|
MAX_CHOICES = 12 # models of the same family offered for a step
|
|
|
|
|
|
def _model(model_id):
|
|
"""(name, folder type, base model) of `model_id`; ToolError when it is gone."""
|
|
folder_type, _root, _base, path = _resolve_model(str(model_id))
|
|
meta = get_metadata(path) or {}
|
|
name = meta.get("custom_name") or meta.get("name") or path.replace("\\", "/").rsplit("/", 1)[-1]
|
|
return name, folder_type, model_advice.base_of(path)
|
|
|
|
|
|
def _choices(model_id, folder_type, base):
|
|
"""Other models of the folder and family of `model_id`, for the user to swap in."""
|
|
found = search_models("", folder_type, "", "", 50)["models"]
|
|
return [{"model_id": item["model_id"], "name": item["name"]} for item in found if item["model_id"] != model_id
|
|
and model_advice.family(item.get("base_model")) == model_advice.family(base)][:MAX_CHOICES]
|
|
|
|
|
|
def _task_id(ctx):
|
|
return ctx.get("task") or ai_tasks.active_task_id(ctx.get("client") or "AI", ctx.get("session") or "")
|
|
|
|
|
|
def _model_id(value, at):
|
|
"""`value` as a model_id: one already, else the one main model whose file or name it is."""
|
|
value = str(value)
|
|
if value.count(":") >= 2:
|
|
return value
|
|
wanted = value.strip().lower()
|
|
found = [item for item in search_models(value, "", "", "", 50)["models"] if item["model_id"].split(":", 1)[0] in model_advice.MAIN_TYPES
|
|
and wanted in (item["name"].lower(), item["model_id"].rsplit("/", 1)[-1].rsplit(".", 1)[0].lower())]
|
|
if len(found) != 1:
|
|
raise ToolError(f"Step {at + 1}: model is a model_id from search_models" + (
|
|
f"; {value!r} is the name of {len(found)}: " + ", ".join(item["model_id"] for item in found[:5]) if found else "."))
|
|
return found[0]["model_id"]
|
|
|
|
|
|
def _box(ctx):
|
|
task_id = _task_id(ctx)
|
|
return ai_toolbox.effective(ai_tasks.get_task(task_id) if task_id else None) or {}
|
|
|
|
|
|
def resolve(ctx, plan):
|
|
"""The card's steps for the AI's `plan`: each its block (the best of its use this ComfyUI can run, or the
|
|
way the user picked), title, model and the models to swap in. ToolError for a plan that cannot be built."""
|
|
from .mcp_canvas_edit import object_info
|
|
|
|
if not isinstance(plan, list) or not 1 < len(plan) <= MAX_STEPS:
|
|
raise ToolError(f"plan is 2 to {MAX_STEPS} steps, the picture first: [{{use: 'picture', model: …}}, {{use: 'refine', model: …}}, …]; "
|
|
"the picture alone is add_nodes(use='picture', model=…).")
|
|
info, ways, steps = object_info(ctx), _box(ctx).get("ways") or (), []
|
|
for at, step in enumerate(plan):
|
|
step = step if isinstance(step, dict) else {}
|
|
try:
|
|
name = str(step["block"]) if step.get("block") in reference_blocks.BLOCKS else \
|
|
reference_blocks.pick(str(step.get("use")), None if step.get("want") is None else str(step["want"]), info, False, ways)
|
|
except (reference_blocks.BlockError, KeyError) as error:
|
|
raise ToolError(f"Step {at + 1}: {error}")
|
|
if reference_blocks.BLOCKS[name].get("takes") in ("picture", "reference") and not step.get("image") and step.get("use"):
|
|
# one for a picture the user hands over (fix_hands): in a plan the step before gives the picture
|
|
chained = reference_blocks.pick(str(step["use"]), None, info, False, ways)
|
|
if reference_blocks.BLOCKS[chained].get("takes") in ("picture", "reference"):
|
|
raise ToolError(f"Step {at + 1}: {name} works on a picture the user hands over, not on the step before's.")
|
|
name = chained
|
|
block = reference_blocks.BLOCKS[name]
|
|
if (at == 0) != bool(block.get("standalone")):
|
|
raise ToolError("A plan starts with the picture (use='picture') and has it once.")
|
|
model = _model_id(step["model"], at) if step.get("model") else (next((item for item in _box(ctx).get("models") or []
|
|
if item.split(":", 1)[0] in model_advice.MAIN_TYPES), None) if at == 0 else None)
|
|
if at == 0 and not model:
|
|
raise ToolError("Step 1: name the picture's model (model_id from search_models), or the user picks one for the task.")
|
|
if model and at and not block.get("own_model"):
|
|
raise ToolError(f"Step {at + 1} ({name}) has no loader of its own for a second model; leave model out.")
|
|
shown = {"block": name, "title": block["title"], "model": model}
|
|
if model:
|
|
model_name, folder_type, base = _model(model)
|
|
shown.update(model_name=model_name, choices=_choices(model, folder_type, base))
|
|
steps.append(shown)
|
|
return steps
|
|
|
|
|
|
def _built(ctx, plan, name):
|
|
loras = [item for item in _box(ctx).get("models") or [] if item.startswith("loras:")]
|
|
chain = block_chain.build(ctx, [{**step, "loras": loras} for step in plan], name)
|
|
ai_plans.mark(_task_id(ctx), ai_plans.PREVIEWS, len(chain["steps"]) > 1)
|
|
return {"built": [step["block"] for step in chain["steps"]], "nodes": chain["nodes"],
|
|
"next": NEXT if len(chain["steps"]) > 1 else ONE_PASS}
|
|
|
|
|
|
def propose(ctx, plan, wait_seconds=ai_panels.MAX_WAIT):
|
|
"""Shows the plan card and waits for the user, building it when they say build (at once with "build without asking")."""
|
|
steps = resolve(ctx, plan)
|
|
client, task = ctx.get("client") or "AI", ctx.get("task")
|
|
try:
|
|
panel = ai_panels.show(client, "Blueprint", [{"type": "plan", "id": "answer", "steps": steps}],
|
|
session=ctx.get("session") or "", task_id=task, tool="add_nodes", title_key="aiPlanTitle")
|
|
if ai_plans.auto():
|
|
kept = ai_plans.kept(steps, {})
|
|
ai_panels.answer(_task_id(ctx), _step(ctx, panel["id"])["id"], "answer", ["build"], extra={"plan": kept, "auto": True})
|
|
return {"panel": panel["id"], **_built(ctx, kept, "blueprint"), "note": "Built without asking: the user's setting."}
|
|
answer = ai_answer_wait.collect(client, panel["id"], max(30.0, float(wait_seconds or 0)))
|
|
except ai_panels.PanelError as error:
|
|
raise ToolError(str(error))
|
|
given = next((item for item in answer.get("answers") or [] if item["question"] == "answer"), None)
|
|
if not given:
|
|
return {"answered": False, "panel": panel["id"], **answer,
|
|
"note": f"When the user says build, add_nodes(route={panel['id']!r}) builds it as they left it; get_answers waits for them."}
|
|
return _given(ctx, panel["id"], given)
|
|
|
|
|
|
def _given(ctx, panel_id, given):
|
|
if given.get("plan"):
|
|
return {"panel": panel_id, **_built(ctx, given["plan"], "blueprint")}
|
|
return {"answered": True, "panel": panel_id, "text": given.get("text") or "",
|
|
"note": "Nothing was built: the user wants the plan changed. Change it as they say and call add_nodes(plan=…) again."}
|
|
|
|
|
|
def is_card(ctx, panel_id):
|
|
return ai_plans.card_of(_step(ctx, panel_id)) is not None
|
|
|
|
|
|
def _step(ctx, panel_id):
|
|
"""The task's panel step showing panel `panel_id`, or None."""
|
|
task_id = _task_id(ctx)
|
|
task = ai_tasks.get_task(task_id) if task_id else None
|
|
return next((item for item in (task or {}).get("steps") or [] if (item.get("panel") or {}).get("id") == str(panel_id)), None)
|
|
|
|
|
|
def build_answered(ctx, panel_id):
|
|
"""add_nodes(route=<a plan card's id>): the plan as the user left it, once they said build."""
|
|
given = (_step(ctx, panel_id) or {}).get("answers", {}).get("answer")
|
|
if not given:
|
|
raise ToolError("The user has not answered the plan card yet: get_answers waits for them.")
|
|
return _given(ctx, panel_id, given)
|