Compare commits
14
Commits
@@ -108,7 +108,7 @@ ComfyUI-Manager 收录审核检查的就是这几类口子(2026-10 审 Anomalo
|
||||
1. **不执行下载来的或用户文件里的代码。** 数据不用 `pickle`、`eval`、`exec`、动态导入读;配置文件当数据解析。模型文件只读 safetensors / GGUF 的文件头,`.ckpt` / `.pt` / `.pth` / `.bin` 不打开,界面提醒它们可能带代码。
|
||||
2. **下载要能核对。** 只从允许的 https 站点下载,先写 `.part`,核对 SHA-256 后才改名放入;没有可核对的指纹时在界面上说明。下载的东西只当数据用。
|
||||
3. **接口不接受任意本机路径。** 只收“类型 + 根目录序号 + 相对路径”,在后端用 `resolve_within` 校验;需要指向本机某个文件时,由服务端自己找到它,只给页面一个令牌。
|
||||
4. **会写入、移动、删除文件或替用户联网的接口只接受本机请求**(请求来自本机,没有经过代理,Host 也是本机)。用 `--listen` 开放到局域网时,别的设备只能查看。新接口必须做到;已有接口还没全部做到,改到哪个补哪个。
|
||||
4. **会写入、移动、删除文件或替用户联网的接口只接受本机请求**(请求来自本机,没有经过代理,Host 也是本机)。用 `--listen` 开放到局域网时,别的设备只能查看。由 `api/local_guard.py` 的中间件统一把关:`/anomalous/` 下 GET 以外的请求默认只接受本机,确实只读的 POST 列进 `OPEN_POSTS`,不只是读的 GET(备份文件、联网、本机文件夹)列进 `LOCAL_GETS`;新接口要想清楚属于哪一类,不要为了方便放进 `OPEN_POSTS`。
|
||||
5. **用户的文件删除一律进回收站,没有回收站就拒绝;从不覆盖**(同名另存为“名字 (2)”或拒绝)。插件自己产生的临时文件(`.part`、暂存区)可以直接删。
|
||||
6. **只读的接口也按第 3 条来。** “只是读”不是例外;能按名字、编号取的,就不收路径,由服务端在自己登记的范围里找到文件。
|
||||
7. **读用户文件要有上限。** 限制读取的大小和条数;读不了或超大的文件跳过并在结果里说明,不让一个坏文件拖垮整个请求。读到的内容进页面只当纯文本(本节第二条)。
|
||||
|
||||
+8
-2
@@ -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.
|
||||
@@ -111,6 +113,8 @@ covered by the `styles.css` manifest.
|
||||
lifecycle; `api/materials.py` owns HTTP mapping and compatibility entry points.
|
||||
`api/node_material.py` saves one canvas node's current values as a
|
||||
`node_parameter_selection` material (the current-node panel's "save").
|
||||
- `api/local_guard.py` decides which requests only this computer may make (every change under
|
||||
`/anomalous/`; other devices may look), as one middleware; see backend.md.
|
||||
- `api/media_routes.py`, `api/gallery_routes.py`, `api/translation_routes.py`, and
|
||||
`api/folder_types.py` own the formerly mixed utility route families.
|
||||
- `model_policies.py` owns shared backend rename and protected-category policy.
|
||||
@@ -294,7 +298,8 @@ covered by the `styles.css` manifest.
|
||||
seeking, search, paging, download and deletion, showing the speech/voice/seed
|
||||
recorded in each file, plus a section for unsaved temp previews that can be
|
||||
copied into `output/audio/`.
|
||||
- `ui_sidebar.js` assembles the browser window (rail, list column, header, page panels)
|
||||
- `ui_sidebar.js` assembles the browser window (rail, list column, header, page panels;
|
||||
`remote_view.js` adds the header's "view only" chip when opened from another device)
|
||||
and renders the model folder list. `ui_shell_rail.js` owns the left icon rail: one
|
||||
entry per page, the tool slots and the settings slot. `ui_shell_nav.js` owns page
|
||||
navigation (`goTo`): the domain each page needs, which pages have a list and whether
|
||||
@@ -346,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,
|
||||
@@ -373,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
|
||||
|
||||
@@ -1,5 +1,34 @@
|
||||
# 📈 Anomalous Model Browser Changelog
|
||||
|
||||
## v1.58.3 Beta (Fix: canvas speed with other extensions) — 2026-10-15
|
||||
|
||||
### 🐢 The canvas stays smooth with other extensions (画布不再被其他插件拖慢)
|
||||
- One style rule of the browser (the list column stepping aside while a workspace is open) made every inline-style change on the page, by any extension and also while the browser is closed, restyle the whole page. Extensions that move their buttons on every canvas frame (ComfyUI-Prompt-Assistant, for one) then brought a large workflow's canvas down to 2–3 fps. The list column now follows a class on the window instead. Thanks to **@Lindemellis** for finding it, measuring it and proposing the fix ([#22](https://github.com/DemonGatanjieu/Anomalous_Model_Browser/issues/22)).
|
||||
|
||||
## 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 (别的设备只能看,不能改)
|
||||
- When ComfyUI is started with `--listen`, other devices on your network can open it. Until now they could also use most of Anomalous Model Browser's actions: delete or rename models, save and delete workflows, combos and prompts, start downloads and scans, export a backup of all your data, translate with your translation key, change settings, switch the plugin's version. Now every change answers only the computer ComfyUI runs on; other devices can still browse models, pictures and workflows.
|
||||
- A window opened from another device shows **View only** next to the page title; pressing a button that changes something there says why it did nothing.
|
||||
- A web page that reaches ComfyUI through another name for this computer, or a proxy, counts as another device too.
|
||||
|
||||
## v1.58.0 Beta (New Layout, Keep & Reuse, Prompt Studio, Model Check, Voices) — 2026-10-09
|
||||
|
||||
### 🧭 New layout (新布局)
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
[](https://github.com/ltdrdata/ComfyUI-Manager)
|
||||
[](LICENSE)
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](https://www.bilibili.com/video/BV1a1bv68EuA/)
|
||||
[](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` 文件夹。更新插件前顺手备份一份,更安心。
|
||||
|
||||
|
||||
+7
-2
@@ -1,7 +1,7 @@
|
||||
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,
|
||||
translation_routes, version_manager,
|
||||
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 *
|
||||
from .config import *
|
||||
@@ -29,6 +29,10 @@ async def no_cache_extension_middleware(request, handler):
|
||||
def setup_routes(app):
|
||||
if hasattr(app, 'middlewares') and no_cache_extension_middleware not in app.middlewares:
|
||||
app.middlewares.append(no_cache_extension_middleware)
|
||||
# Other devices on the network may look; only this computer may change things (local_guard.py).
|
||||
if hasattr(app, 'middlewares') and local_guard.local_only_middleware not in app.middlewares:
|
||||
app.middlewares.append(local_guard.local_only_middleware)
|
||||
app.router.add_get('/anomalous/access', local_guard.api_access)
|
||||
app.router.add_get('/anomalous/folders', model_catalog.api_get_folders)
|
||||
app.router.add_get('/anomalous/all_folder_types', folder_types.api_get_all_folder_types)
|
||||
app.router.add_get('/anomalous/models', model_catalog.api_get_models)
|
||||
@@ -44,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)
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"""What only this computer may do. ComfyUI started with --listen lets other devices on the
|
||||
network open the page: they may look (lists, searches, pictures, covers), but every change
|
||||
under /anomalous/ (writing, moving and deleting files, downloads, scans, translations, backups,
|
||||
version switches, saved settings) answers only requests from the computer ComfyUI runs on
|
||||
(AGENTS.md 安全底线, rule 4). One middleware decides it for all routes: a request other than
|
||||
GET is local-only unless it is a read sent as POST (OPEN_POSTS); a GET is open unless it is not
|
||||
a plain read (LOCAL_GETS). A request is local when it comes from a loopback address, through no
|
||||
proxy, naming a loopback host; checking the Host also keeps out a page that reached this server
|
||||
under another name (DNS rebinding)."""
|
||||
|
||||
import ipaddress
|
||||
import urllib.parse
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
PREFIX = "/anomalous/"
|
||||
PROXY_HEADERS = ("Forwarded", "X-Forwarded-For", "X-Real-IP", "X-Forwarded-Host")
|
||||
LOCAL_NAMES = ("localhost",)
|
||||
# Reads sent as POST (their lists are long): open to other devices too. They write nothing.
|
||||
OPEN_POSTS = frozenset({
|
||||
"/anomalous/resolve_paths_to_previews",
|
||||
"/anomalous/resolve_hash_batch",
|
||||
"/anomalous/inspect_image_material",
|
||||
})
|
||||
# GETs that are more than a read: a backup of all your data, a call to GitHub, your folders.
|
||||
LOCAL_GETS = frozenset({
|
||||
"/anomalous/backup/file",
|
||||
"/anomalous/version/releases",
|
||||
"/anomalous/import/folders",
|
||||
})
|
||||
# Routes that answer other devices themselves (MCP replies in JSON-RPC).
|
||||
OWN_CHECK = frozenset({"/anomalous/mcp"})
|
||||
READS = ("GET", "HEAD", "OPTIONS")
|
||||
MESSAGE = ("只能在运行 ComfyUI 的电脑上操作,别的设备只能浏览。"
|
||||
" Only the computer ComfyUI runs on can do this; other devices can only look.")
|
||||
|
||||
|
||||
def loopback_host(name):
|
||||
if not name:
|
||||
return False
|
||||
name = name.strip("[]").lower()
|
||||
if name in LOCAL_NAMES:
|
||||
return True
|
||||
try:
|
||||
return ipaddress.ip_address(name).is_loopback
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def is_local(request):
|
||||
"""The request comes from this computer, directly, and names a loopback host."""
|
||||
if not loopback_host(request.remote or ""):
|
||||
return False
|
||||
if any(header in request.headers for header in PROXY_HEADERS):
|
||||
return False
|
||||
return loopback_host(urllib.parse.urlsplit("//" + request.headers.get("Host", "")).hostname)
|
||||
|
||||
|
||||
def needs_local(method, path):
|
||||
"""Whether a request to `path` with `method` must come from this computer."""
|
||||
if not path.startswith(PREFIX) or path in OWN_CHECK:
|
||||
return False
|
||||
if method.upper() in READS:
|
||||
return path in LOCAL_GETS
|
||||
return path not in OPEN_POSTS
|
||||
|
||||
|
||||
def refusal():
|
||||
return web.json_response({"status": "error", "success": False, "error": "local_only", "code": "local_only",
|
||||
"message": MESSAGE}, status=403)
|
||||
|
||||
|
||||
@web.middleware
|
||||
async def local_only_middleware(request, handler):
|
||||
if needs_local(request.method, request.path) and not is_local(request):
|
||||
return refusal()
|
||||
return await handler(request)
|
||||
|
||||
|
||||
async def api_access(request):
|
||||
"""GET /anomalous/access - whether this page may change things: {"local": bool}."""
|
||||
return web.json_response({"local": is_local(request)})
|
||||
@@ -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"})
|
||||
@@ -40,6 +40,20 @@ inventory recognizes GGUF and PTH case-insensitively in addition to the existing
|
||||
list formats. This route-level inventory does not expand global model-format,
|
||||
scanner, metadata-parser, recovery, or cloud-identification support.
|
||||
|
||||
## Requests from other devices
|
||||
|
||||
ComfyUI started with `--listen` lets other devices on the network open the page. They may
|
||||
look; every change under `/anomalous/` answers only the computer ComfyUI runs on. One
|
||||
middleware (`api/local_guard.py`, registered first in `setup_routes`) decides it for all routes:
|
||||
a request other than GET must be local unless its path is in `OPEN_POSTS` (reads sent as POST:
|
||||
`resolve_paths_to_previews`, `resolve_hash_batch`, `inspect_image_material`); a GET is open
|
||||
unless its path is in `LOCAL_GETS` (`backup/file`, `version/releases`, `import/folders`).
|
||||
`/anomalous/mcp` answers other devices itself. Local means: a loopback peer, no proxy headers
|
||||
(`Forwarded`, `X-Forwarded-For`, `X-Real-IP`, `X-Forwarded-Host`) and a loopback `Host`, so a
|
||||
page that reached the server under another name (DNS rebinding) is refused too. A refusal is
|
||||
HTTP 403 with `code: local_only` and a bilingual message. `GET /anomalous/access` tells the page
|
||||
whether it is local; `remote_view.js` then shows a "view only" chip in the header.
|
||||
|
||||
## Storage ownership
|
||||
|
||||
Runtime UI settings and newly stored API keys live in `api/config.json`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }
|
||||
],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+83
-56
@@ -402,6 +402,11 @@ export const i18n = {
|
||||
workbenchZoomReset: '1:1 实际尺寸',
|
||||
workbenchFilterNodes: '搜索/过滤节点…',
|
||||
workbenchCopyWorkflowJson: '复制完整工作流 JSON',
|
||||
workbenchCopiedWorkflowJson: '✅ 已复制工作流 JSON',
|
||||
workbenchStepsValue: '{count} 步',
|
||||
workbenchNoModels: '(未检测到模型引用)',
|
||||
workbenchNoNodeParams: '(无底层节点参数数据)',
|
||||
workbenchLocateModel: '🔎 定位模型',
|
||||
materialInspecting: '正在读取图片中的工作流与参数…',
|
||||
materialInspectError: "这张图片没有可复用的完整 UI 工作流。",
|
||||
materialSaveHint: '完整工作流保留精确种子;作为单节点参数应用时会跳过种子。',
|
||||
@@ -571,6 +576,8 @@ export const i18n = {
|
||||
detailBackAssistant: '⬅ 返回助手面板',
|
||||
detailJumpToBottom: '跳到底部',
|
||||
detailEdit: '编辑',
|
||||
remoteViewChip: '仅浏览',
|
||||
remoteViewHint: '这个窗口是从别的设备打开的:可以浏览;删除、移动、下载、扫描、保存这类改动,只能在运行 ComfyUI 的电脑上做。',
|
||||
detailNotesTitle: '📓 专属备注 (Notes)',
|
||||
detailEditNotes: '编辑备注',
|
||||
detailGeneratedGallery: '查看历史生成作品',
|
||||
@@ -592,6 +599,11 @@ export const i18n = {
|
||||
detailCopyPath: '复制路径',
|
||||
detailPathCopied: '已复制',
|
||||
detailPathCopyFailed: '复制失败',
|
||||
detailOpenFolder: '打开所在文件夹',
|
||||
detailOpenFolderFailed: '打不开文件夹:{error}',
|
||||
galleryInsertAsNode: '作为“加载图像”节点插入工作流',
|
||||
galleryInsertNodeFollow: '“加载图像”节点跟着鼠标走:点一下放下,Esc 取消',
|
||||
galleryInsertNodeFailed: '没能放入“加载图像”节点:图片没能复制到 input 文件夹,或画布还没准备好',
|
||||
detailCustomNamePlaceholder: '自定义名称 (留空则使用原名)',
|
||||
detailNotesPlaceholder: '📓 专属备注... (支持多行)',
|
||||
detailPhysicalRename: '同步物理重命名',
|
||||
@@ -2044,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?",
|
||||
@@ -2092,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',
|
||||
@@ -2124,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…",
|
||||
@@ -2186,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}",
|
||||
@@ -2211,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}) ›",
|
||||
@@ -2298,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.',
|
||||
@@ -2324,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',
|
||||
@@ -2366,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})',
|
||||
@@ -2388,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.",
|
||||
@@ -2467,6 +2484,8 @@ export const i18n = {
|
||||
detailBackAssistant: '⬅ Back to Assistant',
|
||||
detailJumpToBottom: 'Jump to bottom',
|
||||
detailEdit: 'Edit',
|
||||
remoteViewChip: 'View only',
|
||||
remoteViewHint: 'This window was opened from another device: you can look around; deleting, moving, downloading, scanning, saving and other changes can only be made on the computer ComfyUI runs on.',
|
||||
detailNotesTitle: '📓 Custom Notes',
|
||||
detailEditNotes: 'Edit Notes',
|
||||
detailGeneratedGallery: 'View Generated Gallery',
|
||||
@@ -2488,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',
|
||||
@@ -2503,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',
|
||||
@@ -2577,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',
|
||||
@@ -2604,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: ',
|
||||
@@ -2662,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',
|
||||
@@ -2724,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})",
|
||||
@@ -2736,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",
|
||||
@@ -2780,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}",
|
||||
@@ -3035,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",
|
||||
@@ -3056,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.",
|
||||
@@ -3112,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.',
|
||||
@@ -3210,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',
|
||||
@@ -3245,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',
|
||||
@@ -3342,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',
|
||||
@@ -3356,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",
|
||||
@@ -3368,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",
|
||||
@@ -3377,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",
|
||||
@@ -3438,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",
|
||||
@@ -3494,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",
|
||||
@@ -3518,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",
|
||||
@@ -3533,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",
|
||||
@@ -3550,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?",
|
||||
@@ -3565,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)",
|
||||
@@ -3584,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.",
|
||||
@@ -3604,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",
|
||||
@@ -3629,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",
|
||||
@@ -3761,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.",
|
||||
@@ -3771,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",
|
||||
@@ -3810,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()) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Opened from another device (ComfyUI started with --listen): a chip in the header says this
|
||||
* window can look but not change anything (the server refuses changes from other devices,
|
||||
* api/local_guard.py). Nothing shows on the computer ComfyUI runs on.
|
||||
*/
|
||||
|
||||
import { translate as t } from './locales.js';
|
||||
|
||||
/** Asks the server once whether this page is on its computer; when not, marks the window and adds the chip to `group`. */
|
||||
export async function markRemoteView(container, group) {
|
||||
try {
|
||||
const response = await fetch('/anomalous/access', { cache: 'no-store' });
|
||||
const data = response.ok ? await response.json() : null;
|
||||
if (!data || data.local !== false) return;
|
||||
} catch {
|
||||
return; // an older server without the route: nothing to say
|
||||
}
|
||||
container.classList.add('anomalous-remote-view');
|
||||
const chip = document.createElement('span');
|
||||
chip.className = 'anomalous-remote-chip';
|
||||
chip.textContent = t('remoteViewChip');
|
||||
chip.title = t('remoteViewHint');
|
||||
group.append(chip);
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* The browser window (rail, list column, header, page panels) and the model folder list.
|
||||
*/
|
||||
|
||||
import { markRemoteView } from './remote_view.js';
|
||||
import { translate } from './locales.js';
|
||||
import { escapeHtml } from './safe_dom.js';
|
||||
import { showUpdateGuide } from './ui_update_guide.js';
|
||||
@@ -12,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';
|
||||
@@ -157,6 +158,7 @@ export function createDOM() {
|
||||
|
||||
rightGroup.append(updateNoticeBtn, dockBtn, closeBtn);
|
||||
header.append(leftGroup, centerGroup, rightGroup);
|
||||
markRemoteView(container, leftGroup); // opened from another device: "view only"
|
||||
|
||||
this.grid = panel('anomalous-grid', '');
|
||||
this.detailPanel = panel('anomalous-detail');
|
||||
@@ -214,11 +216,17 @@ 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);
|
||||
container.append(rail.root, this.sidebarWrapper, content, this.nbPanel);
|
||||
// The list column steps aside while a workspace is open. Several modules open it by
|
||||
// setting its display, so the container gets a class for it; CSS matching the style
|
||||
// attribute inside :has() makes every inline-style write on the page recalc all
|
||||
// styles (issue #22, canvas at 2-3 fps).
|
||||
const syncWorkspaceOpen = () => container.classList.toggle('anomalous-workspace-open', this.nbPanel.style.display === 'flex');
|
||||
new MutationObserver(syncWorkspaceOpen).observe(this.nbPanel, { attributes: true, attributeFilter: ['style'] });
|
||||
this.modal.appendChild(container);
|
||||
|
||||
installShellNavigation(this, { container, rail, listToggle, title: pageTitle });
|
||||
|
||||
+2
-2
@@ -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,7 +13,7 @@
|
||||
@import url("./styles/12-tts-setup.css?v=20261002-theme-1");
|
||||
@import url("./styles/13-script-director.css?v=20261002-voiceover-2");
|
||||
@import url("./styles/14-version-manager.css?v=20261002-theme-1");
|
||||
@import url("./styles/15-shell-rail.css?v=20261003-feedback-2");
|
||||
@import url("./styles/15-shell-rail.css?v=20261015-workspace");
|
||||
@import url("./styles/16-activity.css?v=20261002-theme-1");
|
||||
@import url("./styles/17-model-types.css?v=20261002-theme-1");
|
||||
@import url("./styles/18-scan-page.css?v=20261003-folders-1");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -139,7 +139,7 @@
|
||||
}
|
||||
|
||||
#anomalous-container.anomalous-has-rail.anomalous-sidebar-closed #anomalous-sidebar-wrapper,
|
||||
#anomalous-container.anomalous-has-rail:has(> .anomalous-nb-modal[style*="display: flex"]) #anomalous-sidebar-wrapper {
|
||||
#anomalous-container.anomalous-has-rail.anomalous-workspace-open #anomalous-sidebar-wrapper {
|
||||
width: 0;
|
||||
margin-right: 0;
|
||||
margin-left: 0;
|
||||
@@ -517,3 +517,16 @@
|
||||
color: var(--amb-btn-primary-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Opened from another device (remote_view.js): a quiet "view only" chip beside the page title. */
|
||||
.anomalous-remote-chip {
|
||||
flex-shrink: 0;
|
||||
margin-left: 8px;
|
||||
padding: 2px 9px;
|
||||
border: 1px dashed var(--amb-border-strong);
|
||||
border-radius: 999px;
|
||||
color: var(--amb-text-muted);
|
||||
font-size: 11.5px;
|
||||
white-space: nowrap;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user