diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 8a89a0b..4de9b4a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -143,11 +143,18 @@ covered by the `styles.css` manifest. `web/modules/canvas_build.js` is its pure part (type fit, value checks, a free spot, the layered layout). `open_workflow` opens one of the user's saved workflows in a new tab. `api/ai_toolbox.py` keeps what the user hands the AI to work with in a task ("给 AI 的素材", - `web/modules/ui_ai_toolbox.js`, from the task header): node types grouped by where they come - from (built-in, each custom node folder from `/object_info`'s `python_module`, page-only - helpers, paid API nodes), combos, saved workflows, prompts and models, by the names the AI's - tools use, with a mode: prefer, or only (add_node / new_workflow then refuse other custom or - paid node types). Favourites (`anomalous/ai_toolbox.json`) stand in until the user picks for + `web/modules/ui_ai_toolbox.js`, a large window from the task header: search, kinds, a column + by source / origin / type, a card grid drawn as it scrolls, a detail pane, the picks below), + by the names the AI's tools use, with a mode: prefer, or only (add_node / new_workflow then + refuse other custom or paid node types). Node types come from the page's own definitions + (`web/modules/ai_toolbox_data.js`: LiteGraph's registered types and their `nodeData`, so no + request; page-only helpers go with their pack when their name says which); the rest in one + answer from `api/ai_toolbox_catalog.py` (combos, workflows with covers and prompts, prompts + saved or inside combos and workflows as `combo:` / `workflow:`, models with + covers), read ahead when a task header is drawn and kept a minute. + `web/modules/ui_ai_toolbox_cards.js` draws the cards (a node in miniature with its sockets in + ComfyUI's colours, covers, prompt text) and the detail pane; `web/modules/ui_ai_toolbox_icons.js` + the vector icons, each pack's badge and the empty picture. Favourites (`anomalous/ai_toolbox.json`) stand in until the user picks for a task. A pick is a `user_toolbox` step the AI gets as a message (kind `toolbox`); begin_task names the picks, `get_toolbox` gives them in full, find_nodes lists them first. A rewired input shows in the receipt as a `wired` change diff --git a/CHANGELOG.md b/CHANGELOG.md index fd83114..7a13635 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ ### 给 AI 的素材 / Materials for the AI - AI 工作台每个任务上多了“素材”按钮:按来源分组列出这台 ComfyUI 的全部节点(自带的、你装的每个第三方节点包、只在网页里的小工具、会花积分的付费 API 节点单独放在最后),还有你的搭配、工作流、提示词和模型。勾选这次想让 AI 用的,点“交给 AI”;可以选“优先用这些”或“只能用这些节点”(自带节点照常可用,AI 想加别的第三方或付费节点会被拦下)。☆ 设为常用后,没挑素材的任务自动带上常用的。AI 下一步就会收到,时间线上也记着你给了什么、AI 收到没有。 - AI 能在新标签页里打开你存的工作流,在它的基础上改;工作流详情页也有“✦ 交给 AI”。AI 查节点时会标出你挑的节点和付费节点。 +- 素材窗口重做:大窗口,顶部一个搜索框搜所有素材(按 / 直接搜),左边分类显示各自挑了几个、搜到几个;节点可以按来源筛选,每个第三方节点包有自己的彩色徽标;节点卡片画成一个迷你节点,输入输出的颜色和 ComfyUI 画布上一样,写着有几个参数;搭配、工作流、模型显示封面;提示词显示原文,并且能直接挑搭配和工作流里的提示词。鼠标移到卡片上,右边显示完整说明(节点的每个输入、参数、输出,提示词全文)。下面一排是挑好的素材,带缩略图,可以一个个去掉;“优先用 / 只能用”做成了开关。图标、徽标和空白时的插图都是矢量画的,跟着深浅色主题变。 +- 打开不用等了:节点列表直接用网页里已有的节点信息,其他素材一次读完、提前读好、留一分钟,打开窗口时先显示留着的,再在后台刷新。 - 实测:你装的 10 个第三方节点包(Impact Pack、IPAdapter、Easy-Use、Comfyroll、rgthree、ControlNet 预处理器等)共 1642 种节点,AI 都能放到画布上;用 Comfyroll 节点搭的工作流真实跑出了图。 - A Materials button on each AI task: every node type of this ComfyUI grouped by source (built-in, each custom node pack, page-only helpers, paid API nodes last), plus combos, workflows, prompts and models; picks go to the AI as prefer or only-these-nodes (built-in nodes stay allowed), favourites stand in for tasks without picks. The AI can open a saved workflow in a new tab; a workflow's page can hand it to the AI. diff --git a/api/ai_tasks_routes.py b/api/ai_tasks_routes.py index b464b1d..50a5af0 100644 --- a/api/ai_tasks_routes.py +++ b/api/ai_tasks_routes.py @@ -158,7 +158,7 @@ async def api_toolbox_pick(request): async def api_toolbox(request): - """GET /anomalous/ai_toolbox?kind=&query=: what the picker lists; GET/POST /anomalous/ai_toolbox/favorites.""" + """GET /anomalous/ai_toolbox?kind=all: what the picker lists; GET/POST /anomalous/ai_toolbox/favorites.""" if request.path.endswith("/favorites"): if request.method == "GET": return web.json_response({"favorites": ai_toolbox.favorites()}) @@ -167,10 +167,8 @@ async def api_toolbox(request): return web.json_response({"favorites": ai_toolbox.set_favorites(body if body is not None else None)}) except ai_toolbox.ToolboxError as error: return web.json_response({"error": str(error)}, status=400) - ctx = {"base_url": f"{request.scheme}://{request.host}"} try: - return web.json_response(await asyncio.to_thread(ai_toolbox.catalog, ctx, request.query.get("kind", ""), - request.query.get("query", "")[:200])) + return web.json_response(await asyncio.to_thread(ai_toolbox.catalog, request.query.get("kind", ""))) except ai_toolbox.ToolboxError as error: return web.json_response({"error": str(error)}, status=400) except Exception as error: # noqa: BLE001 - ComfyUI's node list or a store unreadable: say so to the page diff --git a/api/ai_toolbox.py b/api/ai_toolbox.py index d5a7769..c49fc62 100644 --- a/api/ai_toolbox.py +++ b/api/ai_toolbox.py @@ -6,7 +6,7 @@ except ComfyUI's own built-in ones and the helpers that live only in the page). "user_toolbox" step the AI receives like a message (kind toolbox, ai_panels.py); begin_task names the task's picks and get_toolbox gives them in full. The catalogue the picker shows uses the same names the AI's tools use: node types from ComfyUI's /object_info (mcp_canvas_edit.py), -the rest from mcp_tools.py's lists.""" +the rest from ai_toolbox_catalog.py.""" import json import os @@ -21,7 +21,6 @@ KINDS = ("nodes", "combos", "workflows", "prompts", "models") MODES = ("prefer", "only") MAX_ITEMS = 60 MAX_NAME = 300 -CATALOG_LIMIT = 50 # the lists' own most; a search finds the rest _lock = threading.Lock() @@ -84,7 +83,8 @@ def words(box): """The picks in a sentence or two for the AI.""" parts = [] labels = {"nodes": "node types", "combos": "combos (place_combo)", "workflows": "workflows (open_workflow opens one in a new tab)", - "prompts": "saved prompts", "models": "models (model_id for set_model / add_lora / get_model)"} + "prompts": "prompts (combo: / workflow: are the prompts inside that combo or workflow)", + "models": "models (model_id for set_model / add_lora / get_model)"} for kind in KINDS: if box.get(kind): parts.append(f"{labels[kind]}: {', '.join(box[kind])}") @@ -140,29 +140,10 @@ def source_of(info): return "api" if module.startswith("comfy_api_nodes") else "builtin" -def catalog(ctx, kind, query=""): - """What the picker lists for `kind`, by the names the AI's tools use.""" - from . import mcp_tools - from .mcp_canvas_edit import _object_info +def catalog(kind): + """What the picker lists besides node types (ai_toolbox_catalog.py); the page has those itself.""" + from .ai_toolbox_catalog import everything - if kind == "nodes": - groups = {} - for name, info in _object_info(ctx).items(): - groups.setdefault(source_of(info), []).append({ - "type": name, "label": info.get("display_name") or name, "category": info.get("category") or "", - **({"deprecated": True} if info.get("deprecated") else {})}) - return {"groups": [{"source": source, "nodes": sorted(items, key=lambda item: item["label"].lower())} - for source, items in sorted(groups.items(), key=lambda pair: (pair[0] not in ("builtin",), pair[0] == "api", pair[0].lower()))]} - if kind == "combos": - return {"items": [{"name": item["name"], "detail": item.get("main_model") or ""} - for item in mcp_tools.list_combos(query, CATALOG_LIMIT)["combos"]]} - if kind == "workflows": - return {"items": [{"name": item["name"], "detail": " ".join(item.get("tags") or [])} - for item in mcp_tools.list_workflows(query, CATALOG_LIMIT)["workflows"]]} - if kind == "prompts": - return {"items": [{"name": item["name"], "detail": item.get("positive", "")[:120]} - for item in mcp_tools.list_saved_prompts(query, CATALOG_LIMIT)["prompts"]]} - if kind == "models": - return {"items": [{"name": item["model_id"], "label": item["name"], "detail": f"{item['type']} · {item.get('base_model') or ''}".strip(" ·")} - for item in mcp_tools.search_models(query, limit=CATALOG_LIMIT)["models"]]} - raise ToolboxError(f"kind is one of {', '.join(KINDS)}") + if kind != "all": + raise ToolboxError("kind is all; node types come from the page") + return everything() diff --git a/api/ai_toolbox_catalog.py b/api/ai_toolbox_catalog.py new file mode 100644 index 0000000..c89b096 --- /dev/null +++ b/api/ai_toolbox_catalog.py @@ -0,0 +1,86 @@ +"""Everything the materials picker (web/modules/ui_ai_toolbox.js) lists besides node types, in +one answer (GET /anomalous/ai_toolbox?kind=all): combos with their cover and prompt, saved +workflows with their cover, tags and prompts, prompts (the saved ones, and the ones inside +combos and workflows, ids "combo:" and "workflow:"), and models with their cover, +by the names and ids the AI's tools use. Read in one go and kept a few seconds, so the picker +opens at once; node types come from the page's own node definitions. prompt_entries() also +gives get_toolbox the text of picked prompts.""" + +import threading +import time + +from . import mcp_tools +from .model_catalog import _collect_all_scan_models +from .notebooks import _list_notebooks +from .quick_index import compact_models +from .recipe_store import _list_recipes, get_recipes_dir + +CACHE_SECONDS = 15 +MAX_TEXT = 1500 +MAX_THUMBNAIL = 60_000 # an inline thumbnail larger than this is left out; the cover asset or the default stands in +_cache = {"at": 0.0, "data": None} +_lock = threading.Lock() + + +def _join(texts, limit=MAX_TEXT): + text = "\n".join(str(item).strip() for item in texts or [] if str(item or "").strip()) + return text if len(text) <= limit else text[:limit] + "…" + + +def _combos(): + out = [] + for note in _list_notebooks(): + data = note.get("data") or {} + combo, loras = mcp_tools.combo_view(note) + out.append({"name": combo["name"], "model": combo["main_model"], "loras": loras[:12], "base": combo.get("base_model") or "", + "prompt": combo["prompt"], "structure": data.get("kind") == "nodes", + "cover": (data.get("mainModel") or {}).get("preview_url") or "" if isinstance(data.get("mainModel"), dict) else "", + "image": data.get("source_image") if isinstance(data.get("source_image"), dict) else None}) + return out + + +def _workflows(): + out = [] + for recipe in _list_recipes(get_recipes_dir()): + data = recipe.get("data") or {} + params = data.get("params") if isinstance(data.get("params"), dict) else {} + thumbnail = data.get("thumbnail") if isinstance(data.get("thumbnail"), str) and len(data["thumbnail"]) <= MAX_THUMBNAIL else None + out.append({"name": data.get("name") or recipe["filename"], "filename": recipe["filename"], "tags": (data.get("tags") or [])[:8], + "notes": str(data.get("notes") or "")[:300], "partial": data.get("workflow_scope") == "partial", + "nodes": len(params.get("nodes") or []), "base": params.get("baseModel") or "", + "prompt": _join(params.get("promptPositive")), "negative": _join(params.get("promptNegative"), 600), + "cover_asset": (data.get("presentation") or {}).get("cover_asset_id"), "thumbnail": thumbnail, + "image": data.get("source_image") if isinstance(data.get("source_image"), dict) else None, + "saved": data.get("timestamp") or 0}) + return out + + +def _models(): + out = [] + for model in compact_models(_collect_all_scan_models(1, 0).get("models") or []): + place = f"{model['subfolder']}/{model['filename']}" if model["subfolder"] else model["filename"] + out.append({"id": f"{model['type']}:{model['path_idx']}:{place}", "name": model["name"] or model["civitai_name"] or model["filename"], + "file": model["filename"], "type": model["type"], "base": model["base"], "words": model["words"], + "cover": model["preview_url"], "folder": model["subfolder"]}) + return out + + +def prompt_entries(combos, workflows): + """The prompts the picker lists: saved ones (id = name), then those of combos and workflows.""" + entries = [{"id": prompt["name"], "name": prompt["name"], "source": "saved", "tags": prompt["tags"], + "positive": prompt["positive"], "negative": prompt["negative"]} for prompt in mcp_tools.saved_prompts()] + entries += [{"id": f"combo:{combo['name']}", "name": combo["name"], "source": "combo", "tags": [], + "positive": combo["prompt"], "negative": ""} for combo in combos if combo["prompt"]] + entries += [{"id": f"workflow:{flow['name']}", "name": flow["name"], "source": "workflow", "tags": flow["tags"], + "positive": flow["prompt"], "negative": flow["negative"]} for flow in workflows if flow["prompt"] or flow["negative"]] + return entries + + +def everything(fresh=False): + """{combos, workflows, prompts, models}, read at most every CACHE_SECONDS.""" + with _lock: + if fresh or _cache["data"] is None or time.monotonic() - _cache["at"] > CACHE_SECONDS: + combos, workflows = _combos(), _workflows() + _cache.update(at=time.monotonic(), data={"combos": combos, "workflows": workflows, + "prompts": prompt_entries(combos, workflows), "models": _models()}) + return _cache["data"] diff --git a/api/mcp_task_tools.py b/api/mcp_task_tools.py index 0252365..dde444c 100644 --- a/api/mcp_task_tools.py +++ b/api/mcp_task_tools.py @@ -95,15 +95,20 @@ def get_toolbox(ctx, task=None): answer["nodes"] = found["nodes"] if found.get("not_found"): answer["page_only_nodes"] = found["not_found"] # helpers that live only in the page: add_node takes them - for kind, lister, key in (("combos", mcp_tools.list_combos, "combos"), ("workflows", mcp_tools.list_workflows, "workflows"), - ("prompts", mcp_tools.list_saved_prompts, "prompts")): + for kind, lister, key in (("combos", mcp_tools.list_combos, "combos"), ("workflows", mcp_tools.list_workflows, "workflows")): if box.get(kind): answer[kind] = [item for name in box[kind] for item in lister(name, 5)[key] if item["name"] == name][:ai_toolbox.MAX_ITEMS] + if box.get("prompts"): + from .ai_toolbox_catalog import everything + + prompts = {entry["id"]: entry for entry in everything()["prompts"]} + answer["prompts"] = [prompts[name] for name in box["prompts"] if name in prompts] if box.get("models"): answer["models"] = [] for model_id in box["models"]: try: - answer["models"].append(mcp_tools.get_model(model_id)) + found = mcp_tools.get_model(model_id) + answer["models"].append(found[0] if isinstance(found, tuple) else found) # without its cover picture except ToolError: answer["models"].append({"model_id": model_id, "missing": True}) return answer diff --git a/api/mcp_tools.py b/api/mcp_tools.py index ddfcff7..367ef5d 100644 --- a/api/mcp_tools.py +++ b/api/mcp_tools.py @@ -332,28 +332,31 @@ def _model_name(model): return str(model or "") +def combo_view(note): + """A combo (one of _list_notebooks) as the AI and the materials picker see it, with its LoRAs and texts.""" + data = note.get("data") or {} + if data.get("kind") == "nodes": # its own node structure: model and text slots by their names + structure = data.get("structure") or {} + values = data.get("values") or {} + slots = structure.get("slots") or [] + models = [{"slot": s.get("label") or s.get("widget"), "folder": s.get("folder") or "", + "file": os.path.basename(str(values.get(s["id"]) or "").replace("\\", "/"))} + for s in slots if s.get("kind") == "model"] + texts = [{"slot": s.get("label") or s.get("widget"), "text": _text(values.get(s["id"]), 400)} + for s in slots if s.get("kind") == "text"] + combo = {"name": note.get("name"), "kind": "node structure", "models": models, "texts": texts, + "nodes": [n.get("name") or n.get("type") for n in structure.get("nodes") or []], + "main_model": models[0]["file"] if models else "", "prompt": texts[0]["text"] if texts else ""} + return combo, [model["file"] for model in models[1:]] + [text["text"] for text in texts[1:]] + loras = [_model_name(lora) for lora in data.get("loras") or []] + return {"name": note.get("name"), "base_model": data.get("baseModel") or "", "main_model": _model_name(data.get("mainModel")), + "loras": loras, "prompt": _text(data.get("promptEn"), 800)}, loras + + def list_combos(query="", limit=20): terms, found = _terms(query), [] for note in _list_notebooks(): - data = note.get("data") or {} - if data.get("kind") == "nodes": # its own node structure: model and text slots by their names - structure = data.get("structure") or {} - values = data.get("values") or {} - slots = structure.get("slots") or [] - models = [{"slot": s.get("label") or s.get("widget"), "folder": s.get("folder") or "", - "file": os.path.basename(str(values.get(s["id"]) or "").replace("\\", "/"))} - for s in slots if s.get("kind") == "model"] - texts = [{"slot": s.get("label") or s.get("widget"), "text": _text(values.get(s["id"]), 400)} - for s in slots if s.get("kind") == "text"] - combo = {"name": note.get("name"), "kind": "node structure", "models": models, "texts": texts, - "nodes": [n.get("name") or n.get("type") for n in structure.get("nodes") or []], - "main_model": models[0]["file"] if models else "", "prompt": texts[0]["text"] if texts else ""} - loras = [model["file"] for model in models[1:]] + [text["text"] for text in texts[1:]] - else: - loras = [_model_name(lora) for lora in data.get("loras") or []] - combo = {"name": note.get("name"), "base_model": data.get("baseModel") or "", - "main_model": _model_name(data.get("mainModel")), "loras": loras, - "prompt": _text(data.get("promptEn"), 800)} + combo, loras = combo_view(note) if not _matches(terms, combo["name"], combo["main_model"], combo["prompt"], " ".join(loras)): continue found.append(combo) @@ -373,8 +376,8 @@ def list_workflows(query="", limit=20): return {"total_matches": len(found), "workflows": found[:_limit(limit)]} -def list_saved_prompts(query="", limit=20): - terms, found = _terms(query), [] +def saved_prompts(): + """Every saved prompt plan: {name, tags, positive, negative}.""" directory = get_materials_dir() for summary in _list_materials(directory): if summary.get("kind") != "prompt_plan" or summary.get("moved_to_recipe"): @@ -383,10 +386,14 @@ def list_saved_prompts(query="", limit=20): plan = _read_material(os.path.join(directory, summary["filename"])).get("plan") or {} except (OSError, ValueError, KeyError): continue - prompt = {"name": summary.get("name"), "tags": summary.get("tags") or [], - "positive": _text(plan.get("positive"), 1200), "negative": _text(plan.get("negative"), 600)} - if _matches(terms, prompt["name"], " ".join(prompt["tags"]), prompt["positive"], prompt["negative"]): - found.append(prompt) + yield {"name": summary.get("name"), "tags": summary.get("tags") or [], + "positive": _text(plan.get("positive"), 1200), "negative": _text(plan.get("negative"), 600)} + + +def list_saved_prompts(query="", limit=20): + terms = _terms(query) + found = [prompt for prompt in saved_prompts() + if _matches(terms, prompt["name"], " ".join(prompt["tags"]), prompt["positive"], prompt["negative"])] return {"total_matches": len(found), "prompts": found[:_limit(limit)]} diff --git a/docs/guides/mcp.md b/docs/guides/mcp.md index bf3b407..4c02a40 100644 --- a/docs/guides/mcp.md +++ b/docs/guides/mcp.md @@ -130,7 +130,7 @@ claude mcp add --transport http anomalous http://127.0.0.1:8188/anomalous/mcp **让 AI 在画布上搭工作流**:AI 可以先用 `find_nodes` 查这台 ComfyUI 有哪些节点、每个节点要接什么,然后用 `new_workflow` 新开一个标签页(空白,或者一次把整套工作流搭好并排好位置;你正开着的工作流不受影响),再用 `add_node`、`connect_nodes`、`set_values`、`remove_nodes` 加节点、连线、改参数、删节点。每一步你都能在画布上看到,按 Ctrl+Z 能撤回;AI 工作台那一步下面写着改了什么(包括哪根线从哪里连到哪里)。类型接不上、选项不存在时,画布不会动,AI 会收到能用的写法。 -**给 AI 的素材**:任务标题旁边的 **素材** 打开一个窗口:节点按来源分组(ComfyUI 自带、你装的每个第三方节点包、只在网页里的小工具、付费 API 节点),还有搭配、工作流、提示词、模型。勾选后点 **交给 AI**,选“优先用这些”或“只能用这些节点”(自带节点照常可用,别的第三方和付费节点会被拦下)。☆ 设为常用:没挑素材的任务自动带上常用的。AI 用 `get_toolbox` 看到你挑的东西的详细说明,用 `open_workflow` 在新标签页打开你存的工作流。工作流详情页的 **✦ 交给 AI** 会新开一个带着这个工作流的任务。 +**给 AI 的素材**:任务标题旁边的 **✦ 素材** 打开一个大窗口:上面搜索所有素材,左边选种类,节点可以按来源筛(ComfyUI 自带、你装的每个第三方节点包、付费 API 节点),卡片上看得到节点的输入输出、搭配和工作流的封面、提示词原文;鼠标移到卡片上右边显示完整说明。提示词里也能挑搭配和工作流里的提示词。勾选后点 **交给 AI**,选“优先用这些”或“只能用这些节点”(自带节点照常可用,别的第三方和付费节点会被拦下)。☆ 设为常用:没挑素材的任务自动带上常用的。AI 用 `get_toolbox` 看到你挑的东西的详细说明,用 `open_workflow` 在新标签页打开你存的工作流。工作流详情页的 **✦ 交给 AI** 会新开一个带着这个工作流的任务。 **运行进度**:AI 运行工作流后,时间线上那一步下面有进度条(排队时前面还有几个;运行时哪个节点、第几步、节点做完几个)。进度条右边是百分比。AI 用 `run_status` 问同样的进度,或者等到跑完再继续,不会凭时间猜;运行期间 AI 调用别的工具时,回复里的 `your_run` 也带着一句当前进度,跑完后告诉它一次结果,所以它不用自己去翻输出文件夹或者看屏幕。 diff --git a/tests/ai_toolbox_data.mjs b/tests/ai_toolbox_data.mjs new file mode 100644 index 0000000..9235a67 --- /dev/null +++ b/tests/ai_toolbox_data.mjs @@ -0,0 +1,60 @@ +// node tests/ai_toolbox_data.mjs — the materials picker's data: node types from the page's own definitions, the catalogue, search, keeping. +import assert from 'node:assert/strict'; +import { catalogItems, keptCatalog, loadCatalog, nodeItems, nodeSources, packLabel, searchItems } from '../web/modules/ai_toolbox_data.js'; + +assert.equal(packLabel('comfyui_controlnet_aux'), 'controlnet aux'); +assert.equal(packLabel('ComfyUI-Impact-Pack'), 'Impact Pack'); +assert.equal(packLabel('rgthree-comfy'), 'rgthree'); +assert.equal(packLabel('Comfyroll_CustomNodes'), 'Comfyroll CustomNodes'); +assert.equal(packLabel('ComfyUI'), 'ComfyUI'); + +const def = (module, extra = {}) => ({ nodeData: { python_module: module, ...extra } }); +const registered = { + KSampler: def('nodes', { + display_name: 'KSampler', category: 'sampling', + input: { required: { model: ['MODEL'], seed: ['INT', { default: 0 }], sampler_name: [['euler', 'dpmpp_2m']], positive: ['CONDITIONING'] }, + optional: { note: ['STRING', { forceInput: true }] } }, + output: ['LATENT'], output_name: ['LATENT'], + }), + FaceDetailer: def('custom_nodes.ComfyUI-Impact-Pack', { display_name: 'FaceDetailer', nodeSource: { displayText: 'Impact Pack' }, input: {}, output: ['IMAGE'] }), + 'Context (rgthree)': def('custom_nodes.rgthree-comfy', { display_name: 'Context (rgthree)', input: {}, output: [] }), + KlingTextToVideoNode: def('comfy_api_nodes.nodes_kling', { display_name: 'Kling', api_node: true, input: {}, output: ['VIDEO'] }), + 'Fast Groups Muter (rgthree)': { title: 'Fast Groups Muter (rgthree)' }, + Reroute: { title: 'Reroute' }, +}; +const nodes = nodeItems(registered); +const by = Object.fromEntries(nodes.map(item => [item.id, item])); +assert.deepEqual(by.KSampler.inputs.map(input => input.name), ['model', 'positive', 'note'], 'sockets, a forced input among them'); +assert.deepEqual(by.KSampler.settings.map(setting => [setting.name, setting.type, setting.choices]), [['seed', 'INT', 0], ['sampler_name', 'choice', 2]]); +assert.deepEqual(by.KSampler.outputs, [{ slot: 0, name: 'LATENT', type: 'LATENT' }]); +assert.deepEqual([by.FaceDetailer.source.kind, by.FaceDetailer.source.label], ['pack', 'Impact Pack']); +assert.equal(by['Context (rgthree)'].source.label, 'rgthree'); +assert.ok(by.KlingTextToVideoNode.paid); +assert.deepEqual([by['Fast Groups Muter (rgthree)'].source.key, by['Fast Groups Muter (rgthree)'].page], ['rgthree-comfy', true], 'a page-only helper goes with its pack'); +assert.equal(by.Reroute.source.kind, 'builtin'); +assert.deepEqual(nodeSources(nodes).map(source => [source.key, source.count]), + [['builtin', 2], ['ComfyUI-Impact-Pack', 1], ['rgthree-comfy', 2], ['api', 1]], 'own first, packs by name, paid last'); + +const items = catalogItems({ + combos: [{ name: '赛博少女', model: 'a.safetensors', loras: ['neon.safetensors'], prompt: '1girl, neon city' }], + workflows: [{ name: 'SDXL 高清修复', tags: ['SDXL'], prompt: 'castle' }], + prompts: [{ id: 'combo:赛博少女', name: '赛博少女', source: 'combo', positive: '1girl, neon city', negative: '', tags: [] }], + models: [{ id: 'loras:0:neon.safetensors', name: 'Neon', file: 'neon.safetensors', type: 'loras', base: 'SDXL', words: ['neonstyle'] }], +}); +assert.deepEqual(items.map(item => [item.kind, item.id]), [['combos', '赛博少女'], ['workflows', 'SDXL 高清修复'], ['prompts', 'combo:赛博少女'], ['models', 'loras:0:neon.safetensors']]); +assert.deepEqual(searchItems(items, 'neon').map(item => item.kind), ['models', 'combos', 'prompts'], 'a title that starts with the words first'); +assert.deepEqual(searchItems(items, 'sdxl castle').map(item => item.id), ['SDXL 高清修复']); +assert.deepEqual(searchItems(nodes, 'impact').map(item => item.id), ['FaceDetailer'], 'a pack name finds its nodes'); +assert.equal(searchItems(items, '').length, items.length); + +// Kept a minute; one request however many ask at once; a failure says why and keeps nothing. +let calls = 0; +const fetcher = async () => { calls++; return { ok: true, json: async () => ({ combos: [{ name: 'x', loras: [] }] }) }; }; +const [a, b] = await Promise.all([loadCatalog({ fetcher, fresh: true }), loadCatalog({ fetcher })]); +assert.equal(calls, 1); +assert.equal(a, b); +await loadCatalog({ fetcher }); +assert.equal(calls, 1, 'kept'); +assert.equal(keptCatalog()[0].id, 'x'); +await assert.rejects(loadCatalog({ fresh: true, fetcher: async () => ({ ok: false, status: 502, json: async () => ({ error: 'no' }) }) }), /no/); +console.log('ai_toolbox_data: ok'); diff --git a/tests/test_ai_toolbox.py b/tests/test_ai_toolbox.py index ea96346..1f9a539 100644 --- a/tests/test_ai_toolbox.py +++ b/tests/test_ai_toolbox.py @@ -90,10 +90,25 @@ class ToolboxTests(unittest.TestCase): self.assertEqual((box["mode"], box["nodes"][0]["type"], box["page_only_nodes"]), ("only", "FaceDetailer", ["Fast Groups Muter (rgthree)"])) self.assertTrue(box["models"][0]["missing"]) - def test_the_picker_lists_nodes_by_source(self): - groups = ai_toolbox.catalog({}, "nodes")["groups"] - self.assertEqual([group["source"] for group in groups], ["builtin", "ComfyUI-Impact-Pack", "api"]) - self.assertEqual([node["type"] for node in groups[1]["nodes"]], ["FaceDetailer", "SAMLoader"]) + def test_the_picker_gets_everything_in_one_answer_and_prompts_inside_combos_and_workflows(self): + from api import ai_toolbox_catalog as catalog + + combos = [{"name": "赛博少女", "model": "a.safetensors", "loras": [], "base": "", "prompt": "1girl, neon", "structure": False, + "cover": "/anomalous/image?x", "image": None}] + flows = [{"name": "高清修复", "filename": "f.json", "tags": ["SDXL"], "prompt": "city", "negative": "blurry"}, + {"name": "空的", "filename": "e.json", "tags": [], "prompt": "", "negative": ""}] + with mock.patch.object(catalog, "_combos", return_value=combos), mock.patch.object(catalog, "_workflows", return_value=flows), \ + mock.patch.object(catalog, "_models", return_value=[{"id": "loras:0:x.safetensors"}]), \ + mock.patch("api.mcp_tools.saved_prompts", return_value=iter([{"name": "夜景", "tags": [], "positive": "night", "negative": ""}])): + data = ai_toolbox.catalog("all") + ids = [entry["id"] for entry in data["prompts"]] + self.assertEqual(ids, ["夜景", "combo:赛博少女", "workflow:高清修复"], "a workflow without prompts has none to pick") + ai_toolbox.pick(self.task, {"prompts": ["workflow:高清修复"]}, "prefer") + box = mcp_task_tools.get_toolbox(self.ctx) + self.assertEqual((box["prompts"][0]["positive"], box["prompts"][0]["negative"]), ("city", "blurry")) + self.assertIn("workflow:", ai_toolbox.words(ai_toolbox.effective(ai_tasks.get_task(self.task)))) + self.assertIs(ai_toolbox.catalog("all"), data, "kept a few seconds") + catalog._cache.update(at=0.0, data=None) def test_what_is_refused(self): for picks, mode in [(None, "prefer"), ({"nodes": "FaceDetailer"}, "prefer"), ({"nodes": [3]}, "prefer"), @@ -104,7 +119,7 @@ class ToolboxTests(unittest.TestCase): with self.assertRaises(ai_toolbox.ToolboxError): ai_toolbox.pick("no-task", {}, "prefer") with self.assertRaises(ai_toolbox.ToolboxError): - ai_toolbox.catalog({}, "secrets") + ai_toolbox.catalog("nodes") if __name__ == "__main__": diff --git a/web/modules/ai_toolbox_data.js b/web/modules/ai_toolbox_data.js new file mode 100644 index 0000000..ace2868 --- /dev/null +++ b/web/modules/ai_toolbox_data.js @@ -0,0 +1,163 @@ +/** + * The materials picker's data (ui_ai_toolbox.js), no DOM. Node types come from the page's own + * node definitions (LiteGraph's registered types and their nodeData: no request, so the node + * list is there at once): their source (ComfyUI's own, a custom node pack, the paid API nodes, + * or a helper that lives only in the page, put with its pack when its name says which), their + * sockets, settings and outputs. Combos, workflows, prompts and models come in one answer + * (GET /anomalous/ai_toolbox?kind=all), read ahead (prefetchToolbox) and kept a minute; a window + * opened meanwhile uses what is kept and refreshes it behind. searchItems matches and ranks. + */ + +export const KINDS = Object.freeze(['nodes', 'combos', 'workflows', 'prompts', 'models']); +const SETTING_TYPES = new Set(['INT', 'FLOAT', 'STRING', 'BOOLEAN', 'COMBO']); +const KEEP_MS = 60 * 1000; +const lower = value => String(value ?? '').toLowerCase(); + +/** The pack a page-only helper belongs to, by what its name says (rgthree's "(rgthree)" and so on). */ +const PAGE_HINTS = [[/\(rgthree\)$/i, 'rgthree'], [/\|pysssss$/i, 'custom-scripts'], [/^easy /i, 'easy-use']]; + +function socketsAndSettings(data) { + const inputs = []; + const settings = []; + for (const [section, required] of [['required', true], ['optional', false]]) { + for (const [name, conf] of Object.entries(data?.input?.[section] || {})) { + const [kind, options = {}] = Array.isArray(conf) ? conf : [conf]; + const choices = Array.isArray(kind) ? kind : kind === 'COMBO' && Array.isArray(options?.options) ? options.options : null; + if ((choices || SETTING_TYPES.has(kind)) && !options?.forceInput) { + settings.push({ name, type: choices ? 'choice' : String(kind), required, default: options?.default, choices: choices ? choices.length : 0 }); + } else { + inputs.push({ name, type: String(kind), required }); + } + } + } + const names = data?.output_name || data?.output || []; + const outputs = (data?.output || []).map((type, slot) => ({ slot, name: String(names[slot] ?? type), type: String(type) })); + return { inputs, settings, outputs }; +} + +/** A pack's folder or label in plain words: "comfyui_controlnet_aux" → "controlnet aux", "ComfyUI-Impact-Pack" → "Impact Pack". */ +export function packLabel(text) { + const words = String(text || '').split(/[\s_\-.]+/).filter(Boolean); + const kept = words.filter((word, index) => !(/^comfy(ui)?$/i.test(word) && words.length > 1 && (index === 0 || index === words.length - 1))); + return kept.join(' ') || String(text || ''); +} + +function sourceOf(data) { + const module = String(data.python_module || ''); + if (data.api_node || module.startsWith('comfy_api_nodes')) return { key: 'api', kind: 'api' }; + if (module.startsWith('custom_nodes.')) { + const folder = module.split('.')[1]; + return { key: folder, kind: 'pack', label: packLabel(data.nodeSource?.displayText || folder) }; + } + return { key: 'builtin', kind: 'builtin' }; +} + +/** Every node type the page knows, as picker items. `registered`: LiteGraph.registered_node_types. */ +export function nodeItems(registered) { + const items = []; + const packs = new Map(); + const pageOnly = []; + for (const [type, cls] of Object.entries(registered || {})) { + const data = cls?.nodeData; + if (!data) { + pageOnly.push([type, cls]); + continue; + } + const source = sourceOf(data); + if (source.kind === 'pack' && !packs.has(source.key)) packs.set(source.key, source.label); + const shape = socketsAndSettings(data); + items.push({ + kind: 'nodes', id: type, title: String(data.display_name || type), sub: data.display_name && data.display_name !== type ? type : '', + source, category: String(data.category || ''), description: String(data.description || ''), ...shape, + deprecated: Boolean(data.deprecated), experimental: Boolean(data.experimental), paid: source.kind === 'api', page: false, + }); + } + for (const [type, cls] of pageOnly) { + const hint = PAGE_HINTS.find(([pattern]) => pattern.test(type))?.[1]; + const key = hint ? [...packs.keys()].find(folder => lower(folder).includes(hint)) : null; + const source = key ? { key, kind: 'pack', label: packs.get(key) } : { key: 'builtin', kind: 'builtin' }; + items.push({ + kind: 'nodes', id: type, title: String(cls?.title || type), sub: cls?.title && cls.title !== type ? type : '', + source, category: String(cls?.category || ''), description: '', inputs: [], settings: [], outputs: [], + deprecated: false, experimental: false, paid: false, page: true, + }); + } + for (const item of items) { + item.haystack = lower([item.id, item.title, item.category, item.source.label || '', item.description.slice(0, 200)].join(' ')); + } + return items.sort((a, b) => a.title.localeCompare(b.title)); +} + +/** The sources of `items` with how many each has: ComfyUI's own first, packs by name, paid API nodes last. */ +export function nodeSources(items) { + const found = new Map(); + for (const item of items) { + const entry = found.get(item.source.key) || { ...item.source, count: 0 }; + entry.count++; + found.set(item.source.key, entry); + } + const order = source => (source.kind === 'builtin' ? 0 : source.kind === 'api' ? 2 : 1); + return [...found.values()].sort((a, b) => order(a) - order(b) || lower(a.label || a.key).localeCompare(lower(b.label || b.key))); +} + +/** The other kinds (GET /anomalous/ai_toolbox?kind=all) as picker items. */ +export function catalogItems(payload) { + const out = []; + for (const combo of payload?.combos || []) { + out.push({ kind: 'combos', id: combo.name, title: combo.name, sub: combo.model || '', ...combo, + haystack: lower([combo.name, combo.model, combo.base, combo.prompt, ...(combo.loras || [])].join(' ')) }); + } + for (const flow of payload?.workflows || []) { + out.push({ kind: 'workflows', id: flow.name, title: flow.name, sub: (flow.tags || []).join(' · '), ...flow, + haystack: lower([flow.name, flow.base, flow.notes, flow.prompt, ...(flow.tags || [])].join(' ')) }); + } + for (const prompt of payload?.prompts || []) { + out.push({ kind: 'prompts', ...prompt, id: prompt.id, title: prompt.name, + haystack: lower([prompt.name, prompt.positive, prompt.negative, ...(prompt.tags || [])].join(' ')) }); + } + for (const model of payload?.models || []) { + out.push({ kind: 'models', ...model, id: model.id, title: model.name, sub: model.file, + haystack: lower([model.name, model.file, model.folder, model.type, model.base, ...(model.words || [])].join(' ')) }); + } + return out; +} + +/** `items` matching every word of `query`: titles that start with it first, then titles that hold it, then the rest. */ +export function searchItems(items, query) { + const terms = lower(query).split(/\s+/).filter(Boolean); + if (!terms.length) return items; + const whole = terms.join(' '); + return items.filter(item => terms.every(term => item.haystack.includes(term))) + .map((item, index) => { + const title = lower(item.title); + const id = lower(item.id); + return { item, index, rank: title.startsWith(whole) || id.startsWith(whole) ? 0 : terms.every(term => title.includes(term) || id.includes(term)) ? 1 : 2 }; + }) + .sort((a, b) => a.rank - b.rank || a.index - b.index) + .map(entry => entry.item); +} + +let kept = null; // { at, items } +let loading = null; + +/** The catalogue, kept a minute; `fresh` reads it again. Resolves to the items. */ +export function loadCatalog({ fresh = false, fetcher = globalThis.fetch } = {}) { + if (!fresh && kept && Date.now() - kept.at < KEEP_MS) return Promise.resolve(kept.items); + loading ||= fetcher('/anomalous/ai_toolbox?kind=all') + .then(async (response) => { + const data = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`); + kept = { at: Date.now(), items: catalogItems(data) }; + return kept.items; + }) + .finally(() => { loading = null; }); + return loading; +} + +/** What is kept now (even when old), or null: a window shows it at once while loadCatalog refreshes. */ +export const keptCatalog = () => kept?.items || null; + +/** Reads the catalogue ahead, so the picker opens with it; errors wait for the window to say them. */ +export function prefetchToolbox() { + loadCatalog().catch(() => {}); +} diff --git a/web/modules/locales.js b/web/modules/locales.js index d49eed0..18cdc89 100644 --- a/web/modules/locales.js +++ b/web/modules/locales.js @@ -1470,29 +1470,66 @@ export const i18n = { aiTool_get_toolbox: "看了你给的素材", aiTool_open_workflow: "打开了工作流", aiToolbox: "素材", + aiTbSearch: "搜索节点、搭配、工作流、提示词、模型…", + aiTbFor: "给任务“{task}”", + aiTbAll: "全部", + aiTbPickedFilter: "已选", + aiTbLiked: "常用", + aiTbSources: "来源", + aiTbNarrow: "分类", + aiTbEverything: "全部", + aiTbCount: "{count} 个", + aiTbPaneHello: "挑一些给 AI 用的素材", + aiTbPaneCount: "已经挑了 {count} 样", + aiTbPaneTips: "点卡片就是选上,再点一次取消;鼠标移到卡片上,这里显示它的详细说明。☆ 设为常用:没挑素材的任务会自动带上。按 / 直接搜索。", + aiTbPromptOf: "“{name}”里的提示词", + aiTbRemove: "去掉", + aiTbTrayEmpty: "还没挑素材:点上面的卡片", + aiTbClear: "全部去掉", + aiTbMode_prefer: "优先用", + aiTbMode_only: "只能用", + aiTbModeHint_prefer: "AI 会先用你挑的,需要时也能用别的", + aiTbModeHint_only: "AI 只能加你挑的节点(ComfyUI 自带的照常可用),别的第三方和付费节点会被拦下", + aiTbSend: "交给 AI · {count}", + aiTbSource_builtin: "ComfyUI 自带", + aiTbSource_api: "付费 API 节点", + aiTbSource_pack: "第三方节点包", + aiTbPromptFrom_saved: "存的提示词", + aiTbPromptFrom_combo: "来自搭配", + aiTbPromptFrom_workflow: "来自工作流", + aiTbPageTool: "网页小工具:AI 能放,但没有说明可查", + aiTbSettings: "{count} 个参数", + aiTbPaid: "付费", + aiTbPageShort: "网页小工具", + aiTbDeprecated: "已弃用", + aiTbLoras: "{count} 个 LoRA", + aiTbPartial: "片段", + aiTbChoices: "{count} 个选项", + aiTbPick: "选给 AI", + aiTbPicked: "已选上", + aiTbPickPrompt: "只选它的提示词", + aiTbPromptPicked: "已选它的提示词", + aiTbCategory: "分类", + aiTbInputs: "输入", + aiTbSettingsTitle: "参数", + aiTbOutputs: "输出", + aiTbPageExplain: "这是只在网页里运行的小工具(比如转接点、便签、rgthree 的快捷开关),ComfyUI 的节点说明里没有它,AI 只能按名字放到画布上。", + aiTbPaidExplain: "付费 API 节点运行时会花你在 Comfy 账户里的积分。", + aiTbMainModel: "主模型", + aiTbLoraList: "LoRA", + aiTbPositive: "正向提示词", + aiTbNegative: "反向提示词", + aiTbNodeCount: "{count} 个节点", + aiTbWords: "触发词", aiHandWorkflowWish: "用工作流“{name}”:", - aiToolboxCount: "素材 · {count}", aiToolboxTitle: "给 AI 的素材", - aiToolboxIntro: "勾选这次想让 AI 用的节点、搭配、工作流、提示词和模型,点“交给 AI”。☆ 设为常用:以后的任务没挑素材时,自动带上常用的。", - aiToolboxFromFavorites: "现在勾着的是你的常用素材;这里的改动只影响这个任务。", - aiToolboxSearch: "搜索名字", aiToolboxKind_nodes: "节点", aiToolboxKind_combos: "搭配", aiToolboxKind_workflows: "工作流", aiToolboxKind_prompts: "提示词", aiToolboxKind_models: "模型", - aiToolboxSource_builtin: "ComfyUI 自带", - aiToolboxSource_api: "付费 API 节点(会花你的积分)", - aiToolboxSource_page: "只在网页里的小工具(AI 能放,但查不到说明)", - aiToolboxGroupCount: "{count} 个", - aiToolboxMore: "还有 {count} 个,用上面的搜索找", - aiToolboxLoading: "正在读取…", aiToolboxEmptyList: "没有找到", aiToolboxNone: "还没选", - aiToolboxPicked: "已选:", - aiToolboxMode_prefer: "优先用这些", - aiToolboxMode_only: "只能用这些节点(自带节点照常可用)", - aiToolboxSend: "交给 AI", aiToolboxFavorite: "设为常用", aiToolboxUnfavorite: "取消常用", aiToolboxStepMode_prefer: "让 AI 优先用这些", @@ -4460,29 +4497,66 @@ export const i18n = { aiTool_get_toolbox: "Read what you picked", aiTool_open_workflow: "Opened a workflow", aiToolbox: "Materials", + aiTbSearch: "Search nodes, combos, workflows, prompts, models…", + aiTbFor: "For “{task}”", + aiTbAll: "All", + aiTbPickedFilter: "Picked", + aiTbLiked: "Favourites", + aiTbSources: "Sources", + aiTbNarrow: "Narrow", + aiTbEverything: "All", + aiTbCount: "{count}", + aiTbPaneHello: "Pick things for the AI to use", + aiTbPaneCount: "{count} picked", + aiTbPaneTips: "A press on a card picks it, another takes it back; point at a card to see it in full here. ☆ makes a favourite: tasks without picks get them. / jumps to the search.", + aiTbPromptOf: "The prompt in “{name}”", + aiTbRemove: "Remove", + aiTbTrayEmpty: "Nothing picked yet: press a card above", + aiTbClear: "Remove all", + aiTbMode_prefer: "Use first", + aiTbMode_only: "Only these", + aiTbModeHint_prefer: "The AI uses your picks first and others when it needs to", + aiTbModeHint_only: "The AI may add only the nodes you picked (and ComfyUI built-in ones); other custom and paid nodes are refused", + aiTbSend: "Hand to the AI · {count}", + aiTbSource_builtin: "ComfyUI built-in", + aiTbSource_api: "Paid API nodes", + aiTbSource_pack: "Custom node pack", + aiTbPromptFrom_saved: "Saved prompt", + aiTbPromptFrom_combo: "From a combo", + aiTbPromptFrom_workflow: "From a workflow", + aiTbPageTool: "Page-only helper: the AI can place it, not look it up", + aiTbSettings: "{count} settings", + aiTbPaid: "Paid", + aiTbPageShort: "Page-only", + aiTbDeprecated: "Deprecated", + aiTbLoras: "{count} LoRAs", + aiTbPartial: "Fragment", + aiTbChoices: "{count} choices", + aiTbPick: "Pick for the AI", + aiTbPicked: "Picked", + aiTbPickPrompt: "Pick just its prompt", + aiTbPromptPicked: "Its prompt is picked", + aiTbCategory: "Category", + aiTbInputs: "Inputs", + aiTbSettingsTitle: "Settings", + aiTbOutputs: "Outputs", + aiTbPageExplain: "A helper that runs only in the page (reroutes, notes, rgthree switches): ComfyUI has no description of it, so the AI can only place it by name.", + aiTbPaidExplain: "Paid API nodes spend the credits of your Comfy account when they run.", + aiTbMainModel: "Main model", + aiTbLoraList: "LoRAs", + aiTbPositive: "Positive prompt", + aiTbNegative: "Negative prompt", + aiTbNodeCount: "{count} nodes", + aiTbWords: "Trigger words", aiHandWorkflowWish: "With the workflow “{name}”: ", - aiToolboxCount: "Materials · {count}", aiToolboxTitle: "Materials for the AI", - aiToolboxIntro: "Tick the nodes, combos, workflows, prompts and models the AI should use this time, then Hand to the AI. ☆ makes one a favourite: tasks you have not picked for get your favourites.", - aiToolboxFromFavorites: "Ticked now: your favourites; changes here are for this task only.", - aiToolboxSearch: "Search names", aiToolboxKind_nodes: "Nodes", aiToolboxKind_combos: "Combos", aiToolboxKind_workflows: "Workflows", aiToolboxKind_prompts: "Prompts", aiToolboxKind_models: "Models", - aiToolboxSource_builtin: "ComfyUI built-in", - aiToolboxSource_api: "Paid API nodes (spend your credits)", - aiToolboxSource_page: "Page-only helpers (the AI can place them, not look them up)", - aiToolboxGroupCount: "{count}", - aiToolboxMore: "{count} more: search above", - aiToolboxLoading: "Loading…", aiToolboxEmptyList: "Nothing found", aiToolboxNone: "Nothing picked", - aiToolboxPicked: "Picked:", - aiToolboxMode_prefer: "Use these first", - aiToolboxMode_only: "Only these nodes (built-in ones too)", - aiToolboxSend: "Hand to the AI", aiToolboxFavorite: "Make a favourite", aiToolboxUnfavorite: "No longer a favourite", aiToolboxStepMode_prefer: "The AI uses these first", diff --git a/web/modules/ui_ai_toolbox.js b/web/modules/ui_ai_toolbox.js index 7fdf548..4aec926 100644 --- a/web/modules/ui_ai_toolbox.js +++ b/web/modules/ui_ai_toolbox.js @@ -1,217 +1,338 @@ /** - * "给 AI 的素材": what the user hands the AI to work with in a task (api/ai_toolbox.py). A window - * lists node types grouped by where they come from (ComfyUI's own, each custom node pack, the - * helpers that live only in the page, paid API nodes), then the user's combos, saved workflows, - * prompts and models, by the names the AI's tools use. A tick picks one for this task; the star - * keeps it among the favourites a task has until the user picks for it. "Hand to the AI" sends - * the picks and how strictly (prefer / only) as a step the AI receives. toolboxButton() is the - * entry in a task's header, toolboxStep() the timeline row of a pick. + * "给 AI 的素材": what the user hands the AI to work with in a task (api/ai_toolbox.py). A large + * window: a search over everything, the kinds on the left (node types, combos, workflows, + * prompts, models) with how many are picked, a second column to narrow by source (each custom + * node pack with its badge, ComfyUI's own, paid API nodes), by where a prompt comes from or by + * model type, a grid of cards (ui_ai_toolbox_cards.js) drawn as it scrolls, the card in focus + * in full on the right, and the picks below with how strictly the AI keeps to them. Node types + * are the page's own (no waiting); the rest is read ahead and kept (ai_toolbox_data.js). + * toolboxButton() is the entry in a task's header, toolboxStep() the timeline row of a pick. */ import { translate as t } from './locales.js'; import { anomalousAlert } from './ui_dialog.js'; import { aiWindow } from './ui_ai_connect.js'; +import { KINDS, keptCatalog, loadCatalog, nodeItems, nodeSources, prefetchToolbox, searchItems } from './ai_toolbox_data.js'; +import { card, coverUrl, detail, refreshCard } from './ui_ai_toolbox_cards.js'; +import { emptyPicture, icon, sourceBadge } from './ui_ai_toolbox_icons.js'; -const KINDS = ['nodes', 'combos', 'workflows', 'prompts', 'models']; const MODES = ['prefer', 'only']; -const ROWS_SHOWN = 80; -const SEARCH_DELAY_MS = 250; - -let nodeGroups = null; // read once per page: ComfyUI's node list does not change while it runs +const CHUNK = 60; +const SEARCH_DELAY_MS = 120; function el(tag, className, text) { const node = document.createElement(tag); if (className) node.className = className; - if (text !== undefined) node.textContent = text; + if (text !== undefined && text !== null) node.textContent = text; return node; } -function button(className, label, onClick, title) { - const node = el('button', className, label); +function button(className, children, onClick, title) { + const node = el('button', className); node.type = 'button'; + node.append(...[].concat(children).map(child => (typeof child === 'string' ? el('span', '', child) : child))); node.onclick = onClick; if (title) node.title = title; return node; } -async function getJson(url, options) { - const response = await fetch(url, options); +async function postJson(url, body) { + const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); const data = await response.json().catch(() => ({})); if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`); return data; } -const post = (url, body) => getJson(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); const empty = () => Object.fromEntries(KINDS.map(kind => [kind, []])); const total = box => KINDS.reduce((sum, kind) => sum + (box?.[kind]?.length || 0), 0); -const shortName = (kind, name) => (kind === 'models' ? String(name).split(/[\\/:]/).pop() : name); +const keyOf = (kind, id) => `${kind}|${id}`; -/** Node types by source; the page-only helpers (in LiteGraph, not in ComfyUI's list) before the paid API nodes. */ -async function loadNodeGroups() { - if (nodeGroups) return nodeGroups; - const { groups } = await getJson('/anomalous/ai_toolbox?kind=nodes'); - const known = new Set(groups.flatMap(group => group.nodes.map(node => node.type))); - const registered = globalThis.LiteGraph?.registered_node_types || {}; - const pageOnly = Object.keys(registered).filter(type => !known.has(type)).sort() - .map(type => ({ type, label: registered[type]?.title || type })); - const paid = groups.filter(group => group.source === 'api'); - nodeGroups = [...groups.filter(group => group.source !== 'api'), ...(pageOnly.length ? [{ source: 'page', nodes: pageOnly }] : []), ...paid]; - return nodeGroups; +let nodesKept = null; // the page's node types do not change while it is open +const pageNodes = () => (nodesKept ||= nodeItems(globalThis.LiteGraph?.registered_node_types)); + +/** What narrows a kind in the second column: a node's source, a prompt's origin, a model's type. */ +function facetOf(item) { + if (item.kind === 'nodes') return { key: item.source.key, label: item.source.kind === 'pack' ? item.source.label : t(`aiTbSource_${item.source.kind}`), source: item.source }; + if (item.kind === 'prompts') return { key: item.source, label: t(`aiTbPromptFrom_${item.source}`) }; + if (item.kind === 'models') return { key: item.type, label: item.type }; + return null; } -const sourceLabel = source => (['builtin', 'api', 'page'].includes(source) ? t(`aiToolboxSource_${source}`) : source); +function facets(items, kind) { + if (kind === 'nodes') { + return nodeSources(items).map(source => ({ key: source.key, count: source.count, source, + label: source.kind === 'pack' ? source.label : t(`aiTbSource_${source.kind}`) })); + } + const found = new Map(); + for (const item of items) { + const facet = facetOf(item); + if (!facet) continue; + const entry = found.get(facet.key) || { ...facet, count: 0 }; + entry.count++; + found.set(facet.key, entry); + } + return [...found.values()].sort((a, b) => b.count - a.count); +} /** The window for task `task`; `changed()` after the picks were handed over. */ export async function openToolbox(task, changed = () => {}) { - const state = { kind: 'nodes', query: '', mode: task.toolbox?.mode || 'prefer', picks: empty(), favorites: empty(), open: new Set(['builtin']) }; - try { - state.favorites = { ...empty(), ...(await getJson('/anomalous/ai_toolbox/favorites')).favorites }; - } catch (_) { /* no favourites yet; starring still saves */ } const fromTask = Boolean(task.toolbox); + const state = { + kind: 'nodes', query: '', filter: 'all', facet: null, focus: null, mode: task.toolbox?.mode || 'prefer', + picks: empty(), favorites: empty(), nodes: pageNodes(), catalog: keptCatalog(), error: '', + }; + const byKey = new Map(); + const index = () => { + byKey.clear(); + for (const item of [...state.nodes, ...(state.catalog || [])]) byKey.set(keyOf(item.kind, item.id), item); + }; + index(); + try { + const response = await fetch('/anomalous/ai_toolbox/favorites'); + if (response.ok) state.favorites = { ...empty(), ...(await response.json()).favorites }; + } catch (_) { /* none yet; a star still saves */ } for (const kind of KINDS) state.picks[kind] = [...((fromTask ? task.toolbox : state.favorites)[kind] || [])]; - const body = el('div', 'anomalous-ai-toolbox'); - const search = el('input', 'anomalous-ai-toolbox-search'); + const body = el('div', 'anomalous-tb'); + const top = el('div', 'anomalous-tb-top'); + const searchBox = el('label', 'anomalous-tb-search'); + const search = el('input'); search.type = 'search'; - search.placeholder = t('aiToolboxSearch'); - const kinds = el('div', 'anomalous-ai-toolbox-kinds'); - const list = el('div', 'anomalous-ai-toolbox-list'); - const tray = el('div', 'anomalous-ai-toolbox-tray'); - const intro = el('p', 'anomalous-ai-toolbox-intro', `${t('aiToolboxIntro')}${!fromTask && total(state.favorites) ? ` ${t('aiToolboxFromFavorites')}` : ''}`); - const main = el('div', 'anomalous-ai-toolbox-main'); - main.append(kinds, list); - body.append(intro, search, main, tray); + search.placeholder = t('aiTbSearch'); + searchBox.append(icon('search', 18), search, el('kbd', 'anomalous-tb-kbd', '/')); + const filters = el('div', 'anomalous-tb-filters'); + top.append(el('div', 'anomalous-tb-for', t('aiTbFor', { task: task.title || t('aiUntitled') })), searchBox, filters); + const rail = el('nav', 'anomalous-tb-rail'); + const side = el('aside', 'anomalous-tb-facets'); + const gridWrap = el('div', 'anomalous-tb-grid-wrap'); + const gridHead = el('div', 'anomalous-tb-grid-head'); + const scroller = el('div', 'anomalous-tb-scroller'); + const grid = el('div', 'anomalous-tb-grid'); + const sentinel = el('div', 'anomalous-tb-sentinel'); + scroller.append(grid, sentinel); + gridWrap.append(gridHead, scroller); + const pane = el('div', 'anomalous-tb-pane'); + const main = el('div', 'anomalous-tb-main'); + main.append(rail, side, gridWrap, pane); + const tray = el('div', 'anomalous-tb-tray'); + body.append(top, main, tray); const close = aiWindow(t('aiToolboxTitle'), body); - body.closest('.anomalous-ai-connect-dialog')?.classList.add('is-wide'); + body.closest('.anomalous-ai-connect-dialog')?.classList.add('is-toolbox'); - const picked = (kind, name) => state.picks[kind].includes(name); - const toggle = (kind, name, on) => { - state.picks[kind] = on ? [...new Set([...state.picks[kind], name])] : state.picks[kind].filter(item => item !== name); - drawKinds(); + const picked = item => state.picks[item.kind].includes(item.id); + const liked = item => state.favorites[item.kind].includes(item.id); + const cards = new Map(); // key -> drawn card + const ctx = { + picked, favorite: liked, focus: (item) => { state.focus = item; drawPane(); }, + toggle: item => setPick(item.kind, item.id, !picked(item)), + star: item => setFavorite(item), + promptPicked: id => state.picks.prompts.includes(id), + pickPrompt: id => setPick('prompts', id, !state.picks.prompts.includes(id)), + }; + + function setPick(kind, id, on) { + state.picks[kind] = on ? [...new Set([...state.picks[kind], id])] : state.picks[kind].filter(item => item !== id); + const item = byKey.get(keyOf(kind, id)); + const drawn = cards.get(keyOf(kind, id)); + if (drawn && item) refreshCard(drawn, item, ctx); + drawRail(); + drawFilters(); drawTray(); - }; - const star = (kind, name) => { - const on = state.favorites[kind].includes(name); - const node = button(`anomalous-ai-toolbox-star${on ? ' is-on' : ''}`, on ? '★' : '☆', async () => { - const next = { ...state.favorites, [kind]: on ? state.favorites[kind].filter(item => item !== name) : [...state.favorites[kind], name] }; - try { - state.favorites = { ...empty(), ...(await post('/anomalous/ai_toolbox/favorites', next)).favorites }; - } catch (error) { - await anomalousAlert(error.message); - return; - } - node.replaceWith(star(kind, name)); - }, t(on ? 'aiToolboxUnfavorite' : 'aiToolboxFavorite')); - return node; - }; - const row = (kind, name, label, detail) => { - const item = el('label', 'anomalous-ai-toolbox-row'); - const box = el('input'); - box.type = 'checkbox'; - box.checked = picked(kind, name); - box.onchange = () => toggle(kind, name, box.checked); - const words = el('span', 'anomalous-ai-toolbox-name', label || shortName(kind, name)); - item.append(box, words); - if (detail) item.append(el('span', 'anomalous-ai-toolbox-detail', detail)); - item.append(star(kind, name)); - return item; - }; + drawPane(); + if (state.filter === 'picked') drawGrid(); + } - function drawKinds() { - kinds.replaceChildren(...KINDS.map((kind) => { + async function setFavorite(item) { + const next = { ...state.favorites, [item.kind]: liked(item) ? state.favorites[item.kind].filter(id => id !== item.id) : [...state.favorites[item.kind], item.id] }; + try { + state.favorites = { ...empty(), ...(await postJson('/anomalous/ai_toolbox/favorites', next)).favorites }; + } catch (error) { + await anomalousAlert(error.message); + return; + } + const drawn = cards.get(keyOf(item.kind, item.id)); + if (drawn) refreshCard(drawn, item, ctx); + drawFilters(); + drawPane(); + if (state.filter === 'liked') drawGrid(); + } + + const itemsOf = kind => (kind === 'nodes' ? state.nodes : (state.catalog || []).filter(item => item.kind === kind)); + + /** The cards of the current kind after the search, the filter and the second column. */ + function shown() { + let items = searchItems(itemsOf(state.kind), state.query); + if (state.filter === 'picked') items = items.filter(picked); + if (state.filter === 'liked') items = items.filter(liked); + const beforeFacet = items; + if (state.facet) items = items.filter(item => facetOf(item)?.key === state.facet); + return { items, beforeFacet }; + } + + function drawRail() { + rail.replaceChildren(...KINDS.map((kind) => { const count = state.picks[kind].length; - const node = button(`anomalous-ai-toolbox-kind${state.kind === kind ? ' is-on' : ''}`, t(`aiToolboxKind_${kind}`), () => { + const matches = state.query ? searchItems(itemsOf(kind), state.query).length : null; + const node = button(`anomalous-tb-kind${state.kind === kind ? ' is-on' : ''}`, [icon(kind, 20), t(`aiToolboxKind_${kind}`)], () => { state.kind = kind; - drawKinds(); - void drawList(); + state.facet = null; + state.focus = null; + drawAll(); }); - if (count) node.append(el('span', 'anomalous-ai-toolbox-kind-count', String(count))); + const meta = el('span', 'anomalous-tb-kind-meta'); + if (matches !== null) meta.append(el('span', 'anomalous-tb-kind-matches', String(matches))); + if (count) meta.append(el('span', 'anomalous-tb-count', String(count))); + node.append(meta); return node; })); } - async function drawNodes() { - list.replaceChildren(el('div', 'anomalous-ai-toolbox-note', t('aiToolboxLoading'))); - const groups = await loadNodeGroups(); - const query = state.query.toLowerCase(); - const shown = []; - for (const group of groups) { - const nodes = query ? group.nodes.filter(node => `${node.type} ${node.label} ${node.category || ''}`.toLowerCase().includes(query)) : group.nodes; - if (!nodes.length) continue; - const holder = el('details', `anomalous-ai-toolbox-group is-${['builtin', 'api', 'page'].includes(group.source) ? group.source : 'pack'}`); - holder.open = Boolean(query) || state.open.has(group.source); - const count = nodes.filter(node => picked('nodes', node.type)).length; - const summary = el('summary', 'anomalous-ai-toolbox-group-head'); - summary.append(el('span', 'anomalous-ai-toolbox-group-name', sourceLabel(group.source)), - el('span', 'anomalous-ai-toolbox-detail', t('aiToolboxGroupCount', { count: nodes.length }))); - if (count) summary.append(el('span', 'anomalous-ai-toolbox-kind-count', String(count))); - holder.append(summary); - const fill = () => { - if (holder.children.length > 1) return; - holder.append(...nodes.slice(0, ROWS_SHOWN).map(node => row('nodes', node.type, node.label, node.label !== node.type ? node.type : ''))); - if (nodes.length > ROWS_SHOWN) holder.append(el('div', 'anomalous-ai-toolbox-note', t('aiToolboxMore', { count: nodes.length - ROWS_SHOWN }))); - }; - holder.addEventListener('toggle', () => { - if (holder.open) state.open.add(group.source); else state.open.delete(group.source); - if (holder.open) fill(); + function drawFilters() { + const picks = total(state.picks); + const likes = total(state.favorites); + filters.replaceChildren(...[['all', t('aiTbAll'), null], ['picked', t('aiTbPickedFilter'), picks], ['liked', t('aiTbLiked'), likes]].map(([key, label, count]) => { + const node = button(`anomalous-tb-filter${state.filter === key ? ' is-on' : ''}`, [key === 'liked' ? icon('star', 14) : key === 'picked' ? icon('check', 14) : icon('list', 14), label], () => { + state.filter = key; + drawAll(); }); - if (holder.open) fill(); - shown.push(holder); - } - list.replaceChildren(...(shown.length ? shown : [el('div', 'anomalous-ai-toolbox-note', t('aiToolboxEmptyList'))])); + if (count) node.append(el('span', 'anomalous-tb-count', String(count))); + return node; + })); } - async function drawItems(kind) { - list.replaceChildren(el('div', 'anomalous-ai-toolbox-note', t('aiToolboxLoading'))); - const { items } = await getJson(`/anomalous/ai_toolbox?kind=${kind}&query=${encodeURIComponent(state.query)}`); - if (state.kind !== kind) return; - const listed = new Set(items.map(item => item.name)); - // What is picked stays on top, also when a search does not find it, so it can be taken back. - const kept = state.picks[kind].filter(name => !listed.has(name)).map(name => row(kind, name)); - const rows = items.map(item => row(kind, item.name, item.label, item.detail)); - list.replaceChildren(...kept, ...(rows.length || kept.length ? rows : [el('div', 'anomalous-ai-toolbox-note', t('aiToolboxEmptyList'))])); + function drawFacets(beforeFacet) { + const list = facets(beforeFacet, state.kind); + side.hidden = list.length < 2; + if (side.hidden) return; + const row = (facet, label, count, badge) => { + const node = button(`anomalous-tb-facet${state.facet === facet ? ' is-on' : ''}`, [badge || icon('list', 16), label], () => { + state.facet = facet; + drawGrid(); + }); + node.title = label; + node.append(el('span', 'anomalous-tb-facet-count', String(count))); + return node; + }; + side.replaceChildren(el('div', 'anomalous-tb-facets-title', t(state.kind === 'nodes' ? 'aiTbSources' : 'aiTbNarrow')), + row(null, t('aiTbEverything'), beforeFacet.length), + ...list.map(facet => row(facet.key, facet.label, facet.count, facet.source ? sourceBadge(facet.source, 22) : null))); } - async function drawList() { - try { - await (state.kind === 'nodes' ? drawNodes() : drawItems(state.kind)); - } catch (error) { - list.replaceChildren(el('div', 'anomalous-ai-toolbox-note is-error', error.message)); + let observer = null; + function drawGrid() { + observer?.disconnect(); + cards.clear(); + const { items, beforeFacet } = shown(); + drawFacets(beforeFacet); + const facetLabel = state.facet ? (facets(beforeFacet, state.kind).find(facet => facet.key === state.facet)?.label || state.facet) : ''; + gridHead.replaceChildren(el('span', 'anomalous-tb-grid-title', facetLabel || t(`aiToolboxKind_${state.kind}`)), + el('span', 'anomalous-tb-grid-count', t('aiTbCount', { count: items.length }))); + if (state.kind !== 'nodes' && !state.catalog) { + grid.replaceChildren(...Array.from({ length: 8 }, () => el('div', 'anomalous-tb-card is-skeleton'))); + if (state.error) grid.replaceChildren(el('div', 'anomalous-tb-note is-error', state.error)); + return; } + if (!items.length) { + grid.replaceChildren(el('div', 'anomalous-tb-note', state.filter === 'picked' ? t('aiToolboxNone') : t('aiToolboxEmptyList'))); + return; + } + grid.replaceChildren(); + let drawn = 0; + const more = () => { + const next = items.slice(drawn, drawn + CHUNK); + drawn += next.length; + grid.append(...next.map((item) => { + const node = card(item, ctx); + cards.set(keyOf(item.kind, item.id), node); + return node; + })); + if (drawn >= items.length) observer?.disconnect(); + }; + more(); + observer = new IntersectionObserver(entries => { if (entries.some(entry => entry.isIntersecting)) more(); }, { root: scroller, rootMargin: '400px' }); + observer.observe(sentinel); + scroller.scrollTop = 0; + } + + function drawPane() { + if (state.focus) { + pane.replaceChildren(detail(state.focus, ctx)); + return; + } + const box = el('div', 'anomalous-tb-pane-empty'); + box.append(emptyPicture(), el('div', 'anomalous-tb-pane-title', total(state.picks) ? t('aiTbPaneCount', { count: total(state.picks) }) : t('aiTbPaneHello')), + el('p', 'anomalous-tb-text is-soft', t('aiTbPaneTips'))); + pane.replaceChildren(box); + } + + function chip(kind, id) { + const item = byKey.get(keyOf(kind, id)); + const inner = kind === 'prompts' && /^(combo|workflow):/.test(id); + const label = inner ? t('aiTbPromptOf', { name: id.replace(/^(combo|workflow):/, '') }) : item?.title || String(id).split(/[\\/:]/).pop(); + const node = el('span', `anomalous-tb-chip is-${kind}`); + const url = item && ['combos', 'workflows', 'models'].includes(kind) ? coverUrl(item) : ''; + if (url) { + const img = el('img'); + img.src = url; + img.alt = ''; + img.loading = 'lazy'; + img.onerror = () => img.replaceWith(icon(kind, 14)); + node.append(img); + } else if (kind === 'nodes' && item) { + node.append(sourceBadge(item.source, 16)); + } else { + node.append(icon(kind, 14)); + } + node.append(el('span', 'anomalous-tb-chip-name', label)); + node.title = `${t(`aiToolboxKind_${kind}`)} · ${label}`; + node.append(button('anomalous-tb-chip-x', [icon('close', 12)], () => setPick(kind, id, false), t('aiTbRemove'))); + return node; } function drawTray() { - const chips = el('div', 'anomalous-ai-toolbox-chips'); - for (const kind of KINDS) { - for (const name of state.picks[kind]) { - chips.append(button(`anomalous-ai-toolbox-chip is-${kind}`, `${shortName(kind, name)} ×`, () => { - toggle(kind, name, false); - void drawList(); - }, t(`aiToolboxKind_${kind}`))); - } + const chips = el('div', 'anomalous-tb-chips'); + for (const kind of KINDS) for (const id of state.picks[kind]) chips.append(chip(kind, id)); + if (!chips.children.length) chips.append(el('span', 'anomalous-tb-tray-empty', t('aiTbTrayEmpty'))); + const clear = button('anomalous-tb-clear', [icon('trash', 15)], () => { + state.picks = empty(); + for (const [key, node] of cards) refreshCard(node, byKey.get(key), ctx); + drawRail(); + drawFilters(); + drawTray(); + drawPane(); + }, t('aiTbClear')); + clear.hidden = !total(state.picks); + const modes = el('div', 'anomalous-tb-modes'); + modes.setAttribute('role', 'radiogroup'); + for (const mode of MODES) { + const option = button(`anomalous-tb-mode${state.mode === mode ? ' is-on' : ''}`, [icon(mode === 'only' ? 'lock' : 'target', 16), t(`aiTbMode_${mode}`)], () => { + state.mode = mode; + drawTray(); + }, t(`aiTbModeHint_${mode}`)); + option.setAttribute('role', 'radio'); + option.setAttribute('aria-checked', String(state.mode === mode)); + modes.append(option); } - if (!chips.children.length) chips.append(el('span', 'anomalous-ai-toolbox-detail', t('aiToolboxNone'))); - const mode = el('select', 'anomalous-ai-toolbox-mode'); - for (const value of MODES) { - const option = el('option', '', t(`aiToolboxMode_${value}`)); - option.value = value; - option.selected = state.mode === value; - mode.append(option); - } - mode.onchange = () => { state.mode = mode.value; }; - const send = button('anomalous-ai-toolbox-send', t('aiToolboxSend'), async () => { + const send = button('anomalous-tb-send', [icon('sparkle', 17), t('aiTbSend', { count: total(state.picks) })], async () => { + send.disabled = true; try { - await post(`/anomalous/ai_tasks/${encodeURIComponent(task.id)}/toolbox`, { mode: state.mode, ...state.picks }); + await postJson(`/anomalous/ai_tasks/${encodeURIComponent(task.id)}/toolbox`, { mode: state.mode, ...state.picks }); } catch (error) { + send.disabled = false; await anomalousAlert(error.message); return; } close(); changed(); }); - const actions = el('div', 'anomalous-ai-toolbox-actions'); - actions.append(mode, send); - tray.replaceChildren(el('span', 'anomalous-ai-toolbox-detail', t('aiToolboxPicked')), chips, actions); + tray.replaceChildren(chips, clear, modes, send); + } + + function drawAll() { + drawRail(); + drawFilters(); + drawGrid(); + drawPane(); } let timer = null; @@ -219,34 +340,74 @@ export async function openToolbox(task, changed = () => {}) { clearTimeout(timer); timer = setTimeout(() => { state.query = search.value.trim(); - void drawList(); + state.facet = null; + drawRail(); + drawGrid(); }, SEARCH_DELAY_MS); }); - drawKinds(); + body.addEventListener('keydown', (event) => { + if (event.key === '/' && event.target !== search) { + event.preventDefault(); + search.focus(); + } + }); + drawAll(); drawTray(); - await drawList(); search.focus(); + // The rest of the catalogue: what was kept is shown already; this brings it up to date. + loadCatalog({ fresh: Boolean(state.catalog) }).then((items) => { + state.catalog = items; + index(); + drawRail(); + drawTray(); + if (state.kind !== 'nodes') drawGrid(); + }).catch((error) => { + state.error = error.message; + if (state.kind !== 'nodes' && !state.catalog) drawGrid(); + }); } -/** The entry in a task's header: "Materials", with how many the task has. */ +/** The entry in a task's header: an icon, "Materials", and how many the task has. Reads the catalogue ahead. */ export function toolboxButton(task, changed) { + prefetchToolbox(); const count = total(task.toolbox); - return button('anomalous-ai-link', count ? t('aiToolboxCount', { count }) : t('aiToolbox'), () => void openToolbox(task, changed), t('aiToolboxTitle')); + const node = button(`anomalous-tb-open${count ? ' has-picks' : ''}`, [icon('sparkle', 15), t('aiToolbox')], () => void openToolbox(task, changed), t('aiToolboxTitle')); + if (count) node.append(el('span', 'anomalous-tb-count', String(count))); + return node; } /** A pick on the timeline: what was handed over, how strictly, and whether the AI has it. */ export function toolboxStep(step) { - const box = el('div', 'anomalous-ai-toolbox-step'); + const box = el('div', 'anomalous-tb-step'); const picks = step.toolbox || {}; if (!total(picks)) { box.append(el('div', 'anomalous-ai-user-text', t('aiToolboxCleared'))); } else { - box.append(el('div', 'anomalous-ai-toolbox-detail', t(`aiToolboxStepMode_${picks.mode === 'only' ? 'only' : 'prefer'}`))); - const chips = el('div', 'anomalous-ai-toolbox-chips'); + const mode = el('div', `anomalous-tb-step-mode is-${picks.mode === 'only' ? 'only' : 'prefer'}`); + mode.append(icon(picks.mode === 'only' ? 'lock' : 'target', 14), el('span', '', t(`aiToolboxStepMode_${picks.mode === 'only' ? 'only' : 'prefer'}`))); + const chips = el('div', 'anomalous-tb-chips is-static'); + const known = new Map([...(picks.nodes?.length ? pageNodes() : []), ...(keptCatalog() || [])].map(item => [keyOf(item.kind, item.id), item])); for (const kind of KINDS) { - for (const name of picks[kind] || []) chips.append(el('span', `anomalous-ai-toolbox-chip is-${kind}`, shortName(kind, name))); + for (const id of picks[kind] || []) { + const item = known.get(keyOf(kind, id)); + const node = el('span', `anomalous-tb-chip is-${kind}`); + const url = item && kind !== 'prompts' ? coverUrl(item) : ''; + if (url) { + const img = el('img'); + img.src = url; + img.alt = ''; + img.loading = 'lazy'; + img.onerror = () => img.replaceWith(icon(kind, 14)); + node.append(img); + } else { + node.append(kind === 'nodes' && item ? sourceBadge(item.source, 16) : icon(kind, 14)); + } + const inner = kind === 'prompts' && /^(combo|workflow):/.test(id); + node.append(el('span', 'anomalous-tb-chip-name', inner ? t('aiTbPromptOf', { name: id.replace(/^(combo|workflow):/, '') }) : item?.title || String(id).split(/[\\/:]/).pop())); + chips.append(node); + } } - box.append(chips); + box.append(mode, chips); } box.append(el('div', 'anomalous-ai-user-state', t(step.delivered ? 'aiMessageSeen' : 'aiMessageWaiting'))); return box; diff --git a/web/modules/ui_ai_toolbox_cards.js b/web/modules/ui_ai_toolbox_cards.js new file mode 100644 index 0000000..2bf85ce --- /dev/null +++ b/web/modules/ui_ai_toolbox_cards.js @@ -0,0 +1,274 @@ +/** + * The materials picker's cards and detail pane (ui_ai_toolbox.js). A card is one node type, + * combo, workflow, prompt or model: a node shows itself in miniature (its sockets in ComfyUI's + * colours, outputs on the right, how many settings) under its source's badge; combos, + * workflows and models show their cover; a prompt shows its text. A press picks or unpicks, + * the star keeps it among the favourites. The detail pane shows the card in focus in full, + * and for a combo or workflow can pick just the prompt inside it. + */ + +import { translate as t } from './locales.js'; +import { outputThumbnail } from './ui_kept_toast.js'; +import { recipeAssetUrl } from './ui_recipe_media.js'; +import { icon, socketColor, sourceBadge } from './ui_ai_toolbox_icons.js'; + +const SOCKETS_SHOWN = 4; +const withQuery = (url, query) => `${url}${url.includes('?') ? '&' : '?'}${query}`; + +function el(tag, className, text) { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined && text !== null) node.textContent = text; + return node; +} + +/** The picture of a combo, workflow or model: its cover, a kept output image, or nothing. */ +export function coverUrl(item) { + if (item.kind === 'workflows') { + if (item.cover_asset) return recipeAssetUrl(item.filename, item.cover_asset); + if (item.thumbnail) return item.thumbnail; + return item.image?.filename ? outputThumbnail(item.image) : ''; + } + if (item.cover) return withQuery(item.cover, /\.(mp4|webm)(?:&|$)/i.test(item.cover) ? 'variant=poster' : 'variant=card'); + return item.image?.filename ? outputThumbnail(item.image) : ''; +} + +function picture(item, className) { + const box = el('div', className); + const url = coverUrl(item); + const fallback = () => box.replaceChildren(icon(item.kind, 30, 'anomalous-tb-cover-icon')); + if (!url) { + fallback(); + return box; + } + const img = el('img'); + img.loading = 'lazy'; + img.decoding = 'async'; + img.alt = ''; + img.src = url; + img.onerror = fallback; + box.append(img); + return box; +} + +function socketRow(socket, side) { + const row = el('div', `anomalous-tb-socket is-${side}`); + const dot = el('span', 'anomalous-tb-dot'); + dot.style.setProperty('--tb-dot', socketColor(socket.type)); + row.title = `${socket.name} · ${socket.type}`; + row.append(dot, el('span', 'anomalous-tb-socket-name', socket.name)); + return row; +} + +/** A node in miniature: title bar, inputs left, outputs right, its settings counted. */ +function nodePreview(item) { + const box = el('div', 'anomalous-tb-node'); + const head = el('div', 'anomalous-tb-node-head'); + head.append(sourceBadge(item.source, 18), el('span', 'anomalous-tb-node-title', item.title)); + const body = el('div', 'anomalous-tb-node-body'); + const left = el('div', 'anomalous-tb-node-col'); + const right = el('div', 'anomalous-tb-node-col is-out'); + left.append(...item.inputs.slice(0, SOCKETS_SHOWN).map(socket => socketRow(socket, 'in'))); + if (item.inputs.length > SOCKETS_SHOWN) left.append(el('span', 'anomalous-tb-more', `+${item.inputs.length - SOCKETS_SHOWN}`)); + right.append(...item.outputs.slice(0, SOCKETS_SHOWN).map(socket => socketRow(socket, 'out'))); + if (item.outputs.length > SOCKETS_SHOWN) right.append(el('span', 'anomalous-tb-more', `+${item.outputs.length - SOCKETS_SHOWN}`)); + body.append(left, right); + box.append(head, body); + if (item.page) { + box.append(el('div', 'anomalous-tb-node-foot', t('aiTbPageTool'))); + } else if (item.settings.length) { + const foot = el('div', 'anomalous-tb-node-foot'); + foot.append(icon('gear', 12), el('span', '', t('aiTbSettings', { count: item.settings.length }))); + box.append(foot); + } + return box; +} + +function tags(item) { + const row = el('div', 'anomalous-tb-tags'); + const add = (text, kind = '') => row.append(el('span', `anomalous-tb-tag${kind ? ` is-${kind}` : ''}`, text)); + if (item.kind === 'nodes') { + if (item.paid) add(t('aiTbPaid'), 'warn'); + if (item.page) add(t('aiTbPageShort'), 'soft'); + if (item.deprecated) add(t('aiTbDeprecated'), 'soft'); + if (item.category) add(item.category.split('/').pop()); + } else if (item.kind === 'combos') { + if (item.loras?.length) add(t('aiTbLoras', { count: item.loras.length })); + if (item.base) add(item.base); + } else if (item.kind === 'workflows') { + if (item.partial) add(t('aiTbPartial'), 'soft'); + for (const tag of (item.tags || []).slice(0, 3)) add(tag); + } else if (item.kind === 'prompts') { + add(t(`aiTbPromptFrom_${item.source}`), item.source); + } else if (item.kind === 'models') { + add(item.type); + if (item.base) add(item.base); + } + return row; +} + +/** + * One card. `ctx`: { picked(item), favorite(item), toggle(item), star(item), focus(item) }. + * A press picks or unpicks; focus or the pointer shows it in the detail pane. + */ +export function card(item, ctx) { + const node = el('div', `anomalous-tb-card is-${item.kind}${ctx.picked(item) ? ' is-picked' : ''}`); + node.tabIndex = 0; + node.setAttribute('role', 'button'); + node.setAttribute('aria-pressed', String(ctx.picked(item))); + node.dataset.key = `${item.kind}|${item.id}`; + if (item.kind === 'nodes') { + node.append(nodePreview(item)); + } else if (item.kind === 'prompts') { + const quote = el('div', 'anomalous-tb-quote'); + quote.append(el('p', 'anomalous-tb-quote-text', item.positive || item.negative || '')); + node.append(quote); + } else { + node.append(picture(item, 'anomalous-tb-cover')); + } + const body = el('div', 'anomalous-tb-card-body'); + if (item.kind !== 'nodes') body.append(el('div', 'anomalous-tb-card-title', item.title)); + if (item.sub) body.append(el('div', 'anomalous-tb-card-sub', item.sub)); + body.append(tags(item)); + const check = el('span', 'anomalous-tb-check'); + check.append(icon('check', 14)); + const star = el('button', `anomalous-tb-star${ctx.favorite(item) ? ' is-on' : ''}`); + star.type = 'button'; + star.title = t(ctx.favorite(item) ? 'aiToolboxUnfavorite' : 'aiToolboxFavorite'); + star.append(icon(ctx.favorite(item) ? 'starOn' : 'star', 15)); + star.onclick = (event) => { + event.stopPropagation(); + void ctx.star(item); + }; + node.append(body, check, star); + node.onclick = () => ctx.toggle(item); + node.onkeydown = (event) => { + if (event.target === node && (event.key === 'Enter' || event.key === ' ')) { + event.preventDefault(); + ctx.toggle(item); + } + }; + node.onfocus = () => ctx.focus(item); + node.onpointerenter = () => ctx.focus(item); + return node; +} + +/** Updates a drawn card after a pick or a star, without drawing the grid again. */ +export function refreshCard(node, item, ctx) { + const on = ctx.picked(item); + node.classList.toggle('is-picked', on); + node.setAttribute('aria-pressed', String(on)); + const star = node.querySelector('.anomalous-tb-star'); + if (star) { + const liked = ctx.favorite(item); + star.classList.toggle('is-on', liked); + star.title = t(liked ? 'aiToolboxUnfavorite' : 'aiToolboxFavorite'); + star.replaceChildren(icon(liked ? 'starOn' : 'star', 15)); + } +} + +function section(title, children) { + const box = el('section', 'anomalous-tb-section'); + box.append(el('h4', 'anomalous-tb-section-title', title), ...children); + return box; +} + +function textBlock(text, className = '') { + return el('p', `anomalous-tb-text${className ? ` ${className}` : ''}`, text); +} + +function socketList(sockets, side) { + const list = el('div', 'anomalous-tb-socket-list'); + for (const socket of sockets) { + const row = socketRow(socket, side); + row.append(el('span', 'anomalous-tb-socket-type', socket.type)); + list.append(row); + } + return list; +} + +function settingList(settings) { + const list = el('div', 'anomalous-tb-setting-list'); + for (const setting of settings) { + const row = el('div', 'anomalous-tb-setting'); + const value = setting.type === 'choice' ? t('aiTbChoices', { count: setting.choices }) : setting.default !== undefined ? String(setting.default).slice(0, 40) : setting.type; + row.append(el('span', 'anomalous-tb-setting-name', setting.name), el('span', 'anomalous-tb-setting-value', value)); + list.append(row); + } + return list; +} + +/** + * The detail pane for `item`. `ctx` as for card(), plus pickPrompt(id) and promptPicked(id) + * for the prompt inside a combo or workflow. + */ +export function detail(item, ctx) { + const box = el('div', `anomalous-tb-detail is-${item.kind}`); + const head = el('div', 'anomalous-tb-detail-head'); + if (item.kind === 'nodes') { + head.append(sourceBadge(item.source, 40)); + } else if (item.kind !== 'prompts') { + box.append(picture(item, 'anomalous-tb-detail-cover')); + } + const words = el('div', 'anomalous-tb-detail-words'); + words.append(el('div', 'anomalous-tb-detail-title', item.title)); + if (item.kind === 'nodes') { + words.append(el('div', 'anomalous-tb-detail-sub', [item.id, item.source.kind === 'pack' ? item.source.label : t(`aiTbSource_${item.source.kind}`)].join(' · '))); + } else if (item.sub) { + words.append(el('div', 'anomalous-tb-detail-sub', item.sub)); + } + head.append(words); + box.append(head, tags(item)); + const actions = el('div', 'anomalous-tb-detail-actions'); + const take = el('button', `anomalous-tb-take${ctx.picked(item) ? ' is-on' : ''}`); + take.type = 'button'; + take.append(icon(ctx.picked(item) ? 'check' : 'plus', 15), el('span', '', t(ctx.picked(item) ? 'aiTbPicked' : 'aiTbPick'))); + take.onclick = () => ctx.toggle(item); + const star = el('button', `anomalous-tb-like${ctx.favorite(item) ? ' is-on' : ''}`); + star.type = 'button'; + star.append(icon(ctx.favorite(item) ? 'starOn' : 'star', 15), el('span', '', t(ctx.favorite(item) ? 'aiToolboxUnfavorite' : 'aiToolboxFavorite'))); + star.onclick = () => void ctx.star(item); + actions.append(take, star); + const inner = item.kind === 'combos' ? `combo:${item.id}` : item.kind === 'workflows' ? `workflow:${item.id}` : null; + if (inner && (item.prompt || item.negative)) { + const on = ctx.promptPicked(inner); + const prompt = el('button', `anomalous-tb-like${on ? ' is-on' : ''}`); + prompt.type = 'button'; + prompt.append(icon('prompts', 15), el('span', '', t(on ? 'aiTbPromptPicked' : 'aiTbPickPrompt'))); + prompt.onclick = () => ctx.pickPrompt(inner); + actions.append(prompt); + } + box.append(actions); + if (item.kind === 'nodes') { + if (item.description) box.append(textBlock(item.description)); + if (item.category) box.append(section(t('aiTbCategory'), [textBlock(item.category.replaceAll('/', ' › '), 'is-soft')])); + if (item.page) box.append(textBlock(t('aiTbPageExplain'), 'is-soft')); + if (item.paid) box.append(textBlock(t('aiTbPaidExplain'), 'is-warn')); + if (item.inputs.length) box.append(section(t('aiTbInputs'), [socketList(item.inputs, 'in')])); + if (item.settings.length) box.append(section(t('aiTbSettingsTitle'), [settingList(item.settings)])); + if (item.outputs.length) box.append(section(t('aiTbOutputs'), [socketList(item.outputs, 'out')])); + } else if (item.kind === 'combos') { + if (item.model) box.append(section(t('aiTbMainModel'), [textBlock(item.model)])); + if (item.loras?.length) box.append(section(t('aiTbLoraList'), [textBlock(item.loras.join('\n'), 'is-list')])); + if (item.prompt) box.append(section(t('aiTbPositive'), [textBlock(item.prompt, 'is-prompt')])); + } else if (item.kind === 'workflows') { + if (item.notes) box.append(textBlock(item.notes)); + const facts = [item.base, item.nodes ? t('aiTbNodeCount', { count: item.nodes }) : ''].filter(Boolean).join(' · '); + if (facts) box.append(textBlock(facts, 'is-soft')); + if (item.prompt) box.append(section(t('aiTbPositive'), [textBlock(item.prompt, 'is-prompt')])); + if (item.negative) box.append(section(t('aiTbNegative'), [textBlock(item.negative, 'is-prompt is-negative')])); + } else if (item.kind === 'prompts') { + if (item.positive) box.append(section(t('aiTbPositive'), [textBlock(item.positive, 'is-prompt')])); + if (item.negative) box.append(section(t('aiTbNegative'), [textBlock(item.negative, 'is-prompt is-negative')])); + } else if (item.kind === 'models') { + box.append(textBlock([item.folder, item.file].filter(Boolean).join('/'), 'is-soft')); + if (item.words?.length) box.append(section(t('aiTbWords'), [tagsOf(item.words)])); + } + return box; +} + +function tagsOf(words) { + const row = el('div', 'anomalous-tb-tags'); + for (const word of words) row.append(el('span', 'anomalous-tb-tag', word)); + return row; +} diff --git a/web/modules/ui_ai_toolbox_icons.js b/web/modules/ui_ai_toolbox_icons.js new file mode 100644 index 0000000..f7cfa9c --- /dev/null +++ b/web/modules/ui_ai_toolbox_icons.js @@ -0,0 +1,95 @@ +/** + * The materials picker's pictures (ui_ai_toolbox.js), all vector and in the theme's colours: + * line icons for the kinds and actions, a badge for each node source (a custom node pack gets + * its initials on a colour of its own, the same every time), and the picture of an empty + * selection. Static SVG text only; nothing from a user or a file goes into it. + */ + +const PATHS = { + nodes: '', + combos: '', + workflows: '', + prompts: '', + models: '', + search: '', + star: '', + starOn: '', + check: '', + plus: '', + close: '', + sparkle: '', + target: '', + lock: '', + page: '', + coin: '', + gear: '', + trash: '', + list: '', + image: '', +}; + +/** A line icon by name, `size` px, in the text colour. */ +export function icon(name, size = 18, className = '') { + const holder = document.createElement('span'); + holder.className = `anomalous-tb-icon${className ? ` ${className}` : ''}`; + holder.setAttribute('aria-hidden', 'true'); + holder.innerHTML = `${PATHS[name] || ''}`; + return holder; +} + +function hue(text) { + let hash = 0; + for (const char of String(text)) hash = (hash * 31 + char.codePointAt(0)) >>> 0; + return hash % 360; +} + +/** Up to two letters for a pack: its words without "ComfyUI" and "comfy" ("ComfyUI-Easy-Use" → "EU"). */ +export function initials(label) { + const words = String(label || '').replace(/comfy[_-]?ui|comfy/gi, ' ').split(/[\s_\-.]+/).filter(Boolean); + const letters = (words.length > 1 ? words.slice(0, 2).map(word => word[0]) : [...(words[0] || '?').slice(0, 2)]).join(''); + return letters.toUpperCase(); +} + +/** The badge of a node source: ComfyUI's own, the paid API nodes, or a pack's initials on its colour. */ +export function sourceBadge(source, size = 26) { + const badge = document.createElement('span'); + badge.className = `anomalous-tb-badge is-${source.kind}`; + badge.style.setProperty('--tb-size', `${size}px`); + if (source.kind === 'builtin') { + badge.innerHTML = ``; + } else if (source.kind === 'api') { + badge.append(icon('coin', Math.round(size * 0.62))); + } else { + badge.style.setProperty('--tb-hue', String(hue(source.key))); + badge.textContent = initials(source.label || source.key); + } + badge.setAttribute('aria-hidden', 'true'); + return badge; +} + +/** The picture of an empty selection: a toolbox with node cards rising out of it. */ +export function emptyPicture() { + const holder = document.createElement('div'); + holder.className = 'anomalous-tb-empty-art'; + holder.setAttribute('aria-hidden', 'true'); + holder.innerHTML = ` + + + + + + +`; + return holder; +} + +/** The colour ComfyUI draws a socket type with: the open canvas's own when it says, else ComfyUI's defaults. */ +const SOCKET_COLORS = { + MODEL: '#B39DDB', CLIP: '#FFD500', VAE: '#FF6E6E', CONDITIONING: '#FFA931', LATENT: '#FF9CF9', IMAGE: '#64B5F6', + MASK: '#81C784', CONTROL_NET: '#00D78D', CLIP_VISION: '#A8DADC', CLIP_VISION_OUTPUT: '#ad7452', STYLE_MODEL: '#C2FFAE', + UPSCALE_MODEL: '#87CEFA', NOISE: '#B0B0B0', SAMPLER: '#ECB4B4', SIGMAS: '#CDFFCD', GUIDER: '#66FFFF', AUDIO: '#7FD3D4', VIDEO: '#C8A2C8', +}; +export function socketColor(type, canvas = globalThis.app?.canvas) { + const own = canvas?.default_connection_color_byType?.[type]; + return own || SOCKET_COLORS[type] || '#9AA4B2'; +} diff --git a/web/styles.css b/web/styles.css index bfa91e3..967896e 100644 --- a/web/styles.css +++ b/web/styles.css @@ -41,4 +41,4 @@ @import url("./styles/40-ai-side.css?v=20261023-wait"); @import url("./styles/41-ai-results.css?v=20261023-tags"); @import url("./styles/42-ai-flow.css?v=20261023-percent"); -@import url("./styles/43-ai-toolbox.css?v=20261023-toolbox"); +@import url("./styles/43-ai-toolbox.css?v=20261023-toolbox3"); diff --git a/web/styles/43-ai-toolbox.css b/web/styles/43-ai-toolbox.css index 6d840c8..e2033e1 100644 --- a/web/styles/43-ai-toolbox.css +++ b/web/styles/43-ai-toolbox.css @@ -1,120 +1,404 @@ -/* "给 AI 的素材" (ui_ai_toolbox.js): the window that picks node types, combos, workflows, prompts - and models for an AI task, and the timeline row of a pick. */ -.anomalous-ai-connect-dialog.is-wide { width: min(860px, 100%); } -.anomalous-ai-toolbox { display: flex; flex-direction: column; gap: 10px; min-height: 0; } -.anomalous-ai-toolbox-intro { margin: 0; color: var(--amb-text-soft); font-size: 12.5px; line-height: 1.6; } -.anomalous-ai-toolbox-search { width: 100%; box-sizing: border-box; } -.anomalous-ai-toolbox-main { - display: grid; - grid-template-columns: 120px minmax(0, 1fr); - gap: 12px; - min-height: 320px; +/* "给 AI 的素材" (ui_ai_toolbox.js, ui_ai_toolbox_cards.js, ui_ai_toolbox_icons.js): the large + picker window (search, kinds, sources, card grid, detail pane, tray), the entry in a task's + header and the timeline row of a pick. */ +.anomalous-ai-connect-dialog.is-toolbox { + width: min(1340px, 96vw); + height: min(880px, 90vh); + max-height: none; + overflow: hidden; } -.anomalous-ai-toolbox-kinds { display: flex; flex-direction: column; gap: 2px; } -.anomalous-ai-toolbox-kind { +.anomalous-tb { display: flex; flex: 1; flex-direction: column; gap: 12px; min-height: 0; } +.anomalous-tb-icon { display: inline-flex; flex: none; align-items: center; justify-content: center; } +.anomalous-tb-count { + min-width: 18px; + padding: 1px 6px; + border-radius: 999px; + background: var(--amb-link); + color: var(--amb-btn-primary-text, #fff); + font-size: 11px; + font-weight: 700; + line-height: 16px; + text-align: center; + font-variant-numeric: tabular-nums; +} + +/* Top: who it is for, the search, the filters. */ +.anomalous-tb-top { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px; } +.anomalous-tb-for { flex-basis: 100%; margin-top: -6px; color: var(--amb-text-dim); font-size: 12px; } +.anomalous-tb-search { display: flex; + flex: 1 1 360px; align-items: center; - justify-content: space-between; - padding: 7px 10px; + gap: 10px; + height: 42px; + padding: 0 12px; + border: 1px solid var(--amb-border); + border-radius: 12px; + background: rgba(var(--amb-ink-rgb), 0.04); + color: var(--amb-text-dim); + transition: border-color 0.15s, box-shadow 0.15s; +} +.anomalous-tb-search:focus-within { border-color: var(--amb-link); box-shadow: 0 0 0 3px color-mix(in srgb, var(--amb-link) 22%, transparent); color: var(--amb-text-soft); } +.anomalous-tb-search input { flex: 1; min-width: 0; height: 100%; padding: 0; border: 0; outline: 0; background: transparent; color: var(--amb-text-main); font: inherit; font-size: 14px; } +.anomalous-tb-kbd { padding: 1px 7px; border: 1px solid var(--amb-border); border-radius: 6px; color: var(--amb-text-dim); font: 11px/16px ui-monospace, monospace; } +.anomalous-tb-filters { display: inline-flex; gap: 4px; padding: 3px; border: 1px solid var(--amb-border); border-radius: 12px; } +.anomalous-tb-filter { + display: inline-flex; + align-items: center; + gap: 6px; + height: 32px; + padding: 0 12px; border: 0; - border-radius: 8px; + border-radius: 9px; background: transparent; color: var(--amb-text-soft); font: inherit; font-size: 13px; + cursor: pointer; +} +.anomalous-tb-filter:hover { background: rgba(var(--amb-ink-rgb), 0.06); color: var(--amb-text-main); } +.anomalous-tb-filter.is-on { background: color-mix(in srgb, var(--amb-link) 20%, transparent); color: var(--amb-text-main); } + +/* The four columns. */ +.anomalous-tb-main { + display: grid; + flex: 1; + grid-template-columns: 158px 214px minmax(0, 1fr) 320px; + min-height: 0; + border: 1px solid var(--amb-border); + border-radius: 14px; + overflow: hidden; +} +.anomalous-tb-main:has(> .anomalous-tb-facets[hidden]) { grid-template-columns: 158px minmax(0, 1fr) 320px; } +.anomalous-tb-rail, .anomalous-tb-facets { display: flex; flex-direction: column; gap: 2px; padding: 10px 8px; overflow-y: auto; border-right: 1px solid var(--amb-border); } +.anomalous-tb-rail { background: rgba(var(--amb-ink-rgb), 0.025); } +.anomalous-tb-facets[hidden] { display: none; } +.anomalous-tb-kind, .anomalous-tb-facet { + position: relative; + display: flex; + align-items: center; + gap: 10px; + min-height: 40px; + padding: 0 10px; + border: 0; + border-radius: 10px; + background: transparent; + color: var(--amb-text-soft); + font: inherit; + font-size: 13.5px; text-align: left; cursor: pointer; } -.anomalous-ai-toolbox-kind:hover { color: var(--amb-text-main); background: rgba(var(--amb-ink-rgb), 0.05); } -.anomalous-ai-toolbox-kind.is-on { background: color-mix(in srgb, var(--amb-link) 18%, transparent); color: var(--amb-text-main); } -.anomalous-ai-toolbox-kind-count { - min-width: 18px; - padding: 0 5px; - border-radius: 999px; - background: color-mix(in srgb, var(--amb-link) 30%, transparent); - color: var(--amb-text-main); - font-size: 11px; - text-align: center; -} -.anomalous-ai-toolbox-list { display: flex; flex-direction: column; gap: 6px; max-height: 52vh; overflow-y: auto; padding-right: 4px; } -.anomalous-ai-toolbox-group { border: 1px solid var(--amb-border); border-radius: 8px; } -.anomalous-ai-toolbox-group.is-api { border-style: dashed; } -.anomalous-ai-toolbox-group-head { - display: flex; +.anomalous-tb-facet { min-height: 34px; gap: 8px; font-size: 12.5px; } +.anomalous-tb-facet > span:nth-child(2) { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.anomalous-tb-kind:hover, .anomalous-tb-facet:hover { background: rgba(var(--amb-ink-rgb), 0.06); color: var(--amb-text-main); } +.anomalous-tb-kind.is-on, .anomalous-tb-facet.is-on { background: color-mix(in srgb, var(--amb-link) 18%, transparent); color: var(--amb-text-main); } +.anomalous-tb-kind.is-on::before { position: absolute; top: 9px; bottom: 9px; left: -8px; width: 3px; border-radius: 0 3px 3px 0; background: var(--amb-link); content: ""; } +.anomalous-tb-kind-meta { display: inline-flex; gap: 5px; margin-left: auto; } +.anomalous-tb-kind-matches, .anomalous-tb-facet-count { color: var(--amb-text-dim); font-size: 11.5px; font-variant-numeric: tabular-nums; } +.anomalous-tb-facets-title { padding: 4px 10px 6px; color: var(--amb-text-dim); font-size: 11px; font-weight: 600; letter-spacing: 0.06em; } + +/* Source badges: ComfyUI's own, paid API nodes, a pack's initials on its own colour. */ +.anomalous-tb-badge { + display: inline-flex; + flex: none; align-items: center; - gap: 8px; - padding: 7px 10px; - cursor: pointer; - user-select: none; + justify-content: center; + width: var(--tb-size, 26px); + height: var(--tb-size, 26px); + border-radius: calc(var(--tb-size, 26px) * 0.3); + font-size: calc(var(--tb-size, 26px) * 0.4); + font-weight: 800; + letter-spacing: -0.02em; + line-height: 1; } -.anomalous-ai-toolbox-group-name { color: var(--amb-text-main); font-size: 13px; font-weight: 600; } -.anomalous-ai-toolbox-group[open] > .anomalous-ai-toolbox-group-head { border-bottom: 1px solid var(--amb-border); } -.anomalous-ai-toolbox-row { +.anomalous-tb-badge.is-pack { background: hsl(var(--tb-hue, 210) 58% 46%); color: #fff; } +.anomalous-tb-badge.is-builtin { background: color-mix(in srgb, var(--amb-link) 26%, transparent); color: var(--amb-link); } +.anomalous-tb-badge.is-api { background: color-mix(in srgb, var(--amb-warn) 26%, transparent); color: var(--amb-warn); } + +/* The grid. */ +.anomalous-tb-grid-wrap { display: flex; flex-direction: column; min-width: 0; min-height: 0; } +.anomalous-tb-grid-head { display: flex; flex: none; align-items: baseline; gap: 10px; padding: 14px 16px 10px; border-bottom: 1px solid var(--amb-border); } +.anomalous-tb-scroller { flex: 1; min-height: 0; padding: 14px 16px 16px; overflow-y: auto; } +.anomalous-tb-grid-title { color: var(--amb-text-main); font-size: 15px; font-weight: 700; } +.anomalous-tb-grid-count { color: var(--amb-text-dim); font-size: 12px; } +.anomalous-tb-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(196px, 1fr)); gap: 12px; } +.anomalous-tb-sentinel { height: 1px; } +.anomalous-tb-note { grid-column: 1 / -1; padding: 40px 10px; color: var(--amb-text-dim); font-size: 13px; text-align: center; } +.anomalous-tb-note.is-error { color: var(--amb-warn-text, var(--amb-warn)); } + +.anomalous-tb-card { + position: relative; display: flex; - align-items: center; - gap: 8px; - padding: 4px 10px 4px 14px; - font-size: 12.5px; + flex-direction: column; + min-width: 0; + border: 1px solid var(--amb-border); + border-radius: 14px; + background: rgba(var(--amb-ink-rgb), 0.03); cursor: pointer; + outline: 0; + overflow: hidden; + transition: border-color 0.15s, transform 0.15s, background 0.15s, box-shadow 0.15s; } -.anomalous-ai-toolbox-row:hover { background: rgba(var(--amb-ink-rgb), 0.04); } -.anomalous-ai-toolbox-name { min-width: 0; overflow: hidden; color: var(--amb-text-main); text-overflow: ellipsis; white-space: nowrap; } -.anomalous-ai-toolbox-detail { min-width: 0; overflow: hidden; color: var(--amb-text-dim); font-size: 11.5px; text-overflow: ellipsis; white-space: nowrap; } -.anomalous-ai-toolbox-star { - margin-left: auto; - padding: 0 4px; +.anomalous-tb-card:hover { border-color: var(--amb-border-strong, var(--amb-border)); transform: translateY(-2px); background: rgba(var(--amb-ink-rgb), 0.05); } +.anomalous-tb-card:focus-visible { box-shadow: 0 0 0 3px color-mix(in srgb, var(--amb-link) 45%, transparent); } +.anomalous-tb-card.is-picked { + border-color: var(--amb-link); + background: color-mix(in srgb, var(--amb-link) 11%, transparent); + box-shadow: 0 0 0 1px var(--amb-link); +} +.anomalous-tb-card.is-skeleton { min-height: 200px; cursor: default; animation: anomalous-tb-pulse 1.3s ease-in-out infinite; } +@keyframes anomalous-tb-pulse { 50% { opacity: 0.45; } } +.anomalous-tb-check { + position: absolute; + top: 9px; + right: 9px; + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border: 1.5px solid rgba(255, 255, 255, 0.75); + border-radius: 50%; + background: rgba(0, 0, 0, 0.32); + color: transparent; + transform: scale(0.85); + opacity: 0; + transition: opacity 0.15s, transform 0.15s, background 0.15s; +} +.anomalous-tb-card:hover .anomalous-tb-check { opacity: 1; } +.anomalous-tb-card.is-picked .anomalous-tb-check { border-color: var(--amb-link); background: var(--amb-link); color: #fff; opacity: 1; transform: scale(1); } +.anomalous-tb-star { + position: absolute; + right: 8px; + bottom: 8px; + display: inline-flex; + padding: 4px; border: 0; + border-radius: 8px; background: transparent; color: var(--amb-text-dim); - font-size: 15px; cursor: pointer; + opacity: 0; + transition: opacity 0.15s, color 0.15s; } -.anomalous-ai-toolbox-star.is-on { color: var(--amb-warn); } -.anomalous-ai-toolbox-note { padding: 8px 10px; color: var(--amb-text-dim); font-size: 12px; } -.anomalous-ai-toolbox-note.is-error { color: var(--amb-warn-text, var(--amb-warn)); } -.anomalous-ai-toolbox-tray { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px; - padding-top: 10px; - border-top: 1px solid var(--amb-border); -} -.anomalous-ai-toolbox-chips { display: flex; flex: 1 1 300px; flex-wrap: wrap; gap: 6px; min-width: 0; } -.anomalous-ai-toolbox-chip { - max-width: 220px; +.anomalous-tb-card:hover .anomalous-tb-star, .anomalous-tb-star.is-on, .anomalous-tb-card:focus-within .anomalous-tb-star { opacity: 1; } +.anomalous-tb-star:hover { background: rgba(var(--amb-ink-rgb), 0.08); color: var(--amb-text-main); } +.anomalous-tb-star.is-on { color: #f2b544; } +.anomalous-tb-card-body { display: flex; flex-direction: column; gap: 4px; padding: 10px 12px 12px; padding-right: 34px; } +.anomalous-tb-card-title { overflow: hidden; color: var(--amb-text-main); font-size: 13.5px; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; } +.anomalous-tb-card-sub { overflow: hidden; color: var(--amb-text-dim); font-size: 11.5px; text-overflow: ellipsis; white-space: nowrap; } +.anomalous-tb-tags { display: flex; flex-wrap: wrap; gap: 4px; } +.anomalous-tb-tag { + max-width: 100%; overflow: hidden; - padding: 3px 9px; - border: 0; + padding: 1px 7px; border-radius: 6px; - background: color-mix(in srgb, var(--ai-chip, var(--amb-link)) 18%, transparent); - color: var(--amb-text-main); - font: inherit; - font-size: 12px; + background: rgba(var(--amb-ink-rgb), 0.07); + color: var(--amb-text-soft); + font-size: 11px; + line-height: 17px; text-overflow: ellipsis; white-space: nowrap; } -button.anomalous-ai-toolbox-chip { cursor: pointer; } -.anomalous-ai-toolbox-chip.is-combos { --ai-chip: #2bb38a; } -.anomalous-ai-toolbox-chip.is-workflows { --ai-chip: #e08a3c; } -.anomalous-ai-toolbox-chip.is-prompts { --ai-chip: #d65c8f; } -.anomalous-ai-toolbox-chip.is-models { --ai-chip: #8a7be0; } -.anomalous-ai-toolbox-actions { display: flex; align-items: center; gap: 8px; margin-left: auto; } -.anomalous-ai-toolbox-mode { height: 30px; font-size: 12.5px; } -.anomalous-ai-toolbox-send { +.anomalous-tb-tag.is-warn { background: color-mix(in srgb, var(--amb-warn) 22%, transparent); color: var(--amb-warn); } +.anomalous-tb-tag.is-soft { color: var(--amb-text-dim); } +.anomalous-tb-tag.is-combo { background: color-mix(in srgb, #2bb38a 22%, transparent); } +.anomalous-tb-tag.is-workflow { background: color-mix(in srgb, #e08a3c 22%, transparent); } +.anomalous-tb-tag.is-saved { background: color-mix(in srgb, #d65c8f 22%, transparent); } + +/* A node in miniature. */ +.anomalous-tb-node { + margin: 10px 10px 0; + border: 1px solid rgba(var(--amb-ink-rgb), 0.14); + border-radius: 10px; + background: rgba(var(--amb-ink-rgb), 0.06); + overflow: hidden; +} +.anomalous-tb-node-head { display: flex; align-items: center; gap: 7px; padding: 6px 8px; background: rgba(var(--amb-ink-rgb), 0.08); } +.anomalous-tb-node-title { overflow: hidden; color: var(--amb-text-main); font-size: 12.5px; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; } +.anomalous-tb-node-body { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; min-height: 30px; padding: 7px 8px; } +.anomalous-tb-node-col { display: flex; flex-direction: column; gap: 4px; min-width: 0; } +.anomalous-tb-node-col.is-out { align-items: flex-end; } +.anomalous-tb-socket { display: flex; align-items: center; gap: 5px; min-width: 0; max-width: 100%; color: var(--amb-text-soft); font-size: 11px; } +.anomalous-tb-socket.is-out { flex-direction: row-reverse; } +.anomalous-tb-socket-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.anomalous-tb-dot { flex: none; width: 8px; height: 8px; border-radius: 50%; background: var(--tb-dot); box-shadow: 0 0 0 2px color-mix(in srgb, var(--tb-dot) 25%, transparent); } +.anomalous-tb-more { color: var(--amb-text-dim); font-size: 10.5px; } +.anomalous-tb-node-foot { display: flex; align-items: center; gap: 5px; padding: 5px 8px; border-top: 1px dashed rgba(var(--amb-ink-rgb), 0.14); color: var(--amb-text-dim); font-size: 11px; } + +/* Covers and prompt quotes. */ +.anomalous-tb-cover { display: flex; align-items: center; justify-content: center; aspect-ratio: 4 / 3; overflow: hidden; background: rgba(var(--amb-ink-rgb), 0.06); } +.anomalous-tb-cover img, .anomalous-tb-detail-cover img { width: 100%; height: 100%; object-fit: cover; } +.anomalous-tb-card:hover .anomalous-tb-cover img { transform: scale(1.04); transition: transform 0.35s; } +.anomalous-tb-cover-icon { color: var(--amb-text-dim); opacity: 0.7; } +.anomalous-tb-card.is-combos .anomalous-tb-cover { background: color-mix(in srgb, #2bb38a 14%, transparent); } +.anomalous-tb-card.is-workflows .anomalous-tb-cover { background: color-mix(in srgb, #e08a3c 14%, transparent); } +.anomalous-tb-card.is-models .anomalous-tb-cover { background: color-mix(in srgb, #8a7be0 14%, transparent); } +.anomalous-tb-quote { position: relative; margin: 10px 10px 0; padding: 10px 12px; border-left: 3px solid #d65c8f; border-radius: 4px 10px 10px 4px; background: rgba(var(--amb-ink-rgb), 0.05); } +.anomalous-tb-quote-text { + display: -webkit-box; + margin: 0; + overflow: hidden; + color: var(--amb-text-soft); + font-size: 12px; + line-height: 1.55; + word-break: break-word; + -webkit-box-orient: vertical; + -webkit-line-clamp: 6; +} + +/* The detail pane. */ +.anomalous-tb-pane { min-width: 0; padding: 16px; overflow-y: auto; border-left: 1px solid var(--amb-border); background: rgba(var(--amb-ink-rgb), 0.025); } +.anomalous-tb-pane-empty { display: flex; flex-direction: column; align-items: center; gap: 10px; padding-top: 30px; text-align: center; } +.anomalous-tb-pane-title { color: var(--amb-text-main); font-size: 15px; font-weight: 700; } +.anomalous-tb-empty-art svg { color: var(--amb-link); } +.tb-art-box { stroke: var(--amb-text-soft); stroke-width: 2; fill: rgba(var(--amb-ink-rgb), 0.06); } +.tb-art-card { stroke: var(--amb-link); stroke-width: 2; fill: color-mix(in srgb, var(--amb-link) 14%, transparent); } +.tb-art-b { stroke: #e08a3c; fill: color-mix(in srgb, #e08a3c 14%, transparent); } +.tb-art-link { stroke: var(--amb-text-dim); stroke-width: 2; stroke-dasharray: 3 4; } +.tb-art-spark { fill: #f2b544; } +.tb-art-soft { fill: var(--amb-text-dim); opacity: 0.5; } +.anomalous-tb-detail { display: flex; flex-direction: column; gap: 12px; } +.anomalous-tb-detail-cover { aspect-ratio: 16 / 10; overflow: hidden; border-radius: 12px; background: rgba(var(--amb-ink-rgb), 0.06); display: flex; align-items: center; justify-content: center; } +.anomalous-tb-detail-head { display: flex; align-items: center; gap: 12px; } +.anomalous-tb-detail-words { min-width: 0; } +.anomalous-tb-detail-title { color: var(--amb-text-main); font-size: 16px; font-weight: 700; line-height: 1.35; word-break: break-word; } +.anomalous-tb-detail-sub { color: var(--amb-text-dim); font-size: 11.5px; word-break: break-all; } +.anomalous-tb-detail-actions { display: flex; flex-wrap: wrap; gap: 6px; } +.anomalous-tb-take, .anomalous-tb-like { + display: inline-flex; + align-items: center; + gap: 6px; + height: 32px; + padding: 0 12px; + border: 1px solid var(--amb-border); + border-radius: 9px; + background: transparent; + color: var(--amb-text-soft); + font: inherit; + font-size: 12.5px; + cursor: pointer; +} +.anomalous-tb-take { border-color: transparent; background: var(--amb-link); color: #fff; } +.anomalous-tb-take.is-on { border-color: var(--amb-link); background: transparent; color: var(--amb-link); } +.anomalous-tb-like:hover { background: rgba(var(--amb-ink-rgb), 0.06); color: var(--amb-text-main); } +.anomalous-tb-like.is-on { color: var(--amb-text-main); } +.anomalous-tb-like.is-on .anomalous-tb-icon { color: #f2b544; } +.anomalous-tb-section { display: flex; flex-direction: column; gap: 6px; } +.anomalous-tb-section-title { margin: 0; color: var(--amb-text-dim); font-size: 11px; font-weight: 700; letter-spacing: 0.06em; } +.anomalous-tb-text { margin: 0; color: var(--amb-text-soft); font-size: 12.5px; line-height: 1.6; white-space: pre-wrap; word-break: break-word; } +.anomalous-tb-text.is-soft { color: var(--amb-text-dim); font-size: 12px; } +.anomalous-tb-text.is-warn { color: var(--amb-warn); } +.anomalous-tb-text.is-prompt { max-height: 200px; padding: 8px 10px; overflow-y: auto; border-radius: 8px; background: rgba(var(--amb-ink-rgb), 0.05); color: var(--amb-text-main); } +.anomalous-tb-text.is-negative { color: var(--amb-text-soft); } +.anomalous-tb-socket-list, .anomalous-tb-setting-list { display: flex; flex-direction: column; gap: 5px; } +.anomalous-tb-socket-list .anomalous-tb-socket { font-size: 12px; } +.anomalous-tb-socket-list .anomalous-tb-socket.is-out { flex-direction: row; } +.anomalous-tb-socket-type { margin-left: auto; color: var(--amb-text-dim); font: 10.5px ui-monospace, monospace; } +.anomalous-tb-setting { display: flex; justify-content: space-between; gap: 10px; padding: 3px 0; border-bottom: 1px dashed rgba(var(--amb-ink-rgb), 0.1); font-size: 12px; } +.anomalous-tb-setting-name { color: var(--amb-text-soft); } +.anomalous-tb-setting-value { overflow: hidden; color: var(--amb-text-dim); text-overflow: ellipsis; white-space: nowrap; } + +/* The tray: picks, how strictly, hand over. */ +.anomalous-tb-tray { display: flex; align-items: center; gap: 12px; } +.anomalous-tb-chips { display: flex; flex: 1; gap: 6px; min-width: 0; padding: 2px 0; overflow-x: auto; scrollbar-width: thin; } +.anomalous-tb-chips.is-static { flex-wrap: wrap; overflow: visible; } +.anomalous-tb-chip { + display: inline-flex; + flex: none; + align-items: center; + gap: 6px; + max-width: 230px; height: 30px; - padding: 0 16px; + padding: 0 4px 0 6px; + border: 1px solid var(--amb-border); + border-radius: 9px; + background: rgba(var(--amb-ink-rgb), 0.05); + color: var(--amb-text-main); + font-size: 12px; +} +.anomalous-tb-chips.is-static .anomalous-tb-chip { padding-right: 9px; } +.anomalous-tb-chip img { width: 20px; height: 20px; border-radius: 5px; object-fit: cover; } +.anomalous-tb-chip.is-combos { border-color: color-mix(in srgb, #2bb38a 45%, transparent); } +.anomalous-tb-chip.is-workflows { border-color: color-mix(in srgb, #e08a3c 45%, transparent); } +.anomalous-tb-chip.is-prompts { border-color: color-mix(in srgb, #d65c8f 45%, transparent); } +.anomalous-tb-chip.is-models { border-color: color-mix(in srgb, #8a7be0 45%, transparent); } +.anomalous-tb-chip-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.anomalous-tb-chip-x { display: inline-flex; padding: 3px; border: 0; border-radius: 6px; background: transparent; color: var(--amb-text-dim); cursor: pointer; } +.anomalous-tb-chip-x:hover { background: rgba(var(--amb-ink-rgb), 0.1); color: var(--amb-text-main); } +.anomalous-tb-tray-empty { color: var(--amb-text-dim); font-size: 12.5px; line-height: 30px; } +.anomalous-tb-clear { display: inline-flex; padding: 7px; border: 0; border-radius: 9px; background: transparent; color: var(--amb-text-dim); cursor: pointer; } +.anomalous-tb-clear:hover { background: rgba(var(--amb-ink-rgb), 0.08); color: var(--amb-text-main); } +.anomalous-tb-modes { display: inline-flex; flex: none; padding: 3px; border: 1px solid var(--amb-border); border-radius: 12px; } +.anomalous-tb-mode { + display: inline-flex; + align-items: center; + gap: 6px; + height: 34px; + padding: 0 12px; border: 0; - border-radius: 8px; - background: var(--amb-btn-primary-bg); - color: var(--amb-btn-primary-text); + border-radius: 9px; + background: transparent; + color: var(--amb-text-soft); font: inherit; font-size: 13px; cursor: pointer; } -.anomalous-ai-toolbox-step { display: flex; flex-direction: column; gap: 6px; margin-top: 4px; } -.anomalous-ai-toolbox-step .anomalous-ai-toolbox-chips { flex: none; align-items: flex-start; } -@media (max-width: 640px) { - .anomalous-ai-toolbox-main { grid-template-columns: minmax(0, 1fr); } - .anomalous-ai-toolbox-kinds { flex-direction: row; flex-wrap: wrap; } +.anomalous-tb-mode.is-on { background: color-mix(in srgb, var(--amb-link) 20%, transparent); color: var(--amb-text-main); } +.anomalous-tb-mode.is-on:last-child { background: color-mix(in srgb, var(--amb-warn) 22%, transparent); } +.anomalous-tb-send { + display: inline-flex; + flex: none; + align-items: center; + gap: 8px; + height: 42px; + padding: 0 20px; + border: 0; + border-radius: 12px; + background: var(--amb-btn-primary-bg, var(--amb-link)); + color: var(--amb-btn-primary-text, #fff); + font: inherit; + font-size: 14px; + font-weight: 700; + cursor: pointer; + transition: transform 0.12s, filter 0.12s; +} +.anomalous-tb-send:hover { filter: brightness(1.08); transform: translateY(-1px); } +.anomalous-tb-send:disabled { opacity: 0.6; cursor: progress; } + +/* The entry in a task's header, and the timeline row. */ +.anomalous-tb-open { + display: inline-flex; + align-items: center; + gap: 5px; + height: 26px; + padding: 0 10px; + border: 1px solid color-mix(in srgb, var(--amb-link) 45%, transparent); + border-radius: 999px; + background: color-mix(in srgb, var(--amb-link) 10%, transparent); + color: var(--amb-link); + font: inherit; + font-size: 12.5px; + cursor: pointer; +} +.anomalous-tb-open:hover { background: color-mix(in srgb, var(--amb-link) 20%, transparent); } +.anomalous-tb-step { display: flex; flex-direction: column; gap: 7px; margin-top: 4px; } +.anomalous-tb-step-mode { display: inline-flex; align-items: center; gap: 6px; color: var(--amb-text-soft); font-size: 12px; } +.anomalous-tb-step-mode.is-only { color: var(--amb-warn); } + +@media (max-width: 1100px) { + .anomalous-tb-main { grid-template-columns: 140px 190px minmax(0, 1fr); } + .anomalous-tb-main:has(> .anomalous-tb-facets[hidden]) { grid-template-columns: 140px minmax(0, 1fr); } + .anomalous-tb-pane { display: none; } +} +@media (max-width: 720px) { + .anomalous-tb-main, .anomalous-tb-main:has(> .anomalous-tb-facets[hidden]) { grid-template-columns: minmax(0, 1fr); grid-template-rows: auto auto minmax(0, 1fr); } + .anomalous-tb-rail, .anomalous-tb-facets { flex-direction: row; overflow-x: auto; border-right: 0; border-bottom: 1px solid var(--amb-border); } + .anomalous-tb-kind.is-on::before { display: none; } + .anomalous-tb-tray { flex-wrap: wrap; } +} +@media (prefers-reduced-motion: reduce) { + .anomalous-tb-card, .anomalous-tb-card:hover .anomalous-tb-cover img, .anomalous-tb-send { transition: none; transform: none; } + .anomalous-tb-card.is-skeleton { animation: none; } }