Add skills beside the ways: advice an AI fetches for a kind of job

The pose and hands advice for writing prompts moves out of the set_prompt and
run_workflow descriptions into the first skill, prompt_pose (api/skills/). begin_task
lists the skills that are on in a line; get_toolbox(skill=...) gives one; the prompt
arguments only point at it. The tool list is back within its budget (it was 44726 of
44000 characters). The Ways panel lists the skills under the ways, each with a switch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DemonGatanjieu
2026-10-30 04:33:48 +08:00
co-authored by Claude Opus 5.5
parent 1b0d6c5ba1
commit b616487d17
12 changed files with 261 additions and 45 deletions
+6 -1
View File
@@ -206,6 +206,10 @@ covered by the `styles.css` manifest.
recommend (links, size, SHA256, licence, folder); a block's "models" names them and its
"tested" says the card and a run's time, both shown in the Ways panel (a missing model is
downloaded there with `model_download.downloadFrom`) and the time told to the AI with the block.
`api/ai_skills.py` holds the skills beside them: advice the AI reads for a kind of job (how to
write a prompt a model draws well), a data file each in `api/skills/`, building nothing.
begin_task lists the ones on (name and a line), `get_toolbox(skill=…)` gives one, tool
descriptions only point at them (`tests/test_ai_skills.py`).
`web/modules/mcp_canvas_brief.js` is the canvas in brief for an AI (pure): bounded path chains and node lines,
the inputs a run needs that nothing feeds yet with the outputs that fit them, ComfyUI's
errors; every canvas change answers with what is still unwired. `api/mcp_situation.py` is
@@ -391,7 +395,8 @@ covered by the `styles.css` manifest.
(`stage.outputsSeen`). `ui_ai_blocks.js` is the side column's Ways view (做法): the ready-made
blocks by job (GET /anomalous/ai_blocks, `reference_blocks.status`), each block's words from its
data file, greyed with the pack (a way into ComfyUI-Manager) or model it lacks, and a switch that
keeps it from the AI (POST, `user/anomalous/ai_blocks.json`). For a run the AI did not wait
keeps it from the AI (POST, `user/anomalous/ai_blocks.json`); under them the skills
(`ai_skills.status`), each with the same switch (`user/anomalous/ai_skills.json`). For a run the AI did not wait
for (`ai_gates.run_wait` "call") the server marks the ended run `call_ai` until the AI is told,
and the lines over the message box are about it meanwhile. `ui_ai_side.js` is the
stage, the column on the right where the AI's panels are shown (the timeline keeps a card per
+2 -2
View File
@@ -2,8 +2,8 @@
## v1.63.0 — unreleased
- AI 写出图提示词时,AMB 的工具说明会建议:姿势可以选时优先选模型容易画清楚的动作,避免不必要的复杂手势;手不是重点时可选自然遮挡或构图不露手。你指定的姿势和提供的文本照你的要求处理。复杂姿势可以用参考姿势或分步修复,并检查结果。
- When an AI composes an image prompt, AMB's tool descriptions suggest a clear pose suited to the model when the pose is flexible, optionally hiding nonessential hands naturally or framing them out. Explicit poses and supplied text are preserved; pose references or staged repair can help with difficult poses.
- 新增 **skill**,和做法并列:做法是 AMB 替 AI 搭好的节点,skill 是 AI 遇到某类活时自己去取的建议,不搭节点。第一个是“写提示词·动作适合模型”:AI 自己写出图提示词时,没指定姿势就选模型画得清楚的动作;手不是重点时,可以自然遮挡或构图不露手,但这只是可选,不是规定。你指定的姿势和你给的提示词照原样。AI 开工时只看到 skill 的名字和一句话,要写提示词时才去取全文,工具说明里只留一句提示,给 AI 的工具清单不变长。AI 工作台右边的**做法**面板最下面列着 skill,每个有开关,关掉的 AI 就拿不到。
- New: **skills**, beside the ways. A way is nodes AMB builds for the AI; a skill is advice the AI fetches when a kind of job comes up, and it builds nothing. The first: "Writing prompts: poses the model can draw". When an AI writes an image prompt itself and no pose was asked for, it picks one the model draws cleanly; when hands are not the point, it may hide them naturally or frame them out, as an option, not a rule. Poses you ask for and prompts you give stay as they are. At the start of a task the AI sees only each skill's name and a line, and fetches the advice when it writes a prompt; the tool descriptions only point at it, so the tool list AI apps hold does not grow. The skills are at the bottom of the **Ways** panel on the right of the AI workbench, each with a switch: the AI can't fetch the ones turned off.
- 跑图前的尺寸提醒放宽一档:6GB 显卡采样到约 420 万像素才提醒(以前 210 万),12GB 约 840 万。今天在 6GB 的笔记本显卡上实测,400 万像素一次约 100 秒,并不吃紧;以前的门槛会让 AI 动不动就问你要不要停。解码大图的提醒不变。
- The size warning before a run is less eager: on a 6 GB card from about 4.2 megapixels (2.1 before), 8.4 on 12 GB. A 6 GB laptop card sampled 4 megapixels in about 100 s today; the old line had the AI ask you to stop runs that were fine. The warning for decoding a large picture is as before.
+84
View File
@@ -0,0 +1,84 @@
"""Skills: advice on how to do a kind of job, which an AI fetches when the job comes up, beside the
ready-made blocks (reference_blocks.py), which build nodes. A block is something AMB does; a skill
is something the AI reads (how to write a prompt a model can draw). Each skill is a data file in
api/skills/ (<name>.json): what the AI is told it is for (short) and the advice itself (text, a
line a point), and what the user's Ways panel shows (title, about, in both languages,
web/modules/ui_ai_blocks.js). begin_task lists the skills on (name and short); get_toolbox(skill=…)
gives one. The tools' own descriptions only point at a skill, so the list every app holds stays
small. The user turns a skill off in the panel (user/anomalous/ai_skills.json): the AI is not
offered it and get_toolbox refuses it.
"""
import json
import os
import threading
FOLDER = os.path.join(os.path.dirname(__file__), "skills")
NOTE = "Advice: get_toolbox(skill=name) when its job comes up: "
_lock = threading.Lock()
class SkillError(ValueError):
pass
def _load():
found = {}
for file in sorted(os.listdir(FOLDER)):
if file.endswith(".json"):
with open(os.path.join(FOLDER, file), encoding="utf-8") as source:
found[file[:-5]] = json.load(source)
return found
SKILLS = _load()
def _off_path():
from . import ai_tasks
return os.path.join(os.path.dirname(ai_tasks.tasks_dir()), "ai_skills.json")
def turned_off():
"""The skills the user turned off in the panel."""
try:
with open(_off_path(), encoding="utf-8") as file:
return {name for name in json.load(file).get("off", []) if name in SKILLS}
except (OSError, ValueError, AttributeError):
return set()
def set_on(name, on):
"""Turns skill `name` on or off for the AI; the skills now off."""
from .path_utils import atomic_write_json
if name not in SKILLS:
raise SkillError(f"No skill {name!r}.")
with _lock:
off = turned_off() - {name} if on else turned_off() | {name}
atomic_write_json(_off_path(), {"off": sorted(off)})
return off
def status():
"""Every skill as the user's panel shows it."""
off = turned_off()
return [{"name": name, "title": skill["title"], "about": skill["about"], "on": name not in off}
for name, skill in SKILLS.items()]
def directory():
"""The skills on, "name (short)" each after a note on fetching them, a line for begin_task; "" when none is on."""
off = turned_off()
listed = [f"{name} ({skill['short']})" for name, skill in SKILLS.items() if name not in off]
return NOTE + "; ".join(listed) if listed else ""
def get(name):
"""The advice of skill `name`, for get_toolbox."""
if name not in SKILLS:
raise SkillError(f"No skill {name!r}; begin_task lists them: {', '.join(SKILLS)}.")
if name in turned_off():
raise SkillError(f"The user turned the skill {name!r} off: do the job your own way.")
return {"skill": name, "advice": SKILLS[name]["text"]}
+10 -6
View File
@@ -269,22 +269,26 @@ async def api_toolbox(request):
async def api_blocks(request):
"""GET /anomalous/ai_blocks: every ready-made block as the 做法 panel shows it (reference_blocks.status);
POST {name, on}: the user turns one on or off for the AI."""
from . import mcp_canvas_edit, reference_blocks
"""GET /anomalous/ai_blocks: every ready-made block and skill as the 做法 panel shows them
(reference_blocks.status, ai_skills.status); POST {name, on} or {skill, on}: the user turns one on or off for the AI."""
from . import ai_skills, mcp_canvas_edit, reference_blocks
from .mcp_tools import ToolError
if request.method == "POST":
body = await _body(request) or {}
try:
reference_blocks.set_on(str(body.get("name") or ""), bool(body.get("on")))
except reference_blocks.BlockError as error:
if "skill" in body:
ai_skills.set_on(str(body.get("skill") or ""), bool(body.get("on")))
else:
reference_blocks.set_on(str(body.get("name") or ""), bool(body.get("on")))
except (reference_blocks.BlockError, ai_skills.SkillError) as error:
return web.json_response({"error": str(error)}, status=400)
try:
info = await asyncio.to_thread(mcp_canvas_edit.object_info, {"base_url": f"{request.scheme}://{request.host}"})
except ToolError as error:
return web.json_response({"error": str(error)}, status=502)
return web.json_response({"blocks": reference_blocks.status(info), "jobs": list(reference_blocks.JOBS)})
return web.json_response({"blocks": reference_blocks.status(info), "jobs": list(reference_blocks.JOBS),
"skills": ai_skills.status()})
def register_routes(app):
+1 -5
View File
@@ -257,11 +257,7 @@ def speak(ctx, character, text, seed=None, language="auto"):
_NODE = {"type": "integer", "description": "A node id."}
_MODEL = {"type": "string", "description": "model_id from search_models."}
_PROMPT = {"type": "string", "description": "When composing a prompt, if the pose is flexible, prefer a clear, "
"natural pose suited to the model; avoid unnecessary interlocked hands or complex contact. Preserve "
"explicit user requests and supplied text. If hands are not important, natural occlusion or framing "
"them out is optional. For difficult requested poses, consider pose references or staged repair and "
"inspect the result."}
_PROMPT = {"type": "string", "description": "Your own? First get_toolbox(skill=\"prompt_pose\")."}
def _action(name, title, description, run, properties=None, required=(), destructive=False, open_world=False,
+15 -8
View File
@@ -4,7 +4,7 @@ show_panel / update_panel draw a panel from Anomalous's blocks (ai_panels.py), g
reads what the user picked or typed on it, log_outside keeps what the AI did with other tools
(ai_outside.py)."""
from . import ai_answer_wait, ai_decisions, ai_marks, ai_message_refs, ai_outside, ai_panels, ai_repaint, ai_run_control, ai_run_tracking, ai_task_links, ai_task_runs, ai_tasks, ai_toolbox, reference_blocks
from . import ai_answer_wait, ai_decisions, ai_marks, ai_message_refs, ai_outside, ai_panels, ai_repaint, ai_run_control, ai_run_tracking, ai_task_links, ai_task_runs, ai_tasks, ai_toolbox, ai_skills, reference_blocks
from .mcp_tools import MAX_LIMIT, ToolError, _schema
PACK_TYPES = 150 # node types listed per picked pack
@@ -33,6 +33,9 @@ def begin_task(ctx, title, new_task=False):
answer["next"] = ("get_situation shows where things stand; a daily run is one call: run_workflow(workflow: a saved "
"one, with the prompt, seed or size to change).")
answer["blocks"] = _blocks(ctx)
skills = ai_skills.directory()
if skills:
answer["skills"] = skills
box = ai_toolbox.effective(ai_tasks.get_task(task["id"]))
if box:
answer["toolbox"] = ai_toolbox.words(box)
@@ -224,9 +227,14 @@ def stop_run(ctx, task=None):
return {**done, "stopped": True, "note": "Stopping: run_status(wait_seconds=10) confirms it ended."}
def get_toolbox(ctx, task=None):
def get_toolbox(ctx, task=None, skill=None):
from .mcp_canvas_edit import find_nodes
if skill:
try:
return ai_skills.get(str(skill))
except ai_skills.SkillError as error:
raise ToolError(str(error))
task_id = task or ctx.get("task") or ai_tasks.active_task_id(ctx.get("client") or "AI", ctx.get("session") or "")
box = ai_toolbox.effective(ai_tasks.get_task(task_id)) if task_id else None
if not box:
@@ -419,12 +427,11 @@ TASK_TOOLS = dict([
"anyone else's). When run_status warns it is far too big and the user agrees, or when the user asks.",
stop_run, {"task": _TASK_PROP}),
_task_tool("get_toolbox", "What the user picked for you",
"The node types, whole custom node packs, combos, saved workflows, prompts and models the user picked in "
"the AI workbench for this task, in full (inputs and outputs of the nodes, each pack's node types, the "
"combos' models and prompts…), and how strictly: prefer (use them first) or only (add_node and "
"new_workflow accept only those node types and the packs' besides ComfyUI's built-in ones). "
"begin_task says when there are any.",
get_toolbox, {"task": _TASK_PROP}),
"The node types, node packs, combos, workflows, prompts and models the user picked for this task, in "
"full, and how strictly: prefer (use them first) or only (add_node and new_workflow accept only those "
"node types, the packs' and ComfyUI's built-in ones). begin_task says when there are any. "
"skill: a skill begin_task lists, instead.",
get_toolbox, {"task": _TASK_PROP, "skill": {"type": "string"}}),
_task_tool("get_answers", "Read the user's answers and messages",
"What the user picked or typed on your panels and wrote to you in the AI workbench since you last "
"asked, each once (kind=change: something they changed themselves; keep it). panel: one panel. "
+18
View File
@@ -0,0 +1,18 @@
{
"short": "before you write an image prompt yourself",
"title": {
"zh": "写提示词·动作适合模型",
"en": "Writing prompts: poses the model can draw"
},
"about": {
"zh": "AI 自己写出图提示词时参考:没指定姿势就选模型画得清楚的动作;手不是重点时,可以自然遮挡或构图不露手(只是可选)。你指定的姿势和你给的提示词照原样。",
"en": "For an AI writing an image prompt itself: with no pose asked for, a pose the model draws cleanly; when hands are not the point, they may be naturally hidden or framed out (an option only). Poses you ask for and prompts you give stay as they are."
},
"text": [
"For a prompt you write yourself. Text the user gave you goes in as it is; a pose they asked for is kept, however hard.",
"Pose left to you: pick one the model draws cleanly: one clear action, a readable silhouette, few points of contact. Avoid interlocked fingers, hands gripping small things, arms crossed over each other and people touching unless the picture needs them.",
"Hands not the point of the picture: you may hide them naturally (behind the back, in pockets or sleeves, behind something held or near) or frame them out (portrait, upper body). An option, not a rule: do not hide hands the user wants to see or a pose needs.",
"A hard pose the user wants: follow a pose picture (add_nodes use=follow, want=pose) or fix the hands afterwards (use=detail, want=hands), and look at the result (zoom_image) before you call it done.",
"If you chose the pose or hid the hands, say so in report, so the user can ask for another."
]
}
+7 -4
View File
@@ -252,10 +252,13 @@ descriptions. Its picks go to `/anomalous/scan_all` as `targets`, like a list pa
## MCP endpoint
The `prompt` schemas for `set_prompt` and `run_workflow` share concise pose guidance for
agents composing prompts: choose a model-appropriate pose when flexible, optionally frame or
obscure nonessential hands naturally, and preserve explicit requests and supplied text. This
is advice in the MCP tool description; AMB passes text through.
Skills (`api/ai_skills.py`, one data file each in `api/skills/`) are advice an agent fetches
for a kind of job, beside the blocks that build nodes. begin_task lists the skills on (name and
a line), `get_toolbox(skill=…)` returns one, and a skill the user turned off in the Ways panel
(`user/anomalous/ai_skills.json`) is neither listed nor given. Tool descriptions only point at
a skill (the `prompt` of `set_prompt` and `run_workflow` at `prompt_pose`: a pose the model
draws well, nonessential hands optionally hidden, the user's own text and poses kept), so the
tool list stays within its budget; AMB passes prompt text through unchanged.
`track_run(prompt_ids, task, wait_seconds)` attaches existing submissions from this server to
an open task without running anything. IDs must exist in its queue/history; each is owned by
+3 -1
View File
@@ -103,7 +103,7 @@ claude mcp add --transport http anomalous http://127.0.0.1:8188/anomalous/mcp
| find_nodes | 搜索最多五个节点摘要;需要具体参数和选项时,用 `types=[节点类型]` 读取 |
| add_node / connect_nodes | 添加节点并按唯一类型自动连线,或把节点插进已有路径;见下方例子 |
| add_nodes | 一次加一组节点:按 ComfyUI 的 API 格式写,组里的节点互相连,也能连画布上已有的节点(写 `"#12"`,输出写编号或名字),排成一块放在空位上,整组算一步撤销 |
| set_prompt | 改写或追加正向/反向提示词。AI 自己写提示词时,姿势可以选择的话会优先考虑模型容易画清楚的动作;手不是重点时,也可选择自然遮挡或构图不露手。你指定的姿势和提供的文本仍按你的要求处理。`run_workflow` 的提示词也有同样的建议 |
| set_prompt | 改写或追加正向/反向提示词。AI 自己写提示词前会先取 skill“写提示词·动作适合模型”(见下面的 skill);你给的文本照原样写进去。`run_workflow` 的提示词也一样 |
| set_model | 换某个加载器节点里的模型 |
| 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 | 把一个搭配作为一组新节点摆到画布上,原有节点不动 |
@@ -150,6 +150,8 @@ claude mcp add --transport http anomalous http://127.0.0.1:8188/anomalous/mcp
**参考做法**:有几件常见的活,AMB 带着现成的做法,是从擅长这些活的工作流里提炼出来的:**局部重绘·裁切缝合**(圈出的地方裁出来放大到 1024 重画再缝回去,需要节点包 ComfyUI-Inpaint-CropAndStitch)、**局部重绘(官方节点)**(没装那个包时用)、**局部重绘·LanPaint**(融合最好、任何模型都行,但慢好几倍,需要节点包 LanPaint)、**放大**(1.25 倍再轻轻重画一遍,只用自带节点,另存一张,文件名以 upscaled 开头)、**分块放大**(2 倍,需要节点包 ComfyUI_UltimateSDUpscale)、**照着姿势画**和**照着构图画**(你递给 AI 一张图,AMB 读出姿势或深度,挑一个和底模对得上的控制网,需要节点包 comfyui_controlnet_aux)、**迭代放大**(分 3 小步放大到 1.3 倍,用 Impact Pack)、**双模型放大**(重画换一个更稳的模型)、**背景移除**(需要节点包 comfyui-inspyrenet-rembg)、**读图出标签**(需要节点包 ComfyUI-WD14-Tagger,AI 跑完会收到读出的标签)、**部位细化(脸和手)**(需要 Impact Pack、Impact Subpack 和 models/ultralytics/bbox 里的检测模型,细化后的图另存一张,文件名以 detailed 开头)、**修手·先放大再修**(你递给 AI 一张图:整张放大到约 400 万像素轻轻重画,原图越小放得越大,最多 2 倍,让手变大,再逐只修手,另存一张,文件名以 hands_fixed 开头;Anima 模型上有 Anima Turbo LoRA 时只跑 10 步,1280×832 的图在 6GB 显卡上约 100 秒。AI 一次出 4 张,自己先放大看手,把手指数不对、粘连、大小不对的去掉,都不行就再出 4 张,最多两轮,再把好的放进只放大手那块的面板让你挑)。AI 工作台右边的**做法**面板列着全部做法:缺节点包或模型的变灰并写明缺什么,每块有个开关,关掉的 AI 就看不到。AI 开工时只收到开着又能用的那些;它说用哪块,AMB 就把那几个节点搭好、接进画布上那个出图的工作流里(采样器、VAE 解码、保存图片都由 AMB 按插口认出来),一次就好,也是一步 Ctrl+Z。画布上有好几个采样器时,AI 得指明用哪一个;缺节点包时,它会知道缺哪个、该用哪块代替。
**skill**:和做法并列,但不搭节点,是 AI 遇到某类活时自己去取的建议。AI 开工时只看到每个 skill 的名字和一句话,真要做那类活时才用 `get_toolbox(skill=名字)` 取全文,这样给 AI 的工具清单不变长,小模型也吃得消。现在有一个:**写提示词·动作适合模型**(AI 自己写出图提示词时,没指定姿势就选模型画得清楚的动作;手不是重点时可以自然遮挡或构图不露手,只是可选。你指定的姿势和你给的提示词照原样)。做法面板最下面列着 skill,每个有开关,关掉的 AI 就拿不到。
**先对一下重画的范围**:圈得不准也没关系。AI 准备只重画标出的地方时,会先在展台把 **你圈的** 和 **AI 调整后** 的范围并排给你看(红色是要重画的地方;AI 可以挪位置、改成方框或椭圆、往外扩、把边缘做软,图下写着外扩和羽化了多少像素)。点 **用 AI 调整的**、**用我圈的** 或 **我重新圈**,也可以在下面写一句(比如“再往手腕多圈一点”),AI 会照着再调一次;点 **让 AI 定** 就用 AI 调整的。选好以后 AMB 才把带这个范围的 `anomalous_inpaint_….png` 放进 input 文件夹,节点怎么搭由 AI 自己来。
**AI 改副本**(默认):AI 第一次改画布前,AMB 先把你正开着的工作流复制到一个以任务命名的新标签页(副本),之后 AI 的修改、读画布和运行都只在那一页;你切去别的标签页也没关系,它下次动手前 AMB 会先切回去。你原来的工作流一笔不动。AI 用 `new_workflow` / `open_workflow` 打开的标签页就直接算这个任务的。任务标题栏有 **AI 改副本 ▾**(点开可以换成 **改当前画布**,每种都写着是什么意思)和 **切到副本**(点一下切过去);**关卡** 窗口里的“AI 改画布时”设定新任务的默认做法。
+70
View File
@@ -0,0 +1,70 @@
"""Skills (api/ai_skills.py): advice an AI fetches for a kind of job. begin_task lists the ones on,
get_toolbox(skill=…) gives one, a skill the user turned off is neither listed nor given, and the
prompt tools only point at the skill. Temporary folders only."""
import os
import sys
import tempfile
import time
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]), str(PLUGIN / "tests")]
from api import ai_skills, ai_tasks, mcp_actions, mcp_task_tools # noqa: E402
from api.mcp_tools import ToolError # noqa: E402
from test_ai_tasks import isolate, ok # noqa: E402
OFF = os.path.join(tempfile.mkdtemp(prefix="amb_ai_skills_"), "ai_skills.json")
class SkillTests(unittest.TestCase):
def setUp(self):
isolate()
if os.path.exists(OFF):
os.remove(OFF)
patcher = mock.patch.object(ai_skills, "_off_path", lambda: OFF)
patcher.start()
self.addCleanup(patcher.stop)
ai_tasks.record("Gemini", "describe_canvas", {}, ok({}), time.time(), True)
self.ctx = {"client": "Gemini", "task": ai_tasks.list_tasks()[0]["id"]}
def test_every_skill_file_is_complete(self):
self.assertIn("prompt_pose", ai_skills.SKILLS)
for name, skill in ai_skills.SKILLS.items():
with self.subTest(skill=name):
self.assertTrue(skill["short"] and skill["text"])
for field in ("title", "about"):
self.assertTrue(skill[field]["zh"] and skill[field]["en"])
def test_begin_task_lists_them_and_get_toolbox_gives_one(self):
with mock.patch.object(mcp_task_tools, "_blocks", lambda ctx: {}):
listed = mcp_task_tools.begin_task(self.ctx, "a picture")["skills"]
self.assertIn("prompt_pose", listed)
self.assertIn("get_toolbox(skill=", listed)
advice = mcp_task_tools.get_toolbox(self.ctx, skill="prompt_pose")["advice"]
self.assertTrue(any("as it is" in line for line in advice), "the user's own text is kept")
with self.assertRaises(ToolError):
mcp_task_tools.get_toolbox(self.ctx, skill="no_such_skill")
def test_a_skill_turned_off_is_neither_listed_nor_given(self):
ai_skills.set_on("prompt_pose", False)
self.assertFalse(next(item for item in ai_skills.status() if item["name"] == "prompt_pose")["on"])
self.assertNotIn("prompt_pose", ai_skills.directory())
with self.assertRaises(ToolError):
mcp_task_tools.get_toolbox(self.ctx, skill="prompt_pose")
ai_skills.set_on("prompt_pose", True)
self.assertIn("prompt_pose", ai_skills.directory())
with self.assertRaises(ai_skills.SkillError):
ai_skills.set_on("no_such_skill", False)
def test_the_prompt_tools_point_at_the_skill(self):
for tool in ("set_prompt", "run_workflow"):
with self.subTest(tool=tool):
words = mcp_actions.ACTIONS[tool]["spec"]["inputSchema"]["properties"]["prompt"]["description"]
self.assertIn('skill="prompt_pose"', words)
if __name__ == "__main__":
unittest.main()
+4
View File
@@ -1411,6 +1411,8 @@ export const i18n = {
aiBlocksJob_follow: "照着别的图画",
aiBlocksJob_detail: "部位细化",
aiBlocksJob_process: "图片处理",
aiSkillsHeading: "Skill(给 AI 的建议)",
aiSkillsNote: "做法是 AMB 替 AI 搭节点;skill 是 AI 遇到这类活时自己去取的建议,不搭任何节点,你的原话优先。关掉的 AI 就拿不到。",
aiBlocksReady: "可用",
aiBlocksMissing: "缺东西",
aiBlocksNeedsPack: "缺节点包 {name}",
@@ -4862,6 +4864,8 @@ export const i18n = {
aiBlocksJob_follow: "Follow another picture",
aiBlocksJob_detail: "Detailing",
aiBlocksJob_process: "Picture work",
aiSkillsHeading: "Skills (advice for the AI)",
aiSkillsNote: "A way is nodes AMB builds for the AI; a skill is advice the AI fetches when that kind of job comes up. It builds nothing, and your own words come first. The AI can't fetch the ones turned off.",
aiBlocksReady: "Ready",
aiBlocksMissing: "Missing something",
aiBlocksNeedsPack: "Needs {name}",
+41 -18
View File
@@ -5,7 +5,8 @@
* (a pack opens ComfyUI-Manager's list searched for it); and a switch the user turns a block off
* with, so the AI is not offered it; how it was tried (the card, a run's time) and the models it recommends
* (api/block_models.json), each here or a button downloading it, link and SHA256 from the list.
* GET /anomalous/ai_blocks, POST {name, on}. Drawn when shown,
* Under them the skills (api/ai_skills.py): advice the AI fetches for a kind of job, each with its
* switch too. GET /anomalous/ai_blocks, POST {name, on} or {skill, on}. Drawn when shown,
* the list read again each time the view is opened.
*/
@@ -90,6 +91,27 @@ function recommended(block, owner) {
return box;
}
/** The switch that turns `item` on or off for the AI; `key`: {name} for a block, {skill} for a skill. */
function onSwitch(item, key, redraw) {
const toggle = el('input', 'anomalous-ai-block-switch');
toggle.type = 'checkbox';
toggle.setAttribute('role', 'switch');
toggle.checked = item.on;
toggle.title = t(item.on ? 'aiBlocksOnHint' : 'aiBlocksOffHint');
toggle.setAttribute('aria-label', t('aiBlocksSwitch', { name: words(item.title) }));
toggle.onchange = async () => {
toggle.disabled = true;
try {
shown = await ask({ ...key, on: toggle.checked });
} catch (error) {
toggle.checked = !toggle.checked;
shown = { ...shown, note: error.message };
}
redraw();
};
return toggle;
}
function blockRow(block, owner, redraw) {
const row = el('div', `anomalous-ai-block${block.usable ? '' : ' is-missing'}${block.on ? '' : ' is-off'}`);
const body = el('div', 'anomalous-ai-block-body');
@@ -105,26 +127,26 @@ function blockRow(block, owner, redraw) {
if (words(block.source)) more.append(el('div', 'anomalous-ai-block-source', t('aiBlocksSource', { source: words(block.source) })));
more.append(...[tested(block), recommended(block, owner)].filter(Boolean));
if (more.childElementCount > 1) body.append(more);
const toggle = el('input', 'anomalous-ai-block-switch');
toggle.type = 'checkbox';
toggle.setAttribute('role', 'switch');
toggle.checked = block.on;
toggle.title = t(block.on ? 'aiBlocksOnHint' : 'aiBlocksOffHint');
toggle.setAttribute('aria-label', t('aiBlocksSwitch', { name: words(block.title) }));
toggle.onchange = async () => {
toggle.disabled = true;
try {
shown = await ask({ name: block.name, on: toggle.checked });
} catch (error) {
toggle.checked = !toggle.checked;
shown = { ...shown, note: error.message };
}
redraw();
};
row.append(body, toggle);
row.append(body, onSwitch(block, { name: block.name }, redraw));
return row;
}
/** The skills: a title, what it advises, its switch. */
function skillsGroup(skills, redraw) {
const group = el('div', 'anomalous-ai-blocks-group');
group.append(el('div', 'anomalous-ai-blocks-job', t('aiSkillsHeading')), el('div', 'anomalous-ai-blocks-note', t('aiSkillsNote')));
for (const skill of skills) {
const row = el('div', `anomalous-ai-block${skill.on ? '' : ' is-off'}`);
const body = el('div', 'anomalous-ai-block-body');
const head = el('div', 'anomalous-ai-block-head');
head.append(el('span', 'anomalous-ai-block-title', words(skill.title)));
body.append(head, el('div', 'anomalous-ai-block-about', words(skill.about)));
row.append(body, onSwitch(skill, { skill: skill.name }, redraw));
group.append(row);
}
return group;
}
function listBox(owner, redraw) {
const body = el('div', 'anomalous-ai-stage-body anomalous-ai-blocks');
if (!shown) {
@@ -146,6 +168,7 @@ function listBox(owner, redraw) {
group.append(el('div', 'anomalous-ai-blocks-job', t(`aiBlocksJob_${job}`)), ...blocks.map(block => blockRow(block, owner, redraw)));
body.append(group);
}
if (shown.skills?.length) body.append(skillsGroup(shown.skills, redraw));
return body;
}