Compare commits

...
Author SHA1 Message Date
DemonGatanjieuandClaude Opus 5.5 6331b25241 chore(release): v1.57.3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 15:59:03 +08:00
DemonGatanjieuandClaude Opus 5.5 c8689d56e4 fix: offline scans stay offline, workshop snap dock, 400 on malformed requests
From an outside audit (same change as 32a3719 on feature/audio-engines):
- The scan wizard's offline mode still sent the greyed-out rename and
  overwrite options, and scraper.py fetched Civitai model pages and preview
  media for models whose .info held a Civitai id. Offline now implies no
  media and no model page; the folder scan and scan_all refuse a missing or
  non-object body instead of defaulting to an online scan.
- The Prompt Workshop's snap dock referenced leftSearch from the source deck
  module (ReferenceError on click); the deck now exposes focusSearch().
- translate, delete_model, delete_gallery_image and the image-material
  routes answer malformed bodies with 400 instead of 500.
- Removed six unused imports in ui_doctor.js and 16 duplicate locale keys
  per language (the later, effective entry is kept, so nothing changes on
  screen).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 15:44:04 +08:00
DemonGatanjieuandClaude Opus 5.5 3ba16e874c fix(notebooks): make UNet notes runnable and list unlabeled models
Sending a Prompt Note with a UNet main model chained its LoRAs through
LoraLoader, whose clip input nothing could feed. UNet LoRAs now use
LoraLoaderModelOnly and the editor says to connect a text encoder.

Models without a baseModel in their metadata matched no base model, so
they never appeared; base_models now reports them and
compatible_models serves them for "__unlabeled__". Both routes share
one walker and run it off the event loop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 17:57:47 +08:00
DemonGatanjieuandClaude Opus 5.5 42d8c7bbfc fix(backend): restore imports lost in the module split
Saving a workflow recipe failed for any workflow with a model loader:
recipe_images.py called _resolve_exact_model_reference and hashlib, which
stayed behind in recipe_schema.py when 6ad975c split the module. The
resolver moves to path_utils.py so both recipe modules can import it
without a cycle.

The same split left media_routes.py without time/tempfile (card
thumbnails silently fell back to full-size covers) and model_metadata.py
without _resolve_paths_to_model_info_sync (editing a moved model failed).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 17:57:24 +08:00
Claude a4cbe72086 docs(changelog): v1.57.2 also fixes Material Library cards with a node selected
The two apply-mode card fixes (cherry-picked from feature/audio-engines)
ship in v1.57.2; the stylesheet cache version is bumped so browsers load
the new card CSS. Checked in ComfyUI with a selected KSampler: before, two
"⚡ ⚡ Apply Scheme" buttons per card and four overlapping text pairs; after,
one button, no overlaps, and applying still writes the scheme's values.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012RMuwbsWZHnx2eCWpxYqAB
2026-09-26 19:34:38 +00:00
DemonGatanjieu 4e8603b4cc fix(materials): stop apply-mode card text from overlapping
The grid used one fixed row height, so the extra apply button squeezed the
badges, date and node types on top of each other. Cards in apply mode now get
a taller row (the list is marked is-apply-mode when it renders), the apply
button sits at the bottom, and only the tag row may be clipped when space
runs out. The normal row is a little taller so a tag row fits as well.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeQEub5
(cherry picked from commit 9e761d0705)
2026-09-26 19:32:04 +00:00
DemonGatanjieu e1ac84f6d8 fix(materials): one apply button per card, without a doubled icon
In apply mode each card rendered a second apply button (a clone meant for
the list layout's hover actions) that showed as an extra row in the grid,
and both labels prefixed another icon to a locale string that already has
one. Cards now render a single button whose text comes straight from the
locale (textContent), in grid and list layouts alike.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeQEub5
(cherry picked from commit 01fc925bd6)
2026-09-26 19:32:03 +00:00
Claude af3bb60981 fix(perf): stop a :has() rule from slowing canvas dragging (v1.57.2)
`body:has(#…-modal[style*="display: flex"]) #…-tooltip-bubble` made the
browser re-check the whole page on every inline-style change anywhere.
ComfyUI moves every DOM widget (text boxes) on each pan/zoom frame, so every
frame paid for it, with the browser closed too, and much more with plugins
that add DOM per widget (ComfyUI Prompt Assistant).

Measured in ComfyUI (frontend 1.53.6, Chromium) panning a 300-node
workflow for 120 frames, style recalculation in total:
- no plugins 60 ms; AMB 690 ms -> 63 ms
- AMB + Prompt Assistant 2300 ms (~19 ms/frame) -> 86 ms (PA alone 80 ms)

The rule was redundant: both popups hide the tooltip when they open and
showSharedTooltip refuses while either is open (checked in the browser).
Cache-busting versions bumped so browsers load the new stylesheet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012RMuwbsWZHnx2eCWpxYqAB
2026-09-26 19:20:03 +00:00
29 changed files with 286 additions and 225 deletions
+2 -1
View File
@@ -64,7 +64,8 @@ DOM or live LiteGraph state.
### Backend
- `api/config.py` owns configured paths and active model-folder types.
- `api/path_utils.py` owns containment, filename validation, and atomic JSON writes;
- `api/path_utils.py` owns containment, filename validation, atomic JSON writes, and resolving
a saved model value inside the model folders;
`api/utils.py` is a compatibility export surface.
- `api/metadata.py` owns sidecar and safetensors metadata extraction.
- `api/model_catalog.py`, `api/model_resolution.py`, `api/model_metadata.py`, and
+23
View File
@@ -1,5 +1,28 @@
# 📈 Anomalous Model Browser Changelog
## v1.57.3 Beta (Hotfix: Offline Scan, Prompt Workshop) — 2026-09-29
### 🔌 Offline scan and small fixes (离线扫描与小修复)
- **Offline scan stays offline (离线扫描不再联网)**: "Offline fast read" no longer sends the greyed-out rename and overwrite options, and an offline scan never fetches Civitai model pages or preview images, even for models whose `.info` came from Civitai. Scan requests without options are refused instead of starting an online scan.
- Clicking the drop zone at the end of the Prompt Workshop's assembly track focuses the card search again instead of failing silently.
- Malformed requests to translation, deletion, scanning and image-material routes get a clear 400 instead of a server error.
## v1.57.2 Beta (Hotfix: Canvas Performance, Saving Workflows) — 2026-09-27
### 💾 Saving workflows and Prompt Notes (保存工作流与提示词笔记)
- **Saving a workflow works again (保存工作流失败)**: since v1.57, **Save Current Workflow** failed with "Could not save this Workflow Recipe" for any workflow with a model loader, because splitting the backend into smaller files left the save path calling a function that was no longer imported. The same split also made model cards load full-size cover images instead of thumbnails (slow on big folders), and made editing details of a model that had moved fail.
- **Prompt Notes with UNet models (提示词笔记与 UNet 模型)**: sending a note with a UNet / diffusion model to the canvas built a graph that could not run, because its LoRAs waited for a CLIP the UNet does not have. LoRAs of a UNet now load model-only, and the note says to connect your own text encoder to the two prompt nodes. Models whose metadata names no base model (most models not downloaded from Civitai) were missing from every group; they now appear under **No base model listed**. Listing models no longer blocks ComfyUI while it reads the folders.
- Installed v1.57.2 before 2026-09-27 through the version panel? Pick v1.57.2 there once more to get this build; updating through ComfyUI Manager gets it as usual.
### ⚡ Smooth canvas dragging again (画布拖动卡顿修复)
- **Dragging and zooming the canvas no longer slows down with this plugin installed (安装插件后拖动画布掉帧)**: one stylesheet rule, which only hid the sidebar tooltip while the Toolbox or Settings popup was open, made the browser re-check the whole page every time any element's inline style changed. ComfyUI moves every text-box widget on every frame while you pan or zoom, so each frame paid for it, even with the browser closed. Measured on a 300-node workflow in ComfyUI: style work while panning drops from about 11× the no-plugin cost back to the same as without the plugin.
- **Large workflows with ComfyUI Prompt Assistant (与提示词小助手同时安装时的严重卡顿)**: the cost grew with the number of page elements, and Prompt Assistant adds a row of buttons to every text box, so together style work took about 19 ms per frame (more than a whole 60 fps frame): single-digit frame rates or freezes on big workflows. It is now back to the level of Prompt Assistant alone. The two plugins never conflicted otherwise.
- The tooltip still stays hidden while the Toolbox or Settings popup is open; the popups already hide it when they open and it does not reappear until they close.
### 🧩 Material Library with a node selected (选中节点时素材库卡片错位)
- **One apply button per card (每张卡片只有一个应用按钮)**: with a node selected, the Material Library showed every card's **Apply Scheme** button twice, each with a doubled ⚡ icon.
- **No more overlapping text (卡片文字不再重叠)**: the extra button squeezed the badges, date, node types and tags on top of each other. Cards in apply mode now get a taller row with the button at the bottom; applying works as before.
## v1.57.1 Beta (Version Panel: Update, Switch & Roll Back) — 2026-09-23
### 🏷️ Version & Updates Panel (版本号与一键更新 / 版本切换)
+1 -1
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.57.1_Beta-blue?style=for-the-badge)](CHANGELOG.md)
[![Changelog](https://img.shields.io/badge/📖_Changelog-v1.57.3_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)
+5
View File
@@ -83,6 +83,11 @@ async def api_get_gallery_images(request):
async def api_delete_gallery_image(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 = data.get("filename")
subfolder = data.get("subfolder", "")
+2
View File
@@ -25,6 +25,8 @@ def _material_assets_dir(materials_dir, filename, create=False):
def _inspect_source_image(source_image):
source = _normalise_source_image(source_image)
if source is None:
raise ValueError("Missing source image")
if not source["filename"].lower().endswith(".png"):
raise ValueError("Only PNG output images can contain reusable workflow metadata")
source_path = _output_source_path(source)
+2
View File
@@ -5,7 +5,9 @@ import hashlib
import json
import os
import struct
import tempfile
import threading
import time
import urllib.parse
from aiohttp import web
+89 -99
View File
@@ -289,116 +289,106 @@ async def api_find_model(request):
})
async def api_compatible_models(request):
base_model = request.query.get('base_model', '')
target_type = request.query.get('target_type', 'loras')
if not base_model:
return web.json_response({"models": []})
target_types = [t.strip() for t in target_type.split(',')]
compatible_models = []
# Prompt Notes ask for this base model to list models whose metadata names none.
UNLABELED_BASE_MODEL = "__unlabeled__"
def _normalised_base(value):
return str(value or "").strip().lower().replace(" ", "")
def _walk_model_files(target_types):
"""Yield (folder_type, path_idx, base_dir, root, filename, metadata) once per real file."""
seen_files = set()
for t in target_types:
for folder_type in target_types:
try:
paths = folder_paths.get_folder_paths(t)
paths = folder_paths.get_folder_paths(folder_type)
except Exception:
continue
if not paths:
continue
for path_idx, base_dir in enumerate(paths):
for path_idx, base_dir in enumerate(paths or []):
if not os.path.exists(base_dir):
continue
for root, _, files in os.walk(base_dir):
for f in files:
if f.endswith('.safetensors') or f.endswith('.ckpt') or f.endswith('.pt'):
file_path = os.path.join(root, f)
real_path = os.path.realpath(file_path)
if real_path in seen_files:
continue
seen_files.add(real_path)
meta = get_metadata(file_path)
m_bm = str(meta.get("baseModel", "")).strip().lower().replace(" ", "")
req_bm = str(base_model).strip().lower().replace(" ", "")
if req_bm and m_bm and (req_bm in m_bm or m_bm in req_bm):
rel_subfolder = os.path.relpath(root, base_dir)
if rel_subfolder == '.':
rel_subfolder = '/'
else:
rel_subfolder = '/' + rel_subfolder.replace('\\', '/')
base_name = os.path.splitext(f)[0]
preview_file = None
for ext in PREVIEW_SUFFIXES + MEDIA_EXTENSIONS:
if os.path.exists(os.path.join(root, base_name + ext)):
preview_file = base_name + ext
break
preview_url = ""
if preview_file:
q_type = urllib.parse.quote(t)
q_idx = str(path_idx)
q_sub = urllib.parse.quote(rel_subfolder.strip('/')) if rel_subfolder != '/' else ""
q_file = urllib.parse.quote(preview_file)
preview_url = f"/anomalous/image?type={q_type}&path_idx={q_idx}&subfolder={q_sub}&filename={q_file}"
try:
size_bytes = os.path.getsize(file_path)
size_mb = round(size_bytes / (1024 * 1024), 1)
except Exception:
size_mb = 0
size_bytes = 0
for filename in files:
if not filename.lower().endswith(MODEL_EXTENSIONS):
continue
file_path = os.path.join(root, filename)
real_path = os.path.realpath(file_path)
if real_path in seen_files:
continue
seen_files.add(real_path)
yield folder_type, path_idx, base_dir, root, filename, get_metadata(file_path)
compatible_models.append({
"type": t,
"path_idx": path_idx,
"subfolder": rel_subfolder,
"filename": f,
"size_mb": size_mb,
"size_bytes": size_bytes,
"preview_url": preview_url,
"metadata": meta
})
return web.json_response({"models": compatible_models})
def _base_matches(requested, metadata):
model_base = _normalised_base(metadata.get("baseModel"))
if requested == UNLABELED_BASE_MODEL:
return not model_base
wanted = _normalised_base(requested)
return bool(wanted and model_base and (wanted in model_base or model_base in wanted))
def _compatible_models_sync(base_model, target_types):
compatible_models = []
for folder_type, path_idx, base_dir, root, filename, meta in _walk_model_files(target_types):
if not _base_matches(base_model, meta):
continue
rel_subfolder = os.path.relpath(root, base_dir)
rel_subfolder = '/' if rel_subfolder == '.' else '/' + rel_subfolder.replace('\\', '/')
base_name = os.path.splitext(filename)[0]
preview_file = None
for ext in PREVIEW_SUFFIXES + MEDIA_EXTENSIONS:
if os.path.exists(os.path.join(root, base_name + ext)):
preview_file = base_name + ext
break
preview_url = ""
if preview_file:
q_sub = urllib.parse.quote(rel_subfolder.strip('/')) if rel_subfolder != '/' else ""
preview_url = (
f"/anomalous/image?type={urllib.parse.quote(folder_type)}&path_idx={path_idx}"
f"&subfolder={q_sub}&filename={urllib.parse.quote(preview_file)}"
)
try:
size_bytes = os.path.getsize(os.path.join(root, filename))
except OSError:
size_bytes = 0
compatible_models.append({
"type": folder_type,
"path_idx": path_idx,
"subfolder": rel_subfolder,
"filename": filename,
"size_mb": round(size_bytes / (1024 * 1024), 1),
"size_bytes": size_bytes,
"preview_url": preview_url,
"metadata": meta,
})
return compatible_models
async def api_compatible_models(request):
base_model = request.query.get('base_model', '')
if not base_model:
return web.json_response({"models": []})
target_types = [t.strip() for t in request.query.get('target_type', 'loras').split(',') if t.strip()]
models = await asyncio.to_thread(_compatible_models_sync, base_model, target_types)
return web.json_response({"models": models})
def _base_models_sync(target_types):
base_models = set()
unlabeled = False
for *_, meta in _walk_model_files(target_types):
label = str(meta.get("baseModel") or "").strip()
if label:
base_models.add(label)
else:
unlabeled = True
return sorted(base_models), unlabeled
async def api_base_models(request):
target_types = get_active_folder_types()
base_models = set()
seen_files = set()
for t in target_types:
try:
paths = folder_paths.get_folder_paths(t)
except Exception:
continue
if not paths: continue
for base_dir in paths:
if not os.path.exists(base_dir): continue
for root, _, files in os.walk(base_dir):
for f in files:
if f.endswith('.safetensors') or f.endswith('.ckpt') or f.endswith('.pt'):
file_path = os.path.join(root, f)
real_path = os.path.realpath(file_path)
if real_path in seen_files: continue
seen_files.add(real_path)
meta = get_metadata(file_path)
m_bm = meta.get("baseModel", "")
if m_bm and str(m_bm).strip():
# Remove typical generic strings that might pollute
clean_bm = str(m_bm).strip()
base_models.add(clean_bm)
return web.json_response({"base_models": sorted(list(base_models))})
base_models, unlabeled = await asyncio.to_thread(_base_models_sync, get_active_folder_types())
return web.json_response({"base_models": base_models, "unlabeled": unlabeled})
def _model_info_for_path(folder_type, path_idx, base_dir, file_path):
+6
View File
@@ -14,6 +14,7 @@ from .model_constants import (
CIVITAI_BACKUP_SUFFIXES, MEDIA_EXTENSIONS, MODEL_EXTENSIONS,
PREVIEW_SUFFIXES, SIDECAR_SUFFIXES,
)
from .model_catalog import _resolve_paths_to_model_info_sync
from .utils import require_filename, resolve_folder_subdir, resolve_within
def _first_existing_sidecar(base_path, suffixes):
@@ -74,6 +75,11 @@ def _reset_model_cover(base_path):
async def api_delete_model(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:
folder_type = data.get('type', 'checkpoints')
subfolder = data.get('subfolder', '/')
filename = data.get('filename', '')
+35
View File
@@ -6,6 +6,8 @@ import tempfile
import folder_paths
from .recipe_constants import MODEL_FILE_SUFFIXES
def resolve_within(base_dir, *parts):
"""Resolve a path and require it to stay inside base_dir, including through symlinks."""
@@ -19,6 +21,39 @@ def resolve_within(base_dir, *parts):
return candidate
def _model_roots():
for folder_type in getattr(folder_paths, "folder_names_and_paths", {}):
try:
paths = folder_paths.get_folder_paths(folder_type)
except Exception:
continue
for path_index, base_dir in enumerate(paths or []):
if os.path.isdir(base_dir):
yield folder_type, path_index, os.path.realpath(base_dir)
def _resolve_exact_model_reference(saved_value):
"""Resolve one saved model value without recursive scanning or hashing."""
if not isinstance(saved_value, str) or not saved_value.strip():
return None
relative_value = saved_value.replace("/", os.sep)
for folder_type, path_index, base_dir in _model_roots():
candidate = os.path.realpath(os.path.join(base_dir, relative_value))
try:
if os.path.commonpath((base_dir, candidate)) != base_dir:
continue
except ValueError:
continue
if not os.path.isfile(candidate) or not candidate.lower().endswith(MODEL_FILE_SUFFIXES):
continue
return {
"path": candidate,
"folder_type": folder_type,
"path_index": path_index,
}
return None
def get_folder_root(folder_type, path_idx=0):
paths = folder_paths.get_folder_paths(folder_type)
if not paths or not isinstance(path_idx, int) or path_idx < 0 or path_idx >= len(paths):
+2
View File
@@ -1,6 +1,7 @@
"""Contained Recipe image assets and bounded output-gallery inspection."""
from io import BytesIO
import hashlib
import heapq
import json
import os
@@ -8,6 +9,7 @@ import os
import folder_paths
from PIL import Image
from .path_utils import _resolve_exact_model_reference
from .recipe_constants import *
from .utils import require_filename, resolve_within
from .workflow_schema import _node_type, _parameter_signature, _workflow_node_signature
+1 -33
View File
@@ -10,6 +10,7 @@ import folder_paths
from .metadata import get_metadata
from .recipe_constants import *
from .path_utils import _model_roots, _resolve_exact_model_reference
from .recipe_images import _attach_preview_snapshots
from .utils import require_filename, resolve_within
from .workflow_schema import (
@@ -44,39 +45,6 @@ def _model_reference_specs(node):
return specs
def _model_roots():
for folder_type in getattr(folder_paths, "folder_names_and_paths", {}):
try:
paths = folder_paths.get_folder_paths(folder_type)
except Exception:
continue
for path_index, base_dir in enumerate(paths or []):
if os.path.isdir(base_dir):
yield folder_type, path_index, os.path.realpath(base_dir)
def _resolve_exact_model_reference(saved_value):
"""Resolve one saved model value without recursive scanning or hashing."""
if not isinstance(saved_value, str) or not saved_value.strip():
return None
relative_value = saved_value.replace("/", os.sep)
for folder_type, path_index, base_dir in _model_roots():
candidate = os.path.realpath(os.path.join(base_dir, relative_value))
try:
if os.path.commonpath((base_dir, candidate)) != base_dir:
continue
except ValueError:
continue
if not os.path.isfile(candidate) or not candidate.lower().endswith(MODEL_FILE_SUFFIXES):
continue
return {
"path": candidate,
"folder_type": folder_type,
"path_index": path_index,
}
return None
def _identity_for_reference(saved_value):
resolved = _resolve_exact_model_reference(saved_value)
if not resolved:
+8 -4
View File
@@ -274,8 +274,10 @@ async def api_scan_folder(request):
try:
data = await request.json()
except Exception:
data = {}
except ValueError:
data = None
if not isinstance(data, dict):
return web.json_response({"status": "error", "message": "Invalid request body"}, status=400)
offline_only = data.get("offline_only", False)
skip_rename = data.get("skip_rename", False)
@@ -361,8 +363,10 @@ async def api_scan_all(request):
try:
data = await request.json()
except Exception:
data = {}
except ValueError:
data = None
if not isinstance(data, dict):
return web.json_response({"status": "error", "message": "Invalid request body"}, status=400)
offline_only = data.get("offline_only", False)
use_local_metadata = data.get("use_local_metadata", True)
+6 -1
View File
@@ -73,7 +73,12 @@ def _translate_with_mymemory(text, tl):
async def api_translate(request):
try:
data = await request.json()
text = str(data.get("text", "")).strip()
except ValueError:
data = None
if not isinstance(data, dict):
return web.json_response({"status": "error", "message": "Invalid request body"}, status=400)
text = str(data.get("text", "")).strip()
try:
tl = str(data.get("target_lang", "zh-CN")).strip()
if not text:
return web.json_response({"translated": "", "status": "success"})
+4 -1
View File
@@ -230,6 +230,9 @@ def main():
parser.add_argument("--folder-type", default="", help="由 ComfyUI 传入的模型目录类型,用于执行安全策略")
parser.add_argument("--progress-file", default="", help=argparse.SUPPRESS)
args = parser.parse_args()
if args.offline_only:
# Offline means no network at all: previews and model pages come from Civitai.
args.skip_media = True
target_folder = args.folder
if not os.path.isdir(target_folder):
@@ -396,7 +399,7 @@ def main():
# --- 额外获取模型主页的说明文字 ---
model_id = civitai_data.get("modelId")
if model_id and model_id != -1:
if model_id and model_id != -1 and not args.offline_only:
try:
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
+1 -1
View File
@@ -6,7 +6,7 @@ try {
} catch (_) {}
import { app } from "../../scripts/app.js";
import { createBrowserEntry } from "./modules/browser_entry.js?v=20260921-flicker-free-1";
import { createBrowserEntry } from "./modules/browser_entry.js?v=20260929-hotfix-1";
import { createInterfaceSettings, getCurrentLanguage, setAbyssalScarletTheme, t } from "./modules/interface_settings.js";
export { setAbyssalScarletTheme };
+1 -1
View File
@@ -254,7 +254,7 @@ export function createBrowserEntry({ translate, getCurrentLanguage }) {
});
async function setup() {
const cssUrl = '/extensions/Anomalous_Model_Browser/styles.css?v=20260921-flicker-free-1';
const cssUrl = '/extensions/Anomalous_Model_Browser/styles.css?v=20260927-hotfix-1';
if (!document.querySelector('link[href^="/extensions/Anomalous_Model_Browser/styles.css"]')) {
const link = document.createElement('link');
link.rel = 'stylesheet';
+4 -32
View File
@@ -218,11 +218,7 @@ export const i18n = {
recipeScopePill_partial: "局部子图",
recipeViewGrid: "网格视图",
recipeViewList: "列表视图",
recipeDragHint: "按住拖拽至画布打开新工作流",
recipeDragTargetCanvas: "释放以在画布打开新工作流",
recipeStatusReady: "模型全部就绪",
recipeStatusUnverified: "模型待校验",
recipeStatusMissing: "模型缺失",
recipeReadinessHint: "检测工作流所需模型在本地环境中的就绪状态,点击卡片进入详情可进行智能比对与替换",
promptEnablePart: "启用这个片段",
promptUnnamedPart: "未命名片段",
@@ -262,21 +258,9 @@ export const i18n = {
notebookCompanionModels: "配套模型",
notebookMore: "更多",
notebookDoneEditing: "📑 查看双语分词",
editRaw: "✏️ 编辑原始文本",
saveNotebook: "保存笔记",
deleteNotebook: "删除笔记",
delSure: "确认删除?",
sendToCanvas: "发送到画布",
createNotebook: "新建笔记",
newNotebookName: "新笔记名称...",
recipeSearchAndReplace: "词条替换与目标语言",
findPlaceholder: "查找...",
replacePlaceholder: "替换为...",
replaceAll: "全部替换",
recipeDiffNone: "未选择",
loras: "LoRA 模型",
baseModel: "底模",
mainModel: "主模型",
nbNavHeading: "快速直达",
nbNotesHeading: "我的笔记",
nbNavModels: "配套模型",
@@ -717,6 +701,8 @@ export const i18n = {
pickerNoMatches: '没有符合条件的模型。',
pickerOperationFailed: '操作失败:',
notebookSelectMain: '请先选择一个主模型。',
notebookUnlabeledBase: '未标注基础模型',
notebookUnetNeedsTextEncoder: 'UNet 模型不含文本编码器:发送到画布后,把你的 CLIP 加载器接到两个提示词节点上。LoRA 会用“仅模型”加载。',
mainPreflightTitle: '导入工作流 (预检海关)',
mainPreflightDesc: '请在此处粘贴工作流的 JSON 代码(未来将支持分享码)。我们将为您提取并校验所有模型依赖。',
mainCancel: '取消',
@@ -1527,11 +1513,7 @@ export const i18n = {
recipeScopePill_partial: "Sub-graph",
recipeViewGrid: "Grid View",
recipeViewList: "List View",
recipeDragHint: "Hold & drag onto canvas to load workflow",
recipeDragTargetCanvas: "Drop to load workflow in canvas",
recipeStatusReady: "All Models Ready",
recipeStatusUnverified: "Models to Verify",
recipeStatusMissing: "Models Missing",
recipeReadinessHint: "Detects readiness of required models in local environment; click card details for smart matching and replacement",
promptEnablePart: "Enable this fragment",
promptUnnamedPart: "Untitled fragment",
@@ -1571,21 +1553,9 @@ export const i18n = {
notebookCompanionModels: "Companion models",
notebookMore: "More",
notebookDoneEditing: "📑 View Bilingual Tags",
editRaw: "✏️ Edit Raw Prompt",
saveNotebook: "Save Note",
deleteNotebook: "Delete Note",
delSure: "Confirm Delete?",
sendToCanvas: "Send to Canvas",
createNotebook: "New Note",
newNotebookName: "New note name...",
recipeSearchAndReplace: "Find, Replace & Language",
findPlaceholder: "Find...",
replacePlaceholder: "Replace with...",
replaceAll: "Replace All",
recipeDiffNone: "None",
loras: "LoRA Models",
baseModel: "Base Model",
mainModel: "Main Model",
nbNavHeading: "Quick Jump",
nbNotesHeading: "My Notes",
nbNavModels: "Companion Models",
@@ -2026,6 +1996,8 @@ export const i18n = {
pickerNoMatches: 'No models match the current filters.',
pickerOperationFailed: 'Operation failed: ',
notebookSelectMain: 'Please select a Main Model first.',
notebookUnlabeledBase: 'No base model listed',
notebookUnetNeedsTextEncoder: 'UNet models have no text encoder: after sending to the canvas, connect your CLIP loader to the two prompt nodes. LoRAs are loaded model-only.',
mainPreflightTitle: 'Pre-flight Import Workflow',
mainPreflightDesc: 'Paste the workflow JSON here. We will extract and validate all model dependencies before loading.',
mainCancel: 'Cancel',
+12 -4
View File
@@ -5,6 +5,14 @@ import { translate } from './locales.js';
const t = (key, params) => translate(key, params);
/** Base-model value the backend uses for models whose metadata names no base model. */
export const UNLABELED_BASE_MODEL = '__unlabeled__';
/** UNet / diffusion models carry no text encoder, so their LoRAs patch the model only. */
export function isUnetModel(model) {
return model?.type === 'unet' || model?.type === 'diffusion_models';
}
export function sendNotebookToCanvas() {
if (!this.currentNotebook) return;
const data = this.currentNotebook.data || {};
@@ -14,7 +22,7 @@ export function sendNotebookToCanvas() {
}
const groupNodes = [];
const isUnet = data.mainModel.type === 'unet' || data.mainModel.type === 'diffusion_models';
const isUnet = isUnetModel(data.mainModel);
const ckptNode = LiteGraph.createNode(isUnet ? "UNETLoader" : "CheckpointLoaderSimple");
app.graph.add(ckptNode);
@@ -25,14 +33,14 @@ export function sendNotebookToCanvas() {
this.setWidgetValuePath(ckptNode, relPath);
let lastNode = ckptNode;
let lastModelSlot = isUnet ? 0 : 0;
let lastModelSlot = 0;
let lastClipSlot = isUnet ? null : 1;
let relX = 350;
let relY = 0;
data.loras.forEach((lora, idx) => {
const loraNode = LiteGraph.createNode("LoraLoader");
const loraNode = LiteGraph.createNode(isUnet ? "LoraLoaderModelOnly" : "LoraLoader");
app.graph.add(loraNode);
groupNodes.push({ node: loraNode, relX: relX, relY: relY });
@@ -45,7 +53,7 @@ export function sendNotebookToCanvas() {
lastNode = loraNode;
lastModelSlot = 0;
lastClipSlot = 1;
if (!isUnet) lastClipSlot = 1;
relX += 350;
});
+1 -9
View File
@@ -1,19 +1,11 @@
import { applyNodeMaterialValues } from './node_material_actions.js';
import { applyMaterialToSelectedNode } from './ui_material_application.js';
import { app } from "../../../scripts/app.js";
import { translate } from './locales.js';
import { escapeHtml } from './safe_dom.js';
import {
analyzeModelChainInsertion,
getModelChainInsertionCapabilities,
spliceModelChainNode,
} from './graph_splice.js';
import {
collectMainModelContextRequests,
formatModelTypeLabel,
getBaseModelFamily,
inferPickerModelType,
} from './model_picker.js';
import { inferPickerModelType } from './model_picker.js';
import { requiresHashForModelRecovery } from './model_policies.js';
import { findModelComboWidget, getNativeWidgetValues } from './ui_node_model_picker.js';
import { renderParameterPresets } from './ui_node_presets.js';
+8 -13
View File
@@ -349,35 +349,30 @@ export function renderMaterialCard(owner, material) {
material.tags.forEach(tag => text(tags, 'span', tag, 'anomalous-material-tag'));
}
if (owner.materialApplyMode) {
// One apply button per card, in both grid and list layouts. The locale text already carries its icon.
const applyBtn = document.createElement('button');
applyBtn.type = 'button';
applyBtn.className = 'anomalous-material-card-apply-btn';
applyBtn.innerHTML = `<span>⚡</span> <span>${t('assistantApplyScheme') || (window.anomalous_browser_lang === 'zh' ? '应用到节点' : 'Apply to Node')}</span>`;
applyBtn.title = t('materialApplyCard') || (window.anomalous_browser_lang === 'zh' ? '点击将本方案参数应用替换到当前节点' : 'Click to apply parameters to current node');
applyBtn.textContent = t('assistantApplyScheme');
applyBtn.title = t('materialApplyCard');
applyBtn.onclick = async (e) => {
e.stopPropagation();
applyBtn.disabled = true;
applyBtn.innerHTML = `<span>⏳</span> <span>${t('assistantApplying') || '应用中...'}</span>`;
applyBtn.textContent = t('assistantApplying');
try {
await applyLibraryMaterial(owner, material);
applyBtn.innerHTML = `<span>✅</span> <span>${t('assistantApplied') || '已应用'}</span>`;
applyBtn.textContent = t('assistantApplied');
setTimeout(() => {
if (applyBtn) {
applyBtn.disabled = false;
applyBtn.innerHTML = `<span>⚡</span> <span>${t('assistantApplyScheme') || (window.anomalous_browser_lang === 'zh' ? '应用到节点' : 'Apply to Node')}</span>`;
}
applyBtn.disabled = false;
applyBtn.textContent = t('assistantApplyScheme');
}, 2000);
} catch (err) {
console.error(err);
applyBtn.disabled = false;
applyBtn.innerHTML = `<span>⚡</span> <span>${t('assistantApplyScheme') || (window.anomalous_browser_lang === 'zh' ? '应用到节点' : 'Apply to Node')}</span>`;
applyBtn.textContent = t('assistantApplyScheme');
}
};
body.appendChild(applyBtn);
const applyBtnClone = applyBtn.cloneNode(true);
applyBtnClone.onclick = applyBtn.onclick;
actionsWrapper.insertBefore(applyBtnClone, actionsWrapper.firstChild);
}
card.appendChild(body);
card.appendChild(actionsWrapper);
+2
View File
@@ -296,6 +296,8 @@ export async function refreshMaterials(page = this.materialPage || 1) {
return;
}
const fragment = document.createDocumentFragment();
// Apply-mode cards carry an extra button row; the grid gives them a taller row.
this.materialList.classList.toggle('is-apply-mode', Boolean(this.materialApplyMode));
for (const material of materials) fragment.appendChild(renderMaterialCard(this, material));
this.materialList.appendChild(fragment);
} catch (error) {
+16 -4
View File
@@ -7,6 +7,7 @@ import { translate } from './locales.js';
import { escapeHtml } from './safe_dom.js';
import { anomalousAlert, anomalousConfirm } from './ui_dialog.js';
import { showMaterialSaved } from './material_feedback.js';
import { isUnetModel, UNLABELED_BASE_MODEL } from './notebook_canvas.js';
const t = (key, params) => translate(key, params);
@@ -158,7 +159,7 @@ function createCompanionModelsCard(ctx, data) {
loraSelectedBadge.style.color = '#fbbf24';
const updateSummary = () => {
const baseInfo = data.baseModel || 'SDXL';
const baseInfo = data.baseModel === UNLABELED_BASE_MODEL ? t('notebookUnlabeledBase') : (data.baseModel || 'SDXL');
const mainInfo = data.mainModel?.filename ? ` · ${data.mainModel.filename}` : '';
const loraCount = data.loras?.length ? ` · ${data.loras.length} LoRA` : '';
modelsBadge.textContent = `${baseInfo}${mainInfo}${loraCount}`;
@@ -188,7 +189,8 @@ function createCompanionModelsCard(ctx, data) {
baseSelect.innerHTML = '';
bases.forEach(b => {
const opt = document.createElement('option');
opt.value = b; opt.text = b;
opt.value = b;
opt.text = b === UNLABELED_BASE_MODEL ? t('notebookUnlabeledBase') : b;
if (data.baseModel === b) opt.selected = true;
baseSelect.appendChild(opt);
});
@@ -202,8 +204,9 @@ function createCompanionModelsCard(ctx, data) {
const tempBases = ['SD 1.5', 'SD 2.1', 'SDXL', 'SD 3.0', 'SD 3.5', 'Flux.1', 'Pony', 'HunyuanVideo', 'LTX-Video', 'OmniGen'];
buildSelect(tempBases);
fetch('/anomalous/base_models').then(r => r.json()).then(d => {
if (d.base_models && d.base_models.length > 0) {
ctx.baseModelsCache = d.base_models;
const bases = [...(d.base_models || []), ...(d.unlabeled ? [UNLABELED_BASE_MODEL] : [])];
if (bases.length > 0) {
ctx.baseModelsCache = bases;
buildSelect(ctx.baseModelsCache);
}
}).catch(() => { });
@@ -250,6 +253,14 @@ function createCompanionModelsCard(ctx, data) {
loraGallery.className = 'anomalous-nb-gallery-wrap';
loraBox.append(loraRow, loraGallery);
const unetHint = document.createElement('div');
unetHint.className = 'anomalous-nb-unet-hint';
unetHint.textContent = t('notebookUnetNeedsTextEncoder');
mainBox.appendChild(unetHint);
const showUnetHint = () => { unetHint.hidden = !isUnetModel(data.mainModel); };
ctx.updateNotebookUnetHint = showUnetHint;
showUnetHint();
card.append(header, baseRow, mainBox, loraBox);
updateSummary();
@@ -613,6 +624,7 @@ export function fillNotebookGalleries(baseModel, mainGallery, loraGallery, data)
data.mainModel = (data.mainModel && data.mainModel.filename === m.filename) ? null : m;
this.saveCurrentNotebook();
this.updateNotebookModelsSummary?.();
this.updateNotebookUnetHint?.();
buildMainDOM(models);
};
mainGallery.appendChild(card);
+1
View File
@@ -789,5 +789,6 @@ export function createPromptSourceDeck(workbenchGrid, drawer, scope, addSourceCa
refresh: renderSourceCardsList,
sync: syncMaterialsIntoSourceDeck,
hidePreview: (force = false) => hideCardPreviewPopover(force),
focusSearch: () => leftSearch.focus(),
};
}
+1 -1
View File
@@ -704,7 +704,7 @@ export function createPromptWorkbench(owner, container, scope, options) {
`;
setupDropzoneListeners(snapDock);
snapDock.onclick = () => {
leftSearch?.focus?.();
sourceDeck.focusSearch();
showWorkbenchToast(window.anomalous_browser_lang === 'zh' ? '点击左侧词卡即可直接加入此处' : 'Click any card on left to add here');
};
}
+7 -5
View File
@@ -533,12 +533,14 @@ export function openScanWizard({ isGlobal = false, targetFiles = null } = {}) {
// Execute Scan Logic
const doScan = async () => {
try {
// Offline greys out renaming and overwriting, so none of them may be sent.
const offline = scanMode === 'offline';
const reqBody = {
offline_only: scanMode === 'offline',
skip_rename: !enableRename,
virtual_rename: enableRename ? enableVirtualRename : false,
physical_rename: enableRename ? enablePhysicalRename : false,
force_overwrite: enableForceOverwrite
offline_only: offline,
skip_rename: offline || !enableRename,
virtual_rename: !offline && enableRename && enableVirtualRename,
physical_rename: !offline && enableRename && enablePhysicalRename,
force_overwrite: !offline && enableForceOverwrite
};
if (targetMode === 'custom') {
+3 -3
View File
@@ -1,8 +1,8 @@
@import url("./styles/00-foundation-models.css?v=20260921-flicker-free-1");
@import url("./styles/01-shell-tools.css?v=20260918-model-sources-1");
@import url("./styles/02-notebooks.css?v=20260918-model-sources-1");
@import url("./styles/01-shell-tools.css?v=20260926-canvas-perf-1");
@import url("./styles/02-notebooks.css?v=20260927-hotfix-1");
@import url("./styles/03-gallery-recipes.css?v=20260918-model-sources-1");
@import url("./styles/04-dialogs-materials.css?v=20260918-model-sources-1");
@import url("./styles/04-dialogs-materials.css?v=20260926-material-apply-1");
@import url("./styles/05-recipe-studio-overrides.css?v=20260918-model-sources-1");
@import url("./styles/06-material-workbench.css?v=20260918-model-sources-1");
@import url("./styles/07-prompt-studio.css?v=20260918-model-sources-1");
-9
View File
@@ -292,15 +292,6 @@ button:active::after,
color: #38bdf8 !important;
}
/* When toolbox or settings hub modal is open, completely suppress floating tooltip bubble */
body:has(#anomalous-toolbox-modal[style*="display: flex"]) #anomalous-sidebar-tooltip-bubble,
body:has(#anomalous-settings-hub-modal[style*="display: flex"]) #anomalous-sidebar-tooltip-bubble {
display: none !important;
opacity: 0 !important;
visibility: hidden !important;
pointer-events: none !important;
}
.anomalous-sidebar-closed #anomalous-sidebar-actions {
flex-direction: column !important;
}
+11
View File
@@ -700,6 +700,17 @@
margin-bottom: 15px;
}
.anomalous-nb-unet-hint {
margin-top: 10px;
font-size: 0.8rem;
line-height: 1.5;
color: var(--amb-text-muted);
}
.anomalous-nb-unet-hint[hidden] {
display: none;
}
/* Notebook Model Fold & Sections Enhancement */
.anomalous-nb-models-fold {
background: rgba(255, 255, 255, 0.02);
+32 -3
View File
@@ -377,7 +377,7 @@
.anomalous-material-list.is-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
grid-auto-rows: 248px;
grid-auto-rows: 262px;
align-content: start;
gap: 14px;
padding: 16px;
@@ -418,12 +418,41 @@
/* 网格模式下的卡片几何规范 */
.anomalous-material-list.is-grid .anomalous-material-card {
height: 248px;
max-height: 248px;
height: 262px;
max-height: 262px;
flex-direction: column;
border-radius: var(--amb-radius-card, 10px);
}
/* 应用模式:卡片多一行“应用方案”按钮,行高随之增加,信息不被挤压重叠 */
.anomalous-material-list.is-grid.is-apply-mode {
grid-auto-rows: 300px;
}
.anomalous-material-list.is-grid.is-apply-mode .anomalous-material-card {
height: 300px;
max-height: 300px;
}
/* Everything keeps its size except the tag row, which is clipped first when space runs out. */
.anomalous-material-list.is-grid .anomalous-material-card-body {
overflow: hidden;
}
.anomalous-material-list.is-grid .anomalous-material-card-body > * {
flex-shrink: 0;
}
.anomalous-material-list.is-grid .anomalous-material-card-body > .anomalous-material-tags {
flex-shrink: 1;
min-height: 0;
overflow: hidden;
}
.anomalous-material-list.is-grid .anomalous-material-card-apply-btn {
margin-top: auto;
}
.anomalous-material-list.is-grid .anomalous-material-card:hover {
border-color: var(--amb-border-hover, rgba(255, 255, 255, 0.22));
}