Files
DemonGatanjieu-Anomalous_Mo…/api/ai_toolbox.py
T
DemonGatanjieuandClaude Opus 5.5 c3433a9241 Pick a way for a task, and ship official example workflows
Ways: "Use it this time" in the Ways view, for one task. It joins the
task's picks as a new kind, the AI is told, and add_nodes builds that way
for its use whichever one the AI asks for. "Only" lets in the nodes a
picked way builds.

Examples: text to picture, two passes, picture to picture and repainting
an area, on ComfyUI's own nodes. One card, first on the Workflows page,
opens them in a tab of their own. The files name no model or picture; a
checkpoint and a picture that are here are filled in when one is read.
list_workflows gives them to an AI when no saved workflow fits, and they
open and run by name like saved ones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 00:51:26 +08:00

176 lines
7.3 KiB
Python

"""What the user hands the AI to work with ("给 AI 的素材", web/modules/ui_ai_toolbox.js): node
types, whole custom node packs (by folder: every node type in it), combos, workflows, saved
prompts and models picked for a task, and how strictly: prefer
(the AI is told to use them first) or only (add_node and new_workflow refuse other node types,
except ComfyUI's own built-in ones and the helpers that live only in the page). Ways too
(reference_blocks.py, picked in the 做法 view: pick_way): add_nodes builds the picked one for its use. Favourites
(user/anomalous/ai_toolbox.json) are what a task has until the user picks for it. A pick is a
"user_toolbox" step the AI receives like a message (kind toolbox, ai_panels.py); begin_task
names the task's picks and get_toolbox gives them in full. The catalogue the picker shows uses
the same names the AI's tools use: node types from ComfyUI's /object_info (mcp_canvas_edit.py),
the rest from ai_toolbox_catalog.py."""
import json
import os
import threading
import time
import uuid
from . import ai_tasks
from .path_utils import atomic_write_json
KINDS = ("nodes", "packs", "combos", "workflows", "prompts", "models", "ways")
MODES = ("prefer", "only")
MAX_ITEMS = 60
MAX_NAME = 300
_lock = threading.Lock()
class ToolboxError(ValueError):
pass
def _path():
return os.path.join(os.path.dirname(ai_tasks.tasks_dir()), "ai_toolbox.json")
def clean(picks):
"""{kind: [names]} for KINDS: strings only, each kind once per name, at most MAX_ITEMS."""
if not isinstance(picks, dict):
raise ToolboxError("Expected {nodes, packs, combos, workflows, prompts, models, ways}")
out = {}
for kind in KINDS:
items = picks.get(kind) or []
if not isinstance(items, list) or len(items) > MAX_ITEMS:
raise ToolboxError(f"{kind}: a list of up to {MAX_ITEMS} names")
names = []
for item in items:
if not isinstance(item, str) or not item.strip() or len(item) > MAX_NAME:
raise ToolboxError(f"{kind}: names are text of up to {MAX_NAME} characters")
if item.strip() not in names:
names.append(item.strip())
out[kind] = names
return out
def count(box):
return sum(len((box or {}).get(kind) or []) for kind in KINDS)
def favorites():
try:
with open(_path(), encoding="utf-8") as source:
return clean(json.load(source))
except (OSError, ValueError):
return clean({})
def set_favorites(picks):
picks = clean(picks)
with _lock:
atomic_write_json(_path(), picks)
return picks
def effective(task):
"""The task's picks ({mode, kinds…, "from": task|favorites}), or None when it has none."""
box = (task or {}).get("toolbox")
if box:
return {**box, "from": "task"}
liked = favorites()
return {"mode": "prefer", **liked, "from": "favorites"} if count(liked) else None
def words(box):
"""The picks in a sentence or two for the AI."""
from . import reference_blocks
parts = []
labels = {"nodes": "node types", "packs": "custom node packs (every node type in these folders; get_toolbox lists them)",
"combos": "combos (place_combo)", "workflows": "workflows (open_workflow opens one in a new tab)",
"prompts": "prompts (combo:<name> / workflow:<name> are the prompts inside that combo or workflow)",
"models": "models (model_id for set_model / add_lora / get_model)",
"ways": "ways of working (for that use add_nodes builds the one picked)"}
for kind in KINDS:
if box.get(kind):
names = [reference_blocks.call(name) or name for name in box[kind]] if kind == "ways" else box[kind]
parts.append(f"{labels[kind]}: {', '.join(names)}")
how = ("Use ONLY these node types (and those of the picked packs) when you add nodes; ComfyUI's own built-in nodes stay allowed. "
if box.get("mode") == "only" else "Use these first. ")
return f"The user picked these for you to work with. {how}{'; '.join(parts)}. get_toolbox gives them in full."
def pick(task_id, picks, mode="prefer", now=None):
"""The user's picks for task `task_id`: kept on the task and given to the AI as a step. Returns the step."""
if mode not in MODES:
raise ToolboxError("mode is prefer or only")
picks = clean(picks)
now = time.time() if now is None else now
box = {"mode": mode, **picks}
with ai_tasks._lock:
task = ai_tasks._load().get(task_id)
if not task:
raise ToolboxError("No such task")
task["toolbox"] = box
step = {"id": uuid.uuid4().hex[:10], "at": now, "ms": 0, "tool": "user_toolbox", "kind": "user", "ok": True,
"state": "done", "summary": "user_toolbox", "toolbox": box,
"text": words(box) if count(box) else "The user took back the things they picked for you; use what fits.",
"delivered": False, "results": []}
task["steps"].append(step)
del task["steps"][:-ai_tasks.MAX_STEPS]
task["updated"] = max(task.get("updated", 0), now)
ai_tasks._save(task)
ai_tasks._notify(task_id)
return step
def pick_way(task_id, name, on):
"""Way `name` onto the picks of task `task_id` (`on`) or off them, the rest as they stand. Returns the step."""
from . import reference_blocks
if name not in reference_blocks.BLOCKS:
raise ToolboxError("No such way")
box = effective(ai_tasks.get_task(task_id)) or {}
ways = [item for item in box.get("ways") or [] if item != name] + ([name] if on else [])
return pick(task_id, {**box, "ways": ways}, box.get("mode", "prefer"))
def builtin(info):
"""Whether a node type (its /object_info entry) is ComfyUI's own and free: not a custom node, not a paid API node."""
module = str((info or {}).get("python_module") or "")
return module == "nodes" or module.startswith("comfy_extras")
def chosen(box, name, info):
"""Whether node type `name` (its /object_info entry) is among picks `box`: itself, or its pack."""
return bool(box) and (name in (box.get("nodes") or []) or source_of(info) in (box.get("packs") or []))
def refused(task_id, types, info):
"""The node types among `types` the task's "only" picks do not allow (page-only helpers, unknown to /object_info, pass;
so do those of a way picked)."""
from . import reference_blocks
box = effective(ai_tasks.get_task(task_id)) if task_id else None
if not box or box.get("mode") != "only":
return []
ways = reference_blocks.node_types(box.get("ways") or [])
return sorted({name for name in types if name in info and name not in ways and not chosen(box, name, info[name]) and not builtin(info[name])})
def source_of(info):
"""Where a node type comes from: its custom node folder, "builtin", or "api" (paid API nodes)."""
module = str((info or {}).get("python_module") or "")
if module.startswith("custom_nodes."):
return module.split(".")[1]
return "api" if module.startswith("comfy_api_nodes") else "builtin"
def catalog(kind):
"""What the picker lists besides node types (ai_toolbox_catalog.py); the page has those itself."""
from .ai_toolbox_catalog import everything
if kind != "all":
raise ToolboxError("kind is all; node types come from the page")
return everything()