Materials window, redone: cards, sources, details, and no waiting

A large window: one search over every kind (/ jumps to it), kinds on the
left with picks and matches, a column by source (each custom node pack
with a badge of its own colour, ComfyUI's own, paid API nodes), prompt
origin or model type, a card grid drawn as it scrolls, the card in focus
in full on the right, and the picks below with thumbnails and a prefer /
only switch. A node card is the node in miniature, sockets in ComfyUI's
colours; combos, workflows and models show their covers; prompts their
text, including the prompts inside combos and workflows (combo:<name>,
workflow:<name>), which get_toolbox now resolves. Vector icons, badges
and the empty picture follow the theme.

It opens at once: node types come from the page's own definitions, the
rest in one answer (api/ai_toolbox_catalog.py) read ahead when a task
header is drawn and kept a minute, refreshed behind an open window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DemonGatanjieu
2026-10-22 19:46:31 +08:00
co-authored by Claude Opus 5.5
parent 622ae8be9a
commit fd9e415123
17 changed files with 1556 additions and 344 deletions
+12 -5
View File
@@ -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:<name>` / `workflow:<name>`, 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
+2
View File
@@ -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.
+2 -4
View File
@@ -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
+9 -28
View File
@@ -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:<name> / workflow:<name> 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()
+86
View File
@@ -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:<name>" and "workflow:<name>"), 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"]
+8 -3
View File
@@ -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
+32 -25
View File
@@ -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)]}
+1 -1
View File
@@ -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` 也带着一句当前进度,跑完后告诉它一次结果,所以它不用自己去翻输出文件夹或者看屏幕。
+60
View File
@@ -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');
+20 -5
View File
@@ -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:<name>", 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__":
+163
View File
@@ -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(() => {});
}
+102 -28
View File
@@ -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",
+318 -157
View File
@@ -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;
+274
View File
@@ -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;
}
+95
View File
@@ -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: '<rect x="2.5" y="4" width="8" height="6.5" rx="1.6"/><rect x="13.5" y="13.5" width="8" height="6.5" rx="1.6"/><path d="M10.5 7.2h2.2a2.3 2.3 0 0 1 2.3 2.3v4"/><circle cx="10.5" cy="7.2" r=".9" fill="currentColor"/><circle cx="15" cy="13.5" r=".9" fill="currentColor"/>',
combos: '<path d="M12 3.2 21 8l-9 4.8L3 8z"/><path d="m3 12 9 4.8 9-4.8"/><path d="m3 16 9 4.8 9-4.8"/>',
workflows: '<circle cx="5.5" cy="6" r="2.5"/><circle cx="18.5" cy="6" r="2.5"/><circle cx="12" cy="18" r="2.5"/><path d="M8 6h8M7 8.2l3.6 7.5M17 8.2l-3.6 7.5"/>',
prompts: '<path d="M4 5.5h16M4 10h11M4 14.5h14M4 19h8"/><path d="M19.5 14.5 21 19"/>',
models: '<path d="M12 2.8 20.5 7.5v9L12 21.2 3.5 16.5v-9z"/><path d="M3.8 7.6 12 12.2l8.2-4.6M12 12.2v8.8"/>',
search: '<circle cx="11" cy="11" r="6.5"/><path d="m20.5 20.5-4.8-4.8"/>',
star: '<path d="m12 3.3 2.6 5.5 6 .8-4.4 4.1 1.1 5.9L12 16.8l-5.3 2.8 1.1-5.9-4.4-4.1 6-.8z"/>',
starOn: '<path d="m12 3.3 2.6 5.5 6 .8-4.4 4.1 1.1 5.9L12 16.8l-5.3 2.8 1.1-5.9-4.4-4.1 6-.8z" fill="currentColor"/>',
check: '<path d="m5 12.5 4.3 4.3L19 7.2"/>',
plus: '<path d="M12 5v14M5 12h14"/>',
close: '<path d="M6 6l12 12M18 6 6 18"/>',
sparkle: '<path d="M12 3c.6 4.6 2.4 6.4 7 7-4.6.6-6.4 2.4-7 7-.6-4.6-2.4-6.4-7-7 4.6-.6 6.4-2.4 7-7z"/><path d="M19 15.5c.25 1.7.8 2.25 2.5 2.5-1.7.25-2.25.8-2.5 2.5-.25-1.7-.8-2.25-2.5-2.5 1.7-.25 2.25-.8 2.5-2.5z"/>',
target: '<circle cx="12" cy="12" r="8.5"/><circle cx="12" cy="12" r="4.5"/><circle cx="12" cy="12" r="1" fill="currentColor"/>',
lock: '<rect x="4.5" y="10.5" width="15" height="10" rx="2"/><path d="M8 10.5V7.8a4 4 0 0 1 8 0v2.7"/><circle cx="12" cy="15.5" r="1.2" fill="currentColor"/>',
page: '<rect x="3" y="4" width="18" height="16" rx="2.2"/><path d="M3 8.5h18"/><circle cx="6" cy="6.3" r=".7" fill="currentColor"/><circle cx="8.3" cy="6.3" r=".7" fill="currentColor"/>',
coin: '<circle cx="12" cy="12" r="8.5"/><path d="M14.8 9.2c-.6-.9-1.6-1.3-2.8-1.3-1.6 0-2.8.8-2.8 2.1 0 2.9 5.8 1.4 5.8 4.2 0 1.3-1.3 2.1-3 2.1-1.3 0-2.4-.5-3-1.4M12 6.3v1.6M12 16.3v1.4"/>',
gear: '<circle cx="12" cy="12" r="3"/><path d="M12 3.5v2.2M12 18.3v2.2M3.5 12h2.2M18.3 12h2.2M6 6l1.6 1.6M16.4 16.4 18 18M6 18l1.6-1.6M16.4 7.6 18 6"/>',
trash: '<path d="M4.5 7h15M9.5 7V4.8h5V7M6.5 7l.9 12.2h9.2L17.5 7"/>',
list: '<path d="M8.5 6h12M8.5 12h12M8.5 18h12"/><circle cx="4.5" cy="6" r="1" fill="currentColor"/><circle cx="4.5" cy="12" r="1" fill="currentColor"/><circle cx="4.5" cy="18" r="1" fill="currentColor"/>',
image: '<rect x="3.5" y="4.5" width="17" height="15" rx="2.2"/><circle cx="9" cy="10" r="1.8"/><path d="m4 17.5 5-4.5 3.5 3 3-2.5 4.5 4"/>',
};
/** 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 = `<svg viewBox="0 0 24 24" width="${size}" height="${size}" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">${PATHS[name] || ''}</svg>`;
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 = `<svg viewBox="0 0 24 24" width="${Math.round(size * 0.62)}" height="${Math.round(size * 0.62)}" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M16.5 7.5A6 6 0 1 0 16.5 16.5"/><circle cx="17" cy="12" r="1.3" fill="currentColor" stroke="none"/></svg>`;
} 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 = `<svg viewBox="0 0 220 150" width="220" height="150" fill="none" stroke-linecap="round" stroke-linejoin="round">
<g class="tb-art-soft"><circle cx="40" cy="38" r="3"/><circle cx="186" cy="30" r="2.5"/><circle cx="196" cy="98" r="3"/><circle cx="24" cy="102" r="2"/></g>
<g class="tb-art-card tb-art-a" transform="rotate(-10 72 52)"><rect x="46" y="30" width="54" height="40" rx="7"/><path d="M54 42h22M54 50h32M54 58h16"/><circle cx="100" cy="50" r="3"/></g>
<g class="tb-art-card tb-art-b" transform="rotate(8 150 46)"><rect x="124" y="24" width="54" height="40" rx="7"/><path d="M132 36h26M132 44h18M132 52h30"/><circle cx="124" cy="44" r="3"/></g>
<path class="tb-art-link" d="M103 50c10 0 12-6 18-6"/>
<g class="tb-art-box"><path d="M38 84h144l-10 50H48z"/><path d="M38 84l14-14h116l14 14"/><rect x="92" y="96" width="36" height="12" rx="4"/></g>
<path class="tb-art-spark" d="M110 10c.8 6 3.2 8.4 9.2 9.2-6 .8-8.4 3.2-9.2 9.2-.8-6-3.2-8.4-9.2-9.2 6-.8 8.4-3.2 9.2-9.2z"/>
</svg>`;
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';
}
+1 -1
View File
@@ -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");
+371 -87
View File
@@ -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; }
}