Tell the AI what loads a model, file model patches right, add gallery pictures to messages

- model_kind: Anima LLLite, Qwen-Image block-wise and Z-Image Fun ControlNets (and other
  ModelPatchLoader files) go to model_patches, not controlnet; import and Tidy follow.
- api/mcp_model_use.py: search_models / get_model say which node types load each model, or
  where a misfiled one belongs; move_model moves it there behind a new "files" gate (asks by
  default, never overwrites).
- An empty drop-down in add_node says ComfyUI found no file; the task tab switch is no longer
  logged as the user's canvas change.
- The folded task list stays while the pointer is in the box around its button and the list.
- The message box's picture button picks gallery pictures; "@" offers the gallery's newest and
  searches it as you type.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DemonGatanjieu
2026-10-23 19:50:57 +08:00
co-authored by Claude Opus 5.5
parent 6e376e7caf
commit c41af60cd9
31 changed files with 769 additions and 84 deletions
+3 -2
View File
@@ -4,7 +4,7 @@ Every tool (mcp_tools.py reads, mcp_actions.py acts, mcp_task_tools.py the AI's
entry here: its kind (the AI workbench's colour, words and counts; None = not a step of its
own), the stage of the work it belongs to (the flow over a task: models, combo, prompt,
canvas, run, pictures, voice, library) and its gate (what it touches or costs, for the user's
rules on what needs their OK: canvas, gpu, download, network; None = nothing to guard).
rules on what needs their OK: canvas, gpu, download, network, files; None = nothing to guard).
tests/test_action_catalog.py keeps this catalog and the tools in step.
The user's own changes in Anomalous (activity log actions) get stages too, so the ones made
@@ -16,7 +16,7 @@ from collections import namedtuple
Entry = namedtuple("Entry", "kind stage gate")
STAGES = ("model", "combo", "prompt", "canvas", "run", "picture", "voice", "library")
GATES = ("canvas", "gpu", "download", "network")
GATES = ("canvas", "gpu", "download", "network", "files")
TOOLS = {
# reading
@@ -51,6 +51,7 @@ TOOLS = {
"run_workflow": Entry("run", "run", "gpu"),
"download_missing_models": Entry("download", "model", "download"),
"download_model": Entry("download", "model", "download"),
"move_model": Entry("act", "model", "files"),
"scan_models": Entry("scan", "library", "network"),
"speak": Entry("speech", "voice", "gpu"),
"open_in_anomalous": Entry("show", "model", None),
+4 -4
View File
@@ -1,6 +1,6 @@
"""Gates: what the user lets AI apps do through Anomalous's MCP endpoint without asking first.
Each acting tool has a gate in action_catalog.py (canvas, gpu, download, network) and each gate
Each acting tool has a gate in action_catalog.py (canvas, gpu, download, network, files) and each gate
a policy the user sets on the AI workbench: "auto" (go ahead), "ask" (wait for the user's OK)
or "never". mcp_server.py asks check() before running a tool. With "ask" the tool's step waits
in state "asking", showing what it would do, for up to WAIT_SECONDS: the user's Allow lets the
@@ -20,7 +20,7 @@ from . import action_catalog, ai_tasks
from .path_utils import atomic_write_json
POLICIES = ("auto", "ask", "never")
DEFAULTS = {"canvas": "auto", "gpu": "auto", "download": "ask", "network": "auto"}
DEFAULTS = {"canvas": "auto", "gpu": "auto", "download": "ask", "network": "auto", "files": "ask"}
WAIT_SECONDS = 50 # below the minute many AI apps give a tool call
ALLOWED_SECONDS = 30 * 60 # an Allow the AI did not wait for holds this long for the same call
MAX_ARGS = 8
@@ -58,8 +58,8 @@ def describe(tool, arguments):
for key, value in list((arguments or {}).items())[:MAX_ARGS]:
if key == "task":
continue
if key == "model_id":
value = ai_tasks._model_result(value).get("name") or value
if key == "model_id": # its name, and the folder it is in
value = " · ".join(filter(None, (ai_tasks._model_result(value).get("name") or value, str(value).split(":")[0])))
out.append({"label": key, "value": ai_tasks._short(value, 400)})
return out
+9 -3
View File
@@ -28,7 +28,7 @@ _info = {"at": 0.0, "data": None}
_info_lock = threading.Lock()
def _object_info(ctx):
def object_info(ctx):
"""ComfyUI's node definitions, as its own page reads them (kept a little while)."""
with _info_lock:
if _info["data"] is None or time.monotonic() - _info["at"] > INFO_SECONDS:
@@ -39,6 +39,12 @@ def _object_info(ctx):
return _info["data"]
def forget_object_info():
"""After a model moved: the next look reads ComfyUI's lists anew."""
with _info_lock:
_info["data"] = None
def _input(name, conf, required, choices):
kind, options = (conf + [None, None])[:2] if isinstance(conf, list) else (conf, None)
options = options if isinstance(options, dict) else {}
@@ -68,7 +74,7 @@ def _allowed(ctx, types):
"""Refuses node types the user's "only" picks leave out, before the page is asked."""
if not ctx.get("task"):
return
info = _object_info(ctx)
info = object_info(ctx)
refused = ai_toolbox.refused(ctx["task"], types, info)
if refused:
box = ai_toolbox.effective(ai_tasks.get_task(ctx["task"])) or {}
@@ -93,7 +99,7 @@ def _node(type_name, info, choices):
def find_nodes(ctx, query="", types=None):
info = _object_info(ctx)
info = object_info(ctx)
if types:
missing = [name for name in types if name not in info]
found = [_node(name, info[name], MAX_CHOICES_NAMED) for name in types if name in info][:MAX_FOUND]
+166
View File
@@ -0,0 +1,166 @@
"""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_tools, 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
_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 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)
return found
def get_model(ctx, model_id, include_cover=False):
detail, extra = mcp_tools.get_model(model_id, include_cover)
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",
"Find model files (checkpoints, LoRAs, VAEs…) by name, trigger word or base model. Returns model_id for "
"get_model, and loaded_by: the node types whose drop-down lists the file (use one of them). A file in the "
"wrong folder for what it is comes with belongs_in and placement instead: nothing loads it where it is.",
search_models, {
"query": _QUERY,
"type": {"type": "string", "description": "ComfyUI models folder, e.g. checkpoints, loras, vae, diffusion_models, model_patches."},
"base_model": {"type": "string", "description": "Part of the base model name, e.g. SDXL, Pony, Flux, SD 1.5."},
"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 and link, trigger words, base model, description, "
"the user's own name and notes, 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),
])
+2 -1
View File
@@ -27,10 +27,11 @@ from . import ai_gates, ai_panels, ai_run_tracking, ai_task_canvas, ai_task_runs
from .local_guard import is_local as _is_local, loopback_host as _loopback_host
from .mcp_actions import ACTIONS
from .mcp_canvas_edit import CANVAS_TOOLS
from .mcp_model_use import MODEL_TOOLS
from .mcp_task_tools import TASK_TOOLS
from .mcp_tools import TOOLS as READ_TOOLS, call_tool
TOOLS = {**READ_TOOLS, **ACTIONS, **CANVAS_TOOLS, **TASK_TOOLS, **mcp_apps.APP_TOOLS}
TOOLS = {**READ_TOOLS, **MODEL_TOOLS, **ACTIONS, **CANVAS_TOOLS, **TASK_TOOLS, **mcp_apps.APP_TOOLS}
CAPABILITIES = {
"tools": {"listChanged": False}, "prompts": {"listChanged": False}, "resources": {"listChanged": False},
"extensions": {mcp_apps.EXTENSION: {"mimeTypes": [mcp_apps.MIME]}}, # the card in the conversation (mcp_apps.py)
+2 -2
View File
@@ -177,9 +177,9 @@ def get_toolbox(ctx, task=None):
if found.get("not_found"):
answer["page_only_nodes"] = found["not_found"] # helpers that live only in the page: add_node takes them
if box.get("packs"):
from .mcp_canvas_edit import _object_info
from .mcp_canvas_edit import object_info
info = _object_info(ctx)
info = object_info(ctx)
answer["packs"] = []
for pack in box["packs"]:
types = sorted(name for name, item in info.items() if ai_toolbox.source_of(item) == pack)
+2 -15
View File
@@ -1,6 +1,7 @@
"""The MCP endpoint's (mcp_server.py) reading tools: views of the library (mcp_actions.py acts).
`call_tool` runs any tool of a registry, these and mcp_actions.py's.
`call_tool` runs any tool of a registry, these and mcp_actions.py's. search_models and get_model
are offered through mcp_model_use.py, which adds what loads each model.
Each tool is a plain function over the same stores the browser's pages use (model catalog
and sidecars, output gallery, combos, workflow recipes, saved prompts, generated audio, scan
@@ -444,20 +445,6 @@ TOOLS = dict([
_tool("library_overview", "Library overview",
"How many models of each type and base model the user has, how many are scanned, and how many "
"combos, workflow recipes and saved prompts they kept. A good first call.", library_overview),
_tool("search_models", "Search models",
"Find model files (checkpoints, LoRAs, VAEs…) by name, trigger word or base model. Returns model_id for get_model.",
search_models, {
"query": _QUERY,
"type": {"type": "string", "description": "ComfyUI models folder, e.g. checkpoints, loras, vae, diffusion_models."},
"base_model": {"type": "string", "description": "Part of the base model name, e.g. SDXL, Pony, Flux, SD 1.5."},
"source": {"type": "string", "enum": ["civitai", "inferred_from_file", "not_scanned"],
"description": "Where the model's information came from."},
"limit": _LIMIT}),
_tool("get_model", "Model details",
"Everything known about one model: Civitai name and link, trigger words, base model, description, "
"the user's own name and notes. include_cover attaches its cover picture.",
get_model, {"model_id": {"type": "string", "description": "From search_models."},
"include_cover": {"type": "boolean", "default": False}}, ["model_id"]),
_tool("scan_report", "Scan status",
"Which models are matched on Civitai, which are not and why, which were never scanned, and what the last scan did.",
scan_report),
+1 -1
View File
@@ -44,7 +44,7 @@ from .model_download import (
from .path_utils import resolve_within
KINDS = ("checkpoints", "loras", "vae", "text_encoders", "diffusion_models", "controlnet", "embeddings",
"clip_vision", "upscale_models", "ipadapter")
"clip_vision", "upscale_models", "ipadapter", "model_patches")
SEARCH_DEPTH = 2
SEARCH_LIMIT = 20000
TIME_SLACK_MS = 2000
+16 -3
View File
@@ -12,7 +12,8 @@ POST /anomalous/placement/move {type, root, rel, to_type, to_root, to_rel} -> mo
and its sidecars (covers, info, the user's notes); never over a file (" (2)"). Only for
the computer ComfyUI runs on, unless remote changes are allowed (local_guard.may_change).
Removing a copy goes through /anomalous/delete_model (Recycle Bin). Headers and computed
belongs() is the same check for one file (the AI's search_models and move_model,
api/mcp_model_use.py). Removing a copy goes through /anomalous/delete_model (Recycle Bin). Headers and computed
SHA-256s are kept in memory per file (path, size, time).
"""
@@ -116,7 +117,7 @@ def _card(item):
return {"name": name or stem, "version": version, "preview_url": url}
def _home_for(item, kind):
def home_for(item, kind):
"""Where a file of `kind` goes: its own subfolder and name, in a folder of that type,
preferably on its drive (a move is then a rename)."""
roots = []
@@ -136,7 +137,7 @@ def misplaced(files):
kind = result.get("kind")
if not kind or not result.get("sure") or kind == item["type"] or kind not in import_types():
continue
home = _home_for(item, kind)
home = home_for(item, kind)
if home:
found.append({"type": item["type"], "root": item["root"], "rel": item["rel"], "size": item["size"],
"kind": kind, "base": result.get("base", ""), "works": (item["type"], kind) in WORKS_ANYWAY,
@@ -144,6 +145,18 @@ def misplaced(files):
return found
def one(folder_type, root, rel, path):
"""One model file as library() lists it."""
stat = os.stat(path)
return {"type": folder_type, "root": root, "rel": rel, "path": path, "size": stat.st_size, "dev": stat.st_dev, "stat": stat}
def belongs(item):
"""misplaced() of one file (from one()): where it belongs, or None when it is where it goes or its kind is unsure."""
found = misplaced([item])
return found[0] if found else None
def _known_hash(item):
try:
value = str(get_metadata(item["path"]).get("hash") or "").lower()