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:
co-authored by
Claude Opus 5.5
parent
6e376e7caf
commit
c41af60cd9
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user