In one real task the AI built an inpaint branch with 9 add_node and 16 connect_nodes calls. add_nodes takes the whole group in ComfyUI's API form, the format models know best: - links inside the group, and to canvas nodes as "#12", with outputs by slot or name; - a node's wire_to feeds canvas nodes the way add_node does. All of it is checked before anything changes. The page lays the group out as one block in a free spot (canvas_build.placedBlock) and applies it in one transaction: one Ctrl+Z step, and a failure takes every new node out again (applyWires now takes several nodes). The answer maps the AI's keys to node ids and lists what is spliced and what is still unwired. A splice now uses only an input that nothing else in the plan feeds. A node whose latent comes from the group, or from wire_from, replaces the sampler's empty latent instead (replace:true) rather than being fed twice. It is in the daily tool set. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
130 lines
5.5 KiB
Python
130 lines
5.5 KiB
Python
"""Each task's own canvas tab, kept by Anomalous rather than left to the AI: with "new" (the
|
|
default, set in the Gates window; each task can change it) the page copies the workflow open
|
|
now into a new tab named after the task before the AI's first change, and brings that tab back
|
|
before each later change, read or run (web/modules/mcp_task_tab.js); new_workflow and
|
|
open_workflow make the tab they open the task's. The user's own tab is never changed. With
|
|
"current" the AI works on whatever tab is open, as before.
|
|
|
|
mcp_server.py marks each tool call with its task (begin_call); mcp_bridge.ask_page adds the
|
|
task's canvas to the page actions that touch the canvas (spec) and keeps the tab the page
|
|
reports (remember); tell() tells the AI once when a tab was made for it.
|
|
"""
|
|
|
|
import contextvars
|
|
import json
|
|
import os
|
|
import time
|
|
import uuid
|
|
|
|
from . import ai_tasks
|
|
from .path_utils import atomic_write_json
|
|
|
|
MODES = ("new", "current")
|
|
DEFAULT = "new"
|
|
# Page actions (web/modules/mcp_bridge.js) that work on the canvas, so on the task's tab.
|
|
TAB_ACTIONS = {"describe_canvas", "set_prompt", "set_model", "add_lora", "place_combo", "check_models", "fix_models",
|
|
"run_workflow", "download_missing", "add_node", "add_nodes", "connect_nodes", "set_values", "remove_nodes", "new_workflow", "open_workflow",
|
|
"fill_slots"}
|
|
_call = contextvars.ContextVar("anomalous_task_canvas", default=None)
|
|
|
|
|
|
def _path():
|
|
return os.path.join(os.path.dirname(ai_tasks.tasks_dir()), "ai_canvas.json")
|
|
|
|
|
|
def default_mode():
|
|
try:
|
|
with open(_path(), encoding="utf-8") as source:
|
|
mode = json.load(source).get("mode")
|
|
except (OSError, ValueError, AttributeError):
|
|
mode = None
|
|
return mode if mode in MODES else DEFAULT
|
|
|
|
|
|
def set_default(mode):
|
|
if mode not in MODES:
|
|
raise ValueError("canvas_tab is new or current")
|
|
atomic_write_json(_path(), {"mode": mode})
|
|
return mode
|
|
|
|
|
|
def mode_of(task):
|
|
mode = ((task or {}).get("canvas") or {}).get("mode")
|
|
return mode if mode in MODES else default_mode()
|
|
|
|
|
|
def set_mode(task_id, mode):
|
|
"""The user's choice for one task. Returns the task's canvas record."""
|
|
if mode not in MODES:
|
|
raise ValueError("canvas is new or current")
|
|
with ai_tasks._lock:
|
|
task = ai_tasks._load().get(task_id)
|
|
if not task:
|
|
raise ValueError("No such task")
|
|
if (task.get("canvas") or {}).get("mode", default_mode()) != mode:
|
|
# The user's choice: the AI hears of it, and it is one of the task's decisions (ai_decisions.py).
|
|
now = time.time()
|
|
ai_tasks._add_step(task, {"id": uuid.uuid4().hex[:10], "at": now, "ms": 0, "tool": "user_canvas", "kind": "user", "ok": True,
|
|
"state": "done", "summary": "user_canvas", "canvas": mode, "delivered": False, "results": [],
|
|
"text": "The user chose that you work " + ("in a tab of the task's own." if mode == "new" else "on the canvas they have open.")})
|
|
task["canvas"] = {**(task.get("canvas") or {}), "mode": mode}
|
|
ai_tasks._save(task)
|
|
canvas = dict(task["canvas"])
|
|
ai_tasks._notify(task_id)
|
|
return canvas
|
|
|
|
|
|
def begin_call(task_id):
|
|
"""Marks the tool call running now as task `task_id`'s; the holder says later what the page opened."""
|
|
holder = {"task": task_id, "opened": None}
|
|
_call.set(holder)
|
|
return holder
|
|
|
|
|
|
def spec(action):
|
|
"""What the page needs for page action `action` of the call running now: {mode, tab, title}, or None."""
|
|
holder = _call.get()
|
|
if not holder or not holder.get("task") or action not in TAB_ACTIONS:
|
|
return None
|
|
task = ai_tasks.get_task(holder["task"])
|
|
if not task:
|
|
return None
|
|
return {"mode": mode_of(task), "tab": (task.get("canvas") or {}).get("tab"), "title": (task.get("title") or "")[:60]}
|
|
|
|
|
|
def remember(tab):
|
|
"""Keeps the tab the page made or opened for the call's task ({path, name, made?})."""
|
|
holder = _call.get()
|
|
if not holder or not holder.get("task") or not isinstance(tab, dict) or not isinstance(tab.get("path"), str):
|
|
return
|
|
kept = {"path": tab["path"][:300], "name": str(tab.get("name") or "")[:120], "at": time.time()}
|
|
with ai_tasks._lock:
|
|
task = ai_tasks._load().get(holder["task"])
|
|
if not task:
|
|
return
|
|
task["canvas"] = {**(task.get("canvas") or {}), "tab": kept}
|
|
ai_tasks._save(task)
|
|
ai_tasks._notify(holder["task"])
|
|
holder["opened"] = {**kept, "made": bool(tab.get("made"))}
|
|
|
|
|
|
def tell(result, holder):
|
|
"""Says in the tool's answer that Anomalous made the task a tab of its own (once, when it did)."""
|
|
opened = (holder or {}).get("opened")
|
|
if not opened or not opened["made"] or not isinstance(result, dict) or result.get("isError"):
|
|
return result
|
|
data = dict(result.get("structuredContent") or {})
|
|
data["canvas_tab"] = {"name": opened["name"], "note": (
|
|
f"Anomalous copied the user's open workflow to a new tab \"{opened['name']}\" for this task (the user's "
|
|
"setting): your canvas changes, reads and runs go there; the user's own tab stays as it was. Do not switch "
|
|
"tabs yourself.")}
|
|
result["structuredContent"] = data
|
|
content = list(result.get("content") or [])
|
|
text = {"type": "text", "text": json.dumps(data, ensure_ascii=False)}
|
|
if content and isinstance(content[0], dict) and content[0].get("type") == "text":
|
|
content[0] = text
|
|
else:
|
|
content.insert(0, text)
|
|
result["content"] = content
|
|
return result
|