- 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>
205 lines
11 KiB
Python
205 lines
11 KiB
Python
"""Where a model loads, for the AI: search_models and get_model (mcp_tools.py finds the files)
|
|
say which node types list each file among their choices (ComfyUI's /object_info, kept a little
|
|
while by mcp_canvas_edit.py), and, when a file's header says it is another kind than the folder
|
|
it is in (model_placement.py, the models page's Tidy view), where it belongs and that nothing
|
|
loads it where it is. So the AI picks the right node at once, or has the model moved, instead of
|
|
searching ComfyUI itself. move_model moves one model with its covers and notes as Tidy does,
|
|
behind the "files" gate (the user says yes first unless they let it go ahead)."""
|
|
|
|
import os
|
|
import threading
|
|
|
|
import folder_paths
|
|
|
|
from . import mcp_canvas_edit, mcp_fit, mcp_tools, model_advice, model_placement
|
|
from .mcp_actions import _action
|
|
from .mcp_tools import _LIMIT, _QUERY, ToolError
|
|
from .model_constants import MODEL_EXTENSIONS
|
|
from .model_import import Refused, import_types
|
|
|
|
MAX_LOADERS = 6
|
|
SEARCH_WORDS = 4 # trigger words a found model shows; get_model has them all
|
|
|
|
_lock = threading.Lock()
|
|
_index = {"of": None, "inputs": [], "by_choice": {}}
|
|
|
|
|
|
def _plain(name):
|
|
return str(name).replace("\\", "/")
|
|
|
|
|
|
def _choices(conf):
|
|
if not isinstance(conf, list) or not conf:
|
|
return None
|
|
if isinstance(conf[0], list):
|
|
return conf[0]
|
|
options = conf[1] if len(conf) > 1 and isinstance(conf[1], dict) else {}
|
|
return options.get("options") if conf[0] == "COMBO" and isinstance(options.get("options"), list) else None
|
|
|
|
|
|
def _built(info):
|
|
"""The drop-downs of model files in node definitions `info`: [(node type, choices)] and choice -> their places."""
|
|
with _lock:
|
|
if _index["of"] is info:
|
|
return _index
|
|
inputs, by_choice = [], {}
|
|
for node_type, item in info.items():
|
|
for section in ("required", "optional"):
|
|
for conf in ((item.get("input") or {}).get(section) or {}).values():
|
|
listed = [_plain(choice) for choice in _choices(conf) or [] if isinstance(choice, str)]
|
|
if not any(choice.lower().endswith(MODEL_EXTENSIONS) for choice in listed):
|
|
continue
|
|
for choice in listed:
|
|
by_choice.setdefault(choice, []).append(len(inputs))
|
|
inputs.append((node_type, frozenset(listed)))
|
|
_index.update(of=info, inputs=inputs, by_choice=by_choice)
|
|
return _index
|
|
|
|
|
|
def _folder(folder_type):
|
|
return frozenset(_plain(name) for name in folder_paths.get_filename_list(folder_type))
|
|
|
|
|
|
def _readers(ctx, folder_type, rel=None):
|
|
"""Node types with a drop-down of the whole folder `folder_type` (one that lists `rel`, when given)."""
|
|
index = _built(mcp_canvas_edit.object_info(ctx))
|
|
folder = _folder(folder_type)
|
|
if not folder:
|
|
return []
|
|
places = index["by_choice"].get(_plain(rel), []) if rel else range(len(index["inputs"]))
|
|
found = []
|
|
for at in places:
|
|
node_type, choices = index["inputs"][at]
|
|
if folder <= choices and node_type not in found:
|
|
found.append(node_type)
|
|
return found[:MAX_LOADERS]
|
|
|
|
|
|
def _about(ctx, model):
|
|
"""`model` (from mcp_tools) with what loads it: loaded_by, or belongs_in and placement when it is misfiled."""
|
|
folder_type, root, base_dir, path = mcp_tools._resolve_model(model["model_id"])
|
|
rel = os.path.relpath(path, base_dir).replace(os.sep, "/")
|
|
home = model_placement.belongs(model_placement.one(folder_type, root, rel, path))
|
|
if not home or home["works"]:
|
|
model["loaded_by"] = _readers(ctx, folder_type, rel)
|
|
return model
|
|
kind, to = home["kind"], home["to"]
|
|
readers = _readers(ctx, kind)
|
|
words = (f"Its header says it is a {kind} file, but it is in {folder_type}: the nodes listing it there cannot load it"
|
|
+ (f"; {', '.join(readers)} load {kind} files." if readers else "."))
|
|
there = os.path.join(folder_paths.get_folder_paths(kind)[to["root"]], *to["rel"].split("/"))
|
|
if os.path.isfile(there):
|
|
words += (f" A file of the same name is already in {kind} (model_id {kind}:{to['root']}:{to['rel']}): use that one. "
|
|
"If it is the same file, this copy is extra; the user can recycle it in Anomalous's models page (Tidy).")
|
|
else:
|
|
words += f" move_model with to_type {kind} moves it there (the user confirms first)."
|
|
model.update(belongs_in=kind, placement=words)
|
|
return model
|
|
|
|
|
|
def _compact(found):
|
|
"""search_models' answer small enough to be read (mcp_fit.BUDGET): what a model_id says already
|
|
(file, folder, type) left out, loaded_by once for a type when its models share it, a few trigger
|
|
words, and as many models as fit, the rest by name (more_names), so one answer shows all there is
|
|
and the AI need not search again to see the rest."""
|
|
models, kinds = found["models"], {}
|
|
for model in models:
|
|
kinds.setdefault(model["type"], []).append(model)
|
|
shared = {kind: group[0]["loaded_by"] for kind, group in kinds.items()
|
|
if all("loaded_by" in model and model["loaded_by"] == group[0]["loaded_by"] for model in group)}
|
|
for model in models:
|
|
for key in ("file", "folder"):
|
|
model.pop(key, None)
|
|
if model.pop("type") in shared:
|
|
model.pop("loaded_by")
|
|
words = model.get("trigger_words") or []
|
|
if len(words) > SEARCH_WORDS:
|
|
model.update(trigger_words=words[:SEARCH_WORDS], more_trigger_words=len(words) - SEARCH_WORDS)
|
|
answer = {"total_matches": found["total_matches"], **({"loaded_by": shared} if shared else {}), "models": models}
|
|
everything = list(models)
|
|
while mcp_fit.size(answer) > mcp_fit.BUDGET and len(models) > 1:
|
|
models.pop()
|
|
answer["more_names"] = [str(model.get("name") or model["model_id"])[:mcp_fit.NAME] for model in everything[len(models):]]
|
|
answer["left_out"] = (f"{len(answer['more_names'])} more, named in more_names to keep this answer readable: "
|
|
"get_model or a query with the name gives one in full")
|
|
return answer
|
|
|
|
|
|
def search_models(ctx, query="", type="", base_model="", source="", limit=20):
|
|
found = mcp_tools.search_models(query, type, base_model, source, limit)
|
|
for model in found["models"]:
|
|
_about(ctx, model)
|
|
if model["type"] == "loras": # the weight its author's pictures use: picking one needs no get_model each
|
|
path = mcp_tools._resolve_model(model["model_id"])[3]
|
|
weight = model_advice.author_examples(path, "loras", (mcp_tools.get_metadata(path) or {}).get("hash", "")).get("lora_weight")
|
|
if weight:
|
|
model["author_weight"] = weight
|
|
return _compact(found)
|
|
|
|
|
|
def get_model(ctx, model_id, include_cover=False):
|
|
detail, extra = mcp_tools.get_model(model_id, include_cover)
|
|
folder_type, _idx, _base, path = mcp_tools._resolve_model(model_id)
|
|
examples = model_advice.author_examples(path, folder_type, detail.get("sha256", ""))
|
|
if examples:
|
|
detail["author_examples"] = examples
|
|
return _about(ctx, detail), extra
|
|
|
|
|
|
def move_model(ctx, model_id, to_type):
|
|
folder_type, root, base_dir, path = mcp_tools._resolve_model(model_id)
|
|
rel = os.path.relpath(path, base_dir).replace(os.sep, "/")
|
|
item = model_placement.one(folder_type, root, rel, path)
|
|
home = model_placement.belongs(item)
|
|
to_type = str(to_type or "")
|
|
if to_type == folder_type:
|
|
raise ToolError(f"It is in {to_type} already.")
|
|
if to_type not in import_types():
|
|
raise ToolError(f"to_type is one of {', '.join(import_types())}.")
|
|
to = home["to"] if home and home["kind"] == to_type else model_placement.home_for(item, to_type)
|
|
if not to:
|
|
raise ToolError(f"This ComfyUI has no {to_type} folder.")
|
|
if os.path.exists(os.path.join(folder_paths.get_folder_paths(to_type)[to["root"]], *to["rel"].split("/"))):
|
|
raise ToolError(f"A file of the same name is already in {to_type} (model_id {to_type}:{to['root']}:{to['rel']}); "
|
|
"nothing was moved. Use that one; the user can recycle an extra copy in Anomalous's models page (Tidy).")
|
|
try:
|
|
moved = model_placement.move({"type": folder_type, "root": root, "rel": rel,
|
|
"to_type": to_type, "to_root": to["root"], "to_rel": to["rel"]})
|
|
except Refused as error:
|
|
raise ToolError(f"Not moved ({error.code}).")
|
|
mcp_canvas_edit.forget_object_info()
|
|
answer = {"moved": {"from": f"{folder_type}/{rel}", "to": f"{moved['type']}/{moved['rel']}"},
|
|
"model_id": f"{moved['type']}:{moved['root']}:{moved['rel']}",
|
|
"loaded_by": _readers(ctx, moved["type"], moved["rel"])}
|
|
if moved["left"]:
|
|
answer["left_behind"] = moved["left"] # covers or notes that could not go along
|
|
return answer
|
|
|
|
|
|
MODEL_TOOLS = dict([
|
|
_action("search_models", "Search models",
|
|
"Model files by name, trigger word or base model: model_id, and loaded_by (the node types that list "
|
|
"the file). A misfiled one comes with belongs_in: nothing loads it where it is.",
|
|
search_models, {
|
|
"query": _QUERY,
|
|
"type": {"type": "string", "description": "A models folder: checkpoints, loras, vae…"},
|
|
"base_model": {"type": "string", "description": "e.g. SDXL, Pony, Flux."},
|
|
"source": {"type": "string", "enum": ["civitai", "inferred_from_file", "not_scanned"],
|
|
"description": "Where the model's information came from."},
|
|
"limit": _LIMIT}, read_only=True),
|
|
_action("get_model", "Model details",
|
|
"Everything known about one model: Civitai name, version and link, trigger words, base model, description, "
|
|
"the user's own name and notes, author_examples (the sampler, steps, CFG, size and, for a LoRA, the weight "
|
|
"its author's example pictures used: a good start), and loaded_by (or belongs_in and placement) as in "
|
|
"search_models. include_cover attaches its cover picture.",
|
|
get_model, {"model_id": {"type": "string", "description": "From search_models."},
|
|
"include_cover": {"type": "boolean", "default": False}}, ["model_id"], read_only=True),
|
|
_action("move_model", "Move a model to its folder",
|
|
"Moves a model the user has in the wrong models folder (search_models says belongs_in) to where it loads, "
|
|
"with its covers and notes, as Anomalous's Tidy view does; never over a file. The answer gives its new "
|
|
"model_id and loaded_by; the canvas lists it from then on.",
|
|
move_model, {"model_id": {"type": "string", "description": "From search_models."},
|
|
"to_type": {"type": "string", "description": "The models folder to move it to: belongs_in from search_models."}},
|
|
["model_id", "to_type"], destructive=True),
|
|
])
|