- Each run keeps its shape read from the prompt ComfyUI queued (ai_run_map.py): boxes by role with their settings, typed links, plumbing wired through, and what changed since the task's run before. The run step shows a one-line summary, the changes as chips and the boxes (top down, fitted to the column, each locating its node); show_run_map puts it on the stage. - By default the AI works in a tab of the task's own (ai_task_canvas.py, mcp_task_tab.js): the open workflow is copied before its first change, the tab is brought back (a click on ComfyUI's tab) before later changes, reads and runs, and the user's tab stays untouched. Per-task switch in the header, default in the Gates window; the AI is told once. - Output pictures can be handed to the task an AI is on (gallery card menu, the inspector's ✦), with the user's words: the AI gets them attached with what made them (ai_pictures.py). search_images(with_pictures) attaches the first six. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
91 lines
3.8 KiB
Python
91 lines
3.8 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), 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 uuid
|
|
|
|
TIMEOUT_SECONDS = 30
|
|
MAX_WAITING = 20
|
|
|
|
_lock = threading.Lock()
|
|
_waiting = {} # id -> {"event": Event, "claimed": bool, "outcome": dict | None}
|
|
|
|
|
|
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}
|
|
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):
|
|
"""True for the first page that asks to do request `request_id`."""
|
|
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
|
|
entry["claimed"] = True
|
|
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
|