Ways: "Use it this time" in the Ways view, for one task. It joins the task's picks as a new kind, the AI is told, and add_nodes builds that way for its use whichever one the AI asks for. "Only" lets in the nodes a picked way builds. Examples: text to picture, two passes, picture to picture and repainting an area, on ComfyUI's own nodes. One card, first on the Workflows page, opens them in a tab of their own. The files name no model or picture; a checkpoint and a picture that are here are filled in when one is read. list_workflows gives them to an AI when no saved workflow fits, and they open and run by name like saved ones. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
370 lines
18 KiB
Python
370 lines
18 KiB
Python
"""The AI workbench's routes over ai_tasks.py (the record) and ai_panels.py (panels, answers,
|
|
messages): the list and one task (reads, open like the other pages), and the writes the page
|
|
makes - start a task, rename or remove one, answer a panel, write to the AI, answer a gate and
|
|
set the gates' policies (ai_gates.py), stop a run or say how it turned out (ai_run_control.py),
|
|
hand the AI pictures (ai_pictures.py) - (local_guard.py keeps the writes to this computer)."""
|
|
|
|
import asyncio
|
|
|
|
from aiohttp import web
|
|
|
|
from . import activity_log, ai_decisions, ai_gates, ai_marks, ai_pictures, ai_privacy, ai_run_control, ai_task_canvas, ai_task_changes, ai_task_links, ai_tasks, ai_toolbox
|
|
|
|
|
|
async def api_list(request):
|
|
try:
|
|
limit = max(1, min(300, int(request.query.get("limit", 100))))
|
|
except ValueError:
|
|
limit = 100
|
|
return web.json_response({"tasks": ai_tasks.list_tasks(limit)})
|
|
|
|
|
|
async def api_start(request):
|
|
"""POST /anomalous/ai_tasks: {title, about?, from?} - the user starts a task for the next AI app that
|
|
calls; `about` names what it is about ([{type: model | image | combo, model_id | image | name}]),
|
|
`from` the task it goes on from."""
|
|
try:
|
|
body = await request.json()
|
|
except (ValueError, UnicodeDecodeError):
|
|
body = {}
|
|
body = body if isinstance(body, dict) else {}
|
|
title = body.get("title") if isinstance(body.get("title"), str) else ""
|
|
parent = body.get("from") if isinstance(body.get("from"), str) else None
|
|
return web.json_response({"task": ai_tasks.task_summary(ai_tasks.start_task(title, about=body.get("about"), from_task=parent))})
|
|
|
|
|
|
async def api_get(request):
|
|
task = ai_tasks.get_task(request.match_info["task_id"])
|
|
if task:
|
|
task["canvas_mode"] = ai_task_canvas.mode_of(task) # the task's own choice, else the user's default
|
|
task["private"] = ai_privacy.is_private(task) # the same for privacy mode
|
|
task["decisions"], task["decisions_gap"] = ai_decisions.decisions(task), ai_decisions.gap(task)
|
|
task["links"] = ai_task_links.links(task["id"]) # the task it goes on from, those going on from it
|
|
ai_pictures.mark_gone(task) # pictures deleted since: the page says so
|
|
return web.json_response({"task": task}) if task else web.json_response({"error": "not_found"}, status=404)
|
|
|
|
|
|
async def api_change(request):
|
|
"""POST /anomalous/ai_tasks/{id}: {"title": "…"} renames it, {"remove": true} takes it off the list,
|
|
{"canvas": "new" | "current"} sets whether the AI works in a tab of the task's own, {"privacy": bool}
|
|
whether its pictures are kept from the AI (ai_privacy.py), {"from": id | null}
|
|
links it to the task it goes on from (or takes the link off)."""
|
|
task_id = request.match_info["task_id"]
|
|
try:
|
|
body = await request.json()
|
|
except (ValueError, UnicodeDecodeError):
|
|
body = None
|
|
if not isinstance(body, dict):
|
|
return web.json_response({"error": "Expected {title} or {remove: true}"}, status=400)
|
|
if body.get("remove") is True:
|
|
return web.json_response({"removed": ai_task_links.remove(task_id)})
|
|
if "canvas" in body:
|
|
try:
|
|
return web.json_response({"canvas": ai_task_canvas.set_mode(task_id, body["canvas"])})
|
|
except ValueError as error:
|
|
return web.json_response({"error": str(error)}, status=400)
|
|
if "privacy" in body:
|
|
try:
|
|
return web.json_response({"privacy": ai_privacy.set_private(task_id, body["privacy"])})
|
|
except ValueError as error:
|
|
return web.json_response({"error": str(error)}, status=400)
|
|
if "from" in body:
|
|
try:
|
|
return web.json_response({"from": ai_task_links.set_from(task_id, body["from"] if isinstance(body["from"], str) else None)})
|
|
except ai_task_links.LinkError as error:
|
|
return web.json_response({"error": str(error)}, status=400)
|
|
if "title" in body:
|
|
task = ai_tasks.rename_task(task_id, body["title"])
|
|
return web.json_response({"task": ai_tasks.task_summary(task)}) if task else web.json_response({"error": "not_found"}, status=404)
|
|
return web.json_response({"error": "Expected {title} or {remove: true}"}, status=400)
|
|
|
|
|
|
async def api_answer(request):
|
|
"""POST /anomalous/ai_tasks/{id}/answer: {step, block, choice: [ids] | text | prompts, negative, pictures | skip: true} -
|
|
the user's answer on a panel; a prompt box's texts are sealed and the AI gets tokens (ai_sealed.py)."""
|
|
from . import ai_bases, ai_panels, ai_repaint, ai_sealed
|
|
|
|
try:
|
|
body = await request.json()
|
|
except (ValueError, UnicodeDecodeError):
|
|
body = None
|
|
if not isinstance(body, dict):
|
|
return web.json_response({"error": "Expected {step, block, choice | text}"}, status=400)
|
|
task_id, step_id, skip = request.match_info["task_id"], str(body.get("step")), body.get("skip") is True
|
|
try:
|
|
if "prompts" in body and not skip:
|
|
extra = await asyncio.to_thread(ai_sealed.seal_answer, task_id, body["prompts"], body.get("negative"), body.get("pictures"))
|
|
else:
|
|
# A repaint area picked: its picture is made first (outside the lock), so the AI gets it with the answer;
|
|
# a base picked: it is kept in the output folder first (ai_bases.py).
|
|
extra = (await asyncio.to_thread(ai_repaint.prepare, task_id, step_id, body.get("choice"), body.get("text"), skip)
|
|
or await asyncio.to_thread(ai_bases.prepare, task_id, step_id, body.get("choice"), skip))
|
|
except (ai_repaint.RepaintError, ValueError, OSError) as error:
|
|
return web.json_response({"error": str(error)}, status=400)
|
|
try:
|
|
answers = ai_panels.answer(task_id, step_id, str(body.get("block")), body.get("choice"), body.get("text"), skip=skip, extra=extra)
|
|
except ai_panels.PanelError as error:
|
|
ai_repaint.discard(extra)
|
|
ai_bases.discard(extra)
|
|
return web.json_response({"error": str(error)}, status=400)
|
|
await asyncio.to_thread(ai_bases.hold, task_id, extra)
|
|
return web.json_response({"answers": answers})
|
|
|
|
|
|
async def api_typing(request):
|
|
"""POST /anomalous/ai_tasks/{id}/typing: the user is typing an answer or a message (ai_answer_wait.py)."""
|
|
from . import ai_answer_wait
|
|
|
|
if not ai_answer_wait.typing(request.match_info["task_id"]):
|
|
return web.json_response({"error": "not_found"}, status=404)
|
|
return web.json_response({"ok": True})
|
|
|
|
|
|
async def api_message(request):
|
|
"""POST /anomalous/ai_tasks/{id}/message: {text, refs?} - the user writes to the AI on that task (refs: its "@")."""
|
|
from . import ai_panels
|
|
|
|
try:
|
|
body = await request.json()
|
|
except (ValueError, UnicodeDecodeError):
|
|
body = None
|
|
if not isinstance(body, dict):
|
|
return web.json_response({"error": "Expected {text}"}, status=400)
|
|
try:
|
|
step = ai_panels.leave_message(request.match_info["task_id"], body.get("text"), body.get("refs"))
|
|
except ai_panels.PanelError as error:
|
|
return web.json_response({"error": str(error)}, status=400)
|
|
return web.json_response({"step": step})
|
|
|
|
|
|
async def _body(request):
|
|
try:
|
|
body = await request.json()
|
|
except (ValueError, UnicodeDecodeError):
|
|
return None
|
|
return body if isinstance(body, dict) else None
|
|
|
|
|
|
async def api_gate(request):
|
|
"""POST /anomalous/ai_tasks/{id}/gate: {step, decision: allow | deny, text?, remember?}."""
|
|
body = await _body(request)
|
|
if body is None:
|
|
return web.json_response({"error": "Expected {step, decision}"}, status=400)
|
|
try:
|
|
step = ai_gates.decide(request.match_info["task_id"], str(body.get("step")), body.get("decision"),
|
|
body.get("text") or "", bool(body.get("remember")))
|
|
except ValueError as error:
|
|
return web.json_response({"error": str(error)}, status=400)
|
|
return web.json_response({"step": step})
|
|
|
|
|
|
async def api_gates(request):
|
|
"""GET /anomalous/ai_gates: {policies: {gate: policy}, canvas_tab, tools}; POST {gate, policy} changes one,
|
|
POST {canvas_tab: new | current} whether new tasks work in a tab of their own (ai_task_canvas.py),
|
|
POST {privacy: bool} whether new tasks are private (ai_privacy.py),
|
|
POST {runs: call | wait}
|
|
how the AI waits for a run (ai_gates.run_wait), POST {answer_wait: 60 | 180 | 600} for the user's answer,
|
|
POST {no_answer: stop | recommended} what happens when that time runs out,
|
|
POST {nodes: any | ways} which custom nodes the AI adds itself (ai_gates.node_rule),
|
|
POST {routes: allow | ask} an AI placing nodes by itself (ai_gates.route_rule)."""
|
|
def settings():
|
|
return {"policies": ai_gates.policies(), "canvas_tab": ai_task_canvas.default_mode(), "privacy": ai_privacy.default_on(),
|
|
"runs": ai_gates.run_wait(), "answer_wait": ai_gates.answer_wait(), "no_answer": ai_gates.no_answer(),
|
|
"nodes": ai_gates.node_rule(), "routes": ai_gates.route_rule()}
|
|
|
|
if request.method == "GET":
|
|
return web.json_response(settings())
|
|
body = await _body(request)
|
|
try:
|
|
if "canvas_tab" in (body or {}):
|
|
ai_task_canvas.set_default(body["canvas_tab"])
|
|
elif "privacy" in (body or {}):
|
|
ai_privacy.set_default(body["privacy"])
|
|
elif "runs" in (body or {}):
|
|
ai_gates.set_run_wait(body["runs"])
|
|
elif "answer_wait" in (body or {}):
|
|
ai_gates.set_answer_wait(body["answer_wait"])
|
|
elif "no_answer" in (body or {}):
|
|
ai_gates.set_no_answer(body["no_answer"])
|
|
elif "nodes" in (body or {}):
|
|
ai_gates.set_node_rule(body["nodes"])
|
|
elif "routes" in (body or {}):
|
|
ai_gates.set_route_rule(body["routes"])
|
|
else:
|
|
ai_gates.set_policy((body or {}).get("gate"), (body or {}).get("policy"))
|
|
except ValueError as error:
|
|
return web.json_response({"error": str(error)}, status=400)
|
|
return web.json_response(settings())
|
|
|
|
|
|
async def api_marks(request):
|
|
"""POST /anomalous/ai_tasks/{id}/marks: {image, png, marks, text, mode, mask} - the user's marks on a picture for the AI."""
|
|
from . import ai_panels
|
|
|
|
body = await _body(request)
|
|
if body is None:
|
|
return web.json_response({"error": "Expected {image, png, marks}"}, status=400)
|
|
try:
|
|
step = await asyncio.to_thread(ai_marks.save, request.match_info["task_id"], body.get("image"),
|
|
body.get("png"), body.get("marks"), body.get("text"), None, body.get("mask"), body.get("mode"))
|
|
except ai_marks.MarksError as error:
|
|
return web.json_response({"error": str(error)}, status=400)
|
|
ai_panels.wake()
|
|
return web.json_response({"step": step})
|
|
|
|
|
|
async def api_marked(request):
|
|
"""GET /anomalous/ai_tasks/{id}/marks/{name}: a marked picture the user sent."""
|
|
path = ai_marks.path_of(request.match_info["task_id"], request.match_info["name"])
|
|
if not path:
|
|
return web.json_response({"error": "Not found"}, status=404)
|
|
return web.FileResponse(path, headers={"Cache-Control": "private, max-age=86400"})
|
|
|
|
|
|
async def api_way_pick(request):
|
|
"""POST /anomalous/ai_tasks/{id}/way: {name, on} - a way (做法) the user picks for this task, or takes back."""
|
|
from . import ai_panels
|
|
|
|
body = await _body(request) or {}
|
|
try:
|
|
step = ai_toolbox.pick_way(request.match_info["task_id"], str(body.get("name") or ""), bool(body.get("on")))
|
|
except ai_toolbox.ToolboxError as error:
|
|
return web.json_response({"error": str(error)}, status=400)
|
|
ai_panels.wake()
|
|
return web.json_response({"step": step})
|
|
|
|
|
|
async def api_toolbox_pick(request):
|
|
"""POST /anomalous/ai_tasks/{id}/toolbox: {mode, nodes, combos, workflows, prompts, models} - what the user hands the AI."""
|
|
from . import ai_panels
|
|
|
|
body = await _body(request)
|
|
try:
|
|
step = ai_toolbox.pick(request.match_info["task_id"], body if body is not None else None, (body or {}).get("mode", "prefer"))
|
|
except ai_toolbox.ToolboxError as error:
|
|
return web.json_response({"error": str(error)}, status=400)
|
|
ai_panels.wake()
|
|
return web.json_response({"step": step})
|
|
|
|
|
|
async def api_pictures(request):
|
|
"""POST /anomalous/ai_tasks/{id}/pictures: {images: ["subfolder/name.png"], text?} - the user hands the AI pictures."""
|
|
from . import ai_panels, ai_pictures
|
|
|
|
body = await _body(request)
|
|
try:
|
|
step = await asyncio.to_thread(ai_pictures.hand, request.match_info["task_id"], (body or {}).get("images"), (body or {}).get("text") or "")
|
|
except ai_pictures.PicturesError as error:
|
|
return web.json_response({"error": str(error)}, status=400)
|
|
ai_panels.wake()
|
|
return web.json_response({"step": step})
|
|
|
|
|
|
async def api_upload(request):
|
|
"""POST /anomalous/ai_pictures/upload?name= with a picture's bytes - one the user dropped or pasted
|
|
into a message, kept in the output folder (ai_pictures.save_upload): {image, filename, subfolder}."""
|
|
from . import ai_pictures
|
|
|
|
data = await request.read()
|
|
try:
|
|
image = await asyncio.to_thread(ai_pictures.save_upload, data, request.query.get("name", ""))
|
|
except ai_pictures.PicturesError as error:
|
|
return web.json_response({"error": str(error)}, status=400)
|
|
except OSError as error:
|
|
return web.json_response({"error": f"Could not keep the picture: {error}"}, status=500)
|
|
subfolder, _, filename = image.rpartition("/")
|
|
return web.json_response({"image": image, "filename": filename, "subfolder": subfolder})
|
|
|
|
|
|
async def api_stop(request):
|
|
"""POST /anomalous/ai_tasks/{id}/runs/{step}/stop - the user stops a run the AI started."""
|
|
from . import ai_panels
|
|
|
|
try:
|
|
answer = await asyncio.to_thread(ai_run_control.stop, request.match_info["step_id"], "user", request.match_info["task_id"])
|
|
except ai_run_control.RunControlError as error:
|
|
return web.json_response({"error": str(error)}, status=400)
|
|
ai_panels.wake()
|
|
return web.json_response(answer)
|
|
|
|
|
|
async def api_verdict(request):
|
|
"""POST /anomalous/ai_tasks/{id}/runs/{step}/verdict {verdict: good | bad | null} - the user's word on a run, told to the AI."""
|
|
from . import ai_panels
|
|
|
|
body = await _body(request) or {}
|
|
try:
|
|
verdict = await asyncio.to_thread(ai_run_control.set_verdict, request.match_info["task_id"], request.match_info["step_id"], body.get("verdict"))
|
|
except ai_run_control.RunControlError as error:
|
|
return web.json_response({"error": str(error)}, status=400)
|
|
ai_panels.wake()
|
|
return web.json_response({"verdict": verdict})
|
|
|
|
|
|
async def api_toolbox(request):
|
|
"""GET /anomalous/ai_toolbox?kind=all: what the picker lists; GET/POST /anomalous/ai_toolbox/favorites."""
|
|
if request.path.endswith("/favorites"):
|
|
if request.method == "GET":
|
|
return web.json_response({"favorites": ai_toolbox.favorites()})
|
|
body = await _body(request)
|
|
try:
|
|
return web.json_response({"favorites": ai_toolbox.set_favorites(body if body is not None else None)})
|
|
except ai_toolbox.ToolboxError as error:
|
|
return web.json_response({"error": str(error)}, status=400)
|
|
try:
|
|
return web.json_response(await asyncio.to_thread(ai_toolbox.catalog, request.query.get("kind", "")))
|
|
except ai_toolbox.ToolboxError as error:
|
|
return web.json_response({"error": str(error)}, status=400)
|
|
except Exception as error: # noqa: BLE001 - ComfyUI's node list or a store unreadable: say so to the page
|
|
return web.json_response({"error": str(error)}, status=502)
|
|
|
|
|
|
async def api_blocks(request):
|
|
"""GET /anomalous/ai_blocks: every ready-made block and skill as the 做法 panel shows them
|
|
(reference_blocks.status, ai_skills.status); POST {name, on} or {skill, on}: the user turns one on or off for the AI."""
|
|
from . import ai_skills, mcp_canvas_edit, reference_blocks
|
|
from .mcp_tools import ToolError
|
|
|
|
if request.method == "POST":
|
|
body = await _body(request) or {}
|
|
try:
|
|
if "skill" in body:
|
|
ai_skills.set_on(str(body.get("skill") or ""), bool(body.get("on")))
|
|
else:
|
|
reference_blocks.set_on(str(body.get("name") or ""), bool(body.get("on")))
|
|
except (reference_blocks.BlockError, ai_skills.SkillError) as error:
|
|
return web.json_response({"error": str(error)}, status=400)
|
|
try:
|
|
info = await asyncio.to_thread(mcp_canvas_edit.object_info, {"base_url": f"{request.scheme}://{request.host}"})
|
|
except ToolError as error:
|
|
return web.json_response({"error": str(error)}, status=502)
|
|
return web.json_response({"blocks": reference_blocks.status(info), "jobs": list(reference_blocks.JOBS),
|
|
"skills": ai_skills.status()})
|
|
|
|
|
|
def register_routes(app):
|
|
activity_log.add_listener(ai_task_changes.note_activity) # the user's changes while an AI works
|
|
app.router.add_get("/anomalous/ai_tasks", api_list)
|
|
app.router.add_post("/anomalous/ai_tasks", api_start)
|
|
app.router.add_get("/anomalous/ai_tasks/{task_id}", api_get)
|
|
app.router.add_post("/anomalous/ai_tasks/{task_id}", api_change)
|
|
app.router.add_post("/anomalous/ai_tasks/{task_id}/answer", api_answer)
|
|
app.router.add_post("/anomalous/ai_tasks/{task_id}/message", api_message)
|
|
app.router.add_post("/anomalous/ai_tasks/{task_id}/typing", api_typing)
|
|
app.router.add_post("/anomalous/ai_tasks/{task_id}/gate", api_gate)
|
|
app.router.add_post("/anomalous/ai_tasks/{task_id}/marks", api_marks)
|
|
app.router.add_get("/anomalous/ai_tasks/{task_id}/marks/{name}", api_marked)
|
|
app.router.add_post("/anomalous/ai_tasks/{task_id}/toolbox", api_toolbox_pick)
|
|
app.router.add_post("/anomalous/ai_tasks/{task_id}/way", api_way_pick)
|
|
app.router.add_post("/anomalous/ai_tasks/{task_id}/runs/{step_id}/stop", api_stop)
|
|
app.router.add_post("/anomalous/ai_tasks/{task_id}/runs/{step_id}/verdict", api_verdict)
|
|
app.router.add_post("/anomalous/ai_tasks/{task_id}/pictures", api_pictures)
|
|
app.router.add_post("/anomalous/ai_pictures/upload", api_upload)
|
|
app.router.add_get("/anomalous/ai_toolbox", api_toolbox)
|
|
app.router.add_get("/anomalous/ai_toolbox/favorites", api_toolbox)
|
|
app.router.add_post("/anomalous/ai_toolbox/favorites", api_toolbox)
|
|
app.router.add_get("/anomalous/ai_blocks", api_blocks)
|
|
app.router.add_post("/anomalous/ai_blocks", api_blocks)
|
|
app.router.add_get("/anomalous/ai_gates", api_gates)
|
|
app.router.add_post("/anomalous/ai_gates", api_gates)
|