diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 0f2272e..4098a2d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 56b4ca6..8687a32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/api/mcp_server.py b/api/mcp_server.py index a07106b..c6e33c2 100644 --- a/api/mcp_server.py +++ b/api/mcp_server.py @@ -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) diff --git a/api/mcp_tool_sets.py b/api/mcp_tool_sets.py new file mode 100644 index 0000000..386ebed --- /dev/null +++ b/api/mcp_tool_sets.py @@ -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"(? 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 diff --git a/web/modules/locales.js b/web/modules/locales.js index 17c7ab4..cd303b0 100644 --- a/web/modules/locales.js +++ b/web/modules/locales.js @@ -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}", diff --git a/web/modules/ui_ai_connect.js b/web/modules/ui_ai_connect.js index b9bae1d..326caa4 100644 --- a/web/modules/ui_ai_connect.js +++ b/web/modules/ui_ai_connect.js @@ -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. */ diff --git a/web/modules/ui_ai_tool_set.js b/web/modules/ui_ai_tool_set.js new file mode 100644 index 0000000..641c074 --- /dev/null +++ b/web/modules/ui_ai_tool_set.js @@ -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; +} diff --git a/web/styles/42-ai-flow.css b/web/styles/42-ai-flow.css index 4dd39bf..d66c3d5 100644 --- a/web/styles/42-ai-flow.css +++ b/web/styles/42-ai-flow.css @@ -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". */