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:
co-authored by
Claude Opus 5.5
parent
622ae8be9a
commit
fd9e415123
+12
-5
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
@@ -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
@@ -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` 也带着一句当前进度,跑完后告诉它一次结果,所以它不用自己去翻输出文件夹或者看屏幕。
|
||||
|
||||
|
||||
@@ -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');
|
||||
@@ -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__":
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user