Files
DemonGatanjieu-Anomalous_Mo…/api/ai_toolbox.py
T
DemonGatanjieuandClaude Opus 5.5 622ae8be9a Materials for the AI: pick nodes, combos, workflows, prompts and models
A Materials button on each AI task opens a picker: every node type of this
ComfyUI grouped by source (built-in, each custom node pack from
/object_info's python_module, page-only helpers, paid API nodes last), plus
combos, saved workflows, prompts and models by the names the AI's tools
use. Picks go to the AI as a user_toolbox step (message kind toolbox), in
begin_task and in full from get_toolbox; "only" makes add_node and
new_workflow refuse other custom or paid node types (built-in stay
allowed). Favourites stand in for tasks without picks. find_nodes lists
picks first and marks paid API nodes. open_workflow opens a saved
workflow in a new tab; a workflow's page can hand it to the AI.

Checked on the isolated ComfyUI with copies of the user's 10 node packs:
all 1642 node types can be placed, and a Comfyroll workflow ran.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 19:19:58 +08:00

169 lines
7.3 KiB
Python

"""What the user hands the AI to work with ("给 AI 的素材", web/modules/ui_ai_toolbox.js): node
types, 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). 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 mcp_tools.py's lists."""
import json
import os
import threading
import time
import uuid
from . import ai_tasks
from .path_utils import atomic_write_json
KINDS = ("nodes", "combos", "workflows", "prompts", "models")
MODES = ("prefer", "only")
MAX_ITEMS = 60
MAX_NAME = 300
CATALOG_LIMIT = 50 # the lists' own most; a search finds the rest
_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, combos, workflows, prompts, models}")
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."""
parts = []
labels = {"nodes": "node types", "combos": "combos (place_combo)", "workflows": "workflows (open_workflow opens one in a new tab)",
"prompts": "saved prompts", "models": "models (model_id for set_model / add_lora / get_model)"}
for kind in KINDS:
if box.get(kind):
parts.append(f"{labels[kind]}: {', '.join(box[kind])}")
how = ("Use ONLY these node types 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 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 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)."""
box = effective(ai_tasks.get_task(task_id)) if task_id else None
if not box or box.get("mode") != "only":
return []
allowed = set(box.get("nodes") or [])
return sorted({name for name in types if name in info and name not in allowed 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(ctx, kind, query=""):
"""What the picker lists for `kind`, by the names the AI's tools use."""
from . import mcp_tools
from .mcp_canvas_edit import _object_info
if kind == "nodes":
groups = {}
for name, info in _object_info(ctx).items():
groups.setdefault(source_of(info), []).append({
"type": name, "label": info.get("display_name") or name, "category": info.get("category") or "",
**({"deprecated": True} if info.get("deprecated") else {})})
return {"groups": [{"source": source, "nodes": sorted(items, key=lambda item: item["label"].lower())}
for source, items in sorted(groups.items(), key=lambda pair: (pair[0] not in ("builtin",), pair[0] == "api", pair[0].lower()))]}
if kind == "combos":
return {"items": [{"name": item["name"], "detail": item.get("main_model") or ""}
for item in mcp_tools.list_combos(query, CATALOG_LIMIT)["combos"]]}
if kind == "workflows":
return {"items": [{"name": item["name"], "detail": " ".join(item.get("tags") or [])}
for item in mcp_tools.list_workflows(query, CATALOG_LIMIT)["workflows"]]}
if kind == "prompts":
return {"items": [{"name": item["name"], "detail": item.get("positive", "")[:120]}
for item in mcp_tools.list_saved_prompts(query, CATALOG_LIMIT)["prompts"]]}
if kind == "models":
return {"items": [{"name": item["model_id"], "label": item["name"], "detail": f"{item['type']} · {item.get('base_model') or ''}".strip(" ·")}
for item in mcp_tools.search_models(query, limit=CATALOG_LIMIT)["models"]]}
raise ToolboxError(f"kind is one of {', '.join(KINDS)}")