From 2280e55fdeb99f13a7fcd0296d0c2fbcc711488c Mon Sep 17 00:00:00 2001 From: DemonGatanjieu <207458273+DemonGatanjieu@users.noreply.github.com> Date: Fri, 6 Nov 2026 17:51:12 +0800 Subject: [PATCH] Let an AI read the workflows it is offered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A run with Gemini 3.8 Flash asked for picture to picture and built it node by node, 13 failed calls on the way, with an official example ready: - list_workflows was 23,630 characters on 17 cards, 21,033 of them settings. Antigravity files an answer over about 4,000 away, unread. A list of more than 3 now goes with name, route, tags and short notes. - Examples showed only when no saved workflow matched, and only by English words: 图生图 found nothing. They are always named now, and found by their Chinese titles, img2img, txt2img and inpaint. - Every card's route line was empty, and cards_of found nothing, since c84b596: _list_recipes leaves the graph out, and the tests fed cards that had one. Both ask for it now, with a test on a file on disk. A waiting task the user wrote to also takes its name from what they wrote, instead of the AI's "take the task". Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 3 ++- CHANGELOG.md | 4 ++++ api/ai_tasks.py | 5 +++-- api/example_workflows.py | 23 ++++++++++++++++++++++- api/examples/picture_to_picture.json | 2 +- api/examples/repaint.json | 2 +- api/examples/text_to_picture.json | 2 +- api/mcp_routes.py | 2 +- api/mcp_tools.py | 8 ++++---- api/recipe_store.py | 4 ++-- docs/architecture/ui-flows.md | 2 +- docs/guides/mcp.md | 2 +- tests/test_ai_tasks.py | 6 ++++++ tests/test_example_workflows.py | 20 +++++++++++++++++++- tests/test_workflow_route.py | 15 ++++++++++++++- 15 files changed, 82 insertions(+), 18 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 182a8f8..2f3b22a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1118,7 +1118,8 @@ covered by the `styles.css` manifest. `api/example_workflows.py` serves the examples Anomalous ships (`api/examples/*.json`: plans, a version per model family, no model or picture named: filled in from what is here, what is missing said); `ui_recipe_examples.js` is their one card and window on the Workflows page, and list_workflows gives those - that can run here to an AI when no saved workflow fits. + that can run here to an AI when no saved workflow fits, and names them when one does. A long list goes without + settings: an AI app files an answer over about 4,000 characters away, and it may not read it. `ui_recipe_catalog.js` is the Workflows page: one bar (search, the filter menu with kind and tag, the count, grid or list, share and import, save the canvas), the list, the empty page's three-step guide (`renderRecipeEmptyGuide`) and the catalog-wide model readiness check diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a72fec..f202b8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## v1.63.0 — unreleased +- AI 看工作流清楚多了(Gemini 实测发现的):列表以前一次两万多字,AI 软件直接存成文件不看了,现在卡片多时只给名字、路线和备注。每张卡的路线说明以前一直是空的,现在有了;按模型找卡片也恢复了。官方示例总会列出名字,用中文(图生图、两段放大)也搜得到。 +- AI apps see your workflows better (found in a real Gemini run): the list was over 20,000 characters, which an AI app files away unread, and is now names, routes and notes when there are many. Each card's route line was always empty and now says the route, and finding cards by model works again. Official examples are always named, and found by their Chinese names too. +- 新任务里先写了留言的话,任务列表就用这句话当标题,不再都叫“接手任务”。 +- A new task you wrote to takes its name from what you wrote, instead of the AI's "take the task". - 两个 Anima 两段放大示例写明了:要多张图就多跑几次,别调批次。Anima 的 VAE 会把一批图当成视频帧,第二段只剩一张。 - The two Anima two-pass examples now say to run several times for several pictures, not to raise the batch: Anima's VAE takes a batch for video frames, and the second pass is left with one. - 当前节点的提示词卡片现在都标着“正面提示词”或“负面提示词”:从图片存下来的也按它原来接在采样器哪个口上来认,以前只有工作流来源的才标,图片和工作流同时有时标注还会丢。 diff --git a/api/ai_tasks.py b/api/ai_tasks.py index e39a3a9..c808a9a 100644 --- a/api/ai_tasks.py +++ b/api/ai_tasks.py @@ -332,8 +332,9 @@ def begin_task(client, title, now=None, new_task=False, session="", task_id=None if new_task and previous: task["from"] = previous["id"] # begun from that one (ai_task_links.py) elif not task.get("title") or task.get("title_from") == "auto": - task["title"] = _short(title, MAX_TITLE) - task["title_from"] = "ai" + said = next((step["text"] for step in task["steps"] if step.get("tool") == "user_message" and step.get("text")), "") + task["title"] = _short(said or title, MAX_TITLE) + task["title_from"] = "user" if said else "ai" _bind_owner(client, session, task["id"]) task["updated"] = now _save(task) diff --git a/api/example_workflows.py b/api/example_workflows.py index 8802a58..eb33529 100644 --- a/api/example_workflows.py +++ b/api/example_workflows.py @@ -21,6 +21,8 @@ LOADERS = ("CheckpointLoaderSimple", "UNETLoader", "CLIPLoader", "VAELoader", "A FAMILIES = {"sd": ("SDXL", "SD 1.x"), "anima": ("anima",)} # what the examples' settings suit FOR_MODELS = {"sd": "SDXL, SD 1.5", "anima": "Anima"} LOOK_AT = 40 # files looked at for a model of a family +MAX_WITH_SETTINGS = 3 # saved workflows listed in full for an AI: an AI app files a long answer away, unread +SHORT_NOTES = 120 # characters of a card's notes in a longer list PICTURES = (".png", ".jpg", ".jpeg", ".webp") @@ -107,10 +109,29 @@ def for_ai(): """The examples that can run here, as list_workflows gives saved workflows.""" info = _here() fills = _fills(info) - return [{"name": item["name"], "example": True, "for_models": FOR_MODELS[item["family"]], "route": workflow_route.line(item["workflow"]), "notes": item["ai"]} + return [{"name": item["name"], "title": item["title"]["zh"], "example": True, "for_models": FOR_MODELS[item["family"]], + "route": workflow_route.line(item["workflow"]), "notes": item["ai"]} for item in EXAMPLES.values() if not _lacks(item, info, fills)] +def _short(card): + """A saved workflow in a longer list: what tells it apart.""" + shown = {key: value for key, value in card.items() if value and key not in ("settings", "saved")} + return {**shown, "notes": shown["notes"][:SHORT_NOTES]} if "notes" in shown else shown + + +def answer(found, fits, limit): + """What list_workflows gives: the saved workflows `found`, cut short when they are many, or else the examples + that `fits`. The other examples go by name, so an AI with no card for the job knows of them.""" + examples = for_ai() + fitting = [] if found else [item for item in examples if fits(item)] + if len(found) > MAX_WITH_SETTINGS: + found = [_short(card) for card in found] + listed = found or fitting + others = [item["name"] for item in examples if item not in fitting] + return {"total_matches": len(listed), "workflows": listed[:limit], **({"examples": others} if others else {})} + + def file_of(name): """The file name open_workflow and run_workflow take for the example called `name`, or None.""" return next((PREFIX + key for key, item in EXAMPLES.items() if item["name"] == name), None) diff --git a/api/examples/picture_to_picture.json b/api/examples/picture_to_picture.json index 1040975..41ad6cb 100644 --- a/api/examples/picture_to_picture.json +++ b/api/examples/picture_to_picture.json @@ -8,7 +8,7 @@ "zh": "从一张图出发,按提示词改。在 Load Image 节点选图;denoise 越高,改得越多。", "en": "Starts from a picture and changes it by the prompt. Pick it in the Load Image node; a higher denoise changes more." }, - "ai": "Redraws a picture at denoise 0.6. open_workflow, set_model on its loader, then run_workflow with the prompt and image.", + "ai": "Redraws a picture (img2img) at denoise 0.6. open_workflow, set_model on its loader, then run_workflow with the prompt and image.", "version": { "zh": "SDXL / SD 1.5", "en": "SDXL / SD 1.5" diff --git a/api/examples/repaint.json b/api/examples/repaint.json index 0c400e8..d6de4d5 100644 --- a/api/examples/repaint.json +++ b/api/examples/repaint.json @@ -8,7 +8,7 @@ "zh": "只重画图上的一块。在 Load Image 节点选图,右键打开遮罩编辑器,涂出要改的地方。", "en": "Redraws one part of a picture. Pick it in the Load Image node, right-click to open the mask editor and paint the part to change." }, - "ai": "Redraws the area masked on the Load Image node. The user paints that mask; to repaint for them yourself, add_nodes(use='repaint') is better.", + "ai": "Redraws the area masked on the Load Image node (inpaint). The user paints that mask; to repaint for them yourself, add_nodes(use='repaint') is better.", "version": { "zh": "SDXL / SD 1.5", "en": "SDXL / SD 1.5" diff --git a/api/examples/text_to_picture.json b/api/examples/text_to_picture.json index 8a2e676..83b4cba 100644 --- a/api/examples/text_to_picture.json +++ b/api/examples/text_to_picture.json @@ -8,7 +8,7 @@ "zh": "最基础的一条路:写提示词,出一张图。", "en": "The plainest route: write a prompt, get a picture." }, - "ai": "One pass from words. open_workflow, set_model on its loader, then run_workflow with the prompt.", + "ai": "One pass from words (txt2img). open_workflow, set_model on its loader, then run_workflow with the prompt.", "version": { "zh": "SDXL / SD 1.5", "en": "SDXL / SD 1.5" diff --git a/api/mcp_routes.py b/api/mcp_routes.py index 62987b0..022d806 100644 --- a/api/mcp_routes.py +++ b/api/mcp_routes.py @@ -53,7 +53,7 @@ def _can_build(route, base, info): def _cards(): """(name, stages, line) of each saved workflow.""" - for recipe in _list_recipes(get_recipes_dir()): + for recipe in _list_recipes(get_recipes_dir(), with_workflow=True): data = recipe.get("data") or {} workflow = data.get("workflow") stages = [stage for stage in workflow_route.route(workflow)[0] if stage["state"] == "run"] diff --git a/api/mcp_tools.py b/api/mcp_tools.py index 2d0ccb7..e25d472 100644 --- a/api/mcp_tools.py +++ b/api/mcp_tools.py @@ -446,7 +446,7 @@ def plans(): def list_workflows(query="", limit=20, *, exact_name=False): terms, found, saved_plans = _terms(query), [], plans() - for recipe in _list_recipes(get_recipes_dir()): + for recipe in _list_recipes(get_recipes_dir(), with_workflow=True): data = recipe.get("data") or {} name = data.get("name") or recipe["filename"] params = json.dumps(data.get("params") or {}, ensure_ascii=False) @@ -459,8 +459,8 @@ def list_workflows(query="", limit=20, *, exact_name=False): "notes": _text(data.get("notes"), 500), "saved": _when((data.get("timestamp") or 0) / 1000), "settings": params if len(params) <= MAX_TEXT else params[:MAX_TEXT] + "…", **({"plans": own} if own else {})}) # run_workflow(workflow=one of them) runs it with that set - found = found or [item for item in example_workflows.for_ai() if (item["name"] == query if exact_name else _matches(terms, item["name"], item["notes"]))] - return {"total_matches": len(found), "workflows": found[:_limit(limit)]} + fits = (lambda item: item["name"] == query) if exact_name else (lambda item: _matches(terms, item["name"], item["title"], item["notes"])) + return example_workflows.answer(found, fits, _limit(limit)) def saved_prompts(): @@ -536,7 +536,7 @@ TOOLS = dict([ "structure\") model nodes and text nodes saved from the canvas with their links, listed by slot.", list_combos, {"query": _QUERY, "limit": _LIMIT}), _tool("list_workflows", "Workflow recipes", - "The user's saved workflow recipes: route, tags, notes, settings, plans.", list_workflows, + "The user's saved workflow recipes: route, tags, notes, plans; settings when few match. `examples`: official ones by name, opened and run like saved ones.", list_workflows, {"query": _QUERY, "limit": _LIMIT}), _tool("list_saved_prompts", "Saved prompts", "Prompts the user saved in the Prompt Studio, with positive and negative text.", list_saved_prompts, diff --git a/api/recipe_store.py b/api/recipe_store.py index 13224fc..d7ea5cc 100644 --- a/api/recipe_store.py +++ b/api/recipe_store.py @@ -31,7 +31,7 @@ def _read_recipe(path): return json.load(recipe_file) -def _list_recipes(recipes_dir): +def _list_recipes(recipes_dir, with_workflow=False): recipes = [] try: with os.scandir(recipes_dir) as entries: @@ -44,7 +44,7 @@ def _list_recipes(recipes_dir): if not isinstance(data, dict): continue # The graph can be much larger than all card data combined. - summary = {key: value for key, value in data.items() if key != "workflow"} + summary = data if with_workflow else {key: value for key, value in data.items() if key != "workflow"} recipes.append({"filename": filename, "data": summary}) except (OSError, ValueError, json.JSONDecodeError): continue diff --git a/docs/architecture/ui-flows.md b/docs/architecture/ui-flows.md index c8b9b6b..50c9f11 100644 --- a/docs/architecture/ui-flows.md +++ b/docs/architecture/ui-flows.md @@ -143,7 +143,7 @@ AMB 更新以后,AI 软件还拿着旧的工具列表来调用时,标题栏 工作流页第一张卡片是「官方示例」,放最后的话,卡片一多就找不到了。点开是一个窗口,按方案分组(文生图、两段放大、图生图、局部重绘),方案下面是各个版本,每个版本有备注和「新画布打开」。两段放大有三个版本:SDXL / SD 1.5、Anima、Anima · Tile 锁图(「推荐」)。SDXL 和 Anima 必须分开,模型的加载节点不一样。打开时后端按版本填上你已有的模型和文件(`GET /anomalous/example_workflows?id=…`)。缺节点包或模型的版本照样列出来,写明缺什么,节点包能点进 Manager,这一条没有打开按钮。搜索或筛选时这张卡片不显示。 -AI 这边:没有合适的已存工作流时,`list_workflows` 给它这里能跑的版本,每条写着适合什么模型,打开和运行都和已存工作流一样;用户存过的工作流优先,也不会被同名示例盖掉。 +AI 这边:没有合适的已存工作流时,`list_workflows` 给它这里能跑的版本,每条写着适合什么模型,打开和运行都和已存工作流一样;有合适的时也列出示例的名字,免得 AI 不知道。用中文(图生图、两段放大)也搜得到。用户存过的工作流优先,也不会被同名示例盖掉。 为什么:新装的人一张卡片都没有,AI 也没有现成路线可跑。只放一张卡片,不占用户自己的位置;方案只是窗口里的分组,每个版本仍是一条独立的工作流,不是一张卡里放几套参数。Tile 版的数值和做法 upscale_tile 的 Anima 版一致,有测试看着。 diff --git a/docs/guides/mcp.md b/docs/guides/mcp.md index f064d3e..432b278 100644 --- a/docs/guides/mcp.md +++ b/docs/guides/mcp.md @@ -90,7 +90,7 @@ claude mcp add --transport http anomalous http://127.0.0.1:8188/anomalous/mcp | scan_report | 哪些模型匹配上了 C 站,哪些没有、原因是什么,哪些还没扫,上次扫描做了什么 | | search_images / get_image_info | 按提示词、模型、参数找出图;看某张图的全部生成参数,可以附上缩略图。`folder=input` 时看 ComfyUI 输入文件夹里的图(比如你标注时 AMB 存的重绘遮罩图):尺寸、要重画的区域在哪、占多大,也能附上图,不用 AI 自己跑命令去看 | | list_combos | 你的搭配(主模型 + LoRA + 提示词) | -| list_workflows | 你存下的工作流,带标签、备注、主要设置和它的预设(写成“工作流名 / 预设名”) | +| list_workflows | 你存下的工作流,带路线、标签、备注和它的预设(写成“工作流名 / 预设名”),匹配的卡少时带主要设置;还列出官方示例的名字 | | list_saved_prompts | 提示词工坊里存的提示词 | | search_audio | 用 Anomalous TTS 生成过的语音,带台词和角色 | | recent_activity | 插件最近改过什么(操作记录) | diff --git a/tests/test_ai_tasks.py b/tests/test_ai_tasks.py index 5744a34..105bc15 100644 --- a/tests/test_ai_tasks.py +++ b/tests/test_ai_tasks.py @@ -240,6 +240,12 @@ class TaskTests(unittest.TestCase): with mock.patch.object(ai_tasks.time, "time", return_value=310.0): got = mcp_task_tools.begin_task({"client": "Cursor"}, "整理 LoRA") self.assertEqual((got["task"], ai_tasks.get_task(unnamed["id"])["title"]), (unnamed["id"], "整理 LoRA")) + # What the user wrote there names it better than an AI that has only just taken it. + written = ai_tasks.start_task("", now=400.0) + ai_tasks._load()[written["id"]]["steps"].append({"tool": "user_message", "kind": "user", "text": "出一张雪山风景"}) + with mock.patch.object(ai_tasks.time, "time", return_value=410.0): + got = mcp_task_tools.begin_task({"client": "Cursor"}, "接手任务") + self.assertEqual((got["task"], got["title"]), (written["id"], "出一张雪山风景")) def test_a_task_handed_over_from_anomalous_tells_the_ai_what_it_is_about(self): about = [{"type": "model", "model_id": "loras:0:a/b.safetensors", "label": "B"}, {"type": "image", "image": "x/1.png"}, diff --git a/tests/test_example_workflows.py b/tests/test_example_workflows.py index 20ad33c..77e478c 100644 --- a/tests/test_example_workflows.py +++ b/tests/test_example_workflows.py @@ -104,7 +104,25 @@ class ExampleTests(unittest.TestCase): def test_saved_workflows_come_instead_of_them(self): recipe = {"filename": "recipe_1.json", "data": {"name": "Mine"}} - self.assertEqual([item["name"] for item in self.listed(recipes=[recipe])["workflows"]], ["Mine"]) + listed = self.listed(recipes=[recipe]) + self.assertEqual([item["name"] for item in listed["workflows"]], ["Mine"]) + self.assertEqual(len(listed["examples"]), len(examples.ORDER), "named, so an AI with no card for the job knows of them") + + def test_they_are_found_in_the_words_people_use(self): + for query, name in (("图生图", "Example: picture to picture"), ("img2img", "Example: picture to picture"), ("文生图", "Example: text to picture")): + with self.subTest(query=query): + listed = self.listed(query) + self.assertEqual([item["name"] for item in listed["workflows"]], [name]) + self.assertNotIn(name, listed["examples"], "no need to name it twice") + + def test_a_long_list_is_short_enough_for_an_ai_to_read(self): + data = {"params": {"steps": 20, "text": "x" * 900}, "notes": "n" * 300} + recipes = [{"filename": f"r{number}.json", "data": {"name": f"Card {number}", **data}} for number in range(examples.MAX_WITH_SETTINGS + 1)] + many = self.listed(recipes=recipes)["workflows"] + self.assertFalse([item for item in many if "settings" in item]) + self.assertEqual({len(item["notes"]) for item in many}, {examples.SHORT_NOTES}) + few = self.listed(recipes=recipes[:examples.MAX_WITH_SETTINGS])["workflows"] + self.assertTrue(all("settings" in item for item in few), "a look at a few says all") def test_they_open_by_name_but_a_saved_one_of_that_name_wins(self): name = "Example: two passes" diff --git a/tests/test_workflow_route.py b/tests/test_workflow_route.py index 13d2cb1..92e8e48 100644 --- a/tests/test_workflow_route.py +++ b/tests/test_workflow_route.py @@ -1,13 +1,16 @@ """A saved workflow's route in one line for the AI (api/workflow_route.py), read from the serialized graph with stand-in widget names, and its place in list_workflows.""" +import json 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 workflow_route # noqa: E402 +from api import example_workflows, mcp_routes, mcp_tools, workflow_route # noqa: E402 NAMES = { "UNETLoader": ["unet_name", "weight_dtype"], @@ -68,6 +71,16 @@ class RouteTests(unittest.TestCase): self.assertEqual(self.line({"nodes": [node(1, "UNETLoader", values=["a.safetensors"])], "links": []}), "") self.assertIn("on MiaoMiao Harem_Anima_1.5", workflow_route.line(two_passes(), lambda kind: [])) + def test_a_card_on_disk_keeps_its_route_where_the_ai_reads_it(self): + with tempfile.TemporaryDirectory() as directory: + Path(directory, "recipe_1.json").write_text(json.dumps({"name": "Two", "workflow": two_passes()}), encoding="utf-8") + with mock.patch.object(mcp_tools, "get_recipes_dir", return_value=directory), mock.patch.object(mcp_tools, "plans", return_value={}), \ + mock.patch.object(mcp_routes, "get_recipes_dir", return_value=directory), mock.patch.object(example_workflows, "for_ai", return_value=[]): + listed = mcp_tools.list_workflows()["workflows"][0]["route"] + cards = list(mcp_routes._cards()) + self.assertTrue(listed.startswith("2 passes"), listed) + self.assertEqual((cards[0][0], len(cards[0][1]), cards[0][2]), ("Two", 2, listed)) + if __name__ == "__main__": unittest.main()