Compare commits

...
Author SHA1 Message Date
DemonGatanjieuandClaude Opus 5.5 fcad1bf133 The default shortcut is Shift+B, not B
comfyui-browser toggles its own window on a plain b (hard-coded, no
setting), so with both installed one press opened one window and closed the
other. Shift+B keeps the B and leaves comfyui-browser alone (it checks for a
lowercase b). Tested with both plugins loaded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:07:53 +08:00
DemonGatanjieuandClaude Opus 5.5 942733fc22 Esc goes back from a detail and closes from a list; B opens the browser
The user's two wishes for v1.58.2:
- Esc on a page one level down (a model's details, one combo, one recipe)
  presses that page's own back button; on any other page, Workflows and
  Combos included (they are layers, but look like the other rail pages),
  it closes the browser. The model details' back button gets a class on
  the line that makes it, so ui_detail.js does not grow.
- The default shortcut is B, like ComfyUI's single keys for its side panels.
  Ctrl+Shift+M is taken by Chrome and Edge (switch profile) before the page
  sees it. ComfyUI keeps shortcuts the user set; typing in a text box is
  left alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:54:03 +08:00
DemonGatanjieuandClaude Opus 5.5 0b212cc1b4 Gallery: the browser steps aside while a picture is dragged; inserted node follows the pointer
The user's two wishes for v1.58.2:
- Dragging a gallery picture fades the browser out, as for the other cards
  (anomalous-material-dragging), so the canvas and the node under the
  pointer take the drop; it comes back on dragend.
- Insert as a Load Image node closes the browser and the node follows the
  pointer until a click puts it down, Esc takes it off: a combo's Put on
  canvas (notebook_canvas.js's followPointer, now exported).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:42:47 +08:00
DemonGatanjieuandClaude Opus 5.5 f902f60b4d Insert as a Load Image node copies the picture into the input folder
A "tut01/name.png [output]" value ran, but ComfyUI's check before a run
only finds output pictures in this session's history, so an older picture
was flagged "a required media input has no file selected" (seen on the
user's library). The picture is now uploaded through /upload/image like a
file dropped on the node; ComfyUI keeps one copy of the same picture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:34:04 +08:00
DemonGatanjieuandClaude Opus 5.5 fe27ee3ba7 v1.58.2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:26:51 +08:00
DemonGatanjieuandClaude Opus 5.5 4e21b21a72 Show in folder brings Explorer to the front
Windows keeps a background program (ComfyUI) from raising a window, so the
folder opened behind the browser and was easy to miss. After starting
Explorer, the route waits up to 3 s for its window (a new one, or the open
one Explorer reused, matched by the folder's name in the title) and raises
it, borrowing the foreground window's input queue for the moment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:25:33 +08:00
DemonGatanjieuandClaude Opus 5.5 e0129eaa2f Esc and the shortcut close the browser; Show in folder; insert a picture as a node
From a Bilibili comment (2026-10-13):
- Esc closes the top layer: a dialog or menu (the plugin's alert and confirm
  now close on Esc, as OK / Cancel), else the Workflows / Combos workspace,
  else the browser. One capture listener decides before the layers act, so a
  dialog closing on the same Esc still counts as above. The shortcut and the
  Extensions entry toggle the browser.
- A model's file card has Show in folder: POST /anomalous/open_model_folder
  (api/model_folder.py) takes type, folder index, subfolder and file name,
  never a path, and opens Explorer / Finder with the file selected; a POST,
  so local_guard keeps it to this computer. The card moves out of
  ui_detail.js into model_location.js.
- A gallery card's right-click menu (gallery_card_menu.js): Insert as a Load
  Image node reads the output file in place ("name.png [output]"), and View
  Parameters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 01:09:29 +08:00
DemonGatanjieuandClaude Opus 5.5 a23c3ec69d English UI: no Chinese in picture details, one / many in counts
Found while recording in English: picture details showed "20 步" and
"定位模型", and the activity log said "Changed 1 things". Picture
details' last fixed Chinese strings (steps, Show model, copied JSON, two
empty notes) move to locales. formatTranslation learns {count|one|other},
and every English "{count} <plural noun>" uses it (49 strings; names like {engine} or {node} are left alone); two
sentences whose verb followed the count are reworded. The voice guides
name the TTS node by its English title, Character Speech (GPT-SoVITS).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:01:12 +08:00
DemonGatanjieuandClaude Opus 5.5 d2b6c40921 Merge the README network note into main
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 12:06:30 +08:00
DemonGatanjieuandClaude Opus 5.5 594cb70dee README: a word on the network (Civitai, Google Translate, offline scan)
Users in mainland China often have no proxy: say which parts need the
network and that an offline scan works without it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 12:06:29 +08:00
DemonGatanjieuandClaude Opus 5.5 3f5774a724 Picture details: arrows switch pictures again after ticking a checkbox
The key handler ignored every <input> as a text field, so once a node's
checkbox under All parameters was ticked it kept the focus and the arrows
and A / D did nothing until a click elsewhere. Only text entry (text inputs,
textareas, contenteditable) keeps the keys now. In since 2026-09-04.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:56:44 +08:00
21 changed files with 494 additions and 130 deletions
+4 -1
View File
@@ -83,6 +83,8 @@ covered by the `styles.css` manifest.
and no scan replaces or deletes it.
- `api/model_catalog.py`, `api/model_resolution.py`, `api/model_metadata.py`, and
`api/model_media.py` own model listing, identity recovery, mutation, and covers;
`api/model_folder.py` shows a model file in Explorer / Finder (by type, folder index and
file name, never a path; a POST, so only this computer may ask);
`api/models.py` is a compatibility facade; `api/model_constants.py` holds the
model, media and sidecar extensions they share. `api/model_type_listing.py` lists a
whole models folder with its subfolders for the models page's type chips.
@@ -349,6 +351,7 @@ covered by the `styles.css` manifest.
settings control that opens ComfyUI's keybinding editor.
- `ui_model_sources.js` renders the Models page's Sources view (the last type chip): where each model is downloaded, for the open workflow or every model, with each link editable and saved in place; `model_source_links.js` owns its data and actions (collecting the workflow's models, resolving them here, saving a link to the model's information and the workflow, the canvas note and the clipboard list).
- `ui_gallery_card.js` builds one output image's gallery card (viewer, workbench, cover pick, drag, star, delete);
`gallery_card_menu.js` is its right-click menu (insert as a Load Image node reading the output file, parameters);
`ui_keep_menu.js` is the star's keep menu (also the workbench's Keep): the whole workflow, the combo or the prompts,
each saying where it goes, opening what was kept already or removing it (to the Recycle Bin through the recipe,
notebook or material delete route); `image_keep.js` does the keeping (an image as a combo,
@@ -376,7 +379,7 @@ covered by the `styles.css` manifest.
card action buttons (one-click canvas addition with plus icon, model metadata editor, direct precision scanner without wizard modal popups)
with absolute positioning cascades immune to tooltip target conflicts, vibrant hover affordance,
safe docked sidebar preservation upon node addition, and multi-type node dispatch; `ui_detail.js`
coordinates detail display, `ui_model_editor.js` owns metadata editing, and
coordinates detail display (the file card with Show in folder in `model_location.js`), `ui_model_editor.js` owns metadata editing, and
`ui_model_selector.js` owns advanced selection. `ui_gallery.js` (cards in
`ui_gallery_card.js`) and `ui_gallery_detail.js` own generated-image browsing and workbench lifecycle,
with stage interaction in `ui_image_stage.js` and metadata tabs in
+17
View File
@@ -1,5 +1,22 @@
# 📈 Anomalous Model Browser Changelog
## v1.58.2 Beta (Small fixes: Esc closes, Show in folder, insert a picture as a node) — 2026-10-13
### 💬 From a Bilibili comment (来自 B 站评论)
- **Esc closes the browser (Esc 关闭浏览器)**: Esc closes a dialog or menu first; on a page one level down (a model's details, one combo, one workflow) it goes back to the list, as the page's back button does; on any other page, Workflows and Combos included, it closes the browser. The shortcut and the Extensions menu entry now close the browser when it is open. Esc in a text box still belongs to the text box.
- **Shift + B opens the browser (Shift + B 打开浏览器)**: the default shortcut is now **Shift + B** (again to close it). Ctrl + Shift + M is taken by Chrome and Edge (switch profile) before ComfyUI sees it, and B alone already opens comfyui-browser's window. A shortcut you changed yourself stays as it is; change it in ComfyUI Settings → Keybinding.
- **Dragging a gallery picture hides the browser (拖图时浏览器让开)**: while you drag a picture, the browser fades out as it does for other cards, so the canvas and the node under it take the drop.
- **Show in folder (打开所在文件夹)**: a model's details have **Show in folder** next to **Copy path**: Explorer (or Finder) opens in front of the browser with the file selected. Only on the computer ComfyUI runs on.
- **Insert a picture as a node (图片作为节点插入)**: right-click a gallery picture → **Insert as a Load Image node** gives you a Load Image node showing that picture: the browser closes and the node follows the pointer until a click puts it down (Esc cancels), like a combo's **Put on canvas**. The picture is copied into ComfyUI's input folder, as when you drop a file on the node (the same picture is kept once).
### ⌨️ Arrow keys in picture details (查看参数里方向键切图)
- In a picture's details, **← / →** (and **A / D**) switch pictures again after you ticked a node's checkbox under **All parameters**; the ticked checkbox kept the keyboard and the keys did nothing until you clicked elsewhere. Typing in a text box still keeps the arrows for the text.
### 🌐 English interface (英文界面)
- Picture details no longer show Chinese in the English interface: the step count ("20 steps"), **Show model** on the Models tab, the copied-JSON note and the empty-tab notes.
- Counts read right for one: "Changed 1 thing on the canvas", "1 model", "1 file" instead of "1 things", "1 models", "1 files".
- The voice guides name the node as English ComfyUI shows it: **Character Speech (GPT-SoVITS)**.
## v1.58.1 Beta (Security: only this computer can change things) — 2026-10-12
### 🔒 Other devices can look, not change (别的设备只能看,不能改)
+9 -3
View File
@@ -9,7 +9,7 @@
[![ComfyUI Manager](https://img.shields.io/badge/ComfyUI-Manager-green?style=for-the-badge&logo=comfyui)](https://github.com/ltdrdata/ComfyUI-Manager)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow?style=for-the-badge)](LICENSE)
[![Changelog](https://img.shields.io/badge/📖_Changelog-v1.58.1_Beta-blue?style=for-the-badge)](CHANGELOG.md)
[![Changelog](https://img.shields.io/badge/📖_Changelog-v1.58.2_Beta-blue?style=for-the-badge)](CHANGELOG.md)
[![Bilibili Video](https://img.shields.io/badge/Bilibili-视频演示-00A1D6?style=for-the-badge&logo=bilibili)](https://www.bilibili.com/video/BV1a1bv68EuA/)
[![YouTube Video](https://img.shields.io/badge/YouTube-Video_Demo-red?style=for-the-badge&logo=youtube)](https://youtu.be/hAvsj7uiaCw)
@@ -101,10 +101,13 @@ Tools on the rail:
3. For character voices, also install [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS) the same way. It is optional.
4. **Updating**: click the **!** button at the top right of the browser; it shows the current version, and **Check for updates / switch version** opens the version panel. Update to the newest release, go back to an earlier one or return to the latest, then restart ComfyUI. The plugin never checks for updates on its own.
Open the browser with **Ctrl + Shift + M**, the floating button on the canvas, or **Extensions → Anomalous Model Browser** in ComfyUI's menu. **ComfyUI Settings → Anomalous Model Browser → Interface** changes the shortcut, the language and how the browser is opened (floating button, a button next to Run, or the Extensions menu only).
Open the browser with **Shift + B** (again to close), the floating button on the canvas, or **Extensions → Anomalous Model Browser** in ComfyUI's menu. **ComfyUI Settings → Anomalous Model Browser → Interface** changes the shortcut, the language and how the browser is opened (floating button, a button next to Run, or the Extensions menu only).
The first time you open it, start on **Home**: **Scan model folders** fills in covers and trigger words, and **Tour the interface** points at each part of the window.
> [!NOTE]
> **Network:** Scan looks models up on Civitai and prompt translation uses Google Translate, so both need to reach those sites (from mainland China usually through a proxy). Without them, pick **Offline scan**: base models are read from the files, without covers or trigger words, and a later online scan fills in the rest. Browsing models, outputs, workflows and combos needs no network.
> [!WARNING]
> **Keep a copy of your own data:** Workflow Recipes, combos, saved prompts and node values, and parameter presets live in `workflows/anomalous_recipes`, `workflows/anomalous_notebooks`, `workflows/anomalous_materials` and `workflows/anomalous_parameters` in your ComfyUI user folder. A copy of them before updating the plugin is a good habit.
@@ -197,10 +200,13 @@ The first time you open it, start on **Home**: **Scan model folders** fills in c
- 点 **检查更新 / 切换版本** 打开版本面板,可以更新到最新版、回退到以前的版本,或回到最新版;
- 换完版本后重启 ComfyUI。插件不会自己联网检查更新。
打开方式有三种:按 **Ctrl + Shift + M**、点击画布上的悬浮按钮,或者从 ComfyUI 菜单 **扩展 → Anomalous Model Browser** 打开。在 **ComfyUI 设置 → Anomalous Model Browser → 界面** 里,可以修改快捷键、插件语言和打开方式(悬浮按钮、运行按钮旁的按钮,或只用扩展菜单)。
打开方式有三种:按 **Shift + B**(再按一次关闭)、点击画布上的悬浮按钮,或者从 ComfyUI 菜单 **扩展 → Anomalous Model Browser** 打开。在 **ComfyUI 设置 → Anomalous Model Browser → 界面** 里,可以修改快捷键、插件语言和打开方式(悬浮按钮、运行按钮旁的按钮,或只用扩展菜单)。
第一次打开时会停在 **主页**:点 **扫描模型文件夹** 补全封面和触发词;点 **界面导览** 会逐一指给你看窗口的各个部分。
> [!NOTE]
> **关于网络:** 扫描模型要连 Civitai(C 站)查封面和触发词,提示词翻译默认用谷歌翻译,这两样在国内一般要开网络代理才能用。不开代理也行:扫描时选 **离线扫描**,只从模型文件推断底模,没有封面和触发词,以后能连上时再扫一次会自动补上。浏览模型、图库、工作流和搭配都不用联网。
> [!WARNING]
> **备份自己的数据:** 工作流配方、搭配、存下的提示词和节点参数、参数预设存在 ComfyUI 用户目录下的 `workflows/anomalous_recipes`、`workflows/anomalous_notebooks`、`workflows/anomalous_materials` 与 `workflows/anomalous_parameters` 文件夹。更新插件前顺手备份一份,更安心。
+2 -1
View File
@@ -1,6 +1,6 @@
from . import (
activity_log, audio_catalog, folder_types, gallery_routes, materials, media_routes, model_catalog,
model_media, model_metadata, model_resolution, model_type_listing, kept_images, node_material, recipe_packages, recipes, scan_report, scan_summary,
model_folder, model_media, model_metadata, model_resolution, model_type_listing, kept_images, node_material, recipe_packages, recipes, scan_report, scan_summary,
local_guard, translation_routes, version_manager,
)
from .scanner import *
@@ -48,6 +48,7 @@ def setup_routes(app):
app.router.add_get('/anomalous/config', api_get_config)
app.router.add_post('/anomalous/save_config', api_save_config)
app.router.add_post('/anomalous/delete_model', model_metadata.api_delete_model)
app.router.add_post('/anomalous/open_model_folder', model_folder.api_open_model_folder)
app.router.add_get('/anomalous/compatible_models', model_catalog.api_compatible_models)
app.router.add_get('/anomalous/notebooks', api_get_notebooks)
app.router.add_post('/anomalous/save_notebook', api_save_notebook)
+112
View File
@@ -0,0 +1,112 @@
"""Shows a model file in this computer's file manager: Explorer or Finder with the file
selected, the folder on Linux. The request names the model the way the browser lists it
(type, folder index, subfolder, file name), never a path, and local_guard lets only this
computer ask (it is a POST)."""
import os
import subprocess
import sys
import threading
import time
from aiohttp import web
from .utils import require_filename, resolve_folder_subdir, resolve_within
def _explorer_windows():
"""Handles of the open Explorer folder windows (window class CabinetWClass)."""
import ctypes
from ctypes import wintypes
found = []
callback_type = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)
def collect(hwnd, _):
name = ctypes.create_unicode_buffer(64)
ctypes.windll.user32.GetClassNameW(hwnd, name, 64)
if name.value == "CabinetWClass":
found.append(hwnd)
return True
ctypes.windll.user32.EnumWindows(callback_type(collect), 0)
return found
def _window_title(hwnd):
import ctypes
title = ctypes.create_unicode_buffer(512)
ctypes.windll.user32.GetWindowTextW(hwnd, title, 512)
return title.value
def _to_front(hwnd):
"""Windows keeps a background program (ComfyUI here) from raising a window; borrowing the
foreground window's input queue for a moment lets it."""
import ctypes
user32, kernel32 = ctypes.windll.user32, ctypes.windll.kernel32
foreground = user32.GetForegroundWindow()
theirs = user32.GetWindowThreadProcessId(foreground, None)
mine = kernel32.GetCurrentThreadId()
attached = theirs and theirs != mine and user32.AttachThreadInput(mine, theirs, True)
try:
user32.ShowWindow(hwnd, 9 if user32.IsIconic(hwnd) else 5) # SW_RESTORE / SW_SHOW
user32.BringWindowToTop(hwnd)
user32.SetForegroundWindow(hwnd)
finally:
if attached:
user32.AttachThreadInput(mine, theirs, False)
def _raise_explorer(folder, before):
"""Waits up to 3 s for the window showing `folder` (a new one, or the open one Explorer
reused) and brings it to the front."""
names = (os.path.basename(folder).lower(), os.path.normcase(folder).lower())
def shows_folder(hwnd):
# The folder's name (or full path), then " - File Explorer" in the system's language.
title = _window_title(hwnd).lower()
return any(title == name or title.startswith(name + " - ") for name in names)
for _ in range(30):
time.sleep(0.1)
windows = _explorer_windows()
fresh = [hwnd for hwnd in windows if hwnd not in before]
match = fresh or [hwnd for hwnd in windows if shows_folder(hwnd)]
if match:
_to_front(match[0])
return
def _reveal(path):
if sys.platform == "win32":
before = set(_explorer_windows())
subprocess.Popen(f'explorer /select,"{path}"')
threading.Thread(target=_raise_explorer, args=(os.path.dirname(path), before), daemon=True).start()
elif sys.platform == "darwin":
subprocess.Popen(["open", "-R", path])
else:
subprocess.Popen(["xdg-open", os.path.dirname(path)])
async def api_open_model_folder(request):
try:
data = await request.json()
except ValueError:
data = None
if not isinstance(data, dict):
return web.json_response({"status": "error", "message": "Invalid request body"}, status=400)
try:
filename = require_filename(data.get("filename", ""))
_, folder = resolve_folder_subdir(data.get("type", ""), int(data.get("path_idx", 0)), data.get("subfolder", "/"))
path = resolve_within(folder, filename)
except (ValueError, KeyError, TypeError):
return web.json_response({"status": "error", "message": "Invalid request parameters"}, status=400)
if not os.path.isfile(path):
return web.json_response({"status": "error", "message": "Model file not found"}, status=404)
try:
_reveal(path)
except OSError as error:
return web.json_response({"status": "error", "message": str(error)}, status=500)
return web.json_response({"status": "success"})
+2 -1
View File
@@ -104,7 +104,8 @@ button, and `Extensions -> Anomalous Model Browser` command. Entry presentations
are mutually exclusive and reuse the same browser instance. The Extensions
command remains available as a recovery path in every mode.
Native commands own the default `Ctrl + Shift + M` browser binding and the
Native commands own the default `Shift + B` browser binding (Ctrl + Shift + M before v1.58.2: Chrome
and Edge take it for switching profiles) and the
`Ctrl + Shift + L` Prompt Studio binding. Shortcut customization delegates
to ComfyUI's command/keybinding panel and recorder; the plugin does not install
a parallel global keyboard listener or maintain a second shortcut preference.
+8 -1
View File
@@ -155,6 +155,13 @@ export function createBrowserEntry({ translate, getCurrentLanguage }) {
ensureBrowser()?.show();
}
// The shortcut and the Extensions menu entry: open, or close when it is open.
function toggle() {
const browser = ensureBrowser();
if (browser?.modal?.classList.contains('visible')) browser.close();
else browser?.show();
}
// The second shortcut (its ids still say materials) opens Prompt Studio.
async function openMaterials() {
const browser = ensureBrowser();
@@ -464,7 +471,7 @@ export function createBrowserEntry({ translate, getCurrentLanguage }) {
onClick: open
}],
commands: [
{ id: OPEN_BROWSER_COMMAND_ID, label: t('mainOpenTitle'), function: open },
{ id: OPEN_BROWSER_COMMAND_ID, label: t('mainOpenTitle'), function: toggle },
{ id: OPEN_MATERIALS_COMMAND_ID, label: t('mainOpenMaterialsTitle'), function: openMaterials },
{ id: RESET_TRIGGER_POSITION_COMMAND_ID, label: t('mainResetFloatingTriggerPosition'), function: resetPosition }
],
+112
View File
@@ -0,0 +1,112 @@
/**
* A gallery card's right-click menu: put the picture on the canvas as a Load Image node, or
* open its parameters. The picture is uploaded into the input folder the way a dropped file is
* (ComfyUI keeps one copy of the same picture): a "name.png [output]" value would be flagged
* as a missing file by ComfyUI's checks for any picture not made since ComfyUI started.
*/
import { app } from '../../../scripts/app.js';
import { translate as t } from './locales.js';
import { recordCanvasStep } from './canvas_history.js';
import { showWorkbenchToast } from './ui_prompt_toast.js';
import { canvasPoint, followPointer } from './notebook_canvas.js';
let closeOpenMenu = null;
function row(icon, label, run, close) {
const item = document.createElement('button');
item.type = 'button';
item.className = 'anomalous-keep-row';
item.setAttribute('role', 'menuitem');
const mark = document.createElement('span');
mark.className = 'anomalous-keep-row-icon';
mark.textContent = icon;
const text = document.createElement('strong');
text.textContent = label;
item.append(mark, text);
item.onclick = (event) => {
close();
run(event);
};
return item;
}
/** The output picture uploaded into the input folder; its name there for a Load Image node. */
async function copyToInput(image) {
const query = new URLSearchParams({ filename: image.filename, subfolder: image.subfolder || '', type: 'output' });
const picture = await fetch(`/view?${query}`);
if (!picture.ok) throw new Error(`view ${picture.status}`);
const blob = await picture.blob();
const form = new FormData();
form.append('image', new File([blob], image.filename, { type: blob.type }));
form.append('type', 'input');
const response = await fetch('/upload/image', { method: 'POST', body: form });
if (!response.ok) throw new Error(`upload ${response.status}`);
const data = await response.json();
return data.subfolder ? `${data.subfolder}/${data.name}` : data.name;
}
/**
* A Load Image node showing `image`: the browser closes and the node follows the pointer until a
* click puts it down (Esc takes it off again), like a combo's Put on canvas. `at` is the click
* that chose it, where the node starts.
*/
export async function insertLoadImageNode(owner, image, at) {
const node = globalThis.LiteGraph?.createNode('LoadImage');
const widget = node?.widgets?.find(item => item.name === 'image');
let file = null;
try {
if (node && widget && app.graph) file = await copyToInput(image);
} catch (error) {
console.error('[AMB] Could not copy the picture into the input folder:', error);
}
if (!file) {
showWorkbenchToast(t('galleryInsertNodeFailed'));
return;
}
const [x, y] = canvasPoint(at);
node.pos = [x - node.size[0] / 2, y - 20];
app.graph.add(node);
const choices = widget.options?.values;
if (Array.isArray(choices) && !choices.includes(file)) choices.push(file);
widget.value = file;
widget.callback?.(file); // shows the picture in the node
app.graph.setDirtyCanvas(true, true);
owner.close();
followPointer(owner, [{ node, relX: 0, relY: 0 }], () => recordCanvasStep(app));
showWorkbenchToast(t('galleryInsertNodeFollow'));
}
/** Opens the menu at the pointer; `openDetails()` opens the picture's parameters. */
export function openGalleryCardMenu(event, image, { owner, openDetails }) {
event.preventDefault();
closeOpenMenu?.();
const menu = document.createElement('div');
menu.className = 'anomalous-keep-menu anomalous-gallery-card-menu';
menu.setAttribute('role', 'menu');
const onPointer = (pointer) => { if (!menu.contains(pointer.target)) close(); };
const onKey = (key) => {
if (key.key !== 'Escape') return;
key.stopPropagation();
close();
};
const close = () => {
menu.remove();
window.removeEventListener('pointerdown', onPointer, true);
window.removeEventListener('keydown', onKey, true);
window.removeEventListener('wheel', close, true);
if (closeOpenMenu === close) closeOpenMenu = null;
};
menu.append(
row('🖼️', t('galleryInsertAsNode'), (click) => insertLoadImageNode(owner, image, click), close),
row('🔍', t('materialViewParameters'), openDetails, close),
);
document.body.append(menu);
const { width, height } = menu.getBoundingClientRect();
menu.style.left = `${Math.max(8, Math.min(event.clientX, window.innerWidth - width - 8))}px`;
menu.style.top = `${Math.max(8, Math.min(event.clientY, window.innerHeight - height - 8))}px`;
window.addEventListener('pointerdown', onPointer, true);
window.addEventListener('keydown', onKey, true);
window.addEventListener('wheel', close, true);
closeOpenMenu = close;
}
+79 -56
View File
@@ -402,6 +402,11 @@ export const i18n = {
workbenchZoomReset: '1:1 实际尺寸',
workbenchFilterNodes: '搜索/过滤节点…',
workbenchCopyWorkflowJson: '复制完整工作流 JSON',
workbenchCopiedWorkflowJson: '✅ 已复制工作流 JSON',
workbenchStepsValue: '{count} 步',
workbenchNoModels: '(未检测到模型引用)',
workbenchNoNodeParams: '(无底层节点参数数据)',
workbenchLocateModel: '🔎 定位模型',
materialInspecting: '正在读取图片中的工作流与参数…',
materialInspectError: "这张图片没有可复用的完整 UI 工作流。",
materialSaveHint: '完整工作流保留精确种子;作为单节点参数应用时会跳过种子。',
@@ -594,6 +599,11 @@ export const i18n = {
detailCopyPath: '复制路径',
detailPathCopied: '已复制',
detailPathCopyFailed: '复制失败',
detailOpenFolder: '打开所在文件夹',
detailOpenFolderFailed: '打不开文件夹:{error}',
galleryInsertAsNode: '作为“加载图像”节点插入工作流',
galleryInsertNodeFollow: '“加载图像”节点跟着鼠标走:点一下放下,Esc 取消',
galleryInsertNodeFailed: '没能放入“加载图像”节点:图片没能复制到 input 文件夹,或画布还没准备好',
detailCustomNamePlaceholder: '自定义名称 (留空则使用原名)',
detailNotesPlaceholder: '📓 专属备注... (支持多行)',
detailPhysicalRename: '同步物理重命名',
@@ -2046,7 +2056,7 @@ export const i18n = {
promptImportAppendPrompt: "Append to current",
promptPositiveNote: "Positive Prompt Note",
promptNegativeNote: "Negative Prompt Note",
promptWordCount: "{count} chars",
promptWordCount: "{count} {count|char|chars}",
promptDragNoteHint: "Hold card or header to drag onto canvas node",
promptClearText: "Clear note",
promptClearConfirm: "Are you sure you want to clear this note?",
@@ -2094,7 +2104,7 @@ export const i18n = {
materialAllTags: "All tags",
materialDuplicateConfirm: "This image and selection are already saved as “{name}”. Save another copy?",
materialCopyError: "Copy failed; retry",
materialChars: "{count} characters",
materialChars: "{count} {count|character|characters}",
materialCopyPromptText: "Copy prompt",
notebookSaveError: 'Save failed. Your edits remain in the editor; please retry.',
title: 'Anomalous Model Browser',
@@ -2126,7 +2136,7 @@ export const i18n = {
scanPageUnmatched: "Not found",
scanPageNew: "Not scanned yet",
scanPageSummaryFailed: "Couldn't count the models right now; you can still scan.",
scanPageScanNew: "Scan {count} models not looked up yet",
scanPageScanNew: "Scan {count} {count|model|models} not looked up yet",
scanPageScanAgain: "Check again",
scanPageScanPicked: "Scan the {count} picked models",
scanPageScanning: "Scanning…",
@@ -2188,7 +2198,7 @@ export const i18n = {
scanCountMatched: "{count} matched",
scanCountInferred: "{count} inferred from the file",
scanCountFailed: "{count} failed",
scanCountCovers: "{count} covers downloaded",
scanCountCovers: "{count} {count|cover|covers} downloaded",
scanCountRenamed: "{count} renamed",
scanCountNothing: "No model needed an update",
scanFileMatched: "Matched on Civitai: {name}",
@@ -2213,7 +2223,7 @@ export const i18n = {
scanRowScan: "Scan",
scanShowAll: "Show all {count}",
scanBack: "‹ Back to scan",
scanListScanAll: "Scan these {count} models",
scanListScanAll: "Scan these {count} {count|model|models}",
scanListEmpty: "No models left here.",
scanResultList: "The last scan, model by model",
scanResultDetails: "See each model ({count}) ›",
@@ -2300,6 +2310,11 @@ export const i18n = {
workbenchZoomReset: '1:1 Actual Size',
workbenchFilterNodes: 'Search/filter nodes…',
workbenchCopyWorkflowJson: 'Copy Workflow JSON',
workbenchCopiedWorkflowJson: '✅ Workflow JSON copied',
workbenchStepsValue: '{count} {count|step|steps}',
workbenchNoModels: '(No model references found)',
workbenchNoNodeParams: '(No node parameters in this image)',
workbenchLocateModel: '🔎 Show model',
materialInspecting: 'Reading the embedded workflow and parameters…',
materialInspectError: "This image has no complete UI workflow to reuse.",
materialSaveHint: 'The full workflow keeps the exact seed; single-node application skips seed widgets.',
@@ -2326,7 +2341,7 @@ export const i18n = {
materialOpenModelDetail: 'Open model details',
materialModelNotes: 'Notes',
materialIncludedNodes: 'Included Nodes ({count})',
materialParameterCount: '{count} parameters',
materialParameterCount: '{count} {count|parameter|parameters}',
materialNodeId: 'Node ID',
materialNodeMode: 'Node Mode',
materialCopyNodeParameters: '📋 Copy Node JSON',
@@ -2368,7 +2383,7 @@ export const i18n = {
materialSelectNodeForSaving: "Pick this node to save its values",
materialSelectAllNodes: 'Select All Nodes',
materialClearNodeSelection: 'Clear Selection',
materialSelectedNodeCount: '{count} nodes selected',
materialSelectedNodeCount: '{count} {count|node|nodes} selected',
materialSaveNode: '+ Save node parameters',
materialSaveNodeHint: 'Save only this node\'s parameters, to apply from Current node',
materialSaveSelectedAction: 'Save selected node parameters ({count})',
@@ -2390,7 +2405,7 @@ export const i18n = {
comboUse: "Put on canvas",
comboUseHint: "Lays the main model, LoRAs and prompts out wired together; the group follows the pointer until you click, Esc cancels",
comboNoModel: "No model",
comboLoraCount: "{count} LoRAs",
comboLoraCount: "{count} {count|LoRA|LoRAs}",
comboNoPrompt: "(no prompt yet)",
comboEmpty: "No combos yet. Press New combo, pick a main model and some LoRAs and write a prompt; then one press of Put on canvas lays them out, wired.",
comboNoMatches: "No combo matches.",
@@ -2492,6 +2507,11 @@ export const i18n = {
detailCopyPath: 'Copy path',
detailPathCopied: 'Copied',
detailPathCopyFailed: 'Copy failed',
detailOpenFolder: 'Show in folder',
detailOpenFolderFailed: 'Could not open the folder: {error}',
galleryInsertAsNode: 'Insert as a Load Image node',
galleryInsertNodeFollow: 'The Load Image node follows the pointer: click to put it down, Esc to cancel',
galleryInsertNodeFailed: 'Could not add a Load Image node: the picture could not be copied into the input folder, or the canvas is not ready',
detailCustomNamePlaceholder: 'Custom Name (Leave empty to use original)',
detailNotesPlaceholder: '📓 Custom Notes...',
detailPhysicalRename: 'Physical Rename on Disk',
@@ -2507,11 +2527,11 @@ export const i18n = {
detailSaving: 'Saving...',
detailSelectModels: 'Select Models',
detailFolders: 'Folders',
detailSelectedItems: 'Selected {count} items',
detailSelectedItems: 'Selected {count} {count|item|items}',
detailSelectedItemsPrefix: 'Selected ',
detailSelectedItemsSuffix: ' items',
detailNothingSelected: 'Nothing selected',
detailLoadedModels: 'Loaded {loaded} / {total} models',
detailLoadedModels: 'Loaded {loaded} / {total} {total|model|models}',
detailComputing: 'Computing...',
detailSelectNoPreview: 'Select No Preview',
detailSelectNoDesc: 'Select No Desc',
@@ -2581,7 +2601,7 @@ export const i18n = {
doctorRecheck: 'Check again',
doctorSources: 'Where to download',
doctorNoModels: 'The open workflow uses no model files.',
doctorAllHere: 'All {count} models are on this computer; the workflow can run.',
doctorAllHere: 'All models are on this computer ({count}); the workflow can run.',
doctorHowItWorks: '"Put in" only uses the same file (same fingerprint). For the others, pick a local model yourself or download it from Civitai.',
doctorToHandle: 'To handle',
doctorReplaced: 'Just put in',
@@ -2608,12 +2628,12 @@ export const i18n = {
doctorProofLocal: 'Local file of that name',
doctorProofNone: 'none',
doctorChangedMeanwhile: 'This node changed after the check; nothing was replaced. Checked again.',
doctorScanHint: '{count} models in your library have not been scanned yet. Their fingerprints are unknown until scanned; scan, then check again.',
doctorScanHint: '{count} {count|model|models} in your library have not been scanned yet. Their fingerprints are unknown until scanned; scan, then check again.',
doctorToScan: 'Go to scanning',
doctorBannerMissing: 'This workflow uses {count} models this computer does not have.',
doctorBannerMissing: 'This workflow uses {count} {count|model|models} this computer does not have.',
doctorBannerStill: '{count} still missing.',
doctorBannerFixable: '{count} of them are here under another name or folder and can be put back.',
doctorBannerPutIn: 'Put in {count} models.',
doctorBannerPutIn: 'Put in {count} {count|model|models}.',
doctorBannerShow: 'Show',
doctorBannerClose: 'Close',
doctorBannerLabel: 'Model Check: ',
@@ -2666,7 +2686,7 @@ export const i18n = {
pickerCompatibleNoTags: 'ℹ Main model {name} was identified, but no matching LoRA tags were found, so all models remain visible.',
pickerCompatibleUnknown: 'ℹ The main model type could not be identified. All LoRAs remain visible and can be filtered manually.',
pickerAllModels: 'All Models',
pickerResults: '{count} results',
pickerResults: '{count} {count|result|results}',
pickerCurrent: 'Current',
pickerBaseModel: 'Base model',
pickerRoot: 'Root',
@@ -2728,7 +2748,7 @@ export const i18n = {
mainExportTitle: 'Export Workflow Share Code',
mainSkeletonOption: 'Skeleton (Compact, best for comments, loses coords)',
mainFullOption: 'Full (Keeps coords and colors, longer string)',
mainShareFingerprints: "{hashed} of {total} models carry a fingerprint, so others can find the same file even under another name.",
mainShareFingerprints: "{hashed} of {total} {total|model|models} carry a fingerprint, so others can find the same file even under another name.",
mainShareNoFingerprint: "{count} have none (not scanned, or not on this computer); others can only go by name or size, so scan first if you can.",
mainShareProvenanceOff: "\"Record model fingerprints in workflows\" is off in Settings, so the code carries no fingerprints or download links.",
mainShareLinks: "Include model download links ({count})",
@@ -2740,7 +2760,7 @@ export const i18n = {
mainSharePlaceholder: 'Paste AMB0- or AMB1- share code here...',
mainImportLoad: 'Import & Load',
mainShareEmpty: '❌ Share code is empty',
mainImportedNodes: '✅ Successfully imported {count} nodes',
mainImportedNodes: '✅ Successfully imported {count} {count|node|nodes}',
mainDecodeFailed: '❌ Decode Failed: ',
mainUnifiedTitle: "🔄 Workflow Import / Export",
mainExportWorkflow: "📤 Export Workflow to Share Code",
@@ -2784,8 +2804,8 @@ export const i18n = {
activityViewAll: "View all",
activityToday: "Today",
activityYesterday: "Yesterday",
activityCanvasChanged: "Changed {count} things on the canvas",
activityCanvasOpened: "Opened workflow {name} ({count} nodes)",
activityCanvasChanged: "Changed {count} {count|thing|things} on the canvas",
activityCanvasOpened: "Opened workflow {name} ({count} {count|node|nodes})",
activityChangedValue: "{node} · {widget}: {before} → {after}",
activityAdded: "Added node {node}",
activityRemoved: "Removed node {node}",
@@ -3039,12 +3059,12 @@ export const i18n = {
modelSourcesNotFound: "Nothing found; paste the link yourself",
modelSourcesStats: "With a link: {done} / {total}",
modelSourcesKeepAll: "Keep all links in the workflow",
modelSourcesKeptAll: "Kept {count} links in the workflow",
modelSourcesKeptAll: "Kept {count} {count|link|links} in the workflow",
modelSourcesWorkflowEmpty: "The canvas uses no models",
modelSourcesNoteTitle: "Model sources",
modelSourcesNoteFile: "File",
modelSourcesNoteLink: "Link",
modelSourcesNoteCount: "{count} models",
modelSourcesNoteCount: "{count} {count|model|models}",
modelSourcesScopeWorkflow: "This workflow",
modelSourcesScopeLibrary: "All models",
modelSourcesFilterAll: "All",
@@ -3060,7 +3080,7 @@ export const i18n = {
modelSourcesStatusMissingOnDisk: "Not on this computer",
modelSourcesStatusUnfilled: "no link yet",
modelSourcesStatusUnfilledOptional: "optional",
modelSourcesComponentsAdvanced: "{count} components",
modelSourcesComponentsAdvanced: "{count} {count|component|components}",
modelSourcesComponentsOptionalHint: "VAE, text encoders and the like; a link is optional",
modelSourcesLibraryLoading: "Loading all models…",
modelSourcesLibraryLoadFailed: "All models could not be loaded.",
@@ -3116,7 +3136,7 @@ export const i18n = {
recipeVerificationMissing: '{count} model reference(s) are missing verified identity',
recipeVerifyOnSave: 'Verify and save full SHA-256 hashes',
recipeVerifyOnSaveHint: 'Off by default. Saving may take longer when enabled.',
recipeSaveReceipt: 'Saved: {nodes} nodes, {links} links, {groups} groups.',
recipeSaveReceipt: 'Saved: {nodes} {nodes|node|nodes}, {links} {links|link|links}, {groups} groups.',
recipeSaveReceiptMismatch: 'Saved, but the integrity receipt differs from the current canvas. Reopen the recipe to verify it.',
recipeEmpty: 'No Workflow Recipes yet. Save a workflow that is working well.',
recipeLoadError: 'Could not load Workflow Recipes.',
@@ -3214,7 +3234,7 @@ export const i18n = {
recipeGalleryCoverSaved: 'Recipe cover set',
recipeGalleryCoverError: 'Could not set the recipe cover.',
recipeGalleryLoadError: 'Could not load the recipe gallery.',
recipeGalleryScanHint: 'Checked the latest {count} PNGs with workflow metadata.',
recipeGalleryScanHint: 'Checked the latest {count} {count|PNG|PNGs} with workflow metadata.',
recipeGalleryOpenImage: 'View full image',
recipeGalleryCompare: 'Compare parameters',
recipeGalleryComparison: 'Recipe vs image parameters',
@@ -3249,7 +3269,7 @@ export const i18n = {
recipeParameterNodeUnavailable: 'Could not resolve a matching node on the current canvas:',
recipeParameterApply: 'Apply to current workflow',
recipeParameterApplying: 'Checking the skeleton and applying…',
recipeParameterApplied: 'Applied {count} parameters',
recipeParameterApplied: 'Applied {count} {count|parameter|parameters}',
recipeParameterApplyError: 'Could not apply parameters; the current canvas was not fully changed.',
recipeParameterNewHint: 'Edit node parameters from the current recipe skeleton. Runtime-changing values such as seeds are ignored. Saving creates a new read-only snapshot and never overwrites history.',
recipeParameterName: 'Note name',
@@ -3346,13 +3366,13 @@ export const i18n = {
recipeStatusNoModels: "No model loaders found",
recipeMissingHint: "Once it is on the canvas, Model Check looks for them on this computer (renamed or moved ones too) and says where to download the rest.",
recipeModelDownloadPage: "Download page",
recipeStatusMissing: "{count} models missing",
recipeStatusMissing: "{count} {count|model|models} missing",
recipeStatusChecking: '⏳ Checking Environment…',
recipeQuickSpecs: 'Quick Specs',
recipeRawNodesToggle: 'Raw Node Parameters',
recipeRawNodesCount: 'nodes',
recipeRawNodesShowMore: 'Show {count} more nodes…',
recipeRawNodesLoadedCount: 'Loaded {shown} / {total} nodes',
recipeRawNodesLoadedCount: 'Loaded {shown} / {total} {total|node|nodes}',
recipeCardSpecsCheckpoint: 'Base',
recipeCardSpecsLoras: 'LoRA',
recipeCardSpecsSteps: 'Steps',
@@ -3360,7 +3380,7 @@ export const i18n = {
recipeCardSpecsResolution: 'Size',
domainVisualBadge: "Visual",
domainAudioBadge: "Audio",
audioVoiceClipCount: "{count} clips",
audioVoiceClipCount: "{count} {count|clip|clips}",
audioSearchCharacters: "Search characters or emotions",
audioPlay: "Play",
audioPause: "Pause",
@@ -3372,7 +3392,7 @@ export const i18n = {
audioGuideFilesTitle: "1. Import characters",
audioGuideFilesBody: "Press \"Import character\" or drop the GPT and SoVITS models and reference clips here (several characters or whole folders work too, sorted by name). On a card, \"Edit emotions\" picks a reference clip per emotion and \"Pronunciation\" fixes words it keeps misreading. Where characters are kept and pretrained files are under \"GPT-SoVITS settings\" at the bottom of the sidebar.",
audioGuideDragTitle: "Drag: a character onto a node",
audioGuideDragBody: "Drag a character card's header (or the character in the sidebar) onto a \"角色语音 (GPT-SoVITS)\" node to select that character. While dragging, the hint says what releasing will do; a wrong node is refused with the reason, and nothing is written.",
audioGuideDragBody: "Drag a character card's header (or the character in the sidebar) onto a \"Character Speech (GPT-SoVITS)\" node to select that character. While dragging, the hint says what releasing will do; a wrong node is refused with the reason, and nothing is written.",
audioGuideTagsTitle: "Emotions come from {tags} in the text",
audioGuideTagsBody: "Tags in the text switch emotion: {happy} (or {开心}) uses that emotion's reference, {main} goes back to the main voice. Characters with emotions have a “Copy {tag}” button on each row. [Character] changes speaker and [pause:1s] adds a pause, written straight into the text.",
audioGuideScriptTitle: "Voice-over: no hand-written tags",
@@ -3381,7 +3401,7 @@ export const i18n = {
audioTourImportTitle: "Import characters",
audioTourImportBody: "Import the GPT and SoVITS models and reference clips here, or drop files or a whole folder onto this page.",
audioTourDragTitle: "Drag onto a node",
audioTourDragBody: "Hold a character card's header and drop it on a \"角色语音 (GPT-SoVITS)\" node on the canvas to switch it to that character.",
audioTourDragBody: "Hold a character card's header and drop it on a \"Character Speech (GPT-SoVITS)\" node on the canvas to switch it to that character.",
audioTourEditTitle: "Emotions and pronunciation",
audioTourEditBody: "\"Edit emotions\" picks a reference clip per emotion, \"Pronunciation\" fixes words it keeps misreading, \"Add files\" adds clips to this character.",
audioTourTagTitle: "Emotion tags",
@@ -3442,7 +3462,7 @@ export const i18n = {
ttsEmptyTitle: "No characters yet",
ttsEmptyDesc: "Drop a GPT model (.ckpt), a SoVITS model (.pth) and reference clips here, or press the button below, to make your first character.",
ttsSetupTitle: "GPT-SoVITS settings",
ttsSetupCharacters: "{count} characters",
ttsSetupCharacters: "{count} {count|character|characters}",
ttsSetupDownloading: "downloading pretrained files…",
ttsSetupPackagesMissing: "{count} Python packages missing",
ttsSetupPretrained: "Pretrained files",
@@ -3498,7 +3518,7 @@ export const i18n = {
ttsBatchNewDraft: "+ New character",
ttsBatchUnnamed: "Unnamed character",
ttsBatchTray: "Unassigned",
ttsBatchSubmit: "Import {count} characters",
ttsBatchSubmit: "Import {count} {count|character|characters}",
ttsBatchMoveTo: "Which character it belongs to",
ttsBatchRowProblem: "A file failed or cannot be imported: deal with the red row",
ttsBatchDuplicateName: "Two characters in the list are both named \"{name}\"; rename one",
@@ -3522,10 +3542,10 @@ export const i18n = {
ttsLayoutFoot: "A GPT-SoVITS package works too, choose the whole package: the models in GPT_weights and SoVITS_weights are sorted into characters by file name. Files that cannot be sorted are listed for you to assign.",
ttsMenuUploadHint: "upload from the browser",
ttsAddMore: "+ Add more",
ttsToolbarFound: "{count} characters",
ttsToolbarFound: "{count} {count|character|characters}",
ttsToolbarAdding: "Adding to \"{name}\"",
ttsFooterWaiting: "{count} need a look",
ttsFooterTray: "{count} files unassigned",
ttsFooterTray: "{count} {count|file|files} unassigned",
ttsFooterSaveTo: "Saved to {path}",
ttsFooterImportOne: "Import",
ttsDropCover: "Drop to add",
@@ -3537,14 +3557,14 @@ export const i18n = {
ttsCardEmotionProblem: "emotion name",
ttsCardGpt: "GPT model",
ttsCardSovits: "SoVITS model",
ttsCardClips: "{count} clips",
ttsCardClips: "{count} {count|clip|clips}",
ttsCardAddTo: "Add to",
ttsCardNamePlaceholder: "Name the character",
ttsCardNameTitle: "Character name, edit it here",
ttsCardFold: "Fold",
ttsCardUnfold: "Unfold",
ttsCardRemove: "Remove this character",
ttsCardRemoveConfirm: "Remove \"{name}\" and its {count} files? The originals are not touched.",
ttsCardRemoveConfirm: "Remove \"{name}\" and its {count} {count|file|files}? The originals are not touched.",
ttsCardClipsTitle: "Reference clips · {count}",
ttsCardClipsHint: "Pick a clear 3–10 s clip as the main voice; give the others an emotion name such as \"happy\"",
ttsCardAddClips: "+ Add clips",
@@ -3554,7 +3574,7 @@ export const i18n = {
ttsCardMainEmotion: "main voice",
ttsCardMoveHere: "In {name}",
ttsCardMoveTo: "Move to {name}",
ttsCardTrayTitle: "{count} files match no character",
ttsCardTrayTitle: "{count} {count|file matches|files match} no character",
ttsCardTrayHint: "Pick the character each file belongs to on its right; remove the ones you do not need with ×.",
ttsStepChoose: "Choose",
ttsChooseTitle: "What do you have?",
@@ -3569,7 +3589,7 @@ export const i18n = {
ttsToolbarSingle: "New character",
ttsSoloDropHint: "You can also drop files anywhere in this window",
ttsTourReplay: "Show the guide",
ttsCardSteps: "{count} steps left",
ttsCardSteps: "{count} {count|step|steps} left",
ttsCardPickFile: "Choose file",
ttsCardPickShort: "choose",
ttsCheckGpt: "Add the GPT model (.ckpt, in the package's GPT_weights folder)",
@@ -3588,7 +3608,7 @@ export const i18n = {
ttsCheckNameDone: "Name: {name}",
ttsCheckNameGo: "Enter name",
ttsCheckFiles: "Add reference clips, line files or new models",
ttsCheckFilesDone: "{count} files to add",
ttsCheckFilesDone: "{count} {count|file|files} to add",
ttsBatchNoMain: "Pick a 3–10 s clip as the main voice",
ttsTourSingleTitle: "Just one character? Start here",
ttsTourSingleBody: "You're walked through the GPT model, the SoVITS model and the reference clips, with the button for each step right there.",
@@ -3608,20 +3628,20 @@ export const i18n = {
ttsTourImportBody: "This button works once the checklist is complete. Files are copied; the originals stay.",
ttsCheckAudioNoneUsable: "None of these clips is 3–10 s long; add a few that are",
ttsImportLeftOut: "left out",
ttsCardShowAll: "Show all {count} clips",
ttsCardLeftOut: "Left out · {count} clips not 3–10 s long (the node cannot use them), with their line files",
ttsCardTrayFolder: "{folder} · {count} files",
ttsCardShowAll: "Show all {count} {count|clip|clips}",
ttsCardLeftOut: "Left out · {count} {count|clip|clips} not 3–10 s long (the node cannot use them), with their line files",
ttsCardTrayFolder: "{folder} · {count} {count|file|files}",
ttsCardTrayTop: "(top level)",
ttsCardTrayMoveAll: "Move the whole folder to…",
ttsUploadLargeTitle: "A lot to upload",
ttsUploadLarge: "This would upload {count} files, {size} in all. It takes a while and the window may be sluggish meanwhile. Go ahead?",
ttsUploadLarge: "This would upload {count} {count|file|files}, {size} in all. It takes a while and the window may be sluggish meanwhile. Go ahead?",
ttsSkippedFolders: "Skipped these folders (Python environments, base models, or a package's program and training folders): {folders}.",
ttsImportKeptBestMany: "Each character uses its GPT and SoVITS weights of the latest epoch ({count} others not used).",
ttsImportKeptBestMany: "Each character uses its GPT and SoVITS weights of the latest epoch ({count} {count|other|others} not used).",
ttsImportAddTitle: "Add files · {name}",
ttsImportNameTaken: "There is already a character named \"{name}\".",
ttsImportNameTakenVariant: "\"{name}\" already has these versions: {variants}. Please pick another name.",
ttsImportSwitchToAdd: "Add to that character instead",
ttsImportKeptBest: "{kind}: using the latest epoch, {file} ({count} others left out).",
ttsImportKeptBest: "{kind}: using the latest epoch, {file} ({count} {count|other|others} left out).",
ttsImportUnsupportedBlock: "{file} is a {version} model, which is not supported yet. Use a v1, v2, v2Pro or v2ProPlus SoVITS weight.",
ttsImportOutOfRange: "Not 3–10 s long: the node cannot use it, so it is left out",
ttsTextSource_txt: "Line from the .txt of the same name",
@@ -3633,12 +3653,12 @@ export const i18n = {
ttsTextSource_none: "No line found: paste it, or drop a .txt / .lab / .list on the box. Left empty, the clip is used without reference text (lower quality).",
ttsImportPickFiles: "Choose files…",
ttsImportSkipped: "Skipped unsupported or duplicate files: {files}",
ttsImportRenamed: "{count} files had the same name as another file of the character; their folder's name was put in front.",
ttsImportRenamed: "{count} {count|file|files} had the same name as another file of the character; their folder's name was put in front.",
ttsImportRenamedFrom: "Renamed; original file: {file}",
ttsBatchLater: "Many files: one batch is open; {count} more characters are in the next batch, which opens once this one is imported.",
ttsBatchNext: "Next batch ({count} characters)",
ttsBatchImported: "The {count} characters of the last batch are imported; here is the next batch.",
ttsFooterPending: "{count} characters in the next batch",
ttsBatchNext: "Next batch ({count} {count|character|characters})",
ttsBatchImported: "The {count} {count|character|characters} of the last batch are imported; here is the next batch.",
ttsFooterPending: "{count} {count|character|characters} in the next batch",
ttsPreviewStop: "Stop",
ttsPreviewFailed: "Cannot play: {error}",
ttsPreviewUnreadable: "the browser cannot read this audio",
@@ -3765,7 +3785,7 @@ export const i18n = {
audioSeedLabel: "seed {seed}",
audioExpandSpeech: "Show / hide the full speech",
audioLoadFailed: "Failed to load: {error}",
audioTrackCount: "{shown} / {total} tracks",
audioTrackCount: "{shown} / {total} {total|track|tracks}",
audioLoadMore: "Load more",
audioDeleteFailed: "Delete failed: {error}",
scriptDirectorPickNode: "Several {node} nodes are on the canvas. Select the one to write to first.",
@@ -3775,9 +3795,9 @@ export const i18n = {
scriptDirectorEmotionsAvailable: "Available emotions: {emotions}",
scriptDirectorMainEmotion: "main",
scriptDirectorEdit: "Edit the text",
scriptDirectorLinesCount: "{count} lines",
scriptDirectorLinesCount: "{count} {count|line|lines}",
scriptDirectorDeleteLine: "Delete line",
scriptDirectorPackageSummary: "{count} lines · using {emotions}",
scriptDirectorPackageSummary: "{count} {count|line|lines} · using {emotions}",
scriptDirectorCopy: "Copy script",
scriptDirectorCopied: "Copied",
scriptDirectorDragHandle: "Drag to node",
@@ -3814,11 +3834,14 @@ export function resolveLocale(value = globalThis.anomalous_browser_lang) {
return normalizeLocale(value) || DEFAULT_LOCALE;
}
export function formatTranslation(template, params = {}) {
export // `{count}` is the value; `{count|file|files}` picks the word for one or for any other number.
function formatTranslation(template, params = {}) {
if (typeof template !== 'string') return template;
return template.replace(/\{([A-Za-z0-9_.-]+)\}/g, (token, key) => (
hasOwn(params, key) ? String(params[key]) : token
));
return template.replace(/\{([A-Za-z0-9_.-]+)(?:\|([^|{}]*)\|([^|{}]*))?\}/g, (token, key, one, other) => {
if (!hasOwn(params, key)) return token;
if (one === undefined) return String(params[key]);
return Number(params[key]) === 1 ? one : other;
});
}
export function translate(key, params = {}, locale = resolveLocale()) {
+70
View File
@@ -0,0 +1,70 @@
/**
* The model details' location card: the file name and full path, Copy path, and Show in
* folder (Explorer / Finder opens with the file selected; only on the computer ComfyUI runs on).
*/
import { translate as t } from './locales.js';
import { anomalousAlert } from './ui_dialog.js';
function actionButton(label) {
const button = document.createElement('button');
button.type = 'button';
button.className = 'anomalous-model-location-copy';
button.textContent = label;
return button;
}
async function showInFolder(model, where) {
try {
const response = await fetch('/anomalous/open_model_folder', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...where, filename: model.filename }),
});
const data = await response.json().catch(() => ({}));
if (data.status !== 'success') throw new Error(data.message || `HTTP ${response.status}`);
} catch (error) {
await anomalousAlert(t('detailOpenFolderFailed', { error: error.message }));
}
}
/** `where`: the model's { type, path_idx, subfolder } in the browser. */
export function createModelLocationCard(model, where) {
const card = document.createElement('div');
card.className = 'anomalous-model-location';
const appendValue = (labelKey, value) => {
const row = document.createElement('div');
row.className = 'anomalous-model-location-row';
const label = document.createElement('span');
label.className = 'anomalous-model-location-label';
label.textContent = t(labelKey);
const content = document.createElement('code');
content.textContent = value;
content.title = value;
row.append(label, content);
card.appendChild(row);
};
appendValue('detailPhysicalFilename', model.filename);
if (model.file_path) {
appendValue('detailFullPath', model.file_path);
const actions = document.createElement('div');
actions.className = 'anomalous-model-location-actions';
const copyButton = actionButton(t('detailCopyPath'));
copyButton.onclick = async () => {
try {
await navigator.clipboard.writeText(model.file_path);
copyButton.textContent = t('detailPathCopied');
} catch (error) {
copyButton.textContent = t('detailPathCopyFailed');
}
setTimeout(() => { copyButton.textContent = t('detailCopyPath'); }, 1400);
};
const folderButton = actionButton(t('detailOpenFolder'));
folderButton.onclick = () => showInFolder(model, where);
actions.append(copyButton, folderButton);
card.appendChild(actions);
}
return card;
}
+2 -2
View File
@@ -40,7 +40,7 @@ const modelPath = (model) => {
};
/** The pointer's position in canvas coordinates. */
function canvasPoint(event) {
export function canvasPoint(event) {
const canvas = app.canvas;
if (canvas.convertEventToCanvasOffset) return canvas.convertEventToCanvasOffset(event);
const rect = canvas.canvas.getBoundingClientRect();
@@ -55,7 +55,7 @@ function canvasPoint(event) {
* again. While it follows, `owner.placingCombo` holds back the activity log, which is told once
* the group is down (after Esc there is nothing to log).
*/
function followPointer(owner, groupNodes, placed) {
export function followPointer(owner, groupNodes, placed) {
const half = (groupNodes[0].node.size?.[0] || 200) / 2;
const move = (event) => {
if (!app.canvas) return;
+3 -2
View File
@@ -1,6 +1,7 @@
// Shift+B ("B" for browser). Not B alone: comfyui-browser toggles its own window on a plain b
// and has no setting for it. Not Ctrl+Shift+M: Chrome and Edge take it (switch profile).
export const DEFAULT_BROWSER_SHORTCUT = Object.freeze({
key: 'm',
ctrl: true,
key: 'b',
shift: true
});
+17 -10
View File
@@ -56,22 +56,29 @@ export function closeWorkspace() {
restoreWorkspaceReturnPanel(this);
}
// The back buttons of the pages one level down: a model's details, one combo, one recipe.
const BACK_BUTTONS = '.anomalous-detail-back, .anomalous-nb-back, .anomalous-recipe-back-tab';
/**
* Esc on the recipe / combo workspace does what a click beside it does:
* `closeWorkspace()`. Not while one of this plugin's dialogs or a ComfyUI dialog sits
* above it, and not while a text field has the key (Esc there belongs to the field).
* Esc on the browser: a page one level down (a model's details, one combo or one recipe) goes
* back as its own back button does; any other page closes the browser. Not while one of this
* plugin's dialogs, overlays or menus, or a ComfyUI dialog, sits above it (they close
* themselves), and not while a text field has the key (Esc there belongs to the field). It
* listens first (capture), so a dialog that closes on this same Esc still counts as above.
* ComfyUI's own keybindings mark Esc as handled, so `defaultPrevented` says nothing here.
*/
export function bindWorkspaceEscape(owner) {
export function bindBrowserEscape(owner) {
window.addEventListener('keydown', (e) => {
if (e.key !== 'Escape') return;
if (!owner.modal?.classList.contains('visible') || owner.nbPanel?.style.display !== 'flex') return;
if (e.key !== 'Escape' || !owner.modal?.classList.contains('visible')) return;
if (e.target?.closest?.('input, textarea, select, [contenteditable="true"]')) return;
const layered = [...document.body.children, ...owner.nbPanel.children]
.some(el => [...el.classList].some(c => c.startsWith('anomalous-') && c.endsWith('overlay')) && el.getClientRects().length);
const layered = [...document.body.children, ...(owner.nbPanel?.children || [])]
.some(el => el !== owner.modal && el.getClientRects().length
&& (el.getAttribute('role') === 'menu' || [...el.classList].some(c => c.startsWith('anomalous-') && c.endsWith('overlay'))));
if (layered || document.querySelector('dialog[open], .p-dialog-mask')) return;
owner.closeWorkspace();
});
const back = [...owner.modal.querySelectorAll(BACK_BUTTONS)].find(button => button.getClientRects().length);
if (back) back.click();
else owner.close();
}, true);
}
export function hideAllPanels() {
+5 -42
View File
@@ -7,49 +7,10 @@ import { app } from "../../../scripts/app.js";
import { translate } from './locales.js';
import { escapeHtml, setSafeRichHtml } from './safe_dom.js';
import { renderSourceLine } from './model_source.js';
import { createModelLocationCard } from './model_location.js';
const t = (key, params) => translate(key, params);
function createModelLocationCard(model) {
const card = document.createElement('div');
card.className = 'anomalous-model-location';
const appendValue = (labelKey, value) => {
const row = document.createElement('div');
row.className = 'anomalous-model-location-row';
const label = document.createElement('span');
label.className = 'anomalous-model-location-label';
label.textContent = t(labelKey);
const content = document.createElement('code');
content.textContent = value;
content.title = value;
row.append(label, content);
card.appendChild(row);
};
appendValue('detailPhysicalFilename', model.filename);
if (model.file_path) {
appendValue('detailFullPath', model.file_path);
const copyButton = document.createElement('button');
copyButton.type = 'button';
copyButton.className = 'anomalous-model-location-copy';
copyButton.textContent = t('detailCopyPath');
copyButton.onclick = async () => {
try {
await navigator.clipboard.writeText(model.file_path);
copyButton.textContent = t('detailPathCopied');
} catch (error) {
copyButton.textContent = t('detailPathCopyFailed');
}
setTimeout(() => { copyButton.textContent = t('detailCopyPath'); }, 1400);
};
card.appendChild(copyButton);
}
return card;
}
export function showDetail(model) {
if (this.currentDetailObserver) {
this.currentDetailObserver.disconnect();
@@ -79,7 +40,7 @@ export function showDetail(model) {
header.style.alignItems = 'center';
header.style.boxSizing = 'border-box';
const backBtn = document.createElement('button');
const backBtn = Object.assign(document.createElement('button'), { className: 'anomalous-detail-back' }); // Esc presses it
let isFromDoctor = false;
let isFromAssistant = false;
if (this.historyStack.length > 0) {
@@ -389,7 +350,9 @@ export function showDetail(model) {
topRow.appendChild(editMetaBtn);
rightPanel.appendChild(topRow);
rightPanel.appendChild(createModelLocationCard(model));
rightPanel.appendChild(createModelLocationCard(model, {
type: this.currentType, path_idx: this.currentPathIdx, subfolder: this.currentSubfolder,
}));
// 1.5 Custom Notes Section (Google Material Card)
if (m.custom_notes) {
+10
View File
@@ -62,6 +62,11 @@ export function anomalousAlert(message, title = 'Anomalous') {
};
okBtn.onclick = cleanup;
overlay.onkeydown = (e) => {
if (e.key !== 'Escape') return;
e.stopPropagation();
cleanup();
};
footer.appendChild(okBtn);
dialog.append(heading, text, footer);
@@ -144,6 +149,11 @@ export function anomalousConfirm(message, title = 'Anomalous', options = {}) {
cancelBtn.onclick = () => finish(null);
noBtn?.addEventListener('click', () => finish(false));
okBtn.onclick = () => finish(true);
overlay.onkeydown = (e) => {
if (e.key !== 'Escape') return;
e.stopPropagation();
finish(null); // as Cancel
};
footer.append(cancelBtn);
if (noBtn) footer.append(noBtn);
+10 -1
View File
@@ -2,7 +2,8 @@
* One output image's card in the gallery: the picture (opens the viewer, or becomes a
* model's cover while one is being chosen; drags onto the canvas), Parameters (the image
* workbench), the star (ui_keep_menu.js: keep it as a workflow, a combo or its prompts;
* filled once kept as anything) and Delete (to the Recycle Bin, after a confirmation).
* filled once kept as anything), Delete (to the Recycle Bin, after a confirmation) and the
* right-click menu (gallery_card_menu.js: insert as a Load Image node, parameters).
*/
import { translate as t } from './locales.js';
@@ -10,6 +11,7 @@ import { anomalousAlert, anomalousConfirm } from './ui_dialog.js';
import { showImageWorkbench } from './ui_gallery_detail.js';
import { openKeepMenu } from './ui_keep_menu.js';
import { keptKey } from './image_keep.js';
import { openGalleryCardMenu } from './gallery_card_menu.js';
const DETAILS_ICON = `<svg class="anomalous-gallery-details-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><line x1="2" y1="5" x2="8" y2="5"></line><line x1="12" y1="5" x2="14" y2="5"></line><circle cx="10" cy="5" r="2"></circle><line x1="2" y1="11" x2="4" y2="11"></line><line x1="8" y1="11" x2="14" y2="11"></line><circle cx="6" cy="11" r="2"></circle></svg>`;
const DELETE_ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>';
@@ -136,8 +138,15 @@ export function createGalleryCard(owner, imgData, { showViewer }) {
event.dataTransfer.setData('text/plain', fullUrl);
// Chromium may not start dragging very large (hires) images without a small drag image.
if (window.anomalousDragGhostImg) event.dataTransfer.setDragImage(window.anomalousDragGhostImg, 40, 40);
// The browser fades out (as for other cards) so the canvas and its nodes take the drop;
// later than now, so the drag image is captured first.
setTimeout(() => owner.modal?.classList.add('anomalous-material-dragging'), 0);
});
card.addEventListener('dragend', () => owner.modal?.classList.remove('anomalous-material-dragging'));
img.onclick = () => (owner.gallerySelectModel ? useAsCover(owner, image) : showViewer(url));
card.addEventListener('contextmenu', (event) => {
if (!owner.gallerySelectModel) openGalleryCardMenu(event, image, { owner, openDetails: () => openWorkbench(owner, image, url) });
});
const details = button('anomalous-gallery-details', t('materialViewDetails'), () => {
if (!owner.gallerySelectModel) openWorkbench(owner, image, url);
+11 -1
View File
@@ -33,6 +33,16 @@ import {
const t = (key, params) => translate(key, params);
// Inputs that keep the focus after a click but take no typing: the arrows and A / D still
// switch pictures after a node's checkbox in All parameters was ticked.
const NON_TEXT_INPUTS = new Set(['checkbox', 'radio', 'button', 'submit', 'reset', 'range', 'color', 'file']);
function isTyping(target) {
if (!target) return false;
if (target.isContentEditable || target.tagName === 'TEXTAREA') return true;
return target.tagName === 'INPUT' && !NON_TEXT_INPUTS.has(target.type);
}
// In-memory LRU metadata cache: key -> { inspectPayload, clientWorkflow, clientDetails, params }
const imageMetadataCache = new Map();
// A workflow can contain large prompt strings and hundreds of nodes. Keep only
@@ -547,7 +557,7 @@ export async function showImageWorkbench(owner, sourceImage, imageUrl, options =
const onKeyDown = (e) => {
if (wb.inspectingModel) return;
if (document.querySelector('.anomalous-dialog-overlay')) return;
if (e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA')) return;
if (isTyping(e.target)) return;
if (e.key === 'Escape') {
e.preventDefault();
+5 -5
View File
@@ -115,7 +115,7 @@ function buildSpecsGrid(params, blocks, item, suggestedName, context) {
};
addTile(t('materialParamSeed') || '种子 (Seed)', params.seed, { copyable: true, mono: true, accent: 'seed' });
addTile(t('recipeCardSpecsSteps') || '采样步数 (Steps)', params.steps != null ? `${params.steps} 步` : null, { });
addTile(t('recipeCardSpecsSteps') || '采样步数 (Steps)', params.steps != null ? t('workbenchStepsValue', { count: params.steps }) : null, { });
addTile('CFG Scale', params.cfg, { });
addTile(t('materialDenoise') || '重绘降噪 (Denoise)', params.denoise, { });
addTile(t('recipeCardSpecsSampler') || '采样器 (Sampler)', params.sampler_name, { });
@@ -216,7 +216,7 @@ function buildModelsSection(orderedRefs, groups, onOpenModel) {
wrap.className = 'anomalous-workbench-models-wrap';
if (!orderedRefs || !orderedRefs.length) {
const empty = text(wrap, 'div', '(未检测到模型引用)', 'anomalous-workbench-muted');
const empty = text(wrap, 'div', t('workbenchNoModels'), 'anomalous-workbench-muted');
empty.style.padding = '12px';
return wrap;
}
@@ -259,7 +259,7 @@ function buildModelsSection(orderedRefs, groups, onOpenModel) {
const viewBtn = document.createElement('button');
viewBtn.type = 'button';
viewBtn.className = 'anomalous-workbench-mini-action-btn';
viewBtn.textContent = '🔎 定位模型';
viewBtn.textContent = t('workbenchLocateModel');
viewBtn.onclick = (e) => {
e.stopPropagation();
onOpenModel(item.localModel);
@@ -292,7 +292,7 @@ function buildWorkflowNodesSection(blocks, clientWorkflow, item, suggestedName,
wrap.className = 'anomalous-workbench-nodes-wrap';
if (!blocks || !blocks.length) {
text(wrap, 'div', '(无底层节点参数数据)', 'anomalous-workbench-muted');
text(wrap, 'div', t('workbenchNoNodeParams'), 'anomalous-workbench-muted');
return wrap;
}
@@ -343,7 +343,7 @@ function buildWorkflowNodesSection(blocks, clientWorkflow, item, suggestedName,
copyJsonBtn.textContent = `📋 ${t('workbenchCopyWorkflowJson') || '复制完整工作流 JSON'}`;
copyJsonBtn.onclick = () => {
if (clientWorkflow) {
copyToClipboard(JSON.stringify(clientWorkflow, null, 2), copyJsonBtn, '✅ 已复制工作流 JSON', `📋 ${t('workbenchCopyWorkflowJson') || '复制完整工作流 JSON'}`);
copyToClipboard(JSON.stringify(clientWorkflow, null, 2), copyJsonBtn, t('workbenchCopiedWorkflowJson'), `📋 ${t('workbenchCopyWorkflowJson') || '复制完整工作流 JSON'}`);
}
};
jsonActionRow.appendChild(copyJsonBtn);
+2 -2
View File
@@ -13,7 +13,7 @@ import { renderRailTools } from './ui_rail_tools.js';
import { getActiveDomain } from './ui_domain_switcher.js';
import { renderAudioSidebar } from './ui_audio_sidebar.js';
import { createGallerySearchBar } from './ui_gallery.js';
import { bindWorkspaceEscape } from './ui_browser_navigation.js';
import { bindBrowserEscape } from './ui_browser_navigation.js';
import { createShellRail } from './ui_shell_rail.js';
import { installShellNavigation } from './ui_shell_nav.js';
import { bindShellDrag, bindShellResize } from './ui_shell_frame.js';
@@ -216,7 +216,7 @@ export function createDOM() {
this.nbPanel.onclick = (e) => {
if (e.target === this.nbPanel) this.closeWorkspace();
};
bindWorkspaceEscape(this);
bindBrowserEscape(this);
content.append(header, this.grid, this.detailPanel, this.galleryPanel, this.homePanel, this.activityPanel, this.scanPanel, this.settingsPanel, this.doctorPanel,
this.assistantPanel, this.audioStudioPanel, this.scriptPanel, this.audioGalleryPanel);
+1 -1
View File
@@ -1,7 +1,7 @@
@import url("./styles/00-foundation-models.css?v=20261003-settings-1");
@import url("./styles/01-shell-tools.css?v=20261003-settings-1");
@import url("./styles/02-notebooks.css?v=20261009-click-1");
@import url("./styles/03-gallery-recipes.css?v=20261009-click-1");
@import url("./styles/03-gallery-recipes.css?v=20261013-menu-1");
@import url("./styles/04-dialogs-materials.css?v=20261004-studio-1");
@import url("./styles/05-recipe-studio-overrides.css?v=20261002-theme-1");
@import url("./styles/06-material-workbench.css?v=20261004-studio-1");
+13 -1
View File
@@ -198,7 +198,13 @@
display: none;
}
/* The keep menu under a star or the workbench's Keep (ui_keep_menu.js). */
/* The keep menu under a star or the workbench's Keep (ui_keep_menu.js); a gallery card's
right-click menu (gallery_card_menu.js) wears the same look, as wide as its rows. */
.anomalous-gallery-card-menu {
width: max-content;
min-width: 200px;
}
.anomalous-keep-menu {
position: fixed;
z-index: 1000001;
@@ -1213,6 +1219,12 @@ dialog.anomalous-gallery-viewer[open] {
text-overflow: ellipsis;
}
.anomalous-model-location-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.anomalous-model-location-copy {
justify-self: start;
padding: 4px 9px;