- 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>
102 lines
4.7 KiB
Python
102 lines
4.7 KiB
Python
"""The way from an MCP tool to the open ComfyUI page, for what only the page can do (the
|
|
canvas lives in the browser).
|
|
|
|
`ask_page` sends {id, action, args} as the "anomalous.mcp" event over ComfyUI's websocket
|
|
to every open ComfyUI page and waits. One page claims it (the first claim wins, visible
|
|
pages claim first: web/modules/mcp_bridge.js; while the page that did the last action is open,
|
|
it gets the next too: it holds the task's tabs, and with ComfyUI open in two browser tabs one
|
|
must not open a task's tab and the other then not find it), does the action with Anomalous's own canvas
|
|
code and posts the outcome back; `claim` and `resolve` take those posts
|
|
(routes in mcp_server.py). With no page open, or none answering in time, the tool fails
|
|
with words the AI can pass on. Actions on the canvas carry the task's own tab
|
|
(ai_task_canvas.py), and the tab the page made or opened for it is kept.
|
|
"""
|
|
|
|
import threading
|
|
import time
|
|
import uuid
|
|
|
|
TIMEOUT_SECONDS = 30
|
|
MAX_WAITING = 20
|
|
HOLD_SECONDS = 3 # how long the page that did the last action has to claim the next before another page may
|
|
|
|
_lock = threading.Lock()
|
|
_waiting = {} # id -> {"event": Event, "claimed": bool, "outcome": dict | None}
|
|
_last = {"page": None} # the page that did the last action (its own id, web/modules/mcp_bridge.js pageId)
|
|
|
|
|
|
class PageUnavailable(Exception):
|
|
"""No ComfyUI page took or finished the action."""
|
|
|
|
|
|
class PageRefused(Exception):
|
|
"""The page tried and said why it could not (missing node, wrong model type…)."""
|
|
|
|
|
|
def _server():
|
|
from server import PromptServer # ComfyUI's; imported late so tests can run without it
|
|
return PromptServer.instance
|
|
|
|
|
|
def ask_page(action, args=None, timeout=TIMEOUT_SECONDS, required_capability=None):
|
|
"""Runs `action` in the open ComfyUI page; its result value. Call from a worker thread."""
|
|
server = _server()
|
|
if not getattr(server, "sockets", None):
|
|
raise PageUnavailable("ComfyUI is not open in a browser on this computer. Open it, then try again.")
|
|
from . import ai_task_canvas
|
|
|
|
task_canvas = ai_task_canvas.spec(action)
|
|
if task_canvas:
|
|
args = {**(args or {}), "_task_canvas": task_canvas}
|
|
request_id = uuid.uuid4().hex
|
|
entry = {"event": threading.Event(), "claimed": False, "outcome": None, "required": required_capability, "sent": time.monotonic()}
|
|
with _lock:
|
|
if len(_waiting) >= MAX_WAITING:
|
|
raise PageUnavailable("Too many canvas actions are waiting; try again in a moment.")
|
|
_waiting[request_id] = entry
|
|
try:
|
|
server.send_sync("anomalous.mcp", {"id": request_id, "action": action, "args": args or {}})
|
|
if not entry["event"].wait(timeout):
|
|
raise PageUnavailable("The ComfyUI page did not answer in time. Is it open and not frozen? "
|
|
"If the action was taken late, check the canvas before repeating it.")
|
|
finally:
|
|
with _lock:
|
|
_waiting.pop(request_id, None)
|
|
outcome = entry["outcome"] or {}
|
|
if task_canvas and outcome.get("tab"):
|
|
ai_task_canvas.remember(outcome["tab"])
|
|
if not outcome.get("ok"):
|
|
raise PageRefused(str(outcome.get("error") or "The page could not do that."))
|
|
return outcome.get("value")
|
|
|
|
|
|
def claim(request_id, capabilities=None, page=None):
|
|
"""True for the page that may do request `request_id`: the first that asks, but for HOLD_SECONDS
|
|
only the page that did the last action (`page`, its id), when it is another: "later" tells
|
|
another page to ask again then (it is the one left when that page was closed)."""
|
|
with _lock:
|
|
entry = _waiting.get(request_id)
|
|
if entry is None or entry["claimed"] or entry["outcome"] is not None:
|
|
return False
|
|
if entry.get("required") and (not isinstance(capabilities, list) or entry["required"] not in capabilities):
|
|
entry["outcome"] = {"ok": False, "error": "Refresh the ComfyUI browser page before running from AI. Nothing was queued."}
|
|
entry["event"].set()
|
|
return False
|
|
if page and _last["page"] and page != _last["page"] and time.monotonic() - entry.get("sent", 0) < HOLD_SECONDS:
|
|
return "later"
|
|
entry["claimed"] = True
|
|
if page:
|
|
_last["page"] = page
|
|
return True
|
|
|
|
|
|
def resolve(request_id, outcome):
|
|
"""The claiming page's outcome: {ok, value} or {ok: false, error}. False when nobody waits."""
|
|
with _lock:
|
|
entry = _waiting.get(request_id)
|
|
if entry is None or not entry["claimed"] or entry["outcome"] is not None:
|
|
return False
|
|
entry["outcome"] = outcome if isinstance(outcome, dict) else {"ok": False, "error": "Malformed answer"}
|
|
entry["event"].set()
|
|
return True
|