Compare commits

..
Author SHA1 Message Date
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
DemonGatanjieu e788306291 docs(changelog): clean up version entry description in update guide 2026-09-23 22:58:07 +08:00
DemonGatanjieuandClaude Opus 5.5 eabff31c1c fix(ui): show the version in the update guide instead of the sidebar footer
The footer version sat right under the tool dock and invited misclicks.
The "!" update guide now opens with the current version and a
"Check for updates / switch version" button that closes the guide and
opens the version panel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeQEub5
2026-09-23 14:53:49 +00:00
DemonGatanjieu c79b88d464 docs(changelog): fold sidebar footer version chip into v1.57.1 Beta 2026-09-23 22:34:28 +08:00
DemonGatanjieuandClaude Opus 5.5 61b828c716 fix(ui): move the version number to the sidebar footer
In the header it pushed the dock and close buttons out of view when the
browser is docked or the display is scaled. It now sits under the tool
dock and hides with the collapsed sidebar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeQEub5
2026-09-23 14:28:16 +00:00
DemonGatanjieu 58e49cc635 docs: align version references to v1.57.1-beta 2026-09-23 22:04:10 +08:00
DemonGatanjieu 585a745683 docs: update Changelog badge to v1.58_Beta 2026-09-23 21:58:14 +08:00
DemonGatanjieuandClaude Opus 5.5 0fd50b1a18 docs: changelog and README for the v1.58 version panel
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeQEub5
2026-09-23 13:49:27 +00:00
DemonGatanjieuandClaude Opus 5.5 e622850349 feat: version panel with manual update check, switching and rollback
- api/version_manager.py: installed version (local only), published
  releases on request (GitHub API, git tag fallback), switch to a
  published tag, return to the default branch (fast-forward only), undo
  the last switch; refuses to overwrite modified tracked files
- header version chip and "Version & updates" panel; nothing goes online
  until "Check for updates" is clicked; restart via ComfyUI Manager
- zh/en strings, styles, architecture notes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeQEub5
2026-09-23 13:42:58 +00:00
26 changed files with 1405 additions and 177 deletions
+11 -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
@@ -92,6 +93,15 @@ DOM or live LiteGraph state.
local model files with that hash. Terms arrive as repeated `term` params, one
phrase each. `ui_search_chips.js` is the reusable search-block input (Enter or a
comma commits a block); `ui_gallery.js` places it above the gallery.
- `api/version_manager.py` owns the plugin's own version: the installed tag/branch
(`/anomalous/version`, local only), published releases fetched only on request
(GitHub releases API, falling back to `git ls-remote` tags; drafts skipped,
pre-releases never counted as latest), switching to a published tag (detached
checkout), returning to the default branch (fast-forward only), and undoing the
last switch (recorded in the ignored `.anomalous_version.local.json`). Every
switch refuses to run over modified tracked files. `ui_version_manager.js` is
the version line (shown in the update guide opened by the header "!" button) and panel; nothing goes online until "check for updates"
is clicked, and restarts go through ComfyUI Manager's reboot route when present.
- `web/main.js` coordinates extension registration (with `?v=...` versioned module imports busting aggressive browser ES Module caching and unconditional legacy storage key purging). `browser.js` owns the shared
browser class and extracted-method wiring; `browser_entry.js` owns the single
+31 -2
View File
@@ -1,6 +1,31 @@
# 📈 Anomalous Model Browser Changelog
## Unreleased
## 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 (版本号与一键更新 / 版本切换)
- **Version entry in the update guide (更新引导顶部的版本号与更新入口)**: the installed version (e.g. `v1.57.1-beta`) and a **Check for updates / switch version** button now appear at the top of the update guide (opened via the header **!** button), avoiding accidental clicks near the tool dock or crowding the header in docked/scaled layouts. Reading it is local only; nothing goes online.
- **Manual "Check for updates" (手动检查更新)**: the plugin never checks by itself. Clicking **Check for updates / switch version** opens the panel, and only the **Check for updates** button contacts GitHub. Each published release shows its notes, date and a GitHub link; pre-releases are hidden by default and are never offered as the latest version.
- **One-click update to published releases (一键更新到正式发布版本)**: updates only target releases published on GitHub, not in-progress commits, so what you install is a released build.
- **Switch or roll back (切换 / 回退版本)**: any published release can be installed from the list. Rolling back warns that older versions may not read settings or recipes saved by newer ones; nothing is deleted, and it all works again after returning.
- **Back to latest & undo (回到最新版 / 撤销上次切换)**: **Back to latest** returns to the default branch and pulls the newest code; **Undo last switch** returns to exactly where you were before. Versions older than v1.57.1 have no panel, so from those use **Update** for this plugin in ComfyUI Manager to come back.
- **Safe by default (安全保护)**: every switch is refused while plugin files have local modifications (the files are listed), and a local branch with unpublished commits is never overwritten.
- **Restart (重启生效)**: after a switch the panel offers to restart ComfyUI through ComfyUI Manager; without Manager it asks you to restart by hand.
## v1.57.0 Beta (Toolbox Architecture, Gallery Search & Polymorphic Drag) — 2026-09-23
@@ -238,7 +263,11 @@
- **Offline Base Model Inference**: Added a zero-API local inference engine! For models downloaded purely from HuggingFace (or private unreleased models) that return a 404 on Civitai, the scanner no longer gives up. It now forcibly parses the .safetensors structure and uses **Tensor Fingerprinting** (e.g., detecting double_blocks.0.img_attn for Flux) to accurately deduce the underlying base architecture with 100% precision.
- **Universal UI Integration**: Successfully inferred offline models are dynamically assigned a virtual .info payload (ID: -1). This instantly grants them full VIP access to the frontend ecosystem—they seamlessly appear in the Cross-Folder Radar, interact perfectly with the bilingual Notebook, and support one-click Auto-Inject loaders, all completely completely offline!
# 📈 Anomalous Model Browser Changelog
## v2.0.0 (The Workflow & UI Evolution Update)
### 🚀 Major Features
+3 -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_Beta-blue?style=for-the-badge)](CHANGELOG.md)
[![Changelog](https://img.shields.io/badge/📖_Changelog-v1.57.2_Beta-blue?style=for-the-badge)](CHANGELOG.md)
[![Bilibili Video](https://img.shields.io/badge/Bilibili-视频演示-00A1D6?style=for-the-badge&logo=bilibili)](https://www.bilibili.com/video/BV1a1bv68EuA/)
[![YouTube Video](https://img.shields.io/badge/YouTube-Video_Demo-red?style=for-the-badge&logo=youtube)](https://youtu.be/hAvsj7uiaCw)
@@ -49,6 +49,7 @@
git clone https://github.com/DemonGatanjieu/Anomalous_Model_Browser.git
```
2. Restart ComfyUI. *(Alternatively, install via **ComfyUI Manager** by searching for `Anomalous Model Browser`)*
3. **Updating**: click the **!** button in the browser header; the current version is shown at the top, and **Check for updates / switch version** opens the version panel. From there you can update to the newest published release, roll back to an earlier one, or return to the latest version, then restart ComfyUI. The plugin never checks for updates on its own.
Open the browser via **Ctrl + Shift + M**, the floating canvas orb (**📦**), or **Extensions → Anomalous Model Browser** in ComfyUI's menu. **ComfyUI Settings → Anomalous Model Browser → Interface** lets you customize the shortcut, configure the UI language, and select an entry mode (floating button, action-bar button, or Extensions menu only).
@@ -139,6 +140,7 @@ The header provides 3 primary workspaces:
git clone https://github.com/DemonGatanjieu/Anomalous_Model_Browser.git
```
2. 重启 ComfyUI 即可使用。(*也可以直接在 **ComfyUI Manager** 搜索 `Anomalous Model Browser` 点击安装*)
3. **更新插件**:点击顶栏的 **!** 按钮,顶部会显示当前版本,点 **检查更新 / 切换版本** 进入版本面板。可以一键更新到最新发布版本、回退到以前的版本,或回到最新版,之后重启 ComfyUI 即可。插件不会自己联网检查更新。
可以按 **Ctrl + Shift + M**、点击画布上的悬浮 **📦**,或从 ComfyUI 顶部菜单 **扩展 → Anomalous Model Browser** 打开。**ComfyUI 设置 → Anomalous Model Browser → 界面** 会显示当前快捷键,并可直接打开 ComfyUI 原生录入窗口修改,因此冲突与保留按键检查仍然只有一套。该页面还可以让插件语言跟随 ComfyUI 或单独固定为中文/English,并严格三选一显示悬浮入口、运行按钮旁的原生顶部入口或仅使用扩展菜单。只有选择悬浮入口时才显示大小和样式选项。顶部栏重绘不会擅自改变所选模式,扩展菜单命令则始终保留,便于恢复设置。
+2 -1
View File
@@ -1,7 +1,7 @@
from . import (
folder_types, gallery_routes, materials, media_routes, model_catalog,
model_media, model_metadata, model_resolution, recipe_packages, recipes,
translation_routes,
translation_routes, version_manager,
)
from .scanner import *
from .config import *
@@ -97,3 +97,4 @@ def setup_routes(app):
app.router.add_get('/anomalous/model_images', media_routes.api_get_model_images)
app.router.add_post('/anomalous/resolve_paths_to_previews', model_catalog.api_resolve_paths_to_previews)
app.router.add_post('/anomalous/scan_missing_models', api_scan_missing_models)
version_manager.register_routes(app)
+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):
+1
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):
+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:
+364
View File
@@ -0,0 +1,364 @@
"""Plugin version display, published-release listing and version switching.
The plugin is normally installed as a git clone (ComfyUI Manager), so the
installed version is read from git and switching checks out a published
release tag. Nothing runs automatically: every network call starts from a
button in the UI. Switching refuses when tracked files have local changes, and
only tags that are published releases (or at least exist on the remote) can be
checked out.
"""
import asyncio
import json
import os
import re
import shutil
import subprocess
import threading
import time
import urllib.request
from aiohttp import web
PLUGIN_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
STATE_FILE = os.path.join(PLUGIN_DIR, ".anomalous_version.local.json")
# A version whose tree contains this file has the version panel; older ones do not.
SWITCHER_MARKER = "api/version_manager.py"
TAG_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._\-]{0,99}$")
GITHUB_REMOTE = re.compile(r"github\.com[:/]+([^/]+)/([^/]+?)(?:\.git)?/?$", re.IGNORECASE)
RELEASES_CACHE_SECONDS = 300
NETWORK_TIMEOUT = 10
FETCH_TIMEOUT = 180
_lock = threading.Lock()
_releases_cache = {"expires": 0, "data": None}
class VersionError(Exception):
"""A user-facing failure with a stable code for the UI."""
def __init__(self, code, message="", **details):
super().__init__(message or code)
self.code = code
self.details = details
# ---------- git helpers ----------
def _git_executable():
found = shutil.which("git")
if not found:
raise VersionError("git_missing", "git executable not found")
return found
def _git(*args, timeout=30, check=True):
kwargs = {}
if os.name == "nt":
kwargs["creationflags"] = getattr(subprocess, "CREATE_NO_WINDOW", 0)
env = dict(os.environ, GIT_TERMINAL_PROMPT="0")
try:
result = subprocess.run(
[_git_executable(), "-C", PLUGIN_DIR, *args],
capture_output=True, text=True, encoding="utf-8", errors="replace",
timeout=timeout, env=env, **kwargs,
)
except subprocess.TimeoutExpired:
raise VersionError("git_timeout", f"git {' '.join(args[:2])} timed out")
if check and result.returncode != 0:
raise VersionError("git_failed", (result.stderr or result.stdout).strip()[-500:])
return result
def _git_out(*args, **kwargs):
result = _git(*args, check=False, **kwargs)
return result.stdout.strip() if result.returncode == 0 else ""
def version_key(tag):
"""Sortable key for tags such as v1.4.2, v1.57-beta, v1.58.0-rc1 (stable after pre-release)."""
match = re.match(r"^v?(\d+(?:\.\d+)*)(.*)$", str(tag or ""))
if not match:
return ((), 0, str(tag))
numbers = tuple(int(part) for part in match.group(1).split("."))
suffix = match.group(2).lstrip("-_.")
return (numbers, 1 if not suffix else 0, suffix)
def current_state():
"""Local only (no network): what is installed and whether it can be switched."""
if not os.path.isdir(os.path.join(PLUGIN_DIR, ".git")) and _git_out("rev-parse", "--is-inside-work-tree") != "true":
return {"is_git": False}
commit = _git_out("rev-parse", "HEAD")
branch = _git_out("symbolic-ref", "--short", "-q", "HEAD") or None
exact_tag = _git_out("describe", "--tags", "--exact-match", "HEAD") or None
described = _git_out("describe", "--tags", "--always")
base_tag = _git_out("describe", "--tags", "--abbrev=0") or None
# Porcelain lines are "XY path"; keep the leading status column (no strip before slicing).
status = _git("status", "--porcelain", "--untracked-files=no", check=False).stdout
changed = [line[3:] for line in status.splitlines() if line.strip()]
return {
"is_git": True,
"commit": commit[:12],
"branch": branch,
"tag": exact_tag,
"base_tag": base_tag,
"label": exact_tag or described or commit[:12],
"dirty": bool(changed),
"changed_files": changed[:20],
"remote": _git_out("remote", "get-url", "origin") or None,
"previous": _read_state().get("previous"),
}
# ---------- releases ----------
def _github_repo(remote_url):
match = GITHUB_REMOTE.search(remote_url or "")
return (match.group(1), match.group(2)) if match else None
def _fetch_github_releases(owner, repo):
url = f"https://api.github.com/repos/{owner}/{repo}/releases?per_page=50"
request = urllib.request.Request(url, headers={
"Accept": "application/vnd.github+json",
"User-Agent": "Anomalous-Model-Browser",
})
with urllib.request.urlopen(request, timeout=NETWORK_TIMEOUT) as response:
payload = json.loads(response.read().decode("utf-8"))
releases = []
for item in payload if isinstance(payload, list) else []:
if item.get("draft") or not item.get("tag_name"):
continue
releases.append({
"tag": item["tag_name"],
"name": item.get("name") or item["tag_name"],
"published_at": item.get("published_at"),
"prerelease": bool(item.get("prerelease")),
"notes": (item.get("body") or "")[:8000],
"url": item.get("html_url"),
})
return releases
def _remote_tags():
output = _git_out("ls-remote", "--tags", "--refs", "origin", timeout=NETWORK_TIMEOUT * 3)
tags = [line.split("refs/tags/", 1)[1] for line in output.splitlines() if "refs/tags/" in line]
return [{"tag": tag, "name": tag, "published_at": None, "prerelease": False, "notes": "", "url": None} for tag in tags]
def list_releases(force=False):
"""Published releases, newest first. GitHub API first, `git ls-remote` (uses git's proxy) as fallback."""
state = current_state()
if not state.get("is_git"):
raise VersionError("not_git", "The plugin is not a git checkout")
now = time.monotonic()
if not force and _releases_cache["data"] and now < _releases_cache["expires"]:
releases, source = _releases_cache["data"]
else:
releases, source = None, None
repo = _github_repo(state.get("remote"))
if repo:
try:
releases, source = _fetch_github_releases(*repo), "github"
except Exception:
releases = None
if releases is None:
releases, source = _remote_tags(), "git"
if not releases:
raise VersionError("offline", "Could not reach GitHub or the git remote")
releases.sort(key=lambda item: (item["published_at"] or "", version_key(item["tag"])), reverse=True)
if source == "git":
releases.sort(key=lambda item: version_key(item["tag"]), reverse=True)
_releases_cache.update(expires=now + RELEASES_CACHE_SECONDS, data=(releases, source))
current_key = version_key(state.get("tag") or state.get("base_tag"))
stable = [item for item in releases if not item["prerelease"]]
latest = stable[0]["tag"] if stable else None
result = []
for item in releases:
key = version_key(item["tag"])
entry = dict(item)
entry["current"] = item["tag"] == state.get("tag")
entry["latest"] = item["tag"] == latest
entry["direction"] = "same" if entry["current"] else ("newer" if key > current_key else "older")
result.append(entry)
return {"state": state, "releases": result, "latest": latest, "source": source}
# ---------- switching ----------
def _read_state():
try:
with open(STATE_FILE, "r", encoding="utf-8") as handle:
data = json.load(handle)
return data if isinstance(data, dict) else {}
except (OSError, ValueError):
return {}
def _write_state(data):
temporary = STATE_FILE + ".tmp"
with open(temporary, "w", encoding="utf-8") as handle:
json.dump(data, handle, ensure_ascii=False, indent=2)
os.replace(temporary, STATE_FILE)
def _require_switchable():
state = current_state()
if not state.get("is_git"):
raise VersionError("not_git", "The plugin is not a git checkout")
if state["dirty"]:
raise VersionError("dirty", "Tracked files have local changes", files=state["changed_files"])
return state
def _known_tag(tag):
if not TAG_NAME.match(str(tag or "")):
raise VersionError("bad_tag", "Invalid version name")
tags = {item["tag"] for item in list_releases()["releases"]}
if tag not in tags:
raise VersionError("unknown_tag", f"{tag} is not a published version")
return tag
def _fetch_tag(tag):
_git("fetch", "--no-tags", "--force", "origin", f"+refs/tags/{tag}:refs/tags/{tag}", timeout=FETCH_TIMEOUT)
def preview_switch(tag):
"""Fetch the target and report what switching would mean, without changing anything."""
state = _require_switchable()
_known_tag(tag)
_fetch_tag(tag)
has_switcher = _git("cat-file", "-e", f"refs/tags/{tag}:{SWITCHER_MARKER}", check=False).returncode == 0
target_key = version_key(tag)
current_key = version_key(state.get("tag") or state.get("base_tag"))
return {
"tag": tag,
"direction": "same" if tag == state.get("tag") else ("newer" if target_key > current_key else "older"),
"has_switcher": has_switcher,
"from": state["label"],
"from_branch": state["branch"],
}
def switch_to(tag):
with _lock:
state = _require_switchable()
_known_tag(tag)
_fetch_tag(tag)
previous = {"commit": _git_out("rev-parse", "HEAD"), "branch": state["branch"], "label": state["label"]}
_git("-c", "advice.detachedHead=false", "checkout", "--quiet", "--detach", f"refs/tags/{tag}")
_write_state({"previous": previous, "switched_at": time.time()})
return {"state": current_state(), "restart_required": True}
def _default_branch():
ref = _git_out("symbolic-ref", "--short", "refs/remotes/origin/HEAD")
if ref.startswith("origin/"):
return ref.split("/", 1)[1]
for name in ("main", "master"):
if _git("ls-remote", "--exit-code", "--heads", "origin", name, check=False, timeout=NETWORK_TIMEOUT * 3).returncode == 0:
return name
raise VersionError("no_default_branch", "Could not determine the default branch")
def return_to_latest():
"""Back to the default branch at its newest commit; the same path ComfyUI Manager's update takes."""
with _lock:
state = _require_switchable()
branch = _default_branch()
_git("fetch", "--no-tags", "origin", f"+refs/heads/{branch}:refs/remotes/origin/{branch}", timeout=FETCH_TIMEOUT)
previous = {"commit": _git_out("rev-parse", "HEAD"), "branch": state["branch"], "label": state["label"]}
if _git("rev-parse", "--verify", "--quiet", f"refs/heads/{branch}", check=False).returncode == 0:
_git("checkout", "--quiet", branch)
else:
_git("checkout", "--quiet", "-b", branch, f"origin/{branch}")
merge = _git("merge", "--ff-only", "--quiet", f"origin/{branch}", check=False)
if merge.returncode != 0:
raise VersionError("diverged", f"Local {branch} has commits that are not on the remote")
_write_state({"previous": previous, "switched_at": time.time()})
return {"state": current_state(), "restart_required": True}
def undo_last_switch():
with _lock:
previous = _read_state().get("previous")
if not previous or not previous.get("commit"):
raise VersionError("no_previous", "There is no switch to undo")
state = _require_switchable()
here = {"commit": _git_out("rev-parse", "HEAD"), "branch": state["branch"], "label": state["label"]}
if previous.get("branch"):
# Going back to a branch means its current tip, even if it moved since (e.g. a Manager update).
_git("checkout", "--quiet", previous["branch"])
else:
_git("-c", "advice.detachedHead=false", "checkout", "--quiet", "--detach", previous["commit"])
_write_state({"previous": here, "switched_at": time.time()})
return {"state": current_state(), "restart_required": True}
# ---------- routes ----------
def _error_response(error):
status = 409 if error.code in {"dirty", "diverged"} else 400
if error.code in {"offline", "git_timeout", "git_failed"}:
status = 502
return web.json_response({"success": False, "code": error.code, "error": str(error), **error.details}, status=status)
async def _run(handler, *args):
try:
return web.json_response({"success": True, **(await asyncio.to_thread(handler, *args))})
except VersionError as error:
return _error_response(error)
async def _json_body(request):
try:
data = await request.json()
except Exception:
return {}
return data if isinstance(data, dict) else {}
async def api_get_version(request):
"""GET /anomalous/version - Installed version (local, no network)."""
try:
return web.json_response({"success": True, "state": await asyncio.to_thread(current_state)})
except VersionError as error:
return _error_response(error)
async def api_get_releases(request):
"""GET /anomalous/version/releases?refresh=1 - Published releases (network, only when asked)."""
return await _run(list_releases, request.query.get("refresh") == "1")
async def api_preview_switch(request):
"""POST /anomalous/version/preview {tag}"""
return await _run(preview_switch, str((await _json_body(request)).get("tag", "")))
async def api_switch_version(request):
"""POST /anomalous/version/switch {tag}"""
return await _run(switch_to, str((await _json_body(request)).get("tag", "")))
async def api_return_to_latest(request):
"""POST /anomalous/version/latest"""
return await _run(return_to_latest)
async def api_undo_switch(request):
"""POST /anomalous/version/undo"""
return await _run(undo_last_switch)
def register_routes(app):
app.router.add_get('/anomalous/version', api_get_version)
app.router.add_get('/anomalous/version/releases', api_get_releases)
app.router.add_post('/anomalous/version/preview', api_preview_switch)
app.router.add_post('/anomalous/version/switch', api_switch_version)
app.router.add_post('/anomalous/version/latest', api_return_to_latest)
app.router.add_post('/anomalous/version/undo', api_undo_switch)
+7
View File
@@ -67,6 +67,13 @@ output snapshot and deletion (searched through `image_search.py`, which reads
PNG text chunks only and caches per-image records by mtime), `translation_routes.py`
owns provider fallback, and `folder_types.py` owns configured visibility and scan scope.
`version_manager.py` runs git against the plugin checkout only (no shell, no
prompts, `CREATE_NO_WINDOW` on Windows) and accepts only tags that appear in the
published release list. It refuses switches while tracked files are modified,
never rewrites a local branch that has diverged from the remote, and reports
stable error codes (`dirty`, `diverged`, `offline`, `unknown_tag`, ...) that the UI
localizes. Switching changes code only; a ComfyUI restart applies it.
Offline inference sidecars use non-positive Civitai IDs as sentinels. Metadata
normalization must not expose those values as release-page URLs or resolved
model/version identities; only positive IDs may form a Civitai source link.
+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=20260927-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';
+118 -2
View File
@@ -20,7 +20,63 @@ export const i18n = {
updateGuideDone: "知道了",
updateGuideProgress: "第 {current} / {total} 步",
updateGuideNoticeLabel: "新指引",
updateGuideNoticeTooltip: "查看更新引导",
updateGuideNoticeTooltip: "更新引导与版本",
versionCheckOpen: "检查更新 / 切换版本 ›",
versionPanelTitle: "版本与更新",
versionChipUnknown: "版本未知",
versionCurrent: "当前版本",
versionUnknown: "无法识别(不是 git 安装)",
versionDevBuild: "开发版(基于 {base},分支 {branch})",
versionBranch: "分支 {branch}",
versionDetached: "固定在发布版本",
versionDirtyWarning: "插件文件有本地修改,切换版本前请先提交或还原这些修改。",
versionCheck: "检查更新",
versionCheckHint: "点击“检查更新”获取已发布的版本列表。只有点击时才会联网。",
versionChecking: "正在获取发布版本…",
versionReturnLatest: "回到最新版",
versionReturnLatestHint: "切回默认分支并拉取最新代码",
versionUndo: "撤销上次切换(回到 {label})",
versionShowPrerelease: "显示预发布版本",
versionNoReleases: "没有找到已发布的版本。",
versionBadgeCurrent: "当前",
versionBadgeLatest: "最新稳定版",
versionBadgePrerelease: "预发布",
versionNotes: "更新说明",
versionOpenGithub: "在 GitHub 查看",
versionUpdateTo: "更新到此版本",
versionRollbackTo: "回退到此版本",
versionUpToDate: "已是最新稳定版。",
versionNewAvailable: "有新版本:{tag}",
versionSourceGit: "(GitHub 接口不可用,列表来自 git 标签,没有更新说明)",
versionPreparing: "正在准备 {tag}…",
versionSwitching: "正在切换到 {tag}…",
versionConfirmUpdate: "从 {from} 更新到 {tag}?",
versionConfirmRollback: "从 {from} 回退到 {tag}?",
versionConfirmLatest: "切回默认分支并更新到最新代码?",
versionConfirmUndo: "回到切换前的版本 {label}?",
versionRollbackDataWarning: "旧版本可能读不懂新版本保存的设置或配方。你的数据不会被删除,回到新版后即可恢复正常。",
versionNoPanelWarning: "{tag} 还没有版本面板。想回到新版时,请在 ComfyUI Manager 里点“更新”这个插件。",
versionRestartNote: "切换后需要重启 ComfyUI 才会生效。",
versionSwitched: "已切换到 {tag}。",
versionReturnedLatest: "已回到最新版。",
versionPendingRestart: "待重启",
versionRestartQuestion: "现在重启 ComfyUI 吗?",
versionRestartNow: "立即重启",
versionRestartLater: "稍后",
versionRestarting: "ComfyUI 正在重启,稍后请刷新页面。",
versionRestartManual: "无法自动重启(需要 ComfyUI Manager)。请手动重启 ComfyUI。",
versionErrorGeneric: "操作失败:{error}",
versionError_not_git: "插件不是通过 git 安装的,无法检查或切换版本。",
versionError_dirty: "以下插件文件有本地修改,为了不覆盖它们,已取消切换:",
versionError_diverged: "本地分支有远程没有的提交,已取消以免覆盖。",
versionError_offline: "无法连接 GitHub,请检查网络后重试。",
versionError_git_missing: "找不到 git 程序。",
versionError_git_timeout: "git 操作超时,请稍后重试。",
versionError_git_failed: "git 操作失败:{error}",
versionError_bad_tag: "版本名无效。",
versionError_unknown_tag: "这不是已发布的版本。",
versionError_no_previous: "没有可以撤销的切换。",
versionError_no_default_branch: "无法确定默认分支。",
shortcutBarPinned: "已固定",
shortcutBarPin: "固定到快捷栏",
shortcutBarUnpin: "从快捷栏移除",
@@ -661,6 +717,8 @@ export const i18n = {
pickerNoMatches: '没有符合条件的模型。',
pickerOperationFailed: '操作失败:',
notebookSelectMain: '请先选择一个主模型。',
notebookUnlabeledBase: '未标注基础模型',
notebookUnetNeedsTextEncoder: 'UNet 模型不含文本编码器:发送到画布后,把你的 CLIP 加载器接到两个提示词节点上。LoRA 会用“仅模型”加载。',
mainPreflightTitle: '导入工作流 (预检海关)',
mainPreflightDesc: '请在此处粘贴工作流的 JSON 代码(未来将支持分享码)。我们将为您提取并校验所有模型依赖。',
mainCancel: '取消',
@@ -1273,7 +1331,63 @@ export const i18n = {
updateGuideDone: "Got it",
updateGuideProgress: "Step {current} of {total}",
updateGuideNoticeLabel: "Guide",
updateGuideNoticeTooltip: "View update guide",
updateGuideNoticeTooltip: "Update guide & version",
versionCheckOpen: "Check for updates / switch version ›",
versionPanelTitle: "Version & updates",
versionChipUnknown: "Unknown version",
versionCurrent: "Installed",
versionUnknown: "Unknown (not a git install)",
versionDevBuild: "Development build (based on {base}, branch {branch})",
versionBranch: "Branch {branch}",
versionDetached: "Pinned to a release",
versionDirtyWarning: "Plugin files have local changes. Commit or revert them before switching versions.",
versionCheck: "Check for updates",
versionCheckHint: "Click \"Check for updates\" to list published releases. Nothing goes online until you click.",
versionChecking: "Fetching releases…",
versionReturnLatest: "Back to latest",
versionReturnLatestHint: "Switch to the default branch and pull the newest code",
versionUndo: "Undo last switch (back to {label})",
versionShowPrerelease: "Show pre-releases",
versionNoReleases: "No published releases found.",
versionBadgeCurrent: "Installed",
versionBadgeLatest: "Latest stable",
versionBadgePrerelease: "Pre-release",
versionNotes: "Release notes",
versionOpenGithub: "View on GitHub",
versionUpdateTo: "Update to this version",
versionRollbackTo: "Roll back to this version",
versionUpToDate: "You're on the latest stable release.",
versionNewAvailable: "New version available: {tag}",
versionSourceGit: "(GitHub API unavailable; list comes from git tags, without notes)",
versionPreparing: "Preparing {tag}…",
versionSwitching: "Switching to {tag}…",
versionConfirmUpdate: "Update from {from} to {tag}?",
versionConfirmRollback: "Roll back from {from} to {tag}?",
versionConfirmLatest: "Switch to the default branch and update to the newest code?",
versionConfirmUndo: "Go back to {label}, the version before the last switch?",
versionRollbackDataWarning: "Older versions may not understand settings or recipes saved by newer ones. Nothing is deleted; it all works again once you return to the newer version.",
versionNoPanelWarning: "{tag} does not have this version panel yet. To come back, use \"Update\" for this plugin in ComfyUI Manager.",
versionRestartNote: "Restart ComfyUI afterwards for the change to take effect.",
versionSwitched: "Switched to {tag}.",
versionReturnedLatest: "Back on the latest version.",
versionPendingRestart: "restart pending",
versionRestartQuestion: "Restart ComfyUI now?",
versionRestartNow: "Restart now",
versionRestartLater: "Later",
versionRestarting: "ComfyUI is restarting. Refresh the page in a moment.",
versionRestartManual: "Could not restart automatically (needs ComfyUI Manager). Please restart ComfyUI yourself.",
versionErrorGeneric: "Something went wrong: {error}",
versionError_not_git: "The plugin was not installed with git, so versions can't be checked or switched.",
versionError_dirty: "These plugin files have local changes, so the switch was cancelled to avoid overwriting them:",
versionError_diverged: "The local branch has commits the remote doesn't, so nothing was changed.",
versionError_offline: "Couldn't reach GitHub. Check your connection and try again.",
versionError_git_missing: "The git program was not found.",
versionError_git_timeout: "A git command timed out. Please try again.",
versionError_git_failed: "git failed: {error}",
versionError_bad_tag: "Invalid version name.",
versionError_unknown_tag: "That is not a published version.",
versionError_no_previous: "There is no switch to undo.",
versionError_no_default_branch: "Couldn't determine the default branch.",
shortcutBarPinned: "Pinned",
shortcutBarPin: "Pin to shortcuts",
shortcutBarUnpin: "Remove from shortcuts",
@@ -1914,6 +2028,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;
});
+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);
+2
View File
@@ -3,6 +3,7 @@ import { i18n, translate as t } from './locales.js';
import { createViewScope } from './ui_lifecycle.js';
import { text } from './ui_dom.js';
import { startSpotlightTour, ensureTourStyles } from './ui_spotlight_tour.js';
import { createVersionLine } from './ui_version_manager.js';
const acknowledgedThisSession = new Set();
let activeGuide = null;
@@ -58,6 +59,7 @@ export function showUpdateGuide(owner, { force = false, guide = CURRENT_UPDATE_G
dismiss.type = 'button';
dismiss.setAttribute('aria-label', t('close'));
dismiss.onclick = () => closeUpdateGuide(owner, true);
dialog.appendChild(createVersionLine(owner, { beforeOpen: () => closeUpdateGuide(owner, true) }));
const icon = text(dialog, 'div', '', 'anomalous-update-guide-icon');
icon.setAttribute('aria-hidden', 'true');
const title = text(dialog, 'h2', '');
+324
View File
@@ -0,0 +1,324 @@
import { translate } from './locales.js';
import { createViewScope } from './ui_lifecycle.js';
import { anomalousAlert, anomalousConfirm } from './ui_dialog.js';
/**
* Version line in the update guide ("!" in the header) and the "Version & updates" panel:
* shows the installed version, lists published releases only when asked,
* and switches, rolls back, returns to latest or undoes the last switch.
*/
const t = (key, params) => translate(key, params);
let activeScope = null;
function el(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
function button(className, label, onClick) {
const btn = el('button', className, label);
btn.type = 'button';
btn.onclick = onClick;
return btn;
}
async function request(url, options = {}) {
let resp;
try {
resp = await fetch(url, options);
} catch (e) {
return { success: false, code: 'offline', error: e.message };
}
const data = await resp.json().catch(() => ({ success: false, error: `HTTP ${resp.status}` }));
return data;
}
const post = (url, body = {}) => request(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
function errorText(result) {
const key = `versionError_${result.code}`;
const text = t(key, { error: result.error || '' });
const message = text === key ? t('versionErrorGeneric', { error: result.error || result.code || '' }) : text;
return result.files?.length ? `${message}\n\n${result.files.join('\n')}` : message;
}
function formatDate(value) {
if (!value) return '';
const date = new Date(value);
return Number.isNaN(date.getTime()) ? '' : date.toLocaleDateString();
}
function stateLabel(state) {
if (!state?.is_git) return t('versionUnknown');
if (state.tag) return state.tag;
return state.branch ? t('versionDevBuild', { base: state.base_tag || state.commit, branch: state.branch }) : state.label;
}
// ---------- version line ----------
/**
* "Current version · Check for updates" row for the update guide. Reads the installed
* version locally (no network). `beforeOpen` lets the host close itself first, since a
* modal <dialog> would stay above the panel.
*/
export function createVersionLine(owner, { beforeOpen } = {}) {
const line = el('div', 'anomalous-version-line');
const label = el('span', 'anomalous-version-line-label', t('versionCurrent'));
const value = el('strong', 'anomalous-version-line-value', '…');
const open = button('anomalous-version-line-open', t('versionCheckOpen'), () => {
beforeOpen?.();
openVersionPanel(owner);
});
line.append(label, value, open);
request('/anomalous/version').then(data => {
value.textContent = data.success && data.state?.is_git ? (data.state.tag || data.state.base_tag || data.state.label) : t('versionChipUnknown');
});
return line;
}
// ---------- panel ----------
export function openVersionPanel(owner, chip = null) {
activeScope?.dispose();
const scope = createViewScope();
activeScope = scope;
const overlay = el('div', 'anomalous-version-overlay');
const panel = el('div', 'anomalous-version-panel');
panel.setAttribute('role', 'dialog');
panel.setAttribute('aria-modal', 'true');
const close = () => scope.dispose();
scope.onDispose(() => {
overlay.remove();
if (activeScope === scope) activeScope = null;
});
overlay.onclick = (e) => { if (e.target === overlay) close(); };
scope.listen(window, 'keydown', (e) => {
if (e.key !== 'Escape' || document.querySelector('.anomalous-dialog-overlay')) return;
e.stopPropagation();
close();
}, true);
const header = el('div', 'anomalous-version-header');
header.append(el('div', 'anomalous-version-title', t('versionPanelTitle')), button('anomalous-version-close', '×', close));
const current = el('div', 'anomalous-version-current');
const actions = el('div', 'anomalous-version-actions');
const status = el('div', 'anomalous-version-status');
const listBar = el('label', 'anomalous-version-prerelease');
const prereleaseToggle = document.createElement('input');
prereleaseToggle.type = 'checkbox';
listBar.append(prereleaseToggle, el('span', '', t('versionShowPrerelease')));
listBar.hidden = true;
const list = el('div', 'anomalous-version-list');
const view = { state: null, listing: null, busy: false };
const setBusy = (busy, message = '') => {
view.busy = busy;
panel.classList.toggle('is-busy', busy);
status.textContent = message;
status.classList.remove('is-error');
panel.querySelectorAll('button.anomalous-version-action').forEach(btn => { btn.disabled = busy || btn.dataset.blocked === '1'; });
};
const showError = (result) => {
status.textContent = errorText(result);
status.classList.add('is-error');
};
const renderCurrent = () => {
const state = view.state;
current.replaceChildren();
const line = el('div', 'anomalous-version-current-line');
line.append(el('span', 'anomalous-version-current-label', t('versionCurrent')), el('strong', '', stateLabel(state)));
current.appendChild(line);
if (state?.is_git) {
const meta = [state.branch ? t('versionBranch', { branch: state.branch }) : t('versionDetached'), state.commit].filter(Boolean).join(' · ');
current.appendChild(el('div', 'anomalous-version-meta', meta));
}
if (state?.dirty) {
current.appendChild(el('div', 'anomalous-version-warning', t('versionDirtyWarning')));
} else if (state && !state.is_git) {
current.appendChild(el('div', 'anomalous-version-warning', t('versionError_not_git')));
}
};
const blocked = () => !view.state?.is_git || view.state.dirty;
const renderActions = () => {
const check = button('anomalous-version-action primary', t('versionCheck'), () => loadReleases(true));
const latest = button('anomalous-version-action', t('versionReturnLatest'), returnToLatest);
latest.title = t('versionReturnLatestHint');
const items = [check, latest];
const previous = view.state?.previous;
if (previous?.label) items.push(button('anomalous-version-action', t('versionUndo', { label: previous.label }), undoSwitch));
for (const item of items.slice(1)) item.dataset.blocked = blocked() ? '1' : '0';
check.dataset.blocked = view.state?.is_git ? '0' : '1';
items.forEach(item => { item.disabled = view.busy || item.dataset.blocked === '1'; });
actions.replaceChildren(...items);
};
const renderList = () => {
const listing = view.listing;
if (!listing) {
list.replaceChildren(el('div', 'anomalous-version-empty', t('versionCheckHint')));
return;
}
listBar.hidden = !listing.releases.some(item => item.prerelease);
const releases = listing.releases.filter(item => prereleaseToggle.checked || !item.prerelease || item.current);
list.replaceChildren(...releases.map(renderRelease));
if (!releases.length) list.appendChild(el('div', 'anomalous-version-empty', t('versionNoReleases')));
};
const renderRelease = (release) => {
const card = el('div', `anomalous-version-release${release.current ? ' is-current' : ''}`);
const top = el('div', 'anomalous-version-release-top');
const titleBox = el('div', 'anomalous-version-release-title');
titleBox.append(el('strong', '', release.tag));
if (release.name && release.name !== release.tag) titleBox.append(el('span', 'anomalous-version-release-name', release.name));
const badges = el('div', 'anomalous-version-badges');
if (release.current) badges.appendChild(el('span', 'anomalous-version-badge is-current', t('versionBadgeCurrent')));
if (release.latest) badges.appendChild(el('span', 'anomalous-version-badge is-latest', t('versionBadgeLatest')));
if (release.prerelease) badges.appendChild(el('span', 'anomalous-version-badge is-pre', t('versionBadgePrerelease')));
const date = formatDate(release.published_at);
if (date) badges.appendChild(el('span', 'anomalous-version-date', date));
top.append(titleBox, badges);
card.appendChild(top);
const footer = el('div', 'anomalous-version-release-footer');
if (release.notes) {
const notes = el('pre', 'anomalous-version-notes', release.notes);
notes.hidden = true;
const toggle = button('anomalous-version-link', t('versionNotes'), () => {
notes.hidden = !notes.hidden;
});
footer.appendChild(toggle);
card.appendChild(notes);
}
if (release.url) {
const link = el('a', 'anomalous-version-link', t('versionOpenGithub'));
link.href = release.url;
link.target = '_blank';
link.rel = 'noopener noreferrer';
footer.appendChild(link);
}
if (!release.current) {
const label = release.direction === 'newer' ? t('versionUpdateTo') : t('versionRollbackTo');
const switchBtn = button(`anomalous-version-action${release.direction === 'newer' ? ' primary' : ''}`, label, () => switchTo(release));
switchBtn.dataset.blocked = blocked() ? '1' : '0';
switchBtn.disabled = view.busy || blocked();
footer.appendChild(switchBtn);
}
card.appendChild(footer);
return card;
};
const renderAll = () => {
renderCurrent();
renderActions();
renderList();
};
const refreshState = async () => {
const data = await request('/anomalous/version');
if (data.success) view.state = data.state;
if (chip && view.state) chip.textContent = view.state.tag || view.state.base_tag || view.state.label || chip.textContent;
};
const loadReleases = async (force) => {
setBusy(true, t('versionChecking'));
const data = await request(`/anomalous/version/releases${force ? '?refresh=1' : ''}`);
if (scope.signal.aborted) return;
setBusy(false);
if (!data.success) {
showError(data);
renderAll();
return;
}
view.state = data.state;
view.listing = data;
renderAll();
const latest = data.releases.find(item => item.latest);
status.textContent = latest?.current ? t('versionUpToDate') : (latest ? t('versionNewAvailable', { tag: latest.tag }) : '');
if (data.source === 'git') status.textContent += ` ${t('versionSourceGit')}`;
};
/** Shared ending for every switch: tell the user to restart, offer Manager's restart. */
const afterSwitch = async (result, successKey, params) => {
await refreshState();
view.listing = null;
renderAll();
if (!result.success) {
showError(result);
return;
}
if (chip) chip.textContent = `${chip.textContent} · ${t('versionPendingRestart')}`;
status.textContent = t(successKey, params);
const restart = await anomalousConfirm(`${t(successKey, params)}\n\n${t('versionRestartQuestion')}`, t('versionPanelTitle'), {
okLabel: t('versionRestartNow'), cancelLabel: t('versionRestartLater'),
});
if (!restart) return;
// ComfyUI Manager's reboot exits the server, so a dropped connection means it is restarting.
// 404 (no Manager) or 403 (Manager security level) means the user restarts by hand.
const reboot = await post('/api/manager/reboot');
if (reboot.code === 'offline' || reboot.success !== false) {
status.textContent = t('versionRestarting');
} else {
await anomalousAlert(t('versionRestartManual'));
}
};
const switchTo = async (release) => {
if (view.busy) return;
setBusy(true, t('versionPreparing', { tag: release.tag }));
const preview = await post('/anomalous/version/preview', { tag: release.tag });
setBusy(false);
if (scope.signal.aborted) return;
if (!preview.success) {
showError(preview);
return;
}
const lines = [t(preview.direction === 'newer' ? 'versionConfirmUpdate' : 'versionConfirmRollback', { tag: release.tag, from: preview.from })];
if (preview.direction !== 'newer') lines.push(t('versionRollbackDataWarning'));
if (!preview.has_switcher) lines.push(t('versionNoPanelWarning', { tag: release.tag }));
lines.push(t('versionRestartNote'));
if (!await anomalousConfirm(lines.join('\n\n'), t('versionPanelTitle'))) return;
setBusy(true, t('versionSwitching', { tag: release.tag }));
const result = await post('/anomalous/version/switch', { tag: release.tag });
setBusy(false);
await afterSwitch(result, 'versionSwitched', { tag: release.tag });
};
const returnToLatest = async () => {
if (view.busy) return;
if (!await anomalousConfirm(`${t('versionConfirmLatest')}\n\n${t('versionRestartNote')}`, t('versionPanelTitle'))) return;
setBusy(true, t('versionSwitching', { tag: 'main' }));
const result = await post('/anomalous/version/latest');
setBusy(false);
await afterSwitch(result, 'versionReturnedLatest', {});
};
const undoSwitch = async () => {
if (view.busy) return;
const label = view.state?.previous?.label || '';
if (!await anomalousConfirm(`${t('versionConfirmUndo', { label })}\n\n${t('versionRestartNote')}`, t('versionPanelTitle'))) return;
setBusy(true, t('versionSwitching', { tag: label }));
const result = await post('/anomalous/version/undo');
setBusy(false);
await afterSwitch(result, 'versionSwitched', { tag: label });
};
prereleaseToggle.onchange = renderList;
panel.append(header, current, actions, status, listBar, list);
overlay.appendChild(panel);
document.body.appendChild(overlay);
renderAll();
refreshState().then(() => { if (!scope.signal.aborted) renderAll(); });
return close;
}
+4 -3
View File
@@ -1,11 +1,12 @@
@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");
@import url("./styles/08-theme-gallery-overrides.css?v=20260918-model-sources-1");
@import url("./styles/09-prompt-drawers.css?v=20260918-model-sources-1");
@import url("./styles/10-model-sources.css?v=20260918-model-sources-1");
@import url("./styles/14-version-manager.css");
-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));
}
+326
View File
@@ -0,0 +1,326 @@
/* =============================================================================
Version line (update guide) and the "Version & updates" panel
============================================================================= */
/* Row under the update guide header ("!" button). */
.anomalous-version-line {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
font-size: 12px;
color: #94a3b8;
}
.anomalous-version-line-value {
color: #e2e8f0;
font: 600 12px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.anomalous-version-line-open {
margin-left: auto;
padding: 4px 10px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 6px;
color: #e2e8f0;
font-size: 12px;
cursor: pointer;
white-space: nowrap;
}
.anomalous-version-line-open:hover {
background: rgba(255, 255, 255, 0.1);
}
.anomalous-version-overlay {
position: fixed;
inset: 0;
z-index: 1000000;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
box-sizing: border-box;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(3px);
}
.anomalous-version-panel {
display: flex;
flex-direction: column;
gap: 12px;
width: min(620px, 100%);
max-height: min(760px, 100%);
padding: 18px 20px;
box-sizing: border-box;
overflow: hidden;
background: var(--amb-bg-panel, #1a1a1d);
color: var(--amb-text-main, #f3f4f6);
border: 1px solid var(--amb-border-hover, rgba(255, 255, 255, 0.16));
border-radius: var(--amb-radius-panel, 10px);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
font-size: 13px;
}
.anomalous-version-panel.is-busy {
cursor: progress;
}
.anomalous-version-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.anomalous-version-title {
font-size: 16px;
font-weight: 700;
}
.anomalous-version-close {
width: 28px;
height: 28px;
padding: 0;
background: transparent;
color: var(--amb-text-muted, #9ca3af);
border: 1px solid transparent;
border-radius: var(--amb-radius-control, 6px);
font-size: 18px;
cursor: pointer;
}
.anomalous-version-close:hover {
color: var(--amb-text-main, #f3f4f6);
border-color: var(--amb-border-hover, rgba(255, 255, 255, 0.16));
}
.anomalous-version-current {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px 14px;
background: var(--amb-bg-card, #202024);
border: 1px solid var(--amb-border, rgba(255, 255, 255, 0.08));
border-radius: var(--amb-radius-card, 8px);
}
.anomalous-version-current-line {
display: flex;
align-items: baseline;
gap: 10px;
}
.anomalous-version-current-line strong {
font-size: 15px;
}
.anomalous-version-current-label,
.anomalous-version-meta,
.anomalous-version-date,
.anomalous-version-release-name,
.anomalous-version-empty {
color: var(--amb-text-muted, #9ca3af);
}
.anomalous-version-meta {
font: 12px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.anomalous-version-warning {
padding: 8px 10px;
color: #fcd34d;
background: rgba(245, 158, 11, 0.1);
border: 1px solid rgba(245, 158, 11, 0.35);
border-radius: var(--amb-radius-control, 6px);
line-height: 1.45;
}
.anomalous-version-actions,
.anomalous-version-release-footer {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.anomalous-version-action {
height: 30px;
padding: 0 12px;
background: rgba(255, 255, 255, 0.04);
color: var(--amb-text-main, #f3f4f6);
border: 1px solid var(--amb-border-hover, rgba(255, 255, 255, 0.16));
border-radius: var(--amb-radius-control, 6px);
font-size: 12px;
cursor: pointer;
white-space: nowrap;
}
.anomalous-version-action:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.1);
}
.anomalous-version-action.primary {
background: var(--amb-btn-primary-bg, #e5e7eb);
color: var(--amb-btn-primary-text, #111827);
border-color: transparent;
font-weight: 600;
}
.anomalous-version-action.primary:hover:not(:disabled) {
background: var(--amb-btn-primary-hover, #ffffff);
}
.anomalous-version-action:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.anomalous-version-status {
min-height: 18px;
color: var(--amb-text-muted, #9ca3af);
line-height: 1.45;
white-space: pre-wrap;
}
.anomalous-version-status:empty {
display: none;
}
.anomalous-version-status.is-error {
color: #fca5a5;
}
.anomalous-version-prerelease {
display: flex;
align-items: center;
gap: 6px;
color: var(--amb-text-muted, #9ca3af);
cursor: pointer;
}
.anomalous-version-prerelease[hidden] {
display: none;
}
.anomalous-version-list {
display: flex;
flex-direction: column;
gap: 8px;
min-height: 0;
overflow-y: auto;
padding-right: 2px;
}
.anomalous-version-empty {
padding: 22px 10px;
text-align: center;
line-height: 1.5;
}
.anomalous-version-release {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 12px;
background: var(--amb-bg-card, #202024);
border: 1px solid var(--amb-border, rgba(255, 255, 255, 0.08));
border-radius: var(--amb-radius-card, 8px);
}
.anomalous-version-release.is-current {
border-color: var(--amb-accent-line, rgba(229, 231, 235, 0.35));
}
.anomalous-version-release-top {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.anomalous-version-release-title {
display: flex;
align-items: baseline;
gap: 8px;
min-width: 0;
}
.anomalous-version-release-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.anomalous-version-badges {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
}
.anomalous-version-badge {
padding: 2px 7px;
border-radius: 999px;
border: 1px solid var(--amb-border-hover, rgba(255, 255, 255, 0.16));
}
.anomalous-version-badge.is-current {
color: var(--amb-btn-primary-text, #111827);
background: var(--amb-btn-primary-bg, #e5e7eb);
border-color: transparent;
}
.anomalous-version-badge.is-latest {
color: #86efac;
border-color: rgba(34, 197, 94, 0.45);
}
.anomalous-version-badge.is-pre {
color: #fcd34d;
border-color: rgba(245, 158, 11, 0.45);
}
.anomalous-version-link {
padding: 0;
background: none;
border: none;
color: var(--amb-text-muted, #9ca3af);
font-size: 12px;
text-decoration: underline;
text-underline-offset: 2px;
cursor: pointer;
}
.anomalous-version-link:hover {
color: var(--amb-text-main, #f3f4f6);
}
.anomalous-version-release-footer .anomalous-version-action {
margin-left: auto;
}
.anomalous-version-notes {
max-height: 220px;
margin: 0;
padding: 8px 10px;
overflow: auto;
background: var(--amb-bg-input, #151517);
border-radius: var(--amb-radius-control, 6px);
font: 12px/1.5 system-ui, sans-serif;
white-space: pre-wrap;
word-break: break-word;
}
.anomalous-version-notes[hidden] {
display: none;
}
.anomalous-version-panel button:focus-visible,
.anomalous-version-panel a:focus-visible,
.anomalous-version-line-open:focus-visible {
outline: 2px solid var(--amb-accent, #f59e0b);
outline-offset: 2px;
}