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:
co-authored by
Claude Opus 5.5
parent
12257a55ba
commit
bc9f18a838
+7
-2
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
|
||||
@@ -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}` |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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". */
|
||||
|
||||
Reference in New Issue
Block a user