Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6331b25241 | ||
|
|
c8689d56e4 | ||
|
|
3ba16e874c | ||
|
|
42d8c7bbfc | ||
|
|
a4cbe72086 | ||
|
|
4e8603b4cc | ||
|
|
e1ac84f6d8 | ||
|
|
af3bb60981 |
+2
-1
@@ -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
|
||||
|
||||
@@ -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 (版本号与一键更新 / 版本切换)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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", "")
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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', '')
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -789,5 +789,6 @@ export function createPromptSourceDeck(workbenchGrid, drawer, scope, addSourceCa
|
||||
refresh: renderSourceCardsList,
|
||||
sync: syncMaterialsIntoSourceDeck,
|
||||
hidePreview: (force = false) => hideCardPreviewPopover(force),
|
||||
focusSearch: () => leftSearch.focus(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user