From cb523930dad3241dbc3df21037d4e903a12a3601 Mon Sep 17 00:00:00 2001 From: DemonGatanjieu <207458273+DemonGatanjieu@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:26:41 +0800 Subject: [PATCH] Guide the AI with fewer detours and guesses - An AI app calling by an older tool list (tool_help or use_tool not offered, use_tool for a tool offered directly, a tool the list lacks) is told so, and the task header shows how to reconnect until the app fetches tools/list again. - add_lora / set_model compare the base models on the model line (model_advice.py; the page reports the line) and add base_model_note. - get_model gives author_examples (sampler, steps, CFG, size, a LoRA's weight from the Civitai record's example pictures) and the version name. - run_workflow says ComfyUI's refusal as which node, what is wrong and the tool that fixes it (run_refusal.py; mcp_run.js passes the refusal as data). - Gates "When I don't answer": wait for me, or go with the recommendation once the waiting time runs out (choices with a recommended option only), handed to the AI in the same call and shown on the stage and under Decisions. - A tool budget test (count, size, description length, required arguments) and the exam tasks with their scorecard (docs/architecture/tool-exam.md, tools/tool_exam.mjs). The tool set's text recommends Full for Antigravity. Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 9 ++ CHANGELOG.md | 15 +++ api/ai_answer_wait.py | 24 ++++- api/ai_decisions.py | 5 +- api/ai_gates.py | 19 +++- api/ai_panels.py | 2 +- api/ai_tasks_routes.py | 7 +- api/mcp_actions.py | 47 ++++++--- api/mcp_directory.py | 55 +++++++++- api/mcp_model_use.py | 13 ++- api/mcp_server.py | 3 + api/mcp_task_tools.py | 16 ++- api/mcp_tools.py | 1 + api/model_advice.py | 182 +++++++++++++++++++++++++++++++++ api/run_refusal.py | 56 ++++++++++ docs/architecture/tool-exam.md | 45 ++++++++ docs/architecture/ui-flows.md | 2 + docs/guides/mcp.md | 8 +- tests/graph_splice.mjs | 7 +- tests/mcp_run.mjs | 14 ++- tests/test_ai_panels.py | 30 ++++++ tests/test_mcp_directory.py | 26 +++++ tests/test_model_advice.py | 124 ++++++++++++++++++++++ tests/test_run_refusal.py | 56 ++++++++++ tests/test_tool_budget.py | 48 +++++++++ tools/tool_exam.mjs | 53 ++++++++++ web/modules/graph_splice.js | 25 ++++- web/modules/locales.js | 20 +++- web/modules/mcp_bridge.js | 14 ++- web/modules/mcp_run.js | 20 +++- web/modules/ui_ai_decisions.js | 2 +- web/modules/ui_ai_gate.js | 2 + web/modules/ui_ai_panel.js | 1 + web/modules/ui_ai_workbench.js | 1 + web/styles/37-ai-workbench.css | 12 +++ 35 files changed, 913 insertions(+), 51 deletions(-) create mode 100644 api/model_advice.py create mode 100644 api/run_refusal.py create mode 100644 docs/architecture/tool-exam.md create mode 100644 tests/test_model_advice.py create mode 100644 tests/test_run_refusal.py create mode 100644 tests/test_tool_budget.py create mode 100644 tools/tool_exam.mjs diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a49b7ab..99818b9 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -25,6 +25,7 @@ and Current node lists the saved values. See the material contract below. | Saved prompts and node values (material files), image parameter details, keeping an output image | [`docs/architecture/material-library.md`](docs/architecture/material-library.md) | | Model Check, provenance hashes, missing-model recovery, or model scanning | [`docs/architecture/model-resolution.md`](docs/architecture/model-resolution.md) | | Why a current product boundary exists | [`docs/decisions/README.md`](docs/decisions/README.md) | +| The MCP tools AI apps get: adding one, its description or arguments | [`docs/architecture/tool-exam.md`](docs/architecture/tool-exam.md) (the tool budget, `tests/test_tool_budget.py`; the exam tasks and their scorecard, `node tools/tool_exam.mjs`) | | Recurring implementation mistakes and post-mortems | [`docs/architecture/lessons.md`](docs/architecture/lessons.md) | `README.md` is user-facing documentation and `CHANGELOG.md` is user-facing @@ -170,6 +171,11 @@ covered by the `styles.css` manifest. with what loads each model: the node types whose drop-down lists it (from `/object_info`), or, for a file whose header says it belongs in another folder (model_placement.py), where, and `move_model` (behind the "files" gate) moves it there as Tidy does. + `api/model_advice.py` is what Anomalous knows about using a model well: get_model's + `author_examples` (sampler, steps, CFG, size and a LoRA's weight from the example pictures in + the Civitai record a scan keeps), and add_lora / set_model's `base_model_note` when a LoRA and + the main model on its line (`graph_splice.js` loraChain / modelLineStart, reported by the page) + are for different base models. `tests/test_model_advice.py`. `api/mcp_canvas_edit.py` holds the tools that build a workflow where the user watches: `find_nodes` reads this ComfyUI's node types from its own `/object_info` (kept two minutes); `add_node`, `connect_nodes`, `set_values`, `remove_nodes` and `new_workflow` (a @@ -202,6 +208,9 @@ covered by the `styles.css` manifest. node; the fixed blanks are roles (which prompt, which latent), not a list that grows. With them a run asks as the stricter of its gate and the canvas's (`action_catalog.ALSO_CANVAS`). `tests/test_mcp_fill.py`, `tests/mcp_fill.mjs`. + `api/run_refusal.py` says ComfyUI's refusal of a run as what to do next: the page passes it on + as data (`web/modules/mcp_run.js` refusal), each problem becomes a line naming the node, what + is wrong and the tool that fixes it. `tests/test_run_refusal.py`, `tests/mcp_run.mjs`. `api/ai_toolbox.py` keeps what the user hands the AI to work with in a task ("给 AI 的素材", `web/modules/ui_ai_toolbox.js`, from the task header a side panel (on the AI page a view of its side column, `setToolboxHost`; elsewhere docked on the right) that leaves diff --git a/CHANGELOG.md b/CHANGELOG.md index a9626d6..4b617b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ ## v1.62.0 — unreleased +- AI 少走弯路、少猜: + - AI 软件拿着旧的工具列表来调用时(你换了工具组,或 AMB 更新了),任务标题下面会出现一行黄字,告诉你怎么让它重新连接;AI 那边也收到同样的提醒。 + - AI 加 LoRA 或换模型时,AMB 对比 LoRA 和主模型的底模:一个是 SD 1.5、一个是 SDXL 这种用不了的,会提醒 AI 并告诉它按底模去找;Pony 的 LoRA 用在 Illustrious 上这种能用但可能效果差的,也会轻轻提一句。只提醒,不拦着。 + - AI 看模型详情时,能看到作者示例图用的采样器、步数、CFG、尺寸,LoRA 还有作者用的权重(来自扫描时存下的 Civitai 资料),还有版本名。 + - 跑图被 ComfyUI 拒绝时,AI 收到的不再是原样的英文报错,而是“哪个节点、缺什么、该用哪个工具修”。 + - 关卡窗口新增 **等不到回答时**:**停下等我**(默认)或 **按推荐的继续**。选后者时,到了等待时间你还没答,AI 标了推荐项的选择题就按推荐的选,展台和“决策”里都写着“到时间,按推荐选了”;没有推荐项的问题、关卡里“先问我”的事,永远不会替你答。 + - “给 AI 的工具”的说明改了:Antigravity 自己会按需读工具说明,选 **完整** 就对了;**日常** 只给工具数量有上限的软件用。 +- The AI takes fewer detours and guesses less: + - When an AI app calls by an older tool list (you switched the tool set, or Anomalous was updated), a yellow line under the task header says how to reconnect it, and the AI is told too. + - Adding a LoRA or swapping a model, Anomalous compares the base models of the LoRA and the main model: one that cannot work (SD 1.5 on SDXL) is pointed out with how to find a fitting one; one that loads but may look off (a Pony LoRA on Illustrious) gets a softer note. A note only, never a block. + - Model details give the AI the sampler, steps, CFG and size the author's example pictures used, and for a LoRA the weight (from the Civitai record a scan keeps), and the version name. + - When ComfyUI refuses a run, the AI gets which node, what is missing and the tool that fixes it, instead of ComfyUI's raw report. + - The Gates window has **When I don't answer**: **Wait for me** (the default) or **Go with the recommendation**, where a choice the AI marked a recommended option for takes it once your waiting time runs out, shown on the stage and under Decisions; a question without a recommendation, and anything a gate asks first, is never answered for you. + - The tool set's description now says Antigravity reads tool descriptions itself, so **Full** is the one for it; **Daily** is for apps that take a limited number of tools. + - AI 问你的问题更好答:选项是一按就发出去的按钮,右边有箭头,问题下面写着“点一个就发给 AI”,点了马上变成选中的样子;**让 AI 定** 挪到问题右上角,不再跟选项挤在一起被当成“确认”;“都不是”那一行默认收起成 **都不是,我自己说…**,关卡卡片上的原因也收起成 **说说想怎么改…**,点了才出输入框。展台、留言框上方的提问条、关卡卡片都一样。关卡窗口里几条说明改对了:改画布也管加节点、连线、新建工作流;跑完以后那几句话在留言框上方;“先问我”和按钮上的字对上了。 - Questions from the AI are easier to answer: options are buttons that send at a press, with an arrow and "One press sends it to the AI" above them, and the pressed one shows at once; **Let the AI decide** sits at the question's top right instead of under the options where it read as a confirm button; a line of your own stays folded as **None of these, I'll say it…**, and the Gates card's reason as **Say how to change it…**. The same on the stage, the question strip over the message box and the Gates card. Gates window texts fixed: changing the canvas includes adding nodes, wiring and new workflows; the lines after a run are over the message box; the intro names "Ask me" as the button does. diff --git a/api/ai_answer_wait.py b/api/ai_answer_wait.py index 2ea3ef7..58595e8 100644 --- a/api/ai_answer_wait.py +++ b/api/ai_answer_wait.py @@ -5,7 +5,9 @@ the answer to a question still open says whether to wait on with get_answers: un user set in the Gates window (ai_gates.answer_wait, from when it asked), and while the user is typing an answer (the page posts POST /anomalous/ai_tasks/{id}/typing as they type) until a while after their last key. Then the AI ends its turn and the user brings it back with a line to paste -(web/modules/ai_call_lines.js). Typing is kept in memory only. +(web/modules/ai_call_lines.js); or, when the user set so (ai_gates.no_answer "recommended"), each +open choice the AI marked a recommended option for takes it, marked as such for the user and the +AI (recommended_on_timeout), and the AI goes on. Typing is kept in memory only. """ import time @@ -13,9 +15,27 @@ import time from . import ai_gates, ai_panels, ai_tasks AFTER_TYPING = 60 # the user typed this recently: their answer is on its way +TOOK = ("The user did not answer in time and set that your recommended option goes then: Anomalous took it for " + "them (recommended_on_timeout). Go on with it; a question without a recommended option stays open for the user.") _typed = {} # task id -> time of the user's last key +def take_recommended(task_id, step): + """Answers each open choice of panel step `step` that has a recommended option with it. Returns how many.""" + taken = 0 + for block in step["panel"].get("blocks") or []: + picked = [option["id"] for option in block.get("options") or [] if option.get("recommended")] + if block.get("type") != "choices" or not picked or block.get("id") in (step.get("answers") or {}): + continue + try: + ai_panels.answer(task_id, step["id"], block["id"], choice=picked if block.get("multiple") else picked[:1], + extra={"recommended_on_timeout": True}) + taken += 1 + except ai_panels.PanelError: + pass + return taken + + def typing(task_id, now=None): """The user is typing in task `task_id` (an answer or a message). False for no such task.""" if not ai_tasks.get_task(task_id): @@ -34,6 +54,8 @@ def wait_on(panel_id, now=None): typed = _typed.get(task["id"], 0) left = max(step["at"] + ai_gates.answer_wait(), typed + AFTER_TYPING) - now if left <= 0: + if ai_gates.no_answer() == "recommended" and take_recommended(task["id"], step): + return {"keep_waiting": False, "took_recommended": True, "note": TOOK} return {"keep_waiting": False, "note": "You waited as long as the user set: end your turn now. They answer in " "Anomalous and tell you in your chat to go on."} typing_now = now - typed < AFTER_TYPING diff --git a/api/ai_decisions.py b/api/ai_decisions.py index ecfa2c4..44006db 100644 --- a/api/ai_decisions.py +++ b/api/ai_decisions.py @@ -54,7 +54,7 @@ def decisions(task): out.append({"type": "answer", "step": step["id"], "at": given.get("at", step["at"]), "question": block.get("question") or step["panel"].get("title") or "", "answer": "、".join(given.get("labels") or []) if given.get("labels") and given.get("choice") != ["other"] else given.get("text", ""), - "skipped": bool(given.get("skipped"))}) + "skipped": bool(given.get("skipped")), "timed_out": bool(given.get("recommended_on_timeout"))}) elif (step.get("gate") or {}).get("at"): asked = step["gate"] out.append({"type": "gate", "step": step["id"], "at": asked["at"], "tool": tool, @@ -88,7 +88,8 @@ def lines(task, limit=12): if item["type"] == "logged": words.append(f"{item['title']}: {item['decision']}" + (f" (because {item['reason']})" if item["reason"] else "")) elif item["type"] == "answer": - words.append(f"The user answered \"{item['question']}\": " + ("left it to you" if item["skipped"] else item["answer"])) + words.append(f"Nobody answered \"{item['question']}\" in time; your recommended option was taken: {item['answer']}" if item["timed_out"] else + f"The user answered \"{item['question']}\": " + ("left it to you" if item["skipped"] else item["answer"])) elif item["type"] == "gate": words.append(f"The user {'allowed' if item['allowed'] else 'refused'} {item['tool']}" + (f": {item['text']}" if item["text"] else "")) elif item["type"] == "toolbox": diff --git a/api/ai_gates.py b/api/ai_gates.py index 094a5e2..6bcfcba 100644 --- a/api/ai_gates.py +++ b/api/ai_gates.py @@ -14,8 +14,10 @@ action_catalog.DAILY for a small model's everyday runs, the rest on demand throu use_tool; anomalous/ai_tools.json); an app sees a change in a new chat (they keep the list a chat began with). It also sets how an AI waits for a run (run_wait): "call", it ends its turn and the user tells it when the run is done (the AI -workbench gives them a line to paste), or "wait", it waits with run_status; and how long in all an -AI waits for the user's answer to its question (answer_wait, ai_answer_wait.py). +workbench gives them a line to paste), or "wait", it waits with run_status; how long in all an +AI waits for the user's answer to its question (answer_wait, ai_answer_wait.py), and what happens +when that time runs out (no_answer): "stop", the AI ends its turn and waits for the user, or +"recommended", a choice the AI marked a recommended option for takes it (never a gate's question). """ import json @@ -30,6 +32,7 @@ POLICIES = ("auto", "ask", "never") TOOL_SETS = ("full", "daily") RUN_WAITS = ("call", "wait") ANSWER_WAITS = (60, 180, 600) # seconds; the first call waits 30-55 s, get_answers the rest +NO_ANSWERS = ("stop", "recommended") 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 @@ -117,6 +120,18 @@ def set_answer_wait(seconds): return _set_tool_setting("answer_wait", seconds) +def no_answer(): + """When the user's time runs out unanswered: "stop" (the AI waits for them) or "recommended".""" + chosen = _tool_settings().get("no_answer") + return chosen if chosen in NO_ANSWERS else "stop" + + +def set_no_answer(mode): + if mode not in NO_ANSWERS: + raise ValueError("no_answer is stop or recommended") + return _set_tool_setting("no_answer", mode) + + def describe(tool, arguments): """What a call would do, as [{label, value}] for the card the user answers.""" out = [] diff --git a/api/ai_panels.py b/api/ai_panels.py index 9bd644d..470dfe7 100644 --- a/api/ai_panels.py +++ b/api/ai_panels.py @@ -518,7 +518,7 @@ def _collect(client, panel_id, task_id=None): given.update(delivered=True, delivered_at=time.time()) changed.add(task["id"]) item = {"panel": panel["id"], "question": block["id"], "asked": block.get("question", "")} - item.update({key: given[key] for key in ("choice", "labels", "text", "skipped", "inpaint_image", "note") if key in given}) + item.update({key: given[key] for key in ("choice", "labels", "text", "skipped", "inpaint_image", "note", "recommended_on_timeout") if key in given}) out.append(item) for tid in changed: ai_tasks._save(tasks[tid]) diff --git a/api/ai_tasks_routes.py b/api/ai_tasks_routes.py index 8b8a969..52a71fd 100644 --- a/api/ai_tasks_routes.py +++ b/api/ai_tasks_routes.py @@ -147,10 +147,11 @@ 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 {tools: full | daily} which tools AI apps are offered (ai_gates.tool_set), 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.""" + 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.""" def settings(): return {"policies": ai_gates.policies(), "canvas_tab": ai_task_canvas.default_mode(), "tools": ai_gates.tool_set(), - "runs": ai_gates.run_wait(), "answer_wait": ai_gates.answer_wait()} + "runs": ai_gates.run_wait(), "answer_wait": ai_gates.answer_wait(), "no_answer": ai_gates.no_answer()} if request.method == "GET": return web.json_response(settings()) @@ -164,6 +165,8 @@ async def api_gates(request): 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"]) else: ai_gates.set_policy((body or {}).get("gate"), (body or {}).get("policy")) except ValueError as error: diff --git a/api/mcp_actions.py b/api/mcp_actions.py index 89d47fa..3fedd03 100644 --- a/api/mcp_actions.py +++ b/api/mcp_actions.py @@ -15,7 +15,7 @@ import time import urllib.error import urllib.request -from . import ai_gates, mcp_fill +from . import ai_gates, mcp_fill, model_advice, run_refusal from .lora_info import trains_text_encoder from .mcp_bridge import PageRefused, PageUnavailable, ask_page from .mcp_tools import MAX_LIMIT, ToolError, _resolve_model, _schema @@ -35,12 +35,6 @@ def _rel(base_dir, file_path): return os.path.relpath(file_path, base_dir).replace(os.sep, "/") -def _model_ref(model_id): - """{type, path} of a model, as a node's drop-down names it (relative to its folder).""" - folder_type, _idx, base_dir, file_path = _resolve_model(model_id) - return {"type": folder_type, "path": _rel(base_dir, file_path)} - - def _local(ctx, method, path, body=None, timeout=20): """(status, JSON) of one request to this ComfyUI server's own routes.""" data = json.dumps(body).encode("utf-8") if body is not None else None @@ -72,17 +66,36 @@ def set_prompt(ctx, text, role="positive", node_id=None, mode="replace"): return _page("set_prompt", {"text": str(text), "role": role, "node_id": node_id, "mode": mode}) +def _base_note(result, folder_type, file_path): + """The page's answer without the model line it reports, and base_model_note when a LoRA on that + line does not suit its main model (model_advice.py).""" + if not isinstance(result, dict): + return result + line = result.pop("line", None) or {} + if folder_type == "loras": + note = model_advice.lora_note(file_path, line) + else: + notes = (model_advice.lora_note(path, {"model": line.get("model")}) for path in model_advice.lora_paths(line.get("loras"))) + note = next((item for item in notes if item), None) + if note: + result["base_model_note"] = note + return result + + def set_model(ctx, node_id, model_id): - return _page("set_model", {"node_id": node_id, "model": _model_ref(model_id)}) + folder_type, _idx, base_dir, file_path = _resolve_model(model_id) + result = _page("set_model", {"node_id": node_id, "model": {"type": folder_type, "path": _rel(base_dir, file_path)}}) + return _base_note(result, folder_type, file_path) def add_lora(ctx, model_id, after_node_id=None, strength_model=1.0, strength_clip=1.0): folder_type, _idx, base_dir, file_path = _resolve_model(model_id) if folder_type != "loras": raise ToolError("add_lora takes a model from the loras folder.") - return _page("add_lora", {"model": {"type": folder_type, "path": _rel(base_dir, file_path)}, - "text_encoder": trains_text_encoder(file_path), "after_node_id": after_node_id, - "strength_model": float(strength_model), "strength_clip": float(strength_clip)}) + result = _page("add_lora", {"model": {"type": folder_type, "path": _rel(base_dir, file_path)}, + "text_encoder": trains_text_encoder(file_path), "after_node_id": after_node_id, + "strength_model": float(strength_model), "strength_clip": float(strength_clip)}) + return _base_note(result, folder_type, file_path) def place_combo(ctx, name): @@ -132,9 +145,10 @@ def run_workflow(ctx, batch=1, wait_seconds=55, workflow=None, **slots): except (PageUnavailable, PageRefused) as error: raise ToolError(str(error)) ids = answer.get("prompt_ids") if isinstance(answer, dict) else None + page = answer if isinstance(answer, dict) else {} if (not isinstance(ids, list) or not ids or len(ids) > batch or any(not isinstance(pid, str) or not pid or len(pid) > 128 for pid in ids) or len(set(ids)) != len(ids)): - raise ToolError((answer.get("submission_error") if isinstance(answer, dict) else None) + raise ToolError((run_refusal.say(answer.get("refusal")) or answer.get("submission_error") if isinstance(answer, dict) else None) or "No valid submission receipts. Check ComfyUI's queue before retrying; do not repeat automatically.") answer = {"prompt_ids": ids, "queued": len(ids), "requested": batch, "note": ("If this answer does not say it is done, the user tells you when it is: say it is running and end " @@ -146,6 +160,9 @@ def run_workflow(ctx, batch=1, wait_seconds=55, workflow=None, **slots): # Some prompts may have been accepted before validation or a transport failure stopped the batch. if len(ids) < batch: answer["submission_error"] = f"Only {len(ids)} of {batch} prompts were accepted." + told = run_refusal.say(page.get("refusal")) + if told: + answer["submission_error"] += f" {told}" return answer @@ -265,11 +282,13 @@ ACTIONS = dict([ "node_id": _NODE, "mode": {"type": "string", "enum": ["replace", "append"], "default": "replace"}}, ["text"], destructive=True), _action("set_model", "Swap a node's model", - "Puts a model into a loader node (checkpoint, LoRA, VAE…) on the canvas, replacing the one it had.", + "Puts a model into a loader node (checkpoint, LoRA, VAE…) on the canvas, replacing the one it had. " + "base_model_note in the answer: a LoRA and the main model are for different base models.", set_model, {"node_id": _NODE, "model_id": _MODEL}, ["node_id", "model_id"], destructive=True), _action("add_lora", "Add a LoRA", "Inserts a LoRA loader after after_node_id (default: the main model loader, past its LoRAs) and moves " - "what that node fed behind it; the CLIP line too when the LoRA trains the text encoder.", + "what that node fed behind it; the CLIP line too when the LoRA trains the text encoder. base_model_note in " + "the answer: the LoRA is for another base model than the main model.", add_lora, {"model_id": _MODEL, "after_node_id": _NODE, "strength_model": {"type": "number", "default": 1.0}, "strength_clip": {"type": "number", "default": 1.0}}, ["model_id"]), diff --git a/api/mcp_directory.py b/api/mcp_directory.py index 3cf6f23..fc4121b 100644 --- a/api/mcp_directory.py +++ b/api/mcp_directory.py @@ -10,9 +10,17 @@ tools of this module for the rest, which the directory marks: tool_help gives th arguments of tools named in the directory, use_tool calls one. use_tool has no run of its own: mcp_server.py makes the call the tool it names before anything else, so gates, steps and answers are that tool's. + +AI apps keep the tool list they fetched (Antigravity files it away and refetches only when it +reconnects), so after the user switches the tool set, or Anomalous is updated, an app can call by a +list that is no longer offered. stale() spots that from the call; the answer then tells the AI, and +the task notes it (task["stale_tools"]) so the workbench tells the user how to reconnect, until the +app fetches the list again (cleared()). """ -from . import action_catalog +import time + +from . import action_catalog, ai_tasks from .mcp_tools import ToolError, _schema, retext DIRECTORY_TOOLS = ("tool_help", "use_tool") @@ -26,6 +34,10 @@ SECTIONS = ( # stage -> heading, in the order work usually goes (None, "The task and the user"), ) NOTE = "The tools by what you want to do: go straight to the one you need instead of looking around first." +STALE_NOTE = ("Your app holds an older list of Anomalous's tools than the one offered now (the user changed the tool " + "set, or Anomalous was updated). The call went through, but tell the user in report to reconnect the " + "app to Anomalous (Antigravity: restart it, or switch the anomalous MCP server off and on) and to go on " + "in a new chat.") def directory(specs, offered=None): @@ -79,6 +91,47 @@ def hinted(result, name): return result +def stale(name, via, offered): + """Whether a call of `name` (`via` use_tool) shows the app holds an older list than `offered`, the + names offered now: tool_help or use_tool when they are not offered, use_tool for a tool offered + directly, or a tool the list does not have.""" + if via: + return "use_tool" not in offered or name in offered + return name not in offered + + +def tell_stale(result, task_id): + """Says so in the call's answer, and notes it on the task for the workbench.""" + if isinstance(result, dict): + data = result.get("structuredContent") + first = (result.get("content") or [None])[0] + if isinstance(data, dict) and not result.get("isError"): + retext(result, {**data, "tool_list": STALE_NOTE}) + elif isinstance(first, dict) and first.get("type") == "text": + result["content"][0] = {**first, "text": f"{first.get('text', '')} {STALE_NOTE}"} + if not task_id: + return result + with ai_tasks._lock: + task = ai_tasks._load().get(task_id) + if task is None or task.get("stale_tools"): + return result + task["stale_tools"] = time.time() + ai_tasks._save(task) + ai_tasks._notify(task_id) + return result + + +def cleared(client): + """The AI app `client` fetched the tool list again: the notes on its tasks go.""" + with ai_tasks._lock: + changed = [task for task in ai_tasks._load().values() if task.get("stale_tools") and task.get("client") == client] + for task in changed: + task.pop("stale_tools") + ai_tasks._save(task) + for task in changed: + ai_tasks._notify(task["id"]) + + def directory_tools(registry): """tool_help and use_tool, `registry()` giving the server's tools.""" def tool_help(names): diff --git a/api/mcp_model_use.py b/api/mcp_model_use.py index dca484f..0ed7554 100644 --- a/api/mcp_model_use.py +++ b/api/mcp_model_use.py @@ -11,7 +11,7 @@ import threading import folder_paths -from . import mcp_canvas_edit, mcp_tools, model_placement +from . import mcp_canvas_edit, mcp_tools, model_advice, model_placement from .mcp_actions import _action from .mcp_tools import _LIMIT, _QUERY, ToolError from .model_constants import MODEL_EXTENSIONS @@ -105,6 +105,10 @@ def search_models(ctx, query="", type="", base_model="", source="", limit=20): def get_model(ctx, model_id, include_cover=False): detail, extra = mcp_tools.get_model(model_id, include_cover) + folder_type, _idx, _base, path = mcp_tools._resolve_model(model_id) + examples = model_advice.author_examples(path, folder_type, detail.get("sha256", "")) + if examples: + detail["author_examples"] = examples return _about(ctx, detail), extra @@ -150,9 +154,10 @@ MODEL_TOOLS = dict([ "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.", + "Everything known about one model: Civitai name, version and link, trigger words, base model, description, " + "the user's own name and notes, author_examples (the sampler, steps, CFG, size and, for a LoRA, the weight " + "its author's example pictures used: a good start), 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", diff --git a/api/mcp_server.py b/api/mcp_server.py index fdd4344..9b5496a 100644 --- a/api/mcp_server.py +++ b/api/mcp_server.py @@ -238,6 +238,8 @@ async def _call(params, request): mcp_directory.attach(result, name, *_directory_specs()) if via: mcp_directory.hinted(result, name) + if name not in mcp_apps.APP_TOOLS and mcp_directory.stale(name, via, set(_offered())): + await asyncio.to_thread(mcp_directory.tell_stale, result, active_task) return ai_outside.tell(result, outside) @@ -281,6 +283,7 @@ async def api_mcp(request): if method == "ping": return _result(id_, {}, modern) if method == "tools/list": + await asyncio.to_thread(mcp_directory.cleared, ai_callers.client_of(request.headers, params)) return _result(id_, _tool_list(modern), modern) if method == "prompts/list": return _result(id_, mcp_prompts.list_prompts(), modern) diff --git a/api/mcp_task_tools.py b/api/mcp_task_tools.py index 1c629e1..3a18c6b 100644 --- a/api/mcp_task_tools.py +++ b/api/mcp_task_tools.py @@ -132,13 +132,17 @@ def propose_repaint_area(ctx, title, marks, areas=None, grow=12, feather=8, note def _answer_of(ctx, panel_id, wait_seconds): """The answer to the question of panel `panel_id` once the user gives it, waiting at least ASK_WAIT_MIN.""" + client = ctx.get("client") or "AI" try: - answer = ai_panels.get_answers(ctx.get("client") or "AI", panel_id, max(ASK_WAIT_MIN, float(wait_seconds or 0))) + answer = ai_panels.get_answers(client, panel_id, max(ASK_WAIT_MIN, float(wait_seconds or 0))) + given = next((item for item in answer["answers"] if item["question"] == "answer"), None) + if not given: + waited = ai_answer_wait.wait_on(panel_id) + if not waited.get("took_recommended"): + return {"answered": False, "panel": panel_id, **waited} + given = next((item for item in ai_panels.get_answers(client, panel_id, 0)["answers"] if item["question"] == "answer"), {}) except ai_panels.PanelError as error: raise ToolError(str(error)) - given = next((item for item in answer["answers"] if item["question"] == "answer"), None) - if not given: - return {"answered": False, "panel": panel_id, **ai_answer_wait.wait_on(panel_id)} out = {"answered": True, "panel": panel_id, "next": "Go on with the user's answer; log_decision it when it settles a choice."} if given.get("inpaint_image"): out.update(inpaint_image=given["inpaint_image"], inpaint_note=given["note"]) @@ -153,6 +157,8 @@ def _answer_of(ctx, panel_id, wait_seconds): out["note"] = "None of your options: the user wrote their own answer." else: out.update(choice=given.get("choice"), labels=given.get("labels")) + if given.get("recommended_on_timeout"): + out.update(recommended_on_timeout=True, next=ai_answer_wait.TOOK) return out @@ -243,6 +249,8 @@ def get_answers(ctx, panel=None, wait_seconds=0, task=None): if not task_id and ctx.get("session"): task_id = ai_tasks.active_task_id(client, ctx["session"]) or "" answer = ai_answer_wait.for_answer(ai_panels.get_answers(client, panel, wait_seconds, task_id=task_id), panel) + if answer.get("took_recommended"): # Anomalous answered for the user just now: it goes in this answer + answer = {**ai_panels.get_answers(client, panel, 0, task_id=task_id), "note": ai_answer_wait.TOOK} return answer, ai_marks.image_items(answer.get("messages") or []) # marked pictures, as pictures except ai_panels.PanelError as error: raise ToolError(str(error)) diff --git a/api/mcp_tools.py b/api/mcp_tools.py index 100321b..30d74e1 100644 --- a/api/mcp_tools.py +++ b/api/mcp_tools.py @@ -239,6 +239,7 @@ def get_model(model_id, include_cover=False): detail.update({ "trigger_words": list(meta.get("trainedWords") or []), "civitai_name": meta.get("name") or "", + "version": meta.get("version_name") or "", "civitai_url": meta.get("civitai_url") or "", "download_link": meta.get("source_url") or "", "user_notes": meta.get("custom_notes") or "", diff --git a/api/model_advice.py b/api/model_advice.py new file mode 100644 index 0000000..d958c0e --- /dev/null +++ b/api/model_advice.py @@ -0,0 +1,182 @@ +"""What Anomalous knows about using a model well, told to the AI so it need not guess: + +- the settings its author's example pictures were made with (the Civitai record a scan keeps + beside the file: images[].meta), summed up for get_model: how many pictures, the sampler and + scheduler most used, the range of steps and CFG, the most used size and, for a LoRA, the + weight it was used at: by its file hash, name or Civitai version, else the LoRA most of the + pictures name (their names are the author's own files, and often an older version's); +- whether a LoRA suits the main model it is put on: add_lora and set_model compare their base + models (the scan's, else what the file's header tells) and say so when the families differ + (an SD 1.5 LoRA on an SDXL checkpoint does nothing useful) or, more softly, when two bases of one + family differ (a Pony LoRA on an Illustrious checkpoint). A note only: the call is made. +""" + +import json +import os +from collections import Counter + +import folder_paths + +from . import metadata + +# Base model names (Civitai's, or what model_kind.py infers) by the architecture their files share. +FAMILIES = ( + ("SDXL", ("sdxl", "pony", "illustrious", "noob")), ("SD 1.x", ("sd 1",)), ("SD 2.x", ("sd 2",)), + ("SD 3", ("sd 3", "sd3")), ("Flux.1", ("flux.1", "flux 1", "chroma")), ("Flux.2", ("flux.2", "flux 2")), +) +# Bases trained far from their family's: a LoRA of one on another of the family loads but may look off. +OWN_WAYS = ("pony", "illustrious", "noob", "chroma") +MAIN_TYPES = ("checkpoints", "diffusion_models", "unet") +MAX_EXAMPLES = 20 + + +def family(base): + """The architecture family of a base model name ("" when none is known).""" + lowered = str(base or "").strip().lower() + if not lowered or lowered in ("other", "unknown"): + return "" + for name, marks in FAMILIES: + if lowered.startswith(marks): + return name + return lowered + + +def base_of(file_path): + """A model file's base model: the scan's, else what its header tells.""" + meta = metadata.get_metadata(file_path) + return meta.get("baseModel") or meta.get("base_guess") or "" + + +def _main_base(value): + """The base model of a main loader's choice (its drop-down value), or "".""" + for folder_type in MAIN_TYPES: + try: + path = folder_paths.get_full_path(folder_type, value) + except (KeyError, TypeError): + path = None + if path: + return base_of(path) + return "" + + +def lora_paths(values): + """The files of LoRA drop-down values that ComfyUI can find.""" + paths = [] + for value in values or []: + try: + path = folder_paths.get_full_path("loras", value) + except (KeyError, TypeError): + path = None + if path: + paths.append(path) + return paths + + +def lora_note(lora_path, line): + """A note when the LoRA at `lora_path` does not suit the main model of `line` (the page's + {"model": the main loader's choice}), else None.""" + main = _main_base((line or {}).get("model") or "") + lora = base_of(lora_path) + if not main or not lora or main.lower() == lora.lower(): + return None + if family(main) != family(lora): + return (f"Base model clash: this LoRA is for {lora}, the main model ({line['model']}) is {main}; it will not " + f"work as meant. Pick one with search_models(type='loras', base_model='{main}'), or remove it.") + if lora.lower().startswith(OWN_WAYS) or main.lower().startswith(OWN_WAYS): + return (f"This LoRA was made for {lora}, the main model is {main}: same family, so it loads, but it may " + f"look weaker or off. search_models(type='loras', base_model='{main}') finds ones made for it.") + return None + + +def _number(value): + try: + number = float(str(value).strip()) + except (TypeError, ValueError): + return None + return number if number == number and abs(number) < 1e6 else None + + +def _span(numbers): + """'25' or '20–30' for a list of numbers, '' for none.""" + if not numbers: + return "" + low, high = min(numbers), max(numbers) + say = lambda number: f"{number:g}" + return say(low) if low == high else f"{say(low)}–{say(high)}" + + +def _common(values, total): + """'euler (6 of 10)': the most used value and in how many of the `total` pictures.""" + counted = Counter(value for value in values if value) + if not counted: + return "" + value, times = counted.most_common(1)[0] + return str(value) if times == total else f"{value} ({times} of {total})" + + +def _loras(meta): + """[(who, weight)] of the LoRAs one picture's settings name: who is a hash, a file name or "v".""" + found = [] + for item in meta.get("resources") or []: + if isinstance(item, dict) and str(item.get("type", "")).lower() == "lora": + found.append((str(item.get("hash") or item.get("name") or "").lower(), _number(item.get("weight")))) + for item in meta.get("additionalResources") or []: + if isinstance(item, dict) and str(item.get("type", "lora")).lower() == "lora": + found.append((os.path.splitext(os.path.basename(str(item.get("name", ""))))[0].lower(), _number(item.get("strength")))) + for item in meta.get("civitaiResources") or []: + if isinstance(item, dict) and str(item.get("type", "")).lower() == "lora" and item.get("modelVersionId") is not None: + found.append((f"v{item['modelVersionId']}", _number(item.get("weight")))) + return [(who, weight) for who, weight in found if who and weight is not None] + + +def _lora_weights(metas, own): + """The weights this LoRA was used at in its pictures: those naming it (`own`: its hash, file + name, version), else the one LoRA most of them name; none when two are named as often.""" + per = [_loras(meta) for meta in metas] + named = Counter(who for found in per for who in {who for who, _weight in found}) + mine = {who for who in named if who in own} + if not mine: + common = [(who, times) for who, times in named.most_common(2) if times * 5 >= len(metas) * 3] + if common and (len(common) == 1 or common[0][1] > common[1][1]): + mine = {common[0][0]} + return [weight for found in per for who, weight in found if who in mine] + + +def _record(file_path): + """The Civitai record kept beside the model (its .info), or None.""" + base_path = os.path.splitext(file_path)[0] + info_base = metadata._info_base(file_path, base_path) + for path in (metadata.find(info_base, ".info"), f"{info_base}.civitai.info"): + if path and os.path.isfile(path): + try: + with open(path, encoding="utf-8") as handle: + data = json.load(handle) + except (OSError, ValueError): + continue + if isinstance(data, dict) and isinstance(data.get("images"), list): + return data + return None + + +def author_examples(file_path, folder_type, sha256=""): + """What the author's example pictures were made with, summed up; {} when nothing is kept.""" + record = _record(file_path) + metas = [image.get("meta") for image in (record or {}).get("images") or [] + if isinstance(image, dict) and isinstance(image.get("meta"), dict)][:MAX_EXAMPLES] + if not metas: + return {} + sizes = [meta.get("Size") or (f"{meta['width']}x{meta['height']}" if meta.get("width") and meta.get("height") else "") + for meta in metas] + found = { + "pictures": len(metas), + "sampler": _common([str(meta.get("sampler") or "") for meta in metas if str(meta.get("sampler")).lower() != "undefined"], len(metas)), + "scheduler": _common([str(meta.get("scheduler") or meta.get("Schedule type") or "") for meta in metas], len(metas)), + "steps": _span([n for n in (_number(meta.get("steps")) for meta in metas) if n is not None]), + "cfg": _span([n for n in (_number(meta.get("cfgScale")) for meta in metas) if n is not None]), + "size": _common([str(size) for size in sizes], len(metas)), + "clip_skip": _span([n for n in (_number(meta.get("clipSkip") or meta.get("Clip skip")) for meta in metas) if n is not None]), + } + if folder_type == "loras": + own = {(sha256 or "")[:10].lower(), os.path.splitext(os.path.basename(file_path))[0].lower(), f"v{record.get('id')}"} - {""} + found["lora_weight"] = _span(_lora_weights(metas, own)) + return {key: value for key, value in found.items() if value} diff --git a/api/run_refusal.py b/api/run_refusal.py new file mode 100644 index 0000000..5943647 --- /dev/null +++ b/api/run_refusal.py @@ -0,0 +1,56 @@ +"""ComfyUI's refusal of a run, said as what to do next: run_workflow's answer when ComfyUI does not +take the prompt. The page passes the refusal on as data (web/modules/mcp_run.js `refusal`: its +error and each node's, from ComfyUI's validation); each problem becomes one line naming the node, +what is wrong and the tool that fixes it, so the AI does not have to read ComfyUI's raw report and +guess. Kinds not listed here keep ComfyUI's own message.""" + +MAX_LINES = 8 + +NODE_HINTS = { + "required_input_missing": "node {node} ({class_type}) has nothing wired into {input}: connect_nodes into it, or add_node with wire_to={node}", + "bad_linked_input": "node {node} ({class_type}): {input} is wired from an output of the wrong kind; connect_nodes the right one ({details})", + "return_type_mismatch": "node {node} ({class_type}): {input} is wired from an output of the wrong kind; connect_nodes the right one ({details})", + "invalid_input_type": "node {node} ({class_type}): {input} has a value of the wrong kind ({details}); set_values with the right one", + "value_smaller_than_min": "node {node} ({class_type}): {input} = {value} is below its minimum ({details}); set_values within the range", + "value_bigger_than_max": "node {node} ({class_type}): {input} = {value} is above its maximum ({details}); set_values within the range", + "value_not_in_list": ("node {node} ({class_type}): {input} = {value} is not one of its choices. A model this computer " + "lacks: check_workflow_models / fix_workflow_models, or search_models then set_model; otherwise " + "set_values with one of its choices (find_nodes lists them)"), +} +TOP_HINTS = { + "prompt_no_outputs": ("the workflow has no output node: add SaveImage (or PreviewImage) behind the VAE Decode with " + "add_node(type='SaveImage', wire_from=)"), + "invalid_prompt": "{message} {details}: a node type this ComfyUI does not have; find_nodes for one it has, or ask the user to install its node pack", +} + + +class _Blank(dict): + def __missing__(self, key): + return "?" + + +def _say(template, item): + return template.format_map(_Blank({key: str(value) for key, value in item.items() if value not in (None, "")})) + + +def say(refusal): + """What to do about ComfyUI's refusal (the page's `refusal` data), or "" when it says nothing.""" + if not isinstance(refusal, dict): + return "" + lines = [] + for item in refusal.get("nodes") or []: + if isinstance(item, dict): + template = NODE_HINTS.get(item.get("type")) + lines.append(_say(template, item) if template else + _say("node {node} ({class_type}): {message} {details}", item).replace(" ?", "")) + if not lines: + template = TOP_HINTS.get(refusal.get("type")) + if template: + lines.append(_say(template, refusal)) + elif refusal.get("message"): + lines.append(_say("{message} {details}", refusal).replace(" ?", "")) + if not lines: + return "" + more = len(lines) - MAX_LINES + return ("ComfyUI did not take the run: " + "; ".join(lines[:MAX_LINES]) + (f"; and {more} more" if more > 0 else "") + + ". Fix these, then run_workflow again.") diff --git a/docs/architecture/tool-exam.md b/docs/architecture/tool-exam.md new file mode 100644 index 0000000..e6bd281 --- /dev/null +++ b/docs/architecture/tool-exam.md @@ -0,0 +1,45 @@ +# AI 工具考试(MCP 工具改动后用) + +AMB 给 AI 软件的工具,以轻量模型(Gemini Flash)能顺畅用为准。改工具(加一个、改说明、改参数)以后,不靠一次次手试,而是让 Flash 把下面的固定考题做一遍,再用打分脚本看数字。 + +## 预算(`tests/test_tool_budget.py` 自动检查) + +2026-10-26 量的:完整工具组 53 个,约 4.3 万字符(约 1.1 万 token),说明书约 3200 字符;日常组 31 个,约 2.8 万字符。Gemini 3.8 Flash 在 Antigravity 里用完整组调用了约 570 次,几乎没有调用错误。所以现在这样就是上限: + +- 工具总数不再增加:加一个,就要合并或删掉一个。 +- 每个工具的说明不超过 700 字符(`show_panel` 例外:它的各种块没有自己的格式定义,说明就是格式)。 +- 一次调用最多 2 个必填参数,其余都有默认值。 +- 报错要写下一步怎么做(哪个节点、缺什么、调哪个工具),不要只转发原始报错。 +- 不做"转一道"的调用:Antigravity 自己会在第一次用某个工具前读它的说明文件,服务器这边再做一层按需只会让 AI 多绕路(10-26 那次 29 次调用里有 18 次花在查工具和转一道上)。 + +要放宽这些数字,必须同时跑一遍考试,证明 Flash 还是顺的。 + +## 怎么考 + +1. 在隔离的测试 ComfyUI(8199 端口,自己的 user 和 output 文件夹,模型只读)上开 AMB。关卡里把"下载模型"和"挪动模型文件"设成"不许做",不扫描。 +2. AI 软件连到这个测试 ComfyUI(Antigravity:MCP 设置里 anomalous 的地址改成 8199,考完改回来)。连之前先问用户。 +3. 每道题开一个新对话,把"开头那句话"发给 AI;需要你回答的地方照"你怎么答"去答。 +4. 全部做完后打分: + + ```bash + node tools/tool_exam.mjs <测试 ComfyUI 的 user 文件夹>/anomalous/ai_tasks --since=2026-10-26T20:00 + ``` + + 每道题一行:AI 调用了几次(calls)、失败几次(failed)、绕路几次(detours:查工具、或者拿着旧的工具列表来调用)、问了你几次、你答了几次、跑了几次图、失败几次、有没有收尾、用了几分钟。最后一行是总数,下面一行是失败率和绕路率。 + +5. 和上一次的分数比:失败率、绕路率不能变高,每题的调用次数不能明显变多。 + +## 考题 + +| # | 开头那句话 | 你怎么答 | 合格 | +| --- | --- | --- | --- | +| 1 | 用 Anomalous 出一张图:画布上的文生图工作流,提示词写"一只在窗台上晒太阳的橘猫",跑一张。 | 不用答 | 一次 `run_workflow` 出图;不超过 6 次调用 | +| 2 | 用 Anomalous 把主模型换成一个 SDXL 底模,再加一个适合它的 LoRA,权重照作者示例图的来,跑一张。 | 它问用哪个时选推荐的 | 用了 `set_model`、`add_lora`;LoRA 权重来自 `get_model` 的 author_examples;没有底模不配的提醒,或有提醒后换掉了 | +| 3 | 用 Anomalous 给这张图想两个改法,问我选哪个,再照我选的做。 | 选不是推荐的那个 | 用 `ask_user` 问、标了推荐、等到你回答;照你选的做 | +| 4 | 用 Anomalous 修图库里最新一张图的手:我在 AMB 里圈出来。 | 在图上圈手,确认它调整后的范围 | `propose_repaint_area` → 你确认 → AI 自己加重绘节点 → 跑出来 | +| 5 | 用 Anomalous 打开工作流 exam_missing(缺一个模型),把缺的模型补上再跑。 | 不用答 | 用 `check_workflow_models` / `fix_workflow_models`,不下载 | +| 6 | 用 Anomalous 找出最近用 LoRA anime_eyes_detail 出的图,告诉我当时的权重和种子。 | 不用答 | `search_images` + `get_image_info`,答对 | +| 7 | 用 Anomalous 给画布上的工作流加一个放大分支(放大到 1.5 倍),跑一张。 | 不用答 | 用 `add_nodes` 一次搭好并接线,跑出来 | +| 8 | 用 Anomalous 跑一下画布上的工作流。(事先把 KSampler 的 model 线断开) | 不用答 | 收到"哪个节点缺什么"的提示后用 `connect_nodes` 接上,再跑成功;失败不超过 2 次 | + +第 5 题要先在测试 ComfyUI 里存一个工作流 `exam_missing`:把一个工作流里的模型名改成这台电脑没有、但能按指纹找回的文件。 diff --git a/docs/architecture/ui-flows.md b/docs/architecture/ui-flows.md index 12b3beb..b24f9b1 100644 --- a/docs/architecture/ui-flows.md +++ b/docs/architecture/ui-flows.md @@ -44,6 +44,8 @@ node tools/ui_map.mjs build 写出 docs/ui-map.md:按页面列 | 「改名」 / 「从列表移除」 | 移除只删这条记录,图、画布、工作流都不动 | `POST /anomalous/ai_tasks/{task_id}` | | 「接着哪个任务…」 | 把这个任务接在另一个后面,两边都显示链接(`ui_ai_task_links.js`) | `POST /anomalous/ai_tasks/{task_id}`(from) | +AI 软件拿着旧的工具列表来调用时(你换了工具组,或 AMB 更新了),标题栏下面出现一行黄字「AI 软件拿的还是旧的工具列表,会多走弯路。请重新连接:Antigravity 重启一下,或在它的 MCP 设置里把 anomalous 关掉再打开;然后开个新对话。」,AI 那边也会收到同样的提醒(`mcp_directory.stale`)。AI 软件重新取工具列表(`tools/list`)后这行自动消失。 + 标题栏右上角的一排是侧栏的开关:「展台」「节点结构」「决策」「素材」「成果」(`ui_ai_side_views.js`),同一时间只开一个。 ### 中间:等 AI 接手的任务(`ui_ai_ready.js`) diff --git a/docs/guides/mcp.md b/docs/guides/mcp.md index 7690c70..f3cf823 100644 --- a/docs/guides/mcp.md +++ b/docs/guides/mcp.md @@ -85,7 +85,7 @@ claude mcp add --transport http anomalous http://127.0.0.1:8188/anomalous/mcp | 工具 | 查什么 | |---|---| | library_overview | 各类模型的数量、底模分布、扫描情况,存了多少搭配、工作流和提示词 | -| search_models / get_model | 按名字、触发词、底模、来源找模型;看某个模型的 C 站信息、描述、你写的备注,可以附上封面。每个模型都写明哪些节点能加载它;放错文件夹的(比如名字叫 ControlNet、其实是模型补丁的 Anima LLLite)会说明该放哪、放在这里加载不了 | +| search_models / get_model | 按名字、触发词、底模、来源找模型;看某个模型的 C 站信息、版本、描述、你写的备注,以及作者示例图用的采样器、步数、CFG、尺寸(LoRA 还有作者用的权重),可以附上封面。每个模型都写明哪些节点能加载它;放错文件夹的(比如名字叫 ControlNet、其实是模型补丁的 Anima LLLite)会说明该放哪、放在这里加载不了 | | zoom_image | 把一张图的某一块放大给 AI 看(手、脸、接缝、标注的区域),也可以把两张图的同一块并排比较(改之前和改之后)。输出、输入、预览图都能看,只看不改。AI 不必再自己写代码裁图、比图 | | scan_report | 哪些模型匹配上了 C 站,哪些没有、原因是什么,哪些还没扫,上次扫描做了什么 | | search_images / get_image_info | 按提示词、模型、参数找出图;看某张图的全部生成参数,可以附上缩略图。`folder=input` 时看 ComfyUI 输入文件夹里的图(比如你标注时 AMB 存的重绘遮罩图):尺寸、要重画的区域在哪、占多大,也能附上图,不用 AI 自己跑命令去看 | @@ -105,10 +105,10 @@ claude mcp add --transport http anomalous http://127.0.0.1:8188/anomalous/mcp | add_nodes | 一次加一组节点:按 ComfyUI 的 API 格式写,组里的节点互相连,也能连画布上已有的节点(写 `"#12"`,输出写编号或名字),排成一块放在空位上,整组算一步撤销 | | set_prompt | 改写或追加正向/反向提示词 | | set_model | 换某个加载器节点里的模型 | -| add_lora | 插入一个 LoRA,默认接在主模型加载器和已有的 LoRA 后面。LoRA 文件里练过文本编码器的(SD1.5、SDXL、Illustrious 的大多是),CLIP 线也接上:从 checkpoint 接,或者从 UNet 工作流里单独的 CLIP 加载器接,正反向提示词一起改接到它后面;没练过的(多数 Flux、Anima 的 LoRA)只接模型线 | +| add_lora | 插入一个 LoRA,默认接在主模型加载器和已有的 LoRA 后面。LoRA 文件里练过文本编码器的(SD1.5、SDXL、Illustrious 的大多是),CLIP 线也接上:从 checkpoint 接,或者从 UNet 工作流里单独的 CLIP 加载器接,正反向提示词一起改接到它后面;没练过的(多数 Flux、Anima 的 LoRA)只接模型线。LoRA 和主模型的底模对不上时(SD 1.5 的 LoRA 用在 SDXL 上),回复里会提醒 AI 并告诉它按底模去找;换模型(set_model)也一样 | | place_combo | 把一个搭配作为一组新节点摆到画布上,原有节点不动 | | check_workflow_models / fix_workflow_models | 检查缺失的模型;把能确定找回的(同一个文件)放回去,“可能是”的留给你在模型检查里确认 | -| run_workflow | 像按“运行”一样把工作流加入队列 | +| run_workflow | 像按“运行”一样把工作流加入队列。ComfyUI 不收(缺线、模型不在、数值超范围、没有输出节点)时,AI 收到的是“哪个节点、缺什么、该用哪个工具修”,不是原样的报错 | | open_in_anomalous | 在插件里打开某个模型的详情页给你看 | | scan_models | 扫描新模型、重新查找没匹配上的,或扫描指定的模型(会联网访问 Civitai) | | download_missing_models | 下载当前工作流缺的模型:只下能确定来源的(C 站按指纹找到、或工作流带着文件链接),放在设置里“模型下载”说的位置,核对指纹后自动换上 | @@ -162,7 +162,7 @@ claude mcp add --transport http anomalous http://127.0.0.1:8188/anomalous/mcp **运行进度**:AI 运行工作流后,时间线上那一步下面有进度条(排队时前面还有几个;运行时哪个节点、第几步、节点做完几个)。进度条右边是百分比,旁边是红色的 **■ 停止**:一点就停这一次运行(还在排队的移出队列,正在跑的立刻中断,只动这次运行),AI 也会收到“你停止了运行”,不会自己再跑一遍。任务工具栏的运行状态按钮旁边也有一个小 ■。进度条下面写着当前节点 **约剩多久**、每步几秒、**显存剩多少**,再下面一行是 ComfyUI **控制台**最新的那一行(比如 `Model Initializing ...`、`Requested to load …`),点开能看最近几行;这些 AI 也同样看得到。出图刚排上队,AMB 会读这次的工作流算出每个采样器要在多大的图上采样:如果是放大模型放大后又整张重新采样、尺寸超过这张显卡的舒适范围、或者要解码特别大的图,会出现黄色提醒,告诉你可能很慢或爆显存,以及怎么改(只想放大就用纯模型超分,或者先缩小、减步数、分块解码);AI 收到同样的提醒,会被要求先问你要不要停(它也能用 `stop_run` 停自己的运行)。AI 用 `run_status` 问同样的进度,或者等到跑完再继续,不会凭时间猜;运行期间 AI 调用别的工具时,回复里的 `your_run` 也带着一句当前进度,跑完后告诉它一次结果,所以它不用自己去翻输出文件夹或者看屏幕。跑完后,第一个告诉 AI "跑完了" 的回复里直接带着这次出的图(最多 4 张缩略图),AI 不用再一张张去取,看完就能直接给你留言。 -**流程图和关卡**:任务标题下面的一排流程按 AI 做事的先后列出它经过的阶段(模型、搭配、提示词、运行、挑图……),点一下跳到那一步。任务列表上方的 **关卡** 里,改画布、用显卡出图配音、下载模型、联网扫描、挪动模型文件五类事各自可以选 **直接做 / 先问我 / 不许做**(默认下载和挪文件先问)。选了“先问我”,AI 的那一步会停在时间线上列出它要做什么,你点 **允许** 才做;点 **不要** 时可以写原因或想怎么改,AI 会收到。AI 最多等 50 秒,等不及的话会先去问你的回答(`get_answers`),你允许后它再做一次,不会问第二遍。关卡窗口最下面的 **给 AI 的工具** 可以选 **日常**:常用工具直接给,包括跑图、看图、模型、问你、留言、现状一览、展台面板,以及节点搜索、自动接线搭建和参数修改;其余的(下载、扫描、配音、搭配、提示词等)在开始任务时收到的目录里标着 `[use_tool]`,AI 用 `tool_help` 查参数、用 `use_tool` 调用,通过 `use_tool` 的调用在时间线上照样显示成那个工具,关卡也照样按那个工具来问你。要所有工具都直接给,选 **完整**(默认)。**等我回答** 是 AI 问你问题以后最多等多久(1 分钟 / 3 分钟 / 10 分钟):你在回答框或留言框里打字时,停手一分钟内都不算超时;时间到了 AI 收工,你答完以后点一下消息框上方那句话复制给它。AI 每轮做完会先问你下一步,跑图、下载、扫描时除外;问题上的 **让 AI 定** 表示交给 AI 自己决定。AI 软件在一个对话里会记住开头拿到的工具,换了以后要开个新对话。**等跑图** 默认是 **跑完叫我**:AI 提交后最多等 1 分钟,跑得快的当场就拿到结果,还没跑完就告诉你在跑,然后收工,不再每 55 秒查一次进度;跑完后,AI 工作台消息框上方有几句话(“跑完了,看看结果”“再来一次,换个种子”,出错时是“出错了,看看是哪里的问题”),点一下复制,粘到 AI 的对话框里,AI 的下一次调用就拿到结果和图。AI 一知道结果,这几句话就换回平常的。平常 AI 停下来的时候(没在调用工具、没在跑图、没在等你回答),那里也总有一行话可以复制:你在 AMB 里回复了而 AI 还没收到,是“我在 Anomalous 里回复了,看看再接着做”,别的时候是“继续”;最后一句“用 Anomalous 接着做任务:…”给新开的 AI 对话用。想让 AI 自己等到跑完,选 **AI 自己等**。这一项马上生效,不用开新对话。 +**流程图和关卡**:任务标题下面的一排流程按 AI 做事的先后列出它经过的阶段(模型、搭配、提示词、运行、挑图……),点一下跳到那一步。任务列表上方的 **关卡** 里,改画布、用显卡出图配音、下载模型、联网扫描、挪动模型文件五类事各自可以选 **直接做 / 先问我 / 不许做**(默认下载和挪文件先问)。选了“先问我”,AI 的那一步会停在时间线上列出它要做什么,你点 **允许** 才做;点 **不要** 时可以写原因或想怎么改,AI 会收到。AI 最多等 50 秒,等不及的话会先去问你的回答(`get_answers`),你允许后它再做一次,不会问第二遍。关卡窗口最下面的 **给 AI 的工具** 可以选 **日常**:常用工具直接给,包括跑图、看图、模型、问你、留言、现状一览、展台面板,以及节点搜索、自动接线搭建和参数修改;其余的(下载、扫描、配音、搭配、提示词等)在开始任务时收到的目录里标着 `[use_tool]`,AI 用 `tool_help` 查参数、用 `use_tool` 调用,通过 `use_tool` 的调用在时间线上照样显示成那个工具,关卡也照样按那个工具来问你。要所有工具都直接给,选 **完整**(默认)。Antigravity 会在第一次用某个工具前自己读它的说明,用 **完整** 就对了;**日常** 只给工具数量有上限的软件用,不然 AI 会多绕路。**等我回答** 是 AI 问你问题以后最多等多久(1 分钟 / 3 分钟 / 10 分钟):你在回答框或留言框里打字时,停手一分钟内都不算超时;时间到了 AI 收工,你答完以后点一下消息框上方那句话复制给它。AI 每轮做完会先问你下一步,跑图、下载、扫描时除外;问题上的 **让 AI 定** 表示交给 AI 自己决定。AI 软件会记住它拿到的工具列表,换了以后要让它重新连接(Antigravity:重启,或在 MCP 设置里把 anomalous 关掉再打开),再开个新对话;没换过来时,任务标题下面会有一行黄字提醒你。**等不到回答时** 默认是 **停下等我**;选 **按推荐的继续**,到了时间你还没答,AI 标了推荐项的选择题就按推荐的选,展台和“决策”里会写明“到时间,按推荐选了”;没有推荐项的问题、关卡问你的事永远不会替你答。**等跑图** 默认是 **跑完叫我**:AI 提交后最多等 1 分钟,跑得快的当场就拿到结果,还没跑完就告诉你在跑,然后收工,不再每 55 秒查一次进度;跑完后,AI 工作台消息框上方有几句话(“跑完了,看看结果”“再来一次,换个种子”,出错时是“出错了,看看是哪里的问题”),点一下复制,粘到 AI 的对话框里,AI 的下一次调用就拿到结果和图。AI 一知道结果,这几句话就换回平常的。平常 AI 停下来的时候(没在调用工具、没在跑图、没在等你回答),那里也总有一行话可以复制:你在 AMB 里回复了而 AI 还没收到,是“我在 Anomalous 里回复了,看看再接着做”,别的时候是“继续”;最后一句“用 Anomalous 接着做任务:…”给新开的 AI 对话用。想让 AI 自己等到跑完,选 **AI 自己等**。这一项马上生效,不用开新对话。 **少调用几次就接好线**:以下编号只是例子,以当前画布为准。 diff --git a/tests/graph_splice.mjs b/tests/graph_splice.mjs index ade2939..786f525 100644 --- a/tests/graph_splice.mjs +++ b/tests/graph_splice.mjs @@ -1,6 +1,6 @@ // Where a LoRA goes and how it is wired (web/modules/graph_splice.js), on a small stand-in graph. import assert from 'node:assert/strict'; -import { chainEnd, loraInsertionCapabilities, mainModelLoaders, planLoraInsertion, spliceLora } from '../web/modules/graph_splice.js'; +import { chainEnd, loraChain, loraInsertionCapabilities, mainModelLoaders, modelLineStart, planLoraInsertion, spliceLora } from '../web/modules/graph_splice.js'; const SLOTS = { CheckpointLoaderSimple: [[], ['MODEL', 'CLIP', 'VAE']], @@ -85,6 +85,11 @@ function checkpointWorkflow() { const lora2 = spliceLora({ graph: g, plan: plan2, anchorNode: lora, insertedNode: node(null, 'LoraLoaderModelOnly') }); assert.equal(source(g, sampler, 0), `LoraLoaderModelOnly#${lora2.id}`); assert.equal(source(g, pos, 0), `LoraLoader#${lora.id}`); + // The model line, for the server's base-model check: the loader it starts at and its LoRAs in order. + assert.deepEqual(loraChain(g, ckpt, 'MODEL'), [lora, lora2]); + assert.equal(modelLineStart(g, lora2), ckpt); + assert.equal(modelLineStart(g, sampler), ckpt); + assert.equal(modelLineStart(g, ckpt), ckpt, 'a loader starts its own line'); } { diff --git a/tests/mcp_run.mjs b/tests/mcp_run.mjs index b6fd1fc..7b7095f 100644 --- a/tests/mcp_run.mjs +++ b/tests/mcp_run.mjs @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import { queueTracked } from '../web/modules/mcp_run.js'; // Only the host is substituted: its queue drains batches, emits request IDs, and calls widgets around submissions. -function host({ failAt = 0, manual = false } = {}) { +function host({ failAt = 0, manual = false, refuse = null } = {}) { const api = new EventTarget(); const events = (name, detail) => api.dispatchEvent(Object.assign(new Event(name), { detail })); let count = 0; @@ -10,7 +10,7 @@ function host({ failAt = 0, manual = false } = {}) { api.queuePrompt = async () => { const n = ++count; await Promise.resolve(); - if (n === failAt) throw new Error('invalid graph'); + if (n === failAt) throw Object.assign(new Error('invalid graph'), refuse ? { response: refuse, toString: () => 'Prompt outputs failed validation' } : {}); return { prompt_id: `p${n}` }; }; const app = { @@ -90,4 +90,14 @@ for (const manual of [false, true]) { delete app.nextQueueRequestId; await assert.rejects(queueTracked(app, api), /Update the frontend/); } +{ + // ComfyUI's refusal goes on as data too, for the server to say what to do next. + const refuse = { error: { type: 'prompt_outputs_failed_validation', message: 'Prompt outputs failed validation', details: '' }, + node_errors: { 3: { class_type: 'KSampler', errors: [{ type: 'required_input_missing', message: 'Required input is missing', details: 'model', extra_info: { input_name: 'model' } }] } } }; + const result = await queueTracked(...Object.values(host({ failAt: 1, refuse })).slice(0, 2)); + assert.equal(result.queued, 0); + assert.equal(result.refusal.type, 'prompt_outputs_failed_validation'); + assert.deepEqual(result.refusal.nodes, [{ node: '3', class_type: 'KSampler', type: 'required_input_missing', message: 'Required input is missing', + details: 'model', input: 'model', value: undefined }]); +} console.log('mcp_run: exact receipts, manual interleaving, partial failure, busy guard and cleanup passed'); diff --git a/tests/test_ai_panels.py b/tests/test_ai_panels.py index ce08a9b..2ca5a1b 100644 --- a/tests/test_ai_panels.py +++ b/tests/test_ai_panels.py @@ -429,6 +429,36 @@ class AskUserTests(unittest.TestCase): self.assertTrue(answer["keep_waiting"], "get_answers says it too") self.assertFalse(ai_answer_wait.typing("no_such_task")) + def test_on_the_users_word_time_running_out_takes_the_recommended_option(self): + from api import ai_decisions + options = [{"label": "只重画手指", "recommended": True}, {"label": "整张重画"}] + with mock.patch.object(mcp_task_tools, "ASK_WAIT_MIN", 0): + got = mcp_task_tools.ask_user(self.ctx, "手指怎么修?", options, wait_seconds=0) + plain = mcp_task_tools.ask_user(self.ctx, "几张?", ["一", "二"], wait_seconds=0) + task_id = ai_tasks.list_tasks()[0]["id"] + late = time.time() + ai_gates.answer_wait() + 5 + self.assertIn("end your turn", ai_answer_wait.wait_on(got["panel"], now=late)["note"], "by default nobody answers for the user") + with mock.patch.object(ai_gates, "no_answer", return_value="recommended"): + told = ai_answer_wait.wait_on(got["panel"], now=late) + self.assertIn("end your turn", ai_answer_wait.wait_on(plain["panel"], now=late)["note"], "no recommended option: left open") + self.assertEqual((told["keep_waiting"], told["took_recommended"]), (False, True)) + answer, _ = mcp_task_tools.get_answers(self.ctx, panel=got["panel"]) + item = answer["answers"][0] + self.assertEqual((item["labels"], item["recommended_on_timeout"]), (["只重画手指"], True)) + self.assertIn("in time", ai_decisions.lines(ai_tasks.get_task(task_id))[-1], "the decisions say how it was answered") + + def test_the_recommended_option_taken_on_time_comes_in_the_same_call(self): + options = [{"label": "只重画手指", "recommended": True}, {"label": "整张重画"}] + with mock.patch.object(mcp_task_tools, "ASK_WAIT_MIN", 0), mock.patch.object(ai_gates, "answer_wait", return_value=0): + asked = mcp_task_tools.ask_user(self.ctx, "手指怎么修?", options, wait_seconds=0) + self.assertFalse(asked["answered"], "the default: the AI waits for the user") + with mock.patch.object(ai_gates, "no_answer", return_value="recommended"): + answer, _ = mcp_task_tools.get_answers(self.ctx, panel=asked["panel"]) + again = mcp_task_tools.ask_user(self.ctx, "还要放大吗?", options, wait_seconds=0) + self.assertEqual(answer["answers"][0]["labels"], ["只重画手指"], "get_answers hands it over at once") + self.assertIn("took it for them", answer["note"]) + self.assertEqual((again["answered"], again["labels"], again["recommended_on_timeout"]), (True, ["只重画手指"], True)) + def test_a_note_or_panel_alone_says_to_ask_before_stopping(self): self.assertIn("ask_user", mcp_task_tools.report(self.ctx, "换好了 LoRA")["next"]) self.assertIn("ask it with ask_user", mcp_task_tools.report(self.ctx, "要不要再亮一点?")["next"]) diff --git a/tests/test_mcp_directory.py b/tests/test_mcp_directory.py index 453f0bf..86b8d1f 100644 --- a/tests/test_mcp_directory.py +++ b/tests/test_mcp_directory.py @@ -98,5 +98,31 @@ class DailyTests(unittest.TestCase): self.assertIn('tool_help(names=["report"])', failed["content"][0]["text"]) +class StaleListTests(unittest.TestCase): + """An app calling by a tool list it fetched before the user switched the set (mcp_directory.stale).""" + + def test_the_rule(self): + daily = action_catalog.DAILY | set(mcp_directory.DIRECTORY_TOOLS) + full = set(mcp_server.TOOLS) - set(mcp_directory.DIRECTORY_TOOLS) + self.assertFalse(mcp_directory.stale("report", False, full)) + self.assertTrue(mcp_directory.stale("tool_help", False, full), "the full set offers no tool_help") + self.assertTrue(mcp_directory.stale("download_model", True, full), "nor use_tool") + self.assertFalse(mcp_directory.stale("download_model", True, daily)) + self.assertTrue(mcp_directory.stale("report", True, daily), "report is offered directly") + self.assertTrue(mcp_directory.stale("download_model", False, daily), "a tool the daily list lacks") + + def test_the_ai_and_the_workbench_are_told_until_the_app_fetches_the_list(self): + call("begin_task", {"title": "旧列表", "new_task": True}) + with mock.patch.object(ai_gates, "tool_set", return_value="full"): + answer = call("tool_help", {"names": ["report"]}) + self.assertIn("older list", answer["structuredContent"]["tool_list"]) + self.assertIn("tool_list", answer["content"][0]["text"], "the text says it too") + task = next(item for item in ai_tasks.list_tasks() if item["title"] == "旧列表") + self.assertTrue(ai_tasks.get_task(task["id"])["stale_tools"]) + self.assertNotIn("tool_list", call("report", {"text": "好"})["structuredContent"], "a call by the list offered") + rpc("tools/list") + self.assertNotIn("stale_tools", ai_tasks.get_task(task["id"]), "fetched again: the note goes") + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_model_advice.py b/tests/test_model_advice.py new file mode 100644 index 0000000..69978d9 --- /dev/null +++ b/tests/test_model_advice.py @@ -0,0 +1,124 @@ +"""What Anomalous tells the AI about using a model well (api/model_advice.py): the settings of the +author's example pictures in get_model, and the note when add_lora or set_model puts a LoRA on a +main model of another base model.""" +import json +import os +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +PLUGIN = Path(__file__).resolve().parents[1] +sys.path[:0] = [str(PLUGIN), str(PLUGIN.parents[1])] + +from api import mcp_actions, model_advice # noqa: E402 + +SHA = "abcdef0123" + "0" * 54 + + +def pictures(*metas): + return {"id": 77, "baseModel": "Illustrious", "images": [{"url": "x", "meta": meta} for meta in metas] + [{"url": "y", "meta": None}]} + + +class ExampleTests(unittest.TestCase): + def setUp(self): + self.folder = tempfile.mkdtemp(prefix="amb_advice_") + self.model = os.path.join(self.folder, "glow_style.safetensors") + Path(self.model).write_bytes(b"x") + + def keep(self, record): + Path(self.folder, "glow_style.civitai.info").write_text(json.dumps(record), encoding="utf-8") + + def test_the_authors_settings_summed_up(self): + self.keep(pictures( + {"sampler": "Euler a", "steps": 28, "cfgScale": 5, "Size": "832x1216", "Clip skip": "2", + "resources": [{"type": "lora", "name": "glow_style", "hash": "ABCDEF0123", "weight": 0.8}]}, + {"sampler": "Euler a", "steps": "30", "cfgScale": 6, "width": 832, "height": 1216, "clipSkip": 2, + "additionalResources": [{"type": "lora", "name": "loras/glow_style.safetensors", "strength": 0.6}]}, + {"sampler": "DPM++ 2M", "scheduler": "karras", "steps": 25, "cfgScale": 5, "Size": "1024x1024", + "civitaiResources": [{"type": "lora", "modelVersionId": 77, "weight": 1}]}, + )) + found = model_advice.author_examples(self.model, "loras", SHA) + self.assertEqual(found["pictures"], 3, "pictures without settings are left out") + self.assertEqual(found["sampler"], "Euler a (2 of 3)") + self.assertEqual(found["steps"], "25–30") + self.assertEqual(found["cfg"], "5–6") + self.assertEqual(found["size"], "832x1216 (2 of 3)") + self.assertEqual(found["clip_skip"], "2") + self.assertEqual(found["lora_weight"], "0.6–1", "by hash, by file name and by Civitai version") + self.assertEqual(found["scheduler"], "karras (1 of 3)", "counted against all the pictures") + + def test_the_lora_most_pictures_name_is_this_one(self): + lora = lambda name, weight: {"type": "lora", "name": name, "weight": weight} + self.keep(pictures( + {"sampler": "Undefined", "resources": [lora("authors_own_name", 0.7), lora("other_style", 1)]}, + {"resources": [lora("authors_own_name", 0.9)]}, + {"resources": [lora("authors_own_name", 0.8), lora("third", 1)]}, + )) + found = model_advice.author_examples(self.model, "loras") + self.assertEqual(found["lora_weight"], "0.7–0.9", "named in every picture: the author's name for this file") + self.assertNotIn("sampler", found, "Civitai's 'Undefined' is no sampler") + self.keep(pictures({"resources": [lora("a", 0.5), lora("b", 1)]}, {"resources": [lora("a", 0.5), lora("b", 1)]})) + self.assertNotIn("lora_weight", model_advice.author_examples(self.model, "loras"), "two named as often: no guess") + + def test_nothing_kept_says_nothing(self): + self.assertEqual(model_advice.author_examples(self.model, "loras"), {}) + self.keep(pictures()) + self.assertEqual(model_advice.author_examples(self.model, "checkpoints"), {}) + + def test_a_checkpoint_has_no_weight(self): + self.keep(pictures({"sampler": "Euler", "steps": 20})) + self.assertNotIn("lora_weight", model_advice.author_examples(self.model, "checkpoints")) + + +class BaseModelTests(unittest.TestCase): + def bases(self, **by_path): + get = mock.patch.object(model_advice.metadata, "get_metadata", side_effect=lambda path: {"baseModel": by_path.get(path, "")}) + full = mock.patch.object(model_advice.folder_paths, "get_full_path", + side_effect=lambda kind, name: name if kind in ("checkpoints", "loras") and name in by_path else None) + for patcher in (get, full): + patcher.start() + self.addCleanup(patcher.stop) + + def test_families(self): + self.assertEqual(model_advice.family("SDXL 1.0"), model_advice.family("Pony")) + self.assertEqual(model_advice.family("Illustrious"), "SDXL") + self.assertEqual(model_advice.family("SD 1.5"), "SD 1.x") + self.assertEqual(model_advice.family("Flux.1 D"), model_advice.family("Chroma")) + self.assertEqual(model_advice.family("Other"), "") + + def test_another_family_will_not_work(self): + self.bases(lora="SD 1.5", main="SDXL 1.0") + note = model_advice.lora_note("lora", {"model": "main"}) + self.assertIn("clash", note) + self.assertIn("base_model='SDXL 1.0'", note) + + def test_a_branch_of_the_same_family_may_look_off(self): + self.bases(lora="Pony", main="Illustrious") + self.assertIn("same family", model_advice.lora_note("lora", {"model": "main"})) + + def test_the_same_base_or_an_unknown_one_says_nothing(self): + self.bases(lora="SDXL 1.0", main="sdxl 1.0", plain="", flux_d="Flux.1 D", flux_s="Flux.1 S") + self.assertIsNone(model_advice.lora_note("lora", {"model": "main"})) + self.assertIsNone(model_advice.lora_note("plain", {"model": "main"})) + self.assertIsNone(model_advice.lora_note("lora", {"model": "not_there"})) + self.assertIsNone(model_advice.lora_note("flux_d", {"model": "flux_s"})) + + def test_add_lora_and_set_model_answer_with_the_note(self): + self.bases(**{"old.safetensors": "SD 1.5", "main.safetensors": "SDXL 1.0"}) + line = {"model": "main.safetensors", "loras": ["old.safetensors"]} + with mock.patch.object(mcp_actions, "_resolve_model", return_value=("loras", 0, "", "old.safetensors")), \ + mock.patch.object(mcp_actions, "trains_text_encoder", return_value=False), \ + mock.patch.object(mcp_actions, "_page", return_value={"node_id": 9, "line": line}): + answer = mcp_actions.add_lora({}, "m") + self.assertIn("clash", answer["base_model_note"]) + self.assertNotIn("line", answer, "the line is the server's, not the AI's") + with mock.patch.object(mcp_actions, "_resolve_model", return_value=("checkpoints", 0, "", "main.safetensors")), \ + mock.patch.object(mcp_actions, "_page", return_value={"node_id": 4, "line": dict(line)}): + answer = mcp_actions.set_model({}, 4, "m") + self.assertIn("clash", answer["base_model_note"], "a new main model is checked against the LoRAs after it") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_run_refusal.py b/tests/test_run_refusal.py new file mode 100644 index 0000000..dd7aa21 --- /dev/null +++ b/tests/test_run_refusal.py @@ -0,0 +1,56 @@ +"""ComfyUI's refusal of a run said as what to do next (api/run_refusal.py), in run_workflow's answer.""" +import sys +import unittest +from pathlib import Path +from unittest import mock + +PLUGIN = Path(__file__).resolve().parents[1] +sys.path[:0] = [str(PLUGIN), str(PLUGIN.parents[1])] + +from api import mcp_actions, run_refusal # noqa: E402 +from api.mcp_tools import ToolError # noqa: E402 + + +def node(node_id, kind, **extra): + return {"node": node_id, "class_type": "KSampler", "type": kind, "message": "Problem", "details": "", **extra} + + +class SayTests(unittest.TestCase): + def test_each_node_problem_names_the_node_and_the_tool(self): + text = run_refusal.say({"type": "prompt_outputs_failed_validation", "nodes": [ + node("3", "required_input_missing", input="model"), + node("4", "value_not_in_list", input="ckpt_name", value="gone.safetensors"), + node("5", "value_bigger_than_max", input="cfg", value="80", details="max 30"), + ]}) + self.assertIn("node 3 (KSampler) has nothing wired into model: connect_nodes", text) + self.assertIn("ckpt_name = gone.safetensors is not one of its choices", text) + self.assertIn("fix_workflow_models", text) + self.assertIn("cfg = 80 is above its maximum (max 30)", text) + self.assertTrue(text.endswith("run_workflow again.")) + + def test_the_whole_prompt_and_unknown_kinds(self): + self.assertIn("SaveImage", run_refusal.say({"type": "prompt_no_outputs", "message": "Prompt has no outputs", "nodes": []})) + self.assertIn("node 9 (KSampler): Odd thing it said", run_refusal.say({"nodes": [ + {"node": "9", "class_type": "KSampler", "type": "custom_validation_failed", "message": "Odd thing it said"}]})) + self.assertIn("Server down 500", run_refusal.say({"type": "server_error", "message": "Server down", "details": "500", "nodes": []})) + self.assertEqual(run_refusal.say(None), "") + self.assertEqual(run_refusal.say({"nodes": []}), "") + + def test_many_problems_are_cut_short(self): + text = run_refusal.say({"nodes": [node(str(n), "required_input_missing", input="model") for n in range(12)]}) + self.assertIn("and 4 more", text) + + +class RunTests(unittest.TestCase): + def test_run_workflow_says_what_to_fix(self): + refused = {"prompt_ids": [], "queued": 0, "requested": 1, "submission_error": "Prompt outputs failed validation", + "refusal": {"type": "prompt_outputs_failed_validation", "nodes": [node("3", "required_input_missing", input="model")]}} + with mock.patch.object(mcp_actions.mcp_fill, "fill", return_value=None), \ + mock.patch.object(mcp_actions, "ask_page", return_value=refused): + with self.assertRaises(ToolError) as caught: + mcp_actions.run_workflow({}) + self.assertIn("node 3 (KSampler) has nothing wired into model", str(caught.exception)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_tool_budget.py b/tests/test_tool_budget.py new file mode 100644 index 0000000..096da02 --- /dev/null +++ b/tests/test_tool_budget.py @@ -0,0 +1,48 @@ +"""The tool budget: what AI apps are handed stays small enough for a light model (Gemini Flash is the +yardstick). On 2026-10-26 Gemini 3.8 Flash ran about 570 calls over the full set in Antigravity with +hardly a wrong one, so the set as it is, is the ceiling: a new tool, or a longer description, has to +make room by merging or cutting. Raise a number here only together with a new Flash run that +shows it still goes well (docs/architecture/tool-exam.md).""" +import json +import sys +import unittest +from pathlib import Path +from unittest import mock + +PLUGIN = Path(__file__).resolve().parents[1] +sys.path[:0] = [str(PLUGIN), str(PLUGIN.parents[1])] + +from api import ai_gates, mcp_server # noqa: E402 + +MAX_TOOLS = 53 # the full set +MAX_CHARS = 44000 # the full list as AI apps get it (about 11k tokens) +MAX_DAILY_CHARS = 29000 +MAX_DESCRIPTION = 700 # a tool's own description +LONGER = {"show_panel": 2100} # its blocks have no schema of their own: the description is their format +MAX_REQUIRED = 2 # what a call needs at least; the rest has a default +INSTRUCTIONS_CHARS = 3400 + + +def listed(tool_set): + with mock.patch.object(ai_gates, "tool_set", return_value=tool_set): + return mcp_server._tool_list(True)["tools"] + + +class BudgetTests(unittest.TestCase): + def test_the_set_does_not_grow(self): + tools = listed("full") + self.assertLessEqual(len(tools), MAX_TOOLS, "a new tool: merge or cut one") + self.assertLessEqual(len(json.dumps(tools, ensure_ascii=False)), MAX_CHARS) + self.assertLessEqual(len(json.dumps(listed("daily"), ensure_ascii=False)), MAX_DAILY_CHARS) + self.assertLessEqual(len(mcp_server.INSTRUCTIONS), INSTRUCTIONS_CHARS) + + def test_each_tool_is_short_and_needs_little(self): + for tool in listed("full"): + with self.subTest(tool=tool["name"]): + self.assertLessEqual(len(tool.get("description") or ""), LONGER.get(tool["name"], MAX_DESCRIPTION)) + self.assertLessEqual(len(tool["inputSchema"].get("required") or []), MAX_REQUIRED) + self.assertTrue(tool.get("description"), "every tool says what it is for") + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/tool_exam.mjs b/tools/tool_exam.mjs new file mode 100644 index 0000000..057da0b --- /dev/null +++ b/tools/tool_exam.mjs @@ -0,0 +1,53 @@ +// The exam's scorecard (docs/architecture/tool-exam.md): how an AI did on each task the AI +// workbench kept, read from its task files. Read only. +// node tools/tool_exam.mjs [--since=2026-10-26T20:00] +// Per task: the AI's calls, the failed ones, detours (tool_help, or a call by an old tool list), +// questions asked and answered, runs (and the failed ones), whether it ended, and its minutes. +import fs from 'node:fs'; +import path from 'node:path'; + +const [folder, ...rest] = process.argv.slice(2); +if (!folder || !fs.existsSync(folder)) { + console.error('Usage: node tools/tool_exam.mjs <…/user/anomalous/ai_tasks> [--since=YYYY-MM-DDTHH:MM]'); + process.exit(2); +} +const since = rest.find(arg => arg.startsWith('--since=')); +const from = since ? Date.parse(since.slice(8)) / 1000 : 0; +if (Number.isNaN(from)) { + console.error('--since is a date and time, like 2026-10-26T20:00'); + process.exit(2); +} + +/** The scorecard row of one task (its saved JSON). */ +function score(task) { + const steps = task.steps || []; + const ai = steps.filter(step => step.kind !== 'user'); + const asks = ai.filter(step => step.tool === 'ask_user' || (step.kind === 'panel' && (step.panel?.blocks || []) + .some(block => ['choices', 'input', 'picks'].includes(block.type)))); + const runs = ai.filter(step => step.kind === 'run'); + const last = Math.max(task.updated || 0, ...steps.map(step => (step.at || 0) + (step.ms || 0) / 1000)); + return { + task: String(task.title || task.id).slice(0, 28), app: task.client || '', calls: ai.length, + failed: ai.filter(step => step.ok === false).length, + detours: ai.filter(step => step.tool === 'tool_help').length + (task.stale_tools ? 1 : 0), + asked: asks.length, answered: asks.filter(step => Object.keys(step.answers || {}).length).length, + runs: runs.length, runs_failed: runs.filter(step => step.ok === false).length, + ended: task.ended ? 'yes' : 'no', minutes: task.started ? Math.round((last - task.started) / 6) / 10 : 0, + }; +} + +const rows = fs.readdirSync(folder).filter(name => name.endsWith('.json')) + .map(name => { try { return JSON.parse(fs.readFileSync(path.join(folder, name), 'utf8')); } catch { return null; } }) + .filter(task => task && (task.steps || []).some(step => step.kind !== 'user') && (task.started || 0) >= from) + .sort((a, b) => (a.started || 0) - (b.started || 0)) + .map(score); +if (!rows.length) { + console.log('No AI task in that folder' + (since ? ' since then.' : '.')); + process.exit(0); +} +const total = Object.fromEntries(Object.keys(rows[0]).map(key => [key, typeof rows[0][key] === 'number' + ? Math.round(rows.reduce((sum, row) => sum + row[key], 0) * 10) / 10 : ''])); +total.task = `all ${rows.length}`; +console.table([...rows, total]); +const calls = total.calls || 1; +console.log(`Failed calls ${Math.round(total.failed * 100 / calls)}%, detours ${Math.round(total.detours * 100 / calls)}% of ${total.calls} calls.`); diff --git a/web/modules/graph_splice.js b/web/modules/graph_splice.js index 5ad8d41..1b09d25 100644 --- a/web/modules/graph_splice.js +++ b/web/modules/graph_splice.js @@ -48,16 +48,35 @@ function inputSource(graph, node, slot) { return origin ? { node: origin, slot: link.origin_slot } : null; } -/** Follows `type` from `node` through the LoRA loaders chained after it: the last of them. */ -export function chainEnd(graph, node, type) { +/** The LoRA loaders chained after `node` on its `type` line, in order. */ +export function loraChain(graph, node, type) { + const chain = []; let end = node; for (let hop = 0; hop < 20; hop++) { const slot = findSlotIndex(end.outputs, type); const targets = slot < 0 ? [] : outputLinks(graph, end, slot); if (targets.length !== 1 || !/lora/i.test(targets[0].targetNode.type || '')) break; end = targets[0].targetNode; + chain.push(end); } - return end; + return chain; +} + +/** Follows `type` from `node` through the LoRA loaders chained after it: the last of them. */ +export function chainEnd(graph, node, type) { + return loraChain(graph, node, type).at(-1) || node; +} + +/** The node the MODEL line through `node` starts at (its loader): `node` itself when it takes no MODEL. */ +export function modelLineStart(graph, node) { + let at = node; + for (let hop = 0; hop < 20; hop++) { + const slot = findSlotIndex(at.inputs, 'MODEL'); + const source = slot < 0 ? null : inputSource(graph, at, slot); + if (!source) break; + at = source.node; + } + return at; } /** The workflow's main model loaders (checkpoint, UNet), bypassed ones left out. */ diff --git a/web/modules/locales.js b/web/modules/locales.js index c35a556..692a7ba 100644 --- a/web/modules/locales.js +++ b/web/modules/locales.js @@ -1212,6 +1212,7 @@ export const i18n = { aiTaskCount: "{count} 个", aiUntitled: "AI 的任务", aiStepCount: "{count} 步", + aiStaleTools: "AI 软件拿的还是旧的工具列表,会多走弯路。请重新连接:Antigravity 重启一下,或在它的 MCP 设置里把 anomalous 关掉再打开;然后开个新对话。", aiAllTasks: "所有任务", aiPickTask: "在左边选一个任务。", aiNoSteps: "这个任务还没有步骤。", @@ -1275,7 +1276,7 @@ export const i18n = { aiCanvasTab_new: "改副本(新标签页)", aiCanvasTab_current: "改当前画布", aiGate_tools: "给 AI 的工具", - aiGateWhy_tools: "日常:直接给常用工具,包括出图、看图、换模型、查缺失模型、现状一览、展台面板,以及自动接线搭工作流,约 2.3 万字;其余的(下载、扫描、配音、搭配、提示词等)AI 照开始任务时收到的目录现查现用(tool_help 查说明,use_tool 调用)。完整:全部工具都直接给,约 4 万字。换了以后在 AI 软件里开个新对话才生效。", + aiGateWhy_tools: "完整(推荐):全部工具都直接给,约 4.3 万字。Antigravity 会在第一次用某个工具前自己去读它的说明,选这个就对了。日常:只直接给常用的(出图、看图、换模型、查缺失模型、现状一览、展台面板、自动接线搭工作流),约 2.8 万字,其余的 AI 照目录现查现用(tool_help 查说明,use_tool 调用);只给工具数量有上限的 AI 软件用,否则 AI 会多绕路。换了以后要让 AI 软件重新连接(Antigravity:重启它,或在它的 MCP 设置里把 anomalous 关掉再打开),再开个新对话;没换过来的话,任务标题下面会提醒你。", aiToolSet_daily: "日常", aiToolSet_full: "完整", aiGate_answerWait: "等我回答", @@ -1283,6 +1284,10 @@ export const i18n = { aiAnswerWait_60: "1 分钟", aiAnswerWait_180: "3 分钟", aiAnswerWait_600: "10 分钟", + aiGate_noAnswer: "等不到回答时", + aiGateWhy_noAnswer: "到了上面“等我回答”的时间你还没答:停下等我,AI 收工,等你答完叫它;按推荐的继续,AI 标了推荐项的选择题就按推荐的选,AI 接着做,选了什么在展台和“决策”里都写着“到时间,按推荐选了”。没有推荐项的问题、关卡里“先问我”的事,永远不会替你答。马上生效。", + aiNoAnswer_stop: "停下等我", + aiNoAnswer_recommended: "按推荐的继续", aiGate_runs: "等跑图", aiGateWhy_runs: "跑完叫我:AI 提交后最多等 1 分钟,跑得快的当场就拿到结果;还没跑完就告诉你在跑,然后收工,不再一遍遍查进度。跑完后留言框上方有几句话,点一下复制,粘到 AI 的对话框里,它就接着看结果。AI 自己等:AI 每 55 秒查一次,直到跑完。马上生效。", aiRunWait_call: "跑完叫我", @@ -1772,6 +1777,8 @@ export const i18n = { aiPanelSeen: "AI 已收到你的回答", aiPanelSkip: "让 AI 定", aiPanelSkipped: "交给 AI 定了", + aiPanelTimedOut: "到时间没回答,按 AI 推荐的选了", + aiPanelTookRecommended: "{answer}(到时间,按推荐选了)", aiPanelHintOne: "点一个就发给 AI", aiPanelHintSome: "可以选几个,选好点“发送”", aiPanelOwnWords: "都不是,我自己说…", @@ -1899,6 +1906,7 @@ export const i18n = { aiTool_open_in_anomalous: "打开模型页给你看", aiTool_report: "AI 留言", aiTool_get_situation: "看了现状一览", + aiTool_tool_help: "查了工具说明", aiTool_outside_files: "AMB 发现有图片在它之外被改动", aiTool_log_outside: "AI 说明在 AMB 之外做的事", aiOutside_new: "新建了 {name}(不是出图产生的)", @@ -4613,6 +4621,7 @@ export const i18n = { aiTaskCount: "{count}", aiUntitled: "AI task", aiStepCount: "{count} {count|step|steps}", + aiStaleTools: "The AI app still has an older list of Anomalous's tools, so it takes detours. Reconnect it: restart Antigravity, or switch the anomalous MCP server off and on in its settings; then start a new chat.", aiAllTasks: "All tasks", aiPickTask: "Pick a task on the left.", aiNoSteps: "No steps in this task yet.", @@ -4676,7 +4685,7 @@ export const i18n = { aiCanvasTab_new: "Edit a copy (new tab)", aiCanvasTab_current: "Edit the open canvas", aiGate_tools: "Tools for the AI", - aiGateWhy_tools: "Daily: the everyday tools directly (runs, pictures, models, missing models, where things stand, panels, building with auto-wiring), about 23,000 characters; the AI reaches the others (downloads, scans, voices, combos, prompts…) through the directory it gets when it begins a task (tool_help tells what one takes, use_tool calls it). Full: every tool directly, about 40,000 characters. A change takes effect in a new chat in the AI app.", + aiGateWhy_tools: "Full (recommended): every tool directly, about 43,000 characters. Antigravity reads a tool's description itself before it first uses it, so this is the one for it. Daily: only the common ones directly (runs, pictures, models, missing models, the situation, stage panels, building workflows with automatic wiring), about 28,000 characters; the AI looks the rest up in the directory (tool_help) and calls them through use_tool. Only for AI apps that take a limited number of tools: otherwise the AI takes detours. After a change, reconnect the AI app (Antigravity: restart it, or switch the anomalous MCP server off and on in its settings) and start a new chat; if it still has the old list, the task header tells you.", aiToolSet_daily: "Daily", aiToolSet_full: "Full", aiGate_answerWait: "Waiting for your answer", @@ -4684,6 +4693,10 @@ export const i18n = { aiAnswerWait_60: "1 minute", aiAnswerWait_180: "3 minutes", aiAnswerWait_600: "10 minutes", + aiGate_noAnswer: "When I don't answer", + aiGateWhy_noAnswer: "Your waiting time above ran out unanswered: with \"Wait for me\" the AI ends its turn until you answer and call it; with \"Go with the recommendation\" a choice the AI marked a recommended option for takes it and the AI goes on, shown on the stage and under Decisions as taken on time-out. A question without a recommendation, and anything a gate asks you first, is never answered for you. Takes effect at once.", + aiNoAnswer_stop: "Wait for me", + aiNoAnswer_recommended: "Go with the recommendation", aiGate_runs: "Waiting for runs", aiGateWhy_runs: "Tell AI when done: the AI waits up to a minute after queueing, so a quick run comes back with its pictures at once; if it still goes, the AI says so and ends its turn instead of checking again and again. When it is done, lines over the message box are ready to copy into the AI's chat, and it goes on with the result. AI waits: it checks every 55 s until the run is done. Takes effect at once.", aiRunWait_call: "Tell AI when done", @@ -5173,6 +5186,8 @@ export const i18n = { aiPanelSeen: "The AI has your answer", aiPanelSkip: "Let the AI decide", aiPanelSkipped: "Left to the AI", + aiPanelTimedOut: "No answer in time: the AI's recommendation was taken", + aiPanelTookRecommended: "{answer} (time ran out: the recommendation)", aiPanelHintOne: "One press sends it to the AI", aiPanelHintSome: "Pick any, then Send", aiPanelOwnWords: "None of these, I'll say it…", @@ -5300,6 +5315,7 @@ export const i18n = { aiTool_open_in_anomalous: "Showed you a model", aiTool_report: "Note from the AI", aiTool_get_situation: "Looked at where things stand", + aiTool_tool_help: "Looked up how a tool works", aiTool_outside_files: "Pictures changed outside AMB", aiTool_log_outside: "What the AI did outside AMB", aiOutside_new: "New: {name} (no run made it)", diff --git a/web/modules/mcp_bridge.js b/web/modules/mcp_bridge.js index f646d18..19aa729 100644 --- a/web/modules/mcp_bridge.js +++ b/web/modules/mcp_bridge.js @@ -18,7 +18,7 @@ import { canvasBrief } from './mcp_canvas_brief.js'; import { canvasEditActions } from './mcp_canvas_edit.js'; import { onTaskTab } from './mcp_task_tab.js'; import { recordCanvasStep } from './canvas_history.js'; -import { chainEnd, mainModelLoaders, planLoraInsertion, spliceLora } from './graph_splice.js'; +import { chainEnd, loraChain, mainModelLoaders, modelLineStart, planLoraInsertion, spliceLora } from './graph_splice.js'; import { checkWorkflowModels, fixWorkflowModels, isProblem } from './model_check.js'; import { downloadFrom, downloadMissing } from './model_download.js'; import { promptBoxes } from './prompt_boxes.js'; @@ -160,8 +160,15 @@ function setPrompt({ text, role, node_id: nodeId, mode }, getOwner) { }); } +/** The MODEL line through `node` by its drop-down values: its loader's and its LoRAs', for the server's base-model check (model_advice.py). */ +function modelLine(g, node) { + const start = modelLineStart(g, node); + const value = item => findModelComboWidget(item)?.value || ''; + return { model: value(start), loras: loraChain(g, start, 'MODEL').map(value).filter(Boolean) }; +} + function setModel({ node_id: nodeId, model }, getOwner) { - return recorded(getOwner, 'mcpDidModel', () => { + return recorded(getOwner, 'mcpDidModel', (g) => { const node = nodeById(nodeId); const widget = findModelComboWidget(node); if (!widget) throw new Error(`Node ${nodeId} (${node.type}) has no model drop-down.`); @@ -170,7 +177,7 @@ function setModel({ node_id: nodeId, model }, getOwner) { const old = writeWidget(node, widget, value); delete node.color; delete node.bgcolor; - return { node_id: node.id, before: old, after: value, toast: { name: value.split(/[\\/]/).pop() } }; + return { node_id: node.id, before: old, after: value, line: modelLine(g, node), toast: { name: value.split(/[\\/]/).pop() } }; }); } @@ -205,6 +212,7 @@ function addLora({ model, text_encoder: textEncoder, after_node_id: afterId, str recordCanvasStep(app); return { node_id: node.id, node_type: node.type, lora: value, model_from: modelEnd.id, ...(clipEnd ? { clip_from: clipEnd.id } : {}), + line: modelLine(g, node), ...(textEncoder && !clipEnd ? { note: 'The LoRA has text-encoder weights but no single CLIP source was found; only the model is patched.' } : {}), toast: { name: value.split(/[\\/]/).pop() }, }; diff --git a/web/modules/mcp_run.js b/web/modules/mcp_run.js index b396e03..1a9fd27 100644 --- a/web/modules/mcp_run.js +++ b/web/modules/mcp_run.js @@ -1,6 +1,18 @@ // ComfyUI's own words when it refuses a prompt (which node, what is wrong), not just "failed". const reason = failure => String((failure?.response && failure.toString?.()) || failure?.message || failure).slice(0, 800); +/** The same refusal as data (the frontend's PromptExecutionError.response), for the server to say what to do next (run_refusal.py). */ +const refusal = failure => { + const response = failure?.response; + if (!response || typeof response !== 'object') return null; + const top = typeof response.error === 'string' ? { message: response.error } : (response.error || {}); + const nodes = Object.entries(response.node_errors || {}).slice(0, 12).flatMap(([node, item]) => (item?.errors || []).slice(0, 4).map(error => ({ + node, class_type: item?.class_type, type: error?.type, message: error?.message, details: String(error?.details ?? '').slice(0, 200), + input: error?.extra_info?.input_name, value: error?.extra_info?.received_value == null ? undefined : String(error.extra_info.received_value).slice(0, 120), + }))); + return { type: top.type, message: top.message, details: String(top.details ?? '').slice(0, 300), nodes }; +}; + /** Capture submission receipts from one native ComfyUI queue request, preserving its seed/widget hooks. */ export async function queueTracked(app, api, batch = 1) { if (app.processingQueue) throw new Error('ComfyUI is submitting another batch. Wait for submission to finish, then try again.'); @@ -10,7 +22,7 @@ export async function queueTracked(app, api, batch = 1) { const requestId = app.nextQueueRequestId; const original = api.queuePrompt; const ids = []; - let capturing = false, error = '', attempts = 0; + let capturing = false, error = '', refused = null, attempts = 0; let complete; const ownDone = new Promise(resolve => { complete = resolve; }); const started = event => { if (event.detail?.requestId === requestId) capturing = true; }; @@ -27,7 +39,7 @@ export async function queueTracked(app, api, batch = 1) { } return result; } catch (failure) { - if (ours) { error = reason(failure); capturing = false; complete(); } + if (ours) { error = reason(failure); refused = refusal(failure); capturing = false; complete(); } throw failure; // the native error display and partial-batch handling still run } }; @@ -38,7 +50,7 @@ export async function queueTracked(app, api, batch = 1) { // Native queuePrompt may also drain later manual requests. Return our receipt as soon as // our batch ends, but still handle a later rejection from the native queue promise. const nativeDone = Promise.resolve(app.queuePrompt(0, batch)).catch(failure => { - if (ids.length < batch) error ||= reason(failure); + if (ids.length < batch) { error ||= reason(failure); refused ||= refusal(failure); } }); await Promise.race([nativeDone, ownDone]); } catch (failure) { @@ -50,5 +62,5 @@ export async function queueTracked(app, api, batch = 1) { api.removeEventListener('promptQueued', finished); } if (ids.length < batch) error ||= `Only ${ids.length} of ${batch} prompts were accepted. Check ComfyUI before retrying.`; - return { prompt_ids: ids, queued: ids.length, requested: batch, ...(error ? { submission_error: error } : {}) }; + return { prompt_ids: ids, queued: ids.length, requested: batch, ...(error ? { submission_error: error } : {}), ...(refused ? { refusal: refused } : {}) }; } diff --git a/web/modules/ui_ai_decisions.js b/web/modules/ui_ai_decisions.js index c724618..e965028 100644 --- a/web/modules/ui_ai_decisions.js +++ b/web/modules/ui_ai_decisions.js @@ -63,7 +63,7 @@ export function decisionCard(record, numberOf = null) { function described(item) { switch (item.type) { case 'logged': return [item.title, item.decision]; - case 'answer': return [item.question || t('aiDecisionAnswer'), item.skipped ? t('aiPanelSkipped') : item.answer]; + case 'answer': return [item.question || t('aiDecisionAnswer'), item.skipped ? t('aiPanelSkipped') : item.timed_out ? t('aiPanelTookRecommended', { answer: item.answer }) : item.answer]; case 'gate': return [t(toolKey(item.tool)), [t(item.allowed ? 'aiDecisionAllowed' : 'aiDecisionRefused'), item.text].filter(Boolean).join(':')]; case 'toolbox': return [t('aiDecisionToolbox'), item.count ? t('aiDecisionToolboxText', { count: item.count, mode: t(`aiTbMode_${item.mode}`) }) : t('aiToolboxCleared')]; case 'canvas': return [t('aiDecisionCanvas'), t(item.mode === 'new' ? 'aiCanvasChoseNew' : 'aiCanvasChoseCurrent')]; diff --git a/web/modules/ui_ai_gate.js b/web/modules/ui_ai_gate.js index 3a32799..169c719 100644 --- a/web/modules/ui_ai_gate.js +++ b/web/modules/ui_ai_gate.js @@ -144,6 +144,8 @@ export async function openGates() { async set => (await save({ tools: set })).tools), choiceRow('answerWait', [60, 180, 600], known.answer_wait || 180, seconds => t(`aiAnswerWait_${seconds}`), async seconds => (await save({ answer_wait: seconds })).answer_wait), + choiceRow('noAnswer', ['stop', 'recommended'], known.no_answer || 'stop', mode => t(`aiNoAnswer_${mode}`), + async mode => (await save({ no_answer: mode })).no_answer), choiceRow('runs', ['call', 'wait'], known.runs || 'call', mode => t(`aiRunWait_${mode}`), async mode => (await save({ runs: mode })).runs), el('p', 'anomalous-ai-gates-note', t('aiGatesNote'))); diff --git a/web/modules/ui_ai_panel.js b/web/modules/ui_ai_panel.js index 14c341e..b9969bc 100644 --- a/web/modules/ui_ai_panel.js +++ b/web/modules/ui_ai_panel.js @@ -62,6 +62,7 @@ export function noteTyping(taskId) { function answerNote(answer) { const box = el('div', 'anomalous-ai-panel-answer'); if (answer.skipped) box.append(el('div', 'anomalous-ai-panel-typed is-skipped', t('aiPanelSkipped'))); + if (answer.recommended_on_timeout) box.append(el('div', 'anomalous-ai-panel-typed is-skipped', t('aiPanelTimedOut'))); box.append(deliveryState(answer, 'aiPanelSeen')); return box; } diff --git a/web/modules/ui_ai_workbench.js b/web/modules/ui_ai_workbench.js index fb2cab8..bbab13a 100644 --- a/web/modules/ui_ai_workbench.js +++ b/web/modules/ui_ai_workbench.js @@ -255,6 +255,7 @@ function renderTask() { const meta = [task.client, `${dayLabel(task.started)} ${timeLabel(task.started)}`, t('aiStepCount', { count: (task.steps || []).length }), ...taskCounts(summary).map(say)].filter(Boolean); header.replaceChildren(titleLine, el('div', 'anomalous-ai-task-meta', meta.join(' · ')), + ...(task.stale_tools ? [el('div', 'anomalous-ai-task-stale', t('aiStaleTools'))] : []), // mcp_directory.stale taskLinks(task, state.tasks, { open: (id) => { state.pinned = true; void select(id); }, changed: refresh })); const flow = flowMap(task, stepId => showStep(view.timeline, stepId)); diff --git a/web/styles/37-ai-workbench.css b/web/styles/37-ai-workbench.css index aec6d01..a900650 100644 --- a/web/styles/37-ai-workbench.css +++ b/web/styles/37-ai-workbench.css @@ -381,6 +381,18 @@ font-size: 12px; } +/* The AI app calls by an older tool list: how to have it fetch the new one. */ +.anomalous-ai-task-stale { + margin-top: 6px; + padding: 6px 10px; + border: 1px solid color-mix(in srgb, var(--amb-warn) 45%, transparent); + border-radius: 8px; + background: color-mix(in srgb, var(--amb-warn) 12%, transparent); + color: var(--amb-warn-text); + font-size: 12.5px; + line-height: 1.5; +} + /* A way somewhere or something to do under a step: a small button that looks pressable, its icon in the link colour. */ .anomalous-ai-link { display: inline-flex;