Let the user pick which tools an AI app is offered: every tool, a short drawing set, or their own

The tool list sits in the AI's context every round and is at its budget (issue #27). The address
may now name a shorter one: ?tools=core (drawing in the AI app's own chat: no workbench, no file
moves, scans or downloads, no voice; the ways, skills, zoom_image and stop_run stay) or
?tools=mine (the user's checklist by stage, kept in user/anomalous/ai_tool_sets.json). Only the
list shrinks: tools/call takes every tool, an unknown or empty set offers all, and the address as
it is answers exactly as before. The instructions are split into parts and a part naming a tool
outside the set is dropped; without the workbench the AI is told to ask in its own chat.

Connect an AI picks the set above the address (not under a waiting task, which needs the
workbench); the address, the sentence and each app's line follow. Users pick from Anomalous's
tools and never add their own (AD-027).

The structure check passed absolute paths for every web file on one command line, which grew
past what Windows takes; it now passes them relative to the plugin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DemonGatanjieu
2026-11-12 18:57:29 +08:00
co-authored by Claude Opus 5.5
parent 12257a55ba
commit bc9f18a838
13 changed files with 719 additions and 60 deletions
+7 -2
View File
@@ -358,7 +358,10 @@ covered by the `styles.css` manifest.
for the code): each tool under its stage (`action_catalog.TOOLS`) with its title, built from the
registry, in begin_task's answer and in get_situation's while there is no task, the tools by
name alone when the titles would make that answer too long to read (`tests/test_mcp_directory.py`). AI apps are offered every tool (they load what they need
themselves: Antigravity reads a tool's file before its first call); the module also keeps which
themselves: Antigravity reads a tool's file before its first call) unless the address names a shorter set:
`api/mcp_tool_sets.py` serves `?tools=core` (drawing in the AI app's own chat, no workbench) and `?tools=mine`
(the user's pick, `anomalous/ai_tool_sets.json`), with the instructions cut to the listed tools; only the list
shrinks, tools/call takes every tool (`tests/test_mcp_tool_sets.py`). mcp_directory.py also keeps which
list each app fetched last (`anomalous/ai_tool_lists.json`), so a call from an app still holding
the list of an older Anomalous is told so and the task header says how to reconnect it.
`api/mcp_fill.py` gives run_workflow its blanks, a daily run in one call: a saved workflow, or
@@ -594,7 +597,9 @@ covered by the `styles.css` manifest.
`ui_ai_connect.js` says how to connect an AI app (the address, a sentence to send the AI so
it sets itself up and starts on a waiting task, each common app's way), in a window from the
list's "Connect an AI", and as a card (`connectHint`) that opens the guide in place on the
empty page and over a task waiting for an AI until one has ever come. `ui_ai_ready.js` is that
empty page and over a task waiting for an AI until one has ever come; without a waiting task
`ui_ai_tool_set.js` picks above the address which tools the app is offered (all, the short set, or the
user's checklist by stage) and the address follows. `ui_ai_ready.js` is that
waiting task's one page: the sentence to send the AI, and what the user sent so far; what to say
first and pictures go in the message box. The pictures (`web/assets/ai_*.webp`)
were made with ChatGPT. `ai_task_view.js` holds
+2
View File
@@ -78,6 +78,8 @@
- Under a run you marked **not quite right** there is now **what is off**: hands, face, framing, too dark, too bright, blurry, wrong style and more. A click tells the AI which to fix, and you may pick several. In privacy mode the AI is not sent the pictures, so your click is its eyes; in any task it saves the typing.
- 新增拓展功能 **暗房助手**:在你自己电脑上跑的本地模型(Ollama、LM Studio 这类),替隐私任务读云端 AI 看不到的东西。云端 AI 说怎么改,它拿原文去改写提示词,云端 AI 仍然只拿到代号;要看图时图交给它,它只回答固定的六项(清晰度、明暗、手、脸、身体、构图),每项只能是规定的几个词。只连本机地址,内容不出这台电脑。在关卡窗口最下面设置;没装本地模型就当它不存在。
- New extension, the **darkroom helper**: a model running on your own computer (Ollama, LM Studio and the like) reads for a private task what the cloud AI may not. The cloud AI says what to change and the helper rewrites the prompt from its text, the cloud AI still getting only a token; a picture goes to the helper, which answers a fixed list of six checks (sharpness, exposure, hands, face, body, framing), each with one of a few set words. It connects to this computer only. Set it up at the bottom of the Gates window; without a local model it is simply not there.
- 「连接 AI」里能选**给 AI 哪些工具**:全部工具(默认,AMB 里交给 AI 的任务要用这一份)、**跑图精简**(只在 AI 软件自己的对话里跑图,去掉了工作台、挪动/扫描/下载模型和配音,AI 每轮少读约三分之一的工具说明),或者**我选的**(按类别自己勾,保存后用)。下面的地址会跟着变,比如末尾多了 `?tools=core`;只是清单变短,AMB 的功能没有少。点子来自议题 #27。
- "Connect an AI" picks **which tools the AI gets**: every tool (the default; tasks handed over in Anomalous need it), **Drawing only** (for drawing in the AI app's own chat: without the workbench, moving, scanning or downloading models and the voice, so the AI reads about a third less of tool descriptions every round), or **My pick** (tick them by kind, then save). The address below follows, ending in `?tools=core` for example; only the list gets shorter, nothing in Anomalous is taken away. The idea came from issue #27.
## v1.63.0 (AI Ways and Skills, Saves, Official Example Workflows, Privacy Mode) — 2026-11-10
+66 -40
View File
@@ -19,12 +19,13 @@ outside Anomalous since the AI's previous call shown and told (ai_outside.py). P
import asyncio
import base64
import json
import time
import urllib.parse
from aiohttp import web
from . import ai_callers, ai_darkroom, ai_gates, ai_outside, ai_privacy, ai_sealed, ai_sent, ai_panels, ai_run_tracking, ai_task_canvas, ai_task_runs, ai_tasks, job_sheets, mcp_apps, mcp_bridge, mcp_directory, mcp_fit, mcp_prompts
from . import ai_callers, ai_darkroom, ai_gates, ai_outside, ai_privacy, ai_sealed, ai_sent, ai_panels, ai_run_tracking, ai_task_canvas, ai_task_runs, ai_tasks, job_sheets, mcp_apps, mcp_bridge, mcp_directory, mcp_fit, mcp_prompts, mcp_tool_sets
from .local_guard import is_local as _is_local, loopback_host as _loopback_host
from .mcp_actions import ACTIONS
from .mcp_canvas_edit import CANVAS_TOOLS, object_info
@@ -46,42 +47,59 @@ CAPABILITIES = {
MODERN_VERSIONS = ("2026-07-28",)
LEGACY_VERSIONS = ("2025-11-25", "2025-06-18", "2025-03-26")
SERVER_INFO = {"name": "anomalous-model-browser", "title": "Anomalous Model Browser", "version": "1.0.0"}
INSTRUCTIONS = (
# One part per sentence or clause: a set without some tools drops the parts naming them (mcp_tool_sets.py).
INSTRUCTION_PARTS = (
"Tools over the user's ComfyUI library as Anomalous Model Browser keeps it: model files with "
"their Civitai information, output images with their generation settings, combos (a model, "
"LoRAs and a prompt), workflow recipes, saved prompts, generated audio, the scan status and "
"the activity log; and tools that act on the workflow open in ComfyUI (get_situation gives flow chains; "
"describe_canvas only for details) or build one where the user watches: find_nodes searches brief types; "
"the activity log; ",
"and tools that act on the workflow open in ComfyUI (get_situation gives flow chains; "
"describe_canvas only for details) or build one where the user watches: ",
"find_nodes searches brief types; ",
"add_node wires in the same call (wire_from/wire_to; wire_to=ID splices it in front of that node, "
"insert_after=ID behind it); add_nodes adds a whole group in ComfyUI's API form in one call; "
"connect_nodes needs only two IDs unless sockets are ambiguous. Reuse receipts instead of rereading "
"the graph. new_workflow opens a tab only when needed. Tools also scan models or have an "
"Anomalous TTS voice speak. Canvas changes "
"are single Ctrl+Z steps and are logged as the AI's; nothing deletes files. Ids returned by "
"one tool (model_id, image, node ids) are what the others take. For what no tool does, or when the "
"user asks how something in Anomalous works, read_guide has its manual: tell them the way, with the words "
"on screen. begin_task's answer carries a directory of the tools by what you want to do: "
"look there and go straight to the tool. In a new chat on a task, or when unsure where things stand, call get_situation "
"first: the task, the canvas in brief (what is still unwired) and the next step in one answer. "
"insert_after=ID behind it); ",
"add_nodes adds a whole group in ComfyUI's API form in one call; ",
"connect_nodes needs only two IDs unless sockets are ambiguous. ",
"Reuse receipts instead of rereading the graph. ",
"new_workflow opens a tab only when needed. ",
"Tools also scan models or have an Anomalous TTS voice speak. ",
"Canvas changes are single Ctrl+Z steps and are logged as the AI's; nothing deletes files. ",
"Ids returned by one tool (model_id, image, node ids) are what the others take. ",
"For what no tool does, or when the user asks how something in Anomalous works, read_guide has its manual: "
"tell them the way, with the words on screen. ",
"begin_task's answer carries a directory of the tools by what you want to do: "
"look there and go straight to the tool. ",
"In a new chat on a task, or when unsure where things stand, call get_situation "
"first: the task, the canvas in brief (what is still unwired) and the next step in one answer. ",
"The user watches your work in Anomalous's AI workbench: call begin_task "
"with a short title when you start what they asked for (it continues the waiting or active task; "
"new_task=true is only for a separate user request), and report to explain a choice, hand in "
"pictures made another way, or end the task (done=true). The user can write to you or answer "
"pictures made another way, or end the task (done=true). ",
"The user can write to you or answer "
"panel questions there: call get_answers to wait for or read them, and any unread user_messages "
"or user_answers attached to a tool result must also be honoured. While you work on an Anomalous task, ask "
"or user_answers attached to a tool result must also be honoured. ",
"While you work on an Anomalous task, ask "
"the user every question (a choice, a preference, yes or no) with ask_user, never in your own chat or its "
"question form: the user watches and answers in Anomalous. Until they say the task is done, end every round "
"question form: the user watches and answers in Anomalous. ",
"Until they say the task is done, end every round "
"with ask_user (what next, or whether to go ahead), not with a note or panel alone; only a run, download or "
"scan going on lets you end your turn without asking. Likewise write your explanations and replies "
"there with report (reply_to answers their message or marks), not only in your chat. Show plans, workflow explanations "
"scan going on lets you end your turn without asking. ",
"Likewise write your explanations and replies "
"there with report (reply_to answers their message or marks), not only in your chat. ",
"Show plans, workflow explanations "
"and results in chronological order with show_panel; update_panel appends new content below what "
"the user already saw. Use run_workflow for execution: it returns real state/results after a bounded wait. "
"the user already saw. ",
"Use run_workflow for execution: it returns real state/results after a bounded wait. ",
"If you submit through /prompt or another tool, immediately call track_run with YOUR returned prompt_ids "
"and task ID, or the workbench cannot observe it. For running results use run_status(wait_seconds=55); "
"do not inspect output directories or resubmit to find out whether it finished. Change pictures only by "
"running workflows, never with code of your own; if you do use another tool (a command, a script, files), "
"say what you ran with log_outside."
"and task ID, or the workbench cannot observe it. ",
"For running results use run_status(wait_seconds=55); "
"do not inspect output directories or resubmit to find out whether it finished. ",
"Change pictures only by "
"running workflows, never with code of your own; ",
"if you do use another tool (a command, a script, files), "
"say what you ran with log_outside.",
)
INSTRUCTIONS = "".join(INSTRUCTION_PARTS)
LIST_TTL_MS = 300000
# Requests run tools in threads; a few at a time is plenty for one person's AI app.
_slots = asyncio.Semaphore(4)
@@ -122,34 +140,39 @@ def _result(id_, result, modern):
return web.json_response({"jsonrpc": "2.0", "id": id_, "result": result})
def _tools():
"""The tool list AI apps are offered, as tools/list gives it."""
return [mcp_apps.with_card(tool["spec"]) for tool in TOOLS.values()]
def _tools(names=None):
"""The tool list AI apps are offered, as tools/list gives it: every tool, or the set `names` (mcp_tool_sets.py)."""
return [mcp_apps.with_card(tool["spec"]) for name, tool in TOOLS.items() if names is None or name in names]
def _tool_list(modern):
result = {"tools": _tools()}
def _sizes():
"""{tool: characters of its spec as listed}, for the Connect an AI window's counts."""
return {tool["name"]: len(json.dumps(tool, ensure_ascii=False)) for tool in _tools()}
def _tool_list(modern, names=None):
result = {"tools": _tools(names)}
if modern:
result.update(ttlMs=LIST_TTL_MS, cacheScope="public")
return result
def _initialize(params):
def _initialize(params, names=None):
requested = params.get("protocolVersion")
return {
"protocolVersion": requested if requested in LEGACY_VERSIONS else LEGACY_VERSIONS[0],
"capabilities": CAPABILITIES,
"serverInfo": SERVER_INFO,
"instructions": INSTRUCTIONS,
"instructions": mcp_tool_sets.instructions(INSTRUCTION_PARTS, names, TOOLS),
}
def _discover():
def _discover(names=None):
return {
"supportedVersions": list(MODERN_VERSIONS + LEGACY_VERSIONS),
"capabilities": CAPABILITIES,
"_meta": {"io.modelcontextprotocol/serverInfo": SERVER_INFO},
"instructions": INSTRUCTIONS,
"instructions": mcp_tool_sets.instructions(INSTRUCTION_PARTS, names, TOOLS),
"ttlMs": LIST_TTL_MS,
"cacheScope": "public",
}
@@ -168,7 +191,7 @@ def _modern_header_problem(request, method, params, version):
return None
async def _call(params, request):
async def _call(params, request, names=None):
name = params.get("name")
raw_arguments = params.get("arguments") or {}
if not isinstance(name, str) or name not in TOOLS or not isinstance(raw_arguments, dict):
@@ -234,8 +257,9 @@ async def _call(params, request):
await asyncio.to_thread(ai_task_runs.show_pictures, result, resolved_task)
if name != "get_answers" and name not in mcp_apps.APP_TOOLS and isinstance(result, dict) and not result.get("isError"):
await asyncio.to_thread(ai_panels.attach_pending, result, client, task_id=resolved_task or ("" if session else None))
mcp_directory.attach(result, name, {key: tool["spec"] for key, tool in TOOLS.items() if key not in mcp_apps.APP_TOOLS})
if name not in mcp_apps.APP_TOOLS and mcp_directory.stale(client, _tools()):
mcp_directory.attach(result, name, {key: tool["spec"] for key, tool in TOOLS.items()
if key not in mcp_apps.APP_TOOLS and (names is None or key in names)})
if name not in mcp_apps.APP_TOOLS and mcp_directory.stale(client, _tools(names)):
await asyncio.to_thread(mcp_directory.tell_stale, result, active_task)
# Last: a private task's pictures and prompts never leave, whatever put them in (ai_privacy.py).
final_task = active_task or ai_tasks.active_task_id(client, session=session)
@@ -274,9 +298,10 @@ async def api_mcp(request):
return web.Response(status=202) # notifications (initialized, cancelled) need no answer
id_, method = body["id"], body["method"]
params = body.get("params") if isinstance(body.get("params"), dict) else {}
names = mcp_tool_sets.chosen(request.query.get("tools"), TOOLS) # ?tools=core | mine: a shorter list
if method == "initialize":
return _result(id_, _initialize(params), modern=False)
return _result(id_, _initialize(params, names), modern=False)
meta = params.get("_meta") if isinstance(params.get("_meta"), dict) else {}
version = meta.get("io.modelcontextprotocol/protocolVersion")
modern = version is not None
@@ -294,11 +319,11 @@ async def api_mcp(request):
data={"supported": list(MODERN_VERSIONS + LEGACY_VERSIONS), "requested": header})
if method == "server/discover" and modern:
return _result(id_, _discover(), modern)
return _result(id_, _discover(names), modern)
if method == "ping":
return _result(id_, {}, modern)
if method == "tools/list":
listing = _tool_list(modern)
listing = _tool_list(modern, names)
await asyncio.to_thread(mcp_directory.listed, ai_callers.client_of(request.headers, params), listing["tools"])
return _result(id_, listing, modern)
if method == "prompts/list":
@@ -316,7 +341,7 @@ async def api_mcp(request):
return _error(id_, INVALID_PARAMS, f"Unknown resource: {params.get('uri')}")
return _result(id_, resource, modern)
if method == "tools/call":
result = await _call(params, request)
result = await _call(params, request, names)
if result is None:
return _error(id_, INVALID_PARAMS, f"Unknown tool or bad arguments: {params.get('name')}")
return _result(id_, result, modern)
@@ -346,3 +371,4 @@ async def api_mcp_bridge(request):
def register_routes(app):
app.router.add_route("*", "/anomalous/mcp", api_mcp)
app.router.add_post("/anomalous/mcp/bridge", api_mcp_bridge)
mcp_tool_sets.register_routes(app, TOOLS, _sizes)
+121
View File
@@ -0,0 +1,121 @@
"""Which tools an AI app is offered. The address as it is offers every tool; `?tools=core` offers
Anomalous's short set for working in the AI app's own chat, `?tools=mine` the user's own choice (kept in
anomalous/ai_tool_sets.json, picked in the Connect an AI window). An app keeps the whole list in the AI's
context for the whole chat, so a shorter one leaves the AI more room (issue #27). It only shortens the
list: tools/call still takes every tool, and an unknown set name, or "mine" never chosen, offers all.
The short set leaves out the AI workbench (begin_task, report, ask_user…), the tools that move, scan or
download files, and the voice; it keeps the ways and skills (get_toolbox, read_guide), the closer look
(zoom_image) and stop_run. The instructions an app is given name only listed tools (instructions()):
a part naming a tool the set leaves out is dropped, and without the workbench the AI is told to ask and
explain in its own chat. The card's own tools (mcp_apps.APP_TOOLS) come along whenever a listed tool
shows the card.
GET /anomalous/ai_tool_sets: {core, mine, needed, tools: [{name, stage, size}], all_size};
POST {mine: [names]} keeps the user's choice (a write: this computer only, local_guard.py).
"""
import json
import os
import re
from aiohttp import web
from . import action_catalog, ai_tasks, mcp_apps
from .path_utils import atomic_write_json
CORE = (
"get_situation", "describe_canvas", "find_nodes", "check_workflow_models", "run_status",
"library_overview", "scan_report", "search_images", "get_image_info", "zoom_image", "list_combos",
"list_workflows", "list_saved_prompts", "search_audio", "recent_activity", "search_models", "get_model",
"set_prompt", "set_model", "add_lora", "place_combo", "download_missing_models", "run_workflow", "stop_run",
"open_in_anomalous", "add_node", "add_nodes", "connect_nodes", "set_values", "remove_nodes", "new_workflow",
"open_workflow", "get_toolbox", "read_guide",
)
NEEDED = ("begin_task", "report", "ask_user", "get_answers") # what a task handed over in Anomalous needs
CHAT_NOTE = ("This connection has no workbench tools: ask the user and explain in your own chat, also where an "
"answer or a skill says ask_user or report.")
_mine = None # the user's choice, read once
def _path():
return os.path.join(os.path.dirname(ai_tasks.tasks_dir()), "ai_tool_sets.json")
def mine():
"""The tools the user picked, in the registry's order; () before they ever picked."""
global _mine
if _mine is None:
try:
with open(_path(), encoding="utf-8") as source:
stored = json.load(source).get("mine")
except (OSError, ValueError, AttributeError):
stored = None
_mine = tuple(name for name in stored if isinstance(name, str)) if isinstance(stored, list) else ()
return _mine
def save_mine(names, tools):
"""Keeps the user's choice: names of offered tools (the card's own come along by themselves)."""
global _mine
if not isinstance(names, list):
raise ValueError("Expected {mine: [tool names]}")
picked = tuple(name for name in tools if name in names and name not in mcp_apps.APP_TOOLS)
if not picked:
raise ValueError("Pick at least one tool")
atomic_write_json(_path(), {"mine": list(picked)})
_mine = picked
return picked
def chosen(value, tools):
"""The names the address's ?tools= offers, or None for every tool."""
picked = CORE if value == "core" else mine() if value == "mine" else ()
names = {name for name in picked if name in tools}
if not names:
return None
if names & set(mcp_apps.CARD_TOOLS):
names |= set(mcp_apps.APP_TOOLS)
return frozenset(names)
def instructions(parts, names, tools):
"""The server's instructions for the set `names` (None: all of them, as written)."""
if names is None:
return "".join(parts)
pattern = re.compile(r"(?<![\w/])(" + "|".join(sorted(map(re.escape, tools), key=len, reverse=True)) + r")(?!\w)")
kept = [part for part in parts if set(pattern.findall(part)) <= names]
text = "".join(kept).rstrip()
if text.endswith(";"):
text = text[:-1] + "."
if not set(NEEDED) <= names:
text = f"{text} {CHAT_NOTE}"
return text
def register_routes(app, tools, sizes):
"""GET/POST /anomalous/ai_tool_sets; `sizes()` gives {name: characters of its listed spec}."""
def answer():
size = sizes()
offered = [name for name in tools if name not in mcp_apps.APP_TOOLS]
return web.json_response({
"core": list(CORE), "mine": list(mine()), "needed": list(NEEDED), "all_size": sum(size.values()),
"card": {"tools": list(mcp_apps.CARD_TOOLS), "size": sum(size[name] for name in mcp_apps.APP_TOOLS)},
"tools": [{"name": name, "stage": action_catalog.TOOLS[name].stage if name in action_catalog.TOOLS else None,
"size": size[name]} for name in offered],
})
async def api_tool_sets(request):
if request.method == "POST":
try:
body = await request.json()
except (ValueError, UnicodeDecodeError):
body = None
try:
save_mine((body or {}).get("mine") if isinstance(body, dict) else None, tools)
except ValueError as error:
return web.json_response({"error": str(error)}, status=400)
return answer()
app.router.add_get("/anomalous/ai_tool_sets", api_tool_sets)
app.router.add_post("/anomalous/ai_tool_sets", api_tool_sets)
+10
View File
@@ -318,6 +318,16 @@ a skill (the `prompt` of `set_prompt` and `run_workflow` at `prompt_pose`: a pos
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.
Tool sets (`api/mcp_tool_sets.py`): an AI app keeps the whole tool list in the AI's context, so the
address may name a shorter one. `?tools=core` is a fixed set for drawing in the AI app's own chat (no
workbench, no tools that move, scan or download files, no voice; the ways, skills, zoom_image and
stop_run stay); `?tools=mine` is the user's pick (`user/anomalous/ai_tool_sets.json` {mine: [names]},
written by `POST /anomalous/ai_tool_sets`, this computer only). An unknown name or an empty pick offers
every tool. The set changes tools/list, the instructions (`INSTRUCTION_PARTS` in mcp_server.py: a part
naming a tool outside the set is dropped, and without begin_task, report, ask_user and get_answers the
AI is told to ask in its own chat), the directory, and the list an app is checked against for being
stale; tools/call still takes every tool. The card's own tools come along when a listed tool shows it.
`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
one recorded run. Retrying an identical batch returns that run, not the task's newest run.
+1 -1
View File
@@ -25,7 +25,7 @@ node tools/ui_map.mjs build 写出 docs/ui-map.md:按页面列
| 看到的 | 点了以后 | 后端 |
| --- | --- | --- |
| 「新任务」 | 马上开一个任务,显示“等 AI 接手”,要做什么在那一页写(可以不写);下一个来调用的 AI 直接接手它,不会接在旧任务后面 | `POST /anomalous/ai_tasks` |
| 「连接 AI」 | 弹出连接说明:地址、复制给 AI 的那句话、各个软件怎么加(`ui_ai_connect.js`) | 无 |
| 「连接 AI」 | 弹出连接说明:地址、复制给 AI 的那句话、各个软件怎么加(`ui_ai_connect.js`);地址上面选给 AI 哪些工具:全部工具 / 跑图精简 / 我选的,「我选的…」按类别勾选后「保存」,地址跟着加上 `?tools=…`(`ui_ai_tool_set.js`) | `GET/POST /anomalous/ai_tool_sets`(只在保存时写) |
| 「关卡」 | AI 做哪些事要先问你(改画布、用显卡、下载……),还有 AI 怎么等你回答、怎么等跑图(`ui_ai_gate.js`) | `/anomalous/ai_gates` |
| 「全部」「进行中」「等你回答」「出错」 | 只筛这一栏,不改任何东西 | 无 |
| 一个任务 | 中间显示它。看过的、鼠标停过的马上出来,再悄悄补上最新内容(`ai_task_cache.js`) | `GET /anomalous/ai_tasks/{task_id}` |
+8
View File
@@ -254,3 +254,11 @@ picture reaches the AI app only as a fixed list of checks with a few allowed wor
words are dropped, because a description is the picture again. A prompt it writes leaves as a
token, like any prompt. It is an extension: off until the user picks a model, and Anomalous
installs and downloads nothing for it.
## AD-027 — Users pick from Anomalous's tools; they do not add their own
The tool list sits in the AI's context every round, so a user may offer an AI app fewer tools:
Anomalous's short set or their own pick, named in the address. A tool is never added by the user:
it would run someone else's code inside ComfyUI, and privacy mode could not seal what such a tool
sends. An AI app can connect other MCP servers beside Anomalous for that. A set only shortens the
list; the full list stays the default, because a task handed over in Anomalous needs the workbench.
+105
View File
@@ -0,0 +1,105 @@
"""A shorter tool list chosen by the address (?tools=core | mine, api/mcp_tool_sets.py): only the list
shrinks, the instructions name only listed tools, the user's pick is kept, every tool has a label."""
import asyncio
import json
import os
import re
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
from aiohttp.test_utils import make_mocked_request
PLUGIN = Path(__file__).resolve().parents[1]
sys.path[:0] = [str(PLUGIN), str(PLUGIN.parents[1])]
from api import ai_tasks, mcp_apps, mcp_server, mcp_tool_sets # noqa: E402
_AI_TASKS = tempfile.mkdtemp(prefix="amb_tool_sets_")
ai_tasks.tasks_dir = lambda: os.path.join(_AI_TASKS, "anomalous", "ai_tasks")
os.makedirs(ai_tasks.tasks_dir(), exist_ok=True)
ai_tasks._tasks = None
def post(path, body):
transport = mock.Mock()
transport.get_extra_info.side_effect = lambda key, default=None: ("127.0.0.1", 5555) if key == "peername" else default
request = make_mocked_request("POST", path, transport=transport,
headers={"Host": "127.0.0.1:8188", "Content-Type": "application/json", "MCP-Protocol-Version": "2025-06-18"})
request.json = lambda: asyncio.sleep(0, body)
response = asyncio.run(mcp_server.api_mcp(request))
return json.loads(response.text)
def listed(path):
return [tool["name"] for tool in post(path, {"jsonrpc": "2.0", "id": 1, "method": "tools/list"})["result"]["tools"]]
def instructions(path):
body = {"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "sets", "version": "1"}}}
return post(path, body)["result"]["instructions"]
def named(text):
return set(re.findall(r"(?<![\w/])(" + "|".join(sorted(mcp_server.TOOLS, key=len, reverse=True)) + r")(?!\w)", text))
class ToolSetTests(unittest.TestCase):
def setUp(self):
mcp_tool_sets._mine = None
try:
os.remove(mcp_tool_sets._path())
except FileNotFoundError:
pass
def test_the_address_as_it_is_offers_every_tool_and_the_whole_instructions(self):
self.assertEqual(listed("/anomalous/mcp"), list(mcp_server.TOOLS))
self.assertEqual(instructions("/anomalous/mcp"), mcp_server.INSTRUCTIONS)
for unknown in ("/anomalous/mcp?tools=nope", "/anomalous/mcp?tools=mine"): # "mine" never picked: all
self.assertEqual(listed(unknown), list(mcp_server.TOOLS))
def test_core_is_short_and_its_instructions_name_only_its_tools(self):
names = listed("/anomalous/mcp?tools=core")
self.assertEqual(set(names), set(mcp_tool_sets.CORE) | set(mcp_apps.APP_TOOLS)) # run_workflow shows the card
self.assertFalse(set(mcp_tool_sets.NEEDED) & set(names))
size = len(json.dumps(mcp_server._tool_list(False, set(names))["tools"], ensure_ascii=False))
self.assertLess(size, 0.7 * len(json.dumps(mcp_server._tool_list(False)["tools"], ensure_ascii=False)))
text = instructions("/anomalous/mcp?tools=core")
self.assertLessEqual(named(text.replace(mcp_tool_sets.CHAT_NOTE, "")), set(names))
self.assertTrue(text.endswith(mcp_tool_sets.CHAT_NOTE))
self.assertLess(len(text), len(mcp_server.INSTRUCTIONS) * 0.6)
def test_every_core_tool_exists(self):
self.assertLessEqual(set(mcp_tool_sets.CORE), set(mcp_server.TOOLS))
self.assertLessEqual(set(mcp_tool_sets.NEEDED), set(mcp_server.TOOLS))
def test_the_users_pick_is_kept_in_the_registrys_order_and_offered(self):
kept = mcp_tool_sets.save_mine(["search_models", "begin_task", "nope", "app_answer", "get_model"], mcp_server.TOOLS)
self.assertEqual(kept, tuple(name for name in mcp_server.TOOLS if name in ("search_models", "begin_task", "get_model")))
mcp_tool_sets._mine = None # read back from the file
self.assertEqual(listed("/anomalous/mcp?tools=mine"), [name for name in mcp_server.TOOLS if name in kept + tuple(mcp_apps.APP_TOOLS)])
with self.assertRaises(ValueError):
mcp_tool_sets.save_mine(["nope"], mcp_server.TOOLS)
with self.assertRaises(ValueError):
mcp_tool_sets.save_mine("search_models", mcp_server.TOOLS)
def test_a_pick_without_a_card_tool_leaves_the_cards_tools_out(self):
mcp_tool_sets.save_mine(["search_models"], mcp_server.TOOLS)
self.assertEqual(listed("/anomalous/mcp?tools=mine"), ["search_models"])
def test_a_tool_left_out_can_still_be_called(self):
body = {"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "begin_task", "arguments": {"title": "set test"}}}
self.assertNotIn("error", post("/anomalous/mcp?tools=core", body))
def test_every_offered_tool_has_a_label_in_both_languages(self):
words = (PLUGIN / "web" / "modules" / "locales.js").read_text(encoding="utf-8")
for name in mcp_server.TOOLS:
if name not in mcp_apps.APP_TOOLS:
self.assertEqual(words.count(f" aiToolSetTool_{name}: "), 2, name)
if __name__ == "__main__":
unittest.main()
+4 -3
View File
@@ -72,15 +72,16 @@ if (large.length) {
}
// 4. Syntax: every web file must parse as an ES module. `node --check` on a .js file can
// pass what the browser rejects (a raw line break inside a string did), so compile them
// the way the browser does, in one child process.
// the way the browser does, in one child process. Paths go relative to the plugin: absolute ones
// made the command line longer than Windows takes (32,767 characters) once the files grew past ~260.
const PARSE = "const vm = require('node:vm'); const fs = require('node:fs');"
+ " for (const f of process.argv.slice(1)) { try { new vm.SourceTextModule(fs.readFileSync(f, 'utf8')); }"
+ " catch (e) { console.log(f + ' | ' + e.message); } }";
const syntax = execFileSync(process.execPath, ['--experimental-vm-modules', '--no-warnings', '-e', PARSE,
...webFiles.map((file) => path.join(ROOT, file))], { encoding: 'utf8' });
...webFiles], { encoding: 'utf8', cwd: ROOT });
for (const line of syntax.split(/\r?\n/).filter(Boolean)) {
const [file, message] = line.split(' | ');
failures.push(`${path.relative(ROOT, file).split(path.sep).join('/')}: does not parse (${message})`);
failures.push(`${file.split(path.sep).join('/')}: does not parse (${message})`);
}
// 5. No `:has()` that matches the style attribute. Chrome then treats every inline-style
+148
View File
@@ -1848,6 +1848,80 @@ export const i18n = {
aiConnectCopyRules: "复制这段规则",
aiConnectRules: "在 Anomalous Model Browser(AMB)的任务里:\n1. 新开对话或拿不准现在到哪一步时,先调用 get_situation:任务、画布还缺哪几根线、下一步做什么,一次就知道。\n2. 先用 AMB 的 MCP 工具:模型、图片、节点、画布、出图和进度都有对应的工具(search_models、get_image_info、zoom_image、find_nodes、run_workflow、run_status 等)。不要自己去读 ComfyUI 的文件夹、源码或接口。日常出图用 run_workflow,带上 workflow(存好的工作流名)和要改的提示词、种子或尺寸,一步完成。\n3. 只通过运行 ComfyUI 工作流来改图片;绝不用自己的代码去改、裁、涂或覆盖图片文件。\n4. 要问我的事用 ask_user,说明和结果用 report,都在 AMB 里。\n5. 如果还是用了别的工具(命令、脚本、读写文件),紧接着调用 log_outside,写明做了什么、为什么、动了哪些文件。",
aiConnectLocal: "只有运行 ComfyUI 的这台电脑能连接。加好以后,在 AI 软件里直接说要做什么就行;连过一次以后不用再加。",
aiToolSet_all: "全部工具 {count}",
aiToolSet_core: "跑图精简 {count}",
aiToolSet_mine: "我选的 {count}",
aiToolSetAllNote: "AI 每轮都要把整份工具清单读一遍。AMB 里「交给 AI」的任务要用这一份。",
aiToolSetCoreNote: "只在 AI 软件自己的对话里跑图用:去掉了工作台(问你、给你留言)、挪动/扫描/下载模型和配音,清单约为全部的 {share}%。AMB 里交给 AI 的任务用不了这一份。",
aiToolSetMineNote: "你自己勾的工具,清单约为全部的 {share}%。改了以后,已经连着的 AI 软件要重新连接一次才看得到。",
aiToolSetMineEmpty: "还没选:在下面勾好并保存,地址才会变。",
aiToolSetEdit: "改选择",
aiToolSetCount: "已选 {count} 个,约为全部的 {share}%",
aiToolSetNoWorkbench: "没全选 {tools}:AMB 里交给 AI 的任务用不了这份清单,AI 会在它自己的对话里问你。",
aiToolSetSave: "保存",
aiToolSetFromCore: "从精简开始",
aiToolSetEvery: "全选",
aiToolSetFold: "收起",
aiToolSetStageRun: "跑图",
aiToolSetStageCanvas: "画布上的工作流",
aiToolSetStageModel: "模型",
aiToolSetStagePrompt: "提示词",
aiToolSetStageCombo: "搭配",
aiToolSetStagePicture: "图片",
aiToolSetStageLibrary: "模型库",
aiToolSetStageVoice: "语音",
aiToolSetStageTask: "任务和你(工作台)",
aiToolSetTool_library_overview: "看模型库概况",
aiToolSetTool_scan_report: "看扫描结果",
aiToolSetTool_search_images: "翻图库",
aiToolSetTool_get_image_info: "看一张图的参数",
aiToolSetTool_list_combos: "看搭配",
aiToolSetTool_list_workflows: "看工作流卡片",
aiToolSetTool_list_saved_prompts: "看存的提示词",
aiToolSetTool_search_audio: "看生成的音频",
aiToolSetTool_recent_activity: "看操作记录",
aiToolSetTool_search_models: "搜模型",
aiToolSetTool_get_model: "看模型详情",
aiToolSetTool_move_model: "挪动模型文件",
aiToolSetTool_zoom_image: "放大看图的一处",
aiToolSetTool_describe_canvas: "细看画布",
aiToolSetTool_set_prompt: "写提示词",
aiToolSetTool_set_model: "换模型",
aiToolSetTool_add_lora: "加 LoRA",
aiToolSetTool_place_combo: "放下搭配",
aiToolSetTool_check_workflow_models: "查缺的模型",
aiToolSetTool_fix_workflow_models: "补上缺的模型",
aiToolSetTool_download_missing_models: "下载缺的模型",
aiToolSetTool_download_model: "下载模型",
aiToolSetTool_run_workflow: "运行工作流",
aiToolSetTool_open_in_anomalous: "打开模型页给你看",
aiToolSetTool_scan_models: "扫描模型",
aiToolSetTool_list_voices: "看角色语音",
aiToolSetTool_speak: "配音",
aiToolSetTool_find_nodes: "查节点",
aiToolSetTool_add_node: "加节点",
aiToolSetTool_add_nodes: "加一组节点",
aiToolSetTool_connect_nodes: "连线",
aiToolSetTool_set_values: "改参数",
aiToolSetTool_remove_nodes: "删节点",
aiToolSetTool_new_workflow: "新建工作流",
aiToolSetTool_open_workflow: "打开工作流",
aiToolSetTool_track_run: "接入外面提交的运行",
aiToolSetTool_begin_task: "开始任务",
aiToolSetTool_report: "给你留言",
aiToolSetTool_log_outside: "说明在 AMB 之外做的事",
aiToolSetTool_log_decision: "记下决策",
aiToolSetTool_show_panel: "给你画面板",
aiToolSetTool_ask_user: "问你",
aiToolSetTool_propose_repaint_area: "给你看要重画的范围",
aiToolSetTool_update_panel: "改面板",
aiToolSetTool_run_status: "看运行进度",
aiToolSetTool_show_run_map: "展示实际跑了哪些节点",
aiToolSetTool_stop_run: "停止运行",
aiToolSetTool_get_toolbox: "看路数、技能和你挑的素材",
aiToolSetTool_get_answers: "读你的回答和留言",
aiToolSetTool_get_situation: "看现状一览",
aiToolSetTool_read_guide: "查 AMB 的说明",
aiState_waiting: "等 AI 接手",
aiWaitingBody: "发过去就行,它的每一步都会显示在这里。",
aiStagePanels: "{count} 个面板",
@@ -5681,6 +5755,80 @@ export const i18n = {
aiConnectCopyRules: "Copy the rules",
aiConnectRules: "In Anomalous Model Browser (AMB) tasks:\n1. In a new chat, or unsure where things stand, call get_situation first: the task, what the canvas still lacks and the next step, in one answer.\n2. Use AMB's MCP tools first: models, pictures, nodes, the canvas, runs and their progress all have tools (search_models, get_image_info, zoom_image, find_nodes, run_workflow, run_status...). Do not read ComfyUI's folders, source or routes yourself. For a daily run use run_workflow with workflow (a saved one's name) and the prompt, seed or size to change: one call.\n3. Change pictures only by running ComfyUI workflows; never edit, crop, paint over or overwrite picture files with code of your own.\n4. Ask me with ask_user and explain with report, in AMB.\n5. If you still use another tool (a command, a script, files), call log_outside right after: what you ran, why, and which files.",
aiConnectLocal: "Only this computer, where ComfyUI runs, can connect. Once added, just tell the AI app what to do; it stays added.",
aiToolSet_all: "Every tool {count}",
aiToolSet_core: "Drawing only {count}",
aiToolSet_mine: "My pick {count}",
aiToolSetAllNote: "The AI reads the whole tool list every round. Tasks handed over in Anomalous need this one.",
aiToolSetCoreNote: "For drawing in the AI app's own chat: without the workbench (asking you, notes to you), moving, scanning or downloading models and the voice; about {share}% of the whole list. Tasks handed over in Anomalous cannot use it.",
aiToolSetMineNote: "The tools you ticked, about {share}% of the whole list. After a change, an AI app already connected sees it once it reconnects.",
aiToolSetMineEmpty: "Nothing picked yet: tick them below and save; then the address changes.",
aiToolSetEdit: "Change",
aiToolSetCount: "{count} picked, about {share}% of the whole list",
aiToolSetNoWorkbench: "Not all of {tools} ticked: tasks handed over in Anomalous cannot use this list, and the AI asks you in its own chat.",
aiToolSetSave: "Save",
aiToolSetFromCore: "Start from drawing only",
aiToolSetEvery: "Tick all",
aiToolSetFold: "Fold",
aiToolSetStageRun: "Running",
aiToolSetStageCanvas: "The workflow on the canvas",
aiToolSetStageModel: "Models",
aiToolSetStagePrompt: "Prompts",
aiToolSetStageCombo: "Combos",
aiToolSetStagePicture: "Pictures",
aiToolSetStageLibrary: "The library",
aiToolSetStageVoice: "Voice",
aiToolSetStageTask: "The task and you (workbench)",
aiToolSetTool_library_overview: "Library overview",
aiToolSetTool_scan_report: "Scan results",
aiToolSetTool_search_images: "Search the gallery",
aiToolSetTool_get_image_info: "A picture's settings",
aiToolSetTool_list_combos: "Combos",
aiToolSetTool_list_workflows: "Workflow cards",
aiToolSetTool_list_saved_prompts: "Saved prompts",
aiToolSetTool_search_audio: "Generated audio",
aiToolSetTool_recent_activity: "Activity log",
aiToolSetTool_search_models: "Search models",
aiToolSetTool_get_model: "Model details",
aiToolSetTool_move_model: "Move a model file",
aiToolSetTool_zoom_image: "Look closer at a picture",
aiToolSetTool_describe_canvas: "Read the canvas in detail",
aiToolSetTool_set_prompt: "Write a prompt",
aiToolSetTool_set_model: "Swap a model",
aiToolSetTool_add_lora: "Add a LoRA",
aiToolSetTool_place_combo: "Place a combo",
aiToolSetTool_check_workflow_models: "Check missing models",
aiToolSetTool_fix_workflow_models: "Fill in missing models",
aiToolSetTool_download_missing_models: "Download missing models",
aiToolSetTool_download_model: "Download a model",
aiToolSetTool_run_workflow: "Run the workflow",
aiToolSetTool_open_in_anomalous: "Show you a model",
aiToolSetTool_scan_models: "Scan models",
aiToolSetTool_list_voices: "Voices",
aiToolSetTool_speak: "Speak a line",
aiToolSetTool_find_nodes: "Look up nodes",
aiToolSetTool_add_node: "Add a node",
aiToolSetTool_add_nodes: "Add a group of nodes",
aiToolSetTool_connect_nodes: "Wire nodes",
aiToolSetTool_set_values: "Change settings",
aiToolSetTool_remove_nodes: "Remove nodes",
aiToolSetTool_new_workflow: "New workflow",
aiToolSetTool_open_workflow: "Open a workflow",
aiToolSetTool_track_run: "Track a run sent from outside",
aiToolSetTool_begin_task: "Begin a task",
aiToolSetTool_report: "Write to you",
aiToolSetTool_log_outside: "Say what it did outside Anomalous",
aiToolSetTool_log_decision: "Record a decision",
aiToolSetTool_show_panel: "Show you a panel",
aiToolSetTool_ask_user: "Ask you",
aiToolSetTool_propose_repaint_area: "Show the area to repaint",
aiToolSetTool_update_panel: "Change a panel",
aiToolSetTool_run_status: "Run progress",
aiToolSetTool_show_run_map: "Show what really ran",
aiToolSetTool_stop_run: "Stop a run",
aiToolSetTool_get_toolbox: "Ways, skills and your picks",
aiToolSetTool_get_answers: "Read your answers",
aiToolSetTool_get_situation: "Where things stand",
aiToolSetTool_read_guide: "Read Anomalous's guide",
aiState_waiting: "Waiting for an AI",
aiWaitingBody: "That is all: each step it takes shows here.",
aiStagePanels: "{count} {count|panel|panels}",
+26 -14
View File
@@ -4,12 +4,14 @@
* starts on the task, when one waits), the way for each common app, and rules for the AI app to
* keep (use Anomalous's tools, never edit pictures with code, say what it did outside). Only this computer can
* connect; the address is this page's own. Connecting (connectHint) is a card that opens the guide
* in place, under a task waiting for an AI (ui_ai_ready.js) and on the empty page.
* in place, under a task waiting for an AI (ui_ai_ready.js) and on the empty page. Without a waiting
* task the guide also picks which tools the app is offered (ui_ai_tool_set.js), and the address follows.
*/
import { translate as t } from './locales.js';
import { copyText } from './ui_dialog.js';
import { iconButton } from './ui_ai_icons.js';
import { toolSetPicker } from './ui_ai_tool_set.js';
const ART = {
waiting: new URL('../assets/ai_waiting.webp', import.meta.url).href,
@@ -40,7 +42,8 @@ function button(className, label, onClick, title) {
return node;
}
export const mcpAddress = () => `${location.origin}/anomalous/mcp`;
/** The address; `set` 'core' or 'mine' offers a shorter tool list (api/mcp_tool_sets.py). */
export const mcpAddress = (set = 'all') => `${location.origin}/anomalous/mcp${set === 'all' ? '' : `?tools=${set}`}`;
/** A line to copy: the text and a Copy button that says when it is done. */
export function copyLine(text, { code = true, label = t('aiCopyAddress') } = {}) {
@@ -60,20 +63,34 @@ export function copyLine(text, { code = true, label = t('aiCopyAddress') } = {})
}
/** The sentence for the AI: add the server, then (with `title`) start on that task. */
export function sentenceForAi(title = '') {
const add = t('aiConnectSentence', { url: mcpAddress() });
export function sentenceForAi(title = '', set = 'all') {
const add = t('aiConnectSentence', { url: mcpAddress(set) });
return title ? `${add}${t('aiConnectSentenceTask', { task: title })}` : add;
}
/** Everything about connecting, as one block. `title`: the task waiting for an AI, if any. */
export function connectGuide(title = '') {
const url = mcpAddress();
const box = el('div', 'anomalous-ai-connect');
box.append(el('div', 'anomalous-ai-connect-step', t('aiConnectStepAddress')), copyLine(url));
box.append(el('div', 'anomalous-ai-connect-step', t(title ? 'aiConnectStepSayTask' : 'aiConnectStepSay')),
copyLine(sentenceForAi(title), { code: false, label: t('aiConnectCopySentence') }));
const linked = el('div', 'anomalous-ai-connect');
const apps = el('details', 'anomalous-ai-connect-apps');
apps.append(el('summary', '', t('aiConnectByApp')));
const fill = (set) => {
linked.replaceChildren(copyLine(mcpAddress(set)), el('div', 'anomalous-ai-connect-step', t(title ? 'aiConnectStepSayTask' : 'aiConnectStepSay')),
copyLine(sentenceForAi(title, set), { code: false, label: t('aiConnectCopySentence') }));
appWays(apps, mcpAddress(set));
};
box.append(el('div', 'anomalous-ai-connect-step', t('aiConnectStepAddress')));
if (!title) box.append(toolSetPicker(fill)); // a task waiting in Anomalous needs every tool
fill('all');
const rules = copyLine(t('aiConnectRules'), { code: false, label: t('aiConnectCopyRules') });
rules.classList.add('is-rules');
box.append(linked, apps, el('div', 'anomalous-ai-connect-step', t('aiConnectStepRules')), rules,
el('div', 'anomalous-ai-connect-note', t('aiConnectLocal')));
return box;
}
/** The way to add `url` in each common app, into the folding `apps`. */
function appWays(apps, url) {
apps.replaceChildren(el('summary', '', t('aiConnectByApp')));
const ways = [
['Claude Code', `claude mcp add --transport http anomalous ${url}`],
['Gemini CLI', `gemini mcp add --transport http anomalous ${url}`],
@@ -82,11 +99,6 @@ export function connectGuide(title = '') {
];
for (const [name, text] of ways) apps.append(el('div', 'anomalous-ai-connect-app', name), copyLine(text));
apps.append(el('div', 'anomalous-ai-connect-app', 'Cherry Studio'), el('div', 'anomalous-ai-connect-note', t('aiConnectCherry', { url })));
const rules = copyLine(t('aiConnectRules'), { code: false, label: t('aiConnectCopyRules') });
rules.classList.add('is-rules');
box.append(apps, el('div', 'anomalous-ai-connect-step', t('aiConnectStepRules')), rules,
el('div', 'anomalous-ai-connect-note', t('aiConnectLocal')));
return box;
}
/** The card for an AI not connected yet: connecting is done once; its button opens the guide in place. */
+175
View File
@@ -0,0 +1,175 @@
/**
* Which tools the AI app is offered (api/mcp_tool_sets.py), picked in the Connect an AI guide above the
* address: every tool (the address as it is; a task handed over in Anomalous needs the workbench tools),
* Anomalous's short set for drawing in the AI app's own chat (?tools=core), or the user's own pick
* (?tools=mine, a checklist by stage of the work). The guide's address and the lines carrying it follow
* the choice; which one was picked last is remembered in this browser only. GET/POST /anomalous/ai_tool_sets.
*/
import { translate as t } from './locales.js';
const KEY = 'anomalous-ai-tool-set';
const SETS = ['all', 'core', 'mine'];
const STAGES = [['run', 'Run'], ['canvas', 'Canvas'], ['model', 'Model'], ['prompt', 'Prompt'], ['combo', 'Combo'],
['picture', 'Picture'], ['library', 'Library'], ['voice', 'Voice'], [null, 'Task']];
function el(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
function button(className, label, onClick) {
const node = el('button', className, label);
node.type = 'button';
node.onclick = onClick;
return node;
}
function remembered() {
try {
const value = localStorage.getItem(KEY);
return SETS.includes(value) ? value : 'all';
} catch {
return 'all';
}
}
function remember(value) {
try {
localStorage.setItem(KEY, value);
} catch {
// only a convenience: the guide starts on every tool next time
}
}
async function request(body) {
const response = await fetch('/anomalous/ai_tool_sets', body
? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) } : undefined);
const answer = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(answer.error || response.statusText);
return answer;
}
/** How much of the whole list `names` is, in percent (the card's own tools come along with a tool showing it). */
function share(info, names) {
const picked = new Set(names);
let size = info.tools.reduce((sum, tool) => sum + (picked.has(tool.name) ? tool.size : 0), 0);
if (info.card.tools.some(name => picked.has(name))) size += info.card.size;
return Math.round((size * 100) / info.all_size);
}
const label = (name) => {
const text = t(`aiToolSetTool_${name}`);
return text === `aiToolSetTool_${name}` ? name : text;
};
/** The checklist for the user's own pick: `start` ticked; save(names) keeps it, close() folds it away. */
function checklist(info, start, save, close) {
const box = el('div', 'anomalous-ai-toolset-editor');
const count = el('div', 'anomalous-ai-toolset-count');
const warn = el('div', 'anomalous-ai-toolset-warn');
const boxes = new Map();
const picked = () => info.tools.filter(tool => boxes.get(tool.name).checked).map(tool => tool.name);
const update = () => {
const names = picked();
count.textContent = t('aiToolSetCount', { count: names.length, share: names.length ? share(info, names) : 0 });
warn.hidden = info.needed.every(name => names.includes(name));
saveButton.disabled = !names.length;
};
const tick = (names) => {
for (const [name, input] of boxes) input.checked = names.includes(name);
update();
};
const groups = el('div', 'anomalous-ai-toolset-groups');
for (const [stage, word] of STAGES) {
const tools = info.tools.filter(tool => tool.stage === stage);
if (!tools.length) continue;
const group = el('div', 'anomalous-ai-toolset-group');
group.append(el('div', 'anomalous-ai-toolset-stage', t(`aiToolSetStage${word}`)));
for (const tool of tools) {
const row = el('label', 'anomalous-ai-toolset-tool');
const input = el('input');
input.type = 'checkbox';
input.onchange = update;
boxes.set(tool.name, input);
row.title = tool.name;
row.append(input, el('span', 'anomalous-ai-toolset-label', label(tool.name)), el('code', 'anomalous-ai-toolset-name', tool.name));
group.append(row);
}
groups.append(group);
}
warn.textContent = t('aiToolSetNoWorkbench', { tools: info.needed.join(', ') });
const saveButton = button('anomalous-ai-toolset-save', t('aiToolSetSave'), async () => {
saveButton.disabled = true;
try {
await save(picked());
} catch (error) {
count.textContent = error.message;
saveButton.disabled = false;
}
});
const actions = el('div', 'anomalous-ai-toolset-actions');
actions.append(saveButton, button('anomalous-ai-gates-option', t('aiToolSetFromCore'), () => tick(info.core)),
button('anomalous-ai-gates-option', t('aiToolSetEvery'), () => tick(info.tools.map(tool => tool.name))),
button('anomalous-ai-gates-option', t('aiToolSetFold'), close));
box.append(count, warn, groups, actions);
tick(start);
return box;
}
/** The picker. onChange('all' | 'core' | 'mine') whenever the address should change, also once it has loaded. */
export function toolSetPicker(onChange) {
const box = el('div', 'anomalous-ai-toolset');
const choices = el('div', 'anomalous-ai-gates-choice');
const note = el('div', 'anomalous-ai-connect-note');
const editor = el('div');
box.append(choices, note, editor);
let info = null;
let value = remembered();
const effective = () => (value === 'mine' && !info.mine.length ? 'all' : value);
const draw = () => {
const counts = { all: info.tools.length, core: info.core.length, mine: info.mine.length };
choices.replaceChildren(...SETS.map((key) => {
const option = button(`anomalous-ai-gates-option${key === value ? ' is-on' : ''}`, t(`aiToolSet_${key}`, { count: counts[key] }), () => pick(key));
option.setAttribute('aria-pressed', String(key === value));
return option;
}));
const words = value === 'all' ? t('aiToolSetAllNote')
: value === 'core' ? t('aiToolSetCoreNote', { share: share(info, info.core) })
: info.mine.length ? t('aiToolSetMineNote', { share: share(info, info.mine) }) : t('aiToolSetMineEmpty');
note.replaceChildren(words);
if (value === 'mine' && editor.hidden !== false) note.append(' ', button('anomalous-ai-toolset-edit', t('aiToolSetEdit'), openEditor));
};
const closeEditor = () => {
editor.replaceChildren();
editor.hidden = true;
draw();
};
const openEditor = () => {
editor.hidden = false;
editor.replaceChildren(checklist(info, info.mine.length ? info.mine : info.core, async (names) => {
info = await request({ mine: names });
closeEditor();
onChange(effective());
}, closeEditor));
draw();
};
const pick = (key) => {
value = key;
remember(key);
if (key === 'mine' && !info.mine.length) openEditor();
else closeEditor();
onChange(effective());
};
editor.hidden = true;
box.hidden = true;
request().then((answer) => {
info = answer;
box.hidden = false;
draw();
if (effective() !== 'all') onChange(effective());
}, () => { value = 'all'; });
return box;
}
+46
View File
@@ -182,6 +182,52 @@
.anomalous-ai-gates-option:hover:not(.is-on) { background: color-mix(in srgb, var(--amb-link) 14%, transparent); color: var(--amb-text-main); }
.anomalous-ai-gates-option.is-on { background: color-mix(in srgb, var(--amb-link) 22%, transparent); color: var(--amb-text-main); }
/* Which tools the AI app is offered, in the Connect an AI guide (ui_ai_tool_set.js) */
.anomalous-ai-toolset { display: flex; flex-direction: column; align-items: flex-start; gap: 6px; }
.anomalous-ai-toolset .anomalous-ai-gates-choice { flex-wrap: wrap; max-width: 100%; }
.anomalous-ai-toolset-edit {
padding: 0;
border: 0;
background: none;
color: var(--amb-link);
font: inherit;
cursor: pointer;
}
.anomalous-ai-toolset-edit:hover { text-decoration: underline; }
.anomalous-ai-toolset-editor {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
padding: 10px 12px;
border: 1px solid var(--amb-border);
border-radius: 10px;
box-sizing: border-box;
}
.anomalous-ai-toolset-count { color: var(--amb-text-main); font-size: 12.5px; font-weight: 600; }
.anomalous-ai-toolset-warn { color: var(--amb-warn-text); font-size: 12px; line-height: 1.5; }
.anomalous-ai-toolset-groups { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 10px 16px; }
.anomalous-ai-toolset-group { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.anomalous-ai-toolset-stage { margin-bottom: 2px; color: var(--amb-text-soft); font-size: 12px; font-weight: 600; }
.anomalous-ai-toolset-tool { display: flex; align-items: center; gap: 6px; min-width: 0; font-size: 12px; color: var(--amb-text-main); cursor: pointer; }
.anomalous-ai-toolset-tool input { flex: none; margin: 0; }
.anomalous-ai-toolset-label { flex: none; }
.anomalous-ai-toolset-name { overflow: hidden; color: var(--amb-text-dim); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.anomalous-ai-toolset-actions { display: flex; flex-wrap: wrap; gap: 6px; }
.anomalous-ai-toolset-actions .anomalous-ai-gates-option { border: 1px solid var(--amb-border); border-radius: 8px; }
.anomalous-ai-toolset-save {
padding: 5px 14px;
border: 0;
border-radius: 8px;
background: var(--amb-link);
color: var(--amb-on-link);
font: inherit;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.anomalous-ai-toolset-save:disabled { opacity: 0.5; cursor: default; }
@media (prefers-reduced-motion: reduce) { .anomalous-ai-flow-stage.is-running .anomalous-ai-flow-mark { animation: none; } }
/* After "Hand to AI". */