Compare commits

..
15 Commits
Author SHA1 Message Date
DemonGatanjieu 6c6be650c0 docs: streamline README with modern card layout and collapsible guides 2026-08-21 18:00:27 +08:00
DemonGatanjieu a8db942e43 docs: add YouTube and Bilibili video demo links to README 2026-08-21 16:20:26 +08:00
DemonGatanjieu 838904900e Update .gitignore 2026-08-21 03:29:15 +08:00
DemonGatanjieu 5a5552286e feat(models): clarify aliases and foundation recovery 2026-08-21 02:48:40 +08:00
DemonGatanjieu ca730fb08d fix(models): protect foundation components from physical rename 2026-08-21 00:17:44 +08:00
DemonGatanjieu c4e9c2eda2 Merge pull request #12 from DemonGatanjieu/fix/modal-keybinding-guard
fix(ui): restore ComfyUI command shortcuts
2026-08-20 23:57:53 +08:00
DemonGatanjieu 0ada2aab7d fix(ui): restore ComfyUI command shortcuts 2026-08-20 22:31:20 +08:00
DemonGatanjieu f56d337ea0 docs: deprecate ai_changelog and rely on Git history and ARCHITECTURE.md 2026-08-18 18:34:18 +08:00
DemonGatanjieu b38770cfcc feat(recipes): support renaming parameter notebooks with atomic updates and dialog prompt 2026-08-18 04:29:55 +08:00
DemonGatanjieu ec65e6f0c8 docs: clean up public changelog by removing internal .agents doc reference 2026-08-18 04:06:51 +08:00
DemonGatanjieu e4025cadf4 docs: release notes for v1.56 Beta 2026-08-18 04:05:28 +08:00
DemonGatanjieu 6e188d34e9 fix(ui): remove redundant key emoji from hash modal title 2026-08-18 03:58:46 +08:00
DemonGatanjieu cfdf892b7b docs: update ARCHITECTURE.md with view hash modal documentation 2026-08-18 03:53:02 +08:00
DemonGatanjieu d29103e70c feat(doctor): add view hash details dialog with provenance vs local hash comparison and copy button 2026-08-18 03:51:09 +08:00
DemonGatanjieu d2575cd0a0 fix(doctor): support dynamic on-demand hash resolution, unet scan folder and smart civitai domain routing 2026-08-18 03:36:39 +08:00
22 changed files with 1301 additions and 425 deletions
+1
View File
@@ -66,3 +66,4 @@ tests/
# Local diagnostic/test artifacts
grep_doctor.txt
test_folders.json
*.bat
+21 -13
View File
@@ -6,6 +6,8 @@ Workspace lifecycle note: reopening an already initialized Workspace must restor
The floating trigger follows the browser modal lifecycle: opening the main browser hides the trigger, while every normal close path restores it. The hidden state uses a scoped class with pointer-events disabled; initialization errors keep the trigger available as the recovery entry point.
Hidden plugin dialogs must not advertise an active modal state. The model-card settings dialog remains mounted for reuse, but its `aria-modal` value follows the overlay lifecycle: `false` while hidden and `true` only while visibly open. This preserves accessibility semantics without tripping ComfyUI Frontend's global modal guard, which otherwise suppresses command-backed shortcuts such as Queue Prompt, Interrupt, and Escape-to-exit-subgraph. The plugin does not install replacement global keyboard handlers or mutate ComfyUI command bindings.
Origin refresh is an explicit enrichment mode passed into `_enrich_recipe`; it must not mutate the Python list of references with ad-hoc state. Origin refresh writes schema v5, while normal updates preserve previously imported identity/origin records when the current machine cannot re-resolve them.
Recipe identity badges use short, bounded labels in the card layout; detailed explanations belong in the help tooltip. Identity badges and model-name rows must remain shrinkable flex children so localized or imported text cannot expand the recipe card beyond its container.
@@ -38,7 +40,7 @@ The "Parameter Notebook" system has been fully integrated into the Workflow Reci
When a user saves or updates a recipe, the system automatically creates a new, immutable Parameter Notebook capturing the exact generation values of that moment, and associates it with the recipe (`recipe_filename`). In the Recipe Detail view, the "Parameters" tab is now a two-pane interface: a sidebar listing the history of Parameter Notebooks for that recipe, and a main viewer showing the parameters and matching `sha256-params-v1` gallery for the selected notebook. The global sidebar entry for Parameter Notebooks has been removed, solidifying recipes as the primary structural template and parameter notebooks as their concrete instantiation history.
The Parameters tab keeps runtime-volatile values such as sampler seeds visible by name but redacts their values from summaries, copies, editing, and parameter matching; the raw serialized workflow retains the slot only for ComfyUI compatibility. Common values use readable horizontal label/value cards and preserve full text instead of silently truncating it, and complex arrays like LoRAs are formatted into clean multi-line bulleted lists. The right-hand viewer identifies the active notebook by name and timestamp, and a notebook switch uses a staggered waterfall transition animation to provide clear visual feedback; stale parameter-gallery responses cannot overwrite the newly selected notebook. “New parameter note” clones the selected recipe/snapshot into an editable draft and saves a new immutable snapshot; it never edits or overwrites historical notes.
The Parameters tab keeps runtime-volatile values such as sampler seeds visible by name but redacts their values from summaries, copies, editing, and parameter matching; the raw serialized workflow retains the slot only for ComfyUI compatibility. Common values use readable horizontal label/value cards and preserve full text instead of silently truncating it, and complex arrays like LoRAs are formatted into clean multi-line bulleted lists. The right-hand viewer identifies the active notebook by name and timestamp, and a notebook switch uses a staggered waterfall transition animation to provide clear visual feedback; stale parameter-gallery responses cannot overwrite the newly selected notebook. “New parameter note” clones the selected recipe/snapshot into an editable draft and saves a new immutable snapshot; it never edits or overwrites historical notes. Parameter notebooks support renaming via `POST /anomalous/rename_parameter` (`filename`, `name`), safely updating the notebook name with atomic writes and invalidating in-memory caches. The UI exposes renaming both in the sidebar list rows and on the active notebook detail heading.
Parameter Notebook actions include “Read current and create” and “Apply to current workflow”. Both use the same skeleton preflight: every saved node must have a matching local node by type/shape (with stable-ID/title preference), while extra local nodes are allowed. Read-current refuses to create a draft when the recipe skeleton is incomplete. Apply uses serialized nodes only for matching, then resolves each match back to the live ComfyUI node before reading `widgets` or invoking callbacks; serialized workflow records must never be treated as runtime widget objects. It validates all widget slots before mutation, skips volatile values, writes widget values inside `beforeChange`/`afterChange`, and calls `onWidgetChanged(index, value, previousValue, liveWidget)` so current ComfyUI error-clearing hooks receive the widget object they require. It marks the canvas dirty and restores prior widget values if a callback fails.
@@ -64,7 +66,7 @@ The extension is a UI-only ComfyUI integration: `NODE_CLASS_MAPPINGS` is intenti
`web/modules/locales.js` is the canonical home for user-facing translation data. The current codebase still contains substantial inline `zh`/`en` branches across `main.js`, Sidebar, Detail, Gallery, Doctor, Notebook, Dialog, Grid, and the optional hash resolver. The first i18n migration snapshot now provides `supportedLocales`, `normalizeLocale`, `resolveLocale`, `formatTranslation`, `translate`, and `createTranslator`. `main.js` and `ui_dialog.js` use this contract; the remaining modules are intentionally unmigrated until their own reviewable snapshots. The refactor must preserve current Chinese and English behavior, keep model names/paths/node types/user data out of locale dictionaries, and keep translated HTML inside the existing `safe_dom.js` boundary. Missing translations must never prevent a panel or action from rendering. Language selection remains the existing zh/en toggle until a later phase introduces a registered locale selector.
Every i18n or licensing change is a local, reviewable snapshot. The implementation must update this document and `.agents/logs/ai_changelog.md` in the same change, run proportional syntax/runtime checks, and create a local Git commit. Remote pushes require explicit user authorization. The MIT license applies to project-owned code and documentation only; external service content and host-project dependencies retain their own terms.
Every i18n or licensing change is a local, reviewable snapshot. The implementation must update this document in the same change, run proportional syntax/runtime checks, and create a local Git commit. Remote pushes require explicit user authorization. The MIT license applies to project-owned code and documentation only; external service content and host-project dependencies retain their own terms.
The second migration snapshot extends the shared translation contract to `ui_grid.js` and `ui_gallery.js`. Gallery confirmation and cover-selection banners are assembled with DOM nodes and `textContent` rather than translated raw HTML; user-controlled model names remain text content. These modules resolve strings at render time through `translate`, so existing global language changes continue to be reflected without introducing a new locale selector.
@@ -84,10 +86,10 @@ The sixth migration snapshot completes the Stage 2 UI i18n pass for `ui_recipes.
Anomalous_Model_Browser/
├── .agents/ # Local, ignored AI handoff material
│ ├── logs/
│ │ ├── ai_changelog.md # Engineering snapshots, decisions, checks, and handoff notes
│ │ └── ai_lessons.md # Curated experience summary and recurring pitfalls
│ └── plans/ # Product/design proposals that are not runtime behavior
├── __init__.py # ComfyUI Extension Entry Point. Registers nodes and sets WEB_DIRECTORY = "./web"
├── model_policies.py # Shared backend rename and hash-only recovery policy for protected categories
├── api/ # Backend Python API (Modularized)
│ ├── __init__.py # Appends all modular routes to server
│ ├── config.py # Configuration and paths setup
@@ -106,6 +108,7 @@ Anomalous_Model_Browser/
│ ├── hash_resolver.js # Frontend auto-fix engine: scans workflows for missing models
│ ├── styles.css # Vanilla CSS with scoped classes (.anomalous-*)
│ └── modules/ # Extracted UI logic modules
│ ├── model_policies.js # Frontend category inference plus protected rename/recovery policy
│ ├── ui_sidebar.js # Sidebar and folder tree logic
│ ├── ui_detail.js # Model detail panel
│ ├── ui_doctor.js # Model Doctor and Assistant panel
@@ -145,6 +148,7 @@ All endpoints are prefixed with `/anomalous/`.
* `.civitai_bak.*` is a persistent restore source created from a real Civitai download. Setting a custom cover MUST modify only `.preview.*`; it MUST NOT delete, overwrite, or repurpose `.civitai_bak.*`.
* Reset cover priority is fixed: restore `.civitai_bak.*` to `.preview.*`; otherwise remove `.preview.*` only when a bare original cover (`model.png`, etc.) can take over naturally. If the active `.preview.*` is the only image, preserve it and return a visible warning instead of causing irreversible cover loss. Reset does not silently redownload from the network.
* Physical rename means **move/rename** every recognized sidecar, including `.civitai_bak.*`, to the new model stem. It never means deleting the Civitai backup. Model deletion may clean sidecars only after the selected main model was successfully deleted.
* Foundation components (`vae`, `vae_approx`, `clip`, `text_encoders`, and `clip_vision`) are never physical-rename targets. The scan wizard may still fetch their metadata and apply virtual display names, but its physical-rename mode must visibly disclose the protection. Detail UI, scan APIs, metadata updates, and the standalone scraper all enforce the same denial so stale clients or direct requests cannot bypass it.
* Main model extensions (`.safetensors`, `.ckpt`, `.pt`, `.bin`) are never sidecar suffixes. A cleanup operation MUST NOT delete a same-stem model with another extension. If such a sibling survives, stem-keyed sidecars are ambiguous and must be preserved for it.
* Performance boundary: sidecar operations use centralized immutable suffix tuples and a constant number of exact-path checks. Do not use `os.walk`, directory-wide globbing, full-directory indexing, hashes, or network calls for rename/delete/reset. This keeps the operation independent of model-folder size.
* Preserve the product-language distinction in documentation and UI: **delete cleans sidecars; rename migrates sidecars**. Never describe both operations as deleting or "taking away" the files.
@@ -173,8 +177,8 @@ Instead of fragmenting the class scope and losing context, we extracted all UI p
### UI Components (Dynamically Created):
* **Sidebar (`anomalous-sidebar`)**: Renders the nested folder structure.
* **Folder Manager (📁 Manage Folders)**: An interactive modal in `ui_sidebar.js` allowing drag-and-drop reordering and visibility toggling of folders. Drives backend I/O optimization.
* **Grid (`anomalous-grid`)**: The main model display area. Emptied (`innerHTML = ''`) and repopulated on every folder click.
* **Detail Panel (`anomalous-detail-panel`)**: Slides out when a specific model is clicked.
* **Grid (`anomalous-grid`)**: The main model display area. Emptied (`innerHTML = ''`) and repopulated on every folder click. A virtual/custom display name is the card title when present, while the physical filename remains a smaller secondary line. Global search tries physical filename/path first and only then scans cached sidecar metadata for the virtual display name; disabling virtual naming therefore still leaves the physical filename as the title.
* **Detail Panel (`anomalous-detail-panel`)**: Slides out when a specific model is clicked. It identifies the physical filename and absolute local path with a copy action. Relative paths are intentionally omitted because the folder tree already owns navigation context; `[Root]` folder behavior is unchanged.
* **Gallery Viewer (`ui_gallery.js`)**: A fullscreen modal for viewing images.
* **Prompt Notes (`ui_notebooks.js`, legacy internal name)**: A specialized editor for composing prompts and drag-dropping LoRA/model nodes. Its shared user-facing surface is **Workspace / 创作工作台**, with **Prompt Notes / 提示词笔记** and **Workflow Recipes / 工作流配方** as its two sections. This presentation rename intentionally leaves notebook routes, storage, and internal property names stable for compatibility.
* **Workflow Recipes (`ui_recipes.js`, `ui_recipe_detail.js`)**: A second section inside the shared Workspace modal, not a separate top-level navigation surface. Recipes save the complete serialized graph and add two summary layers: adapters for common model/LoRA/prompt/sampling semantics, plus bounded generic widget summaries for every node (including third-party nodes). Prompt extraction utilizes topological backward-tracing (from KSampler input links backwards to CLIPTextEncode nodes) to rigorously identify Positive and Negative tags without relying on fragile node-title regex matching. Save captures the serialized graph exactly once before the dialog; that snapshot, not a later live-canvas serialization, is the workflow sent to the API. Schema v3 introduced a canonical SHA-256 workflow fingerprint and explicit model-reference identity records; schema v4 adds optional recipe-owned model-preview snapshot descriptors. Schema v5 separates model identity from C-site Official origin metadata (model_name/model_url, which can be manually edited or auto-fetched via Civitai API by hash directly from the detail view), ensuring UI rendering prioritizes official names over obfuscated physical filenames. The card API stays light; the full graph and history are fetched only after edit, restore, or “View details.” The detail panel owns Overview, Parameters, and Versions tabs. Parameters are sorted topologically (using Kahn's algorithm on graph links) to match workflow execution order, and their display names dynamically resolve through `LiteGraph.registered_node_types` (and dummy node instantiation for widgets) to automatically support translation plugins. The save flow uses the product default for bounded model-preview snapshots without exposing a per-recipe checkbox; a future Workflow Recipe global setting may own that preference. The edit dialog resolves safe primitive values from the full workflow by node ID/widget index before updating the matching serialized slot; bounded summaries are never the edit source. Structural edits are explicitly loaded onto the canvas and saved back through the same update path. Every update archives the previous full recipe locally (bounded to 20 versions). Missing-node preflight also reads authoritative workflow node types. `recipe_parser.js` never mutates the live canvas.
@@ -227,7 +231,8 @@ A highly complex subsystem responsible for automatically resolving missing/broke
### NON-NEGOTIABLE: Model Doctor Identity Boundary / 模型医生身份判定死规矩
* Model Doctor exists to recover the same physical model referenced by provenance data embedded by this plugin in an exported workflow or image. Local path differences and local renames are the problem being solved; therefore a path, filename, display name, custom name, source filename, or fuzzy name similarity MUST NEVER be used as evidence that two models are identical.
* Automatic identity evidence is limited to plugin-carried cryptographic hashes, exact physical byte size as a controlled fallback/disambiguator, and the model category required by the target widget. Byte size alone is acceptable only when it produces exactly one candidate inside the required category and no target hash contradicts it; a supplied hash mismatch never falls back to a filename or size-only guess.
* Automatic identity evidence is limited to plugin-carried cryptographic hashes, exact physical byte size as a controlled fallback/disambiguator for ordinary model categories, and the model category required by the target widget. Foundation components (`vae`, `vae_approx`, `clip`, `text_encoders`, and `clip_vision`) are hash-only recovery categories: byte size alone never repairs them. A supplied hash mismatch never falls back to a filename or size-only guess.
* An existing native combo value remains usable even when a foundation component's current local hash differs from the workflow provenance hash. Model Doctor shows a non-blocking identity-change warning because ComfyUI itself can load the same-named file; it does not mark the node missing or replace the value. If the filename is absent, foundation components may be redirected only by one exact in-category hash match. A workflow without that hash is left unresolved for manual handling.
* Paths and filenames may be used only after model identity has already been established: to return the resolved local dropdown value and to verify that value against ComfyUI's native combo choices. They must not participate in candidate selection, ranking, tie-breaking, or ambiguity resolution.
* Normalizing slash direction for a value that already has an exact equivalent in the widget's native ComfyUI options is representation normalization, not model discovery, and does not weaken this identity boundary.
* If a legacy/corrupt hash cannot be recovered uniquely from allowed evidence (for example several in-category files have the same byte size), Model Doctor MUST report ambiguity or remain unresolved. It must never guess from the old filename/path. Manual replacement is the explicit fallback.
@@ -241,8 +246,8 @@ A highly complex subsystem responsible for automatically resolving missing/broke
### Model Provenance Binding (模型溯源绑定 - `anomalous_inject_hash`)
* Controls whether hashes are invisibly injected into the `extra_pnginfo` and workflow JSON when a user saves a workflow or generates an image.
* **Logic Flow**: The active graph constructor's `prototype.serialize` is hooked when available (falling back to the legacy global `LGraph` only when present). If enabled, it intercepts the serialization, looks up every model widget's filename in `window.anomalous_hash_cache`, and explicitly writes `extraObj.anomalous_hashes[node_id_filename] = {hash, size}`. A missing graph API disables only this optional hook.
* Exact-path checks are limited to pre-flight verification that an already-resolved local reference exists; they are outside Model Doctor provenance recovery and must never be used as its fallback. For provenance recovery, the backend constrains the search to the model category inferred from the node/widget and intersects the saved hash with the saved byte size. If no file in that category owns a legacy/stale hash, only a unique in-category byte-size match may recover the model as `stale_hash`. Equal-sized candidates remain unresolved, and a real hash/size conflict is rejected instead of choosing arbitrarily.
* **Logic Flow**: The active graph constructor's `prototype.serialize` is hooked when available (falling back to the legacy global `LGraph` only when present). If enabled, it intercepts the serialization, looks up every model widget's filename in `window.anomalous_hash_cache`, and explicitly writes `extraObj.anomalous_hashes[node_id_filename] = {hash, size}`. The cache includes VAE/VAE Approx, CLIP/Text Encoder, and CLIP Vision directories as well as ordinary model categories. A foundation component with no recorded hash is not given size-only provenance. A missing graph API disables only this optional hook.
* Exact-path checks are limited to pre-flight verification that an already-resolved local reference exists; they are outside Model Doctor provenance recovery and must never be used as its fallback. For provenance recovery, the backend constrains the search to the model category inferred from the node/widget and intersects the saved hash with the saved byte size. Ordinary model categories may use one unique in-category byte-size match when no hash is available; equal-sized candidates remain unresolved. Foundation components remain unresolved without a hash, and a real hash/size conflict is rejected instead of choosing arbitrarily.
* Civitai `.info` files may describe several physical files (for example a diffusion model, text encoder, and VAE). `get_metadata()` must select the matching `files[]` entry by exact physical byte size and may use an unmatched entry only when it is the sole hash candidate. It must never take the first SHA256 or use a filename to choose between entries.
### The Triple-Fallback Scanning Engine (`api/scanner.py` & `scraper.py`)
@@ -255,8 +260,11 @@ When "Deep Hash Scan" is triggered, it runs in a background thread to prevent UI
### Resolution Execution (`window.anomalous_resolve_all_missing_nodes`)
Scans all nodes in `app.graph._nodes` that are colored red.
* Extracts the saved hash from the graph's `anomalous_hashes`.
* Sends hash, byte size, and the inferred model category to `/anomalous/resolve_hash`. The endpoint accepts a result only when the identity signals agree, or when a unique in-category size resolves a request that has no usable hash; a contradictory supplied hash remains unresolved.
* Extracts the saved hash from the graph's `anomalous_hashes` using normalized path separators (`/` and `\`).
* Sends hash, byte size, and the inferred model category to `/anomalous/resolve_hash` (or `/anomalous/resolve_hash_batch`).
* **On-Demand Dynamic Hash Resolution**: If size matches a candidate that lacks cached hash metadata, the backend calculates its SHA256 on demand. If the hash matches, it establishes identity and writes an offline inferred `.info` file to cache the provenance.
* **Civitai Direct Lookup & Domain Routing**: Clicking the Civitai button queries the official `/api/v1/model-versions/by-hash/<hash>` endpoint to resolve `modelId` and version, dynamically routing NSFW models to `civitai.red` and safe models to `civitai.com`.
* **View Hash Details Inspection (`openHashDetailDialog`)**: Every model card provides a dedicated `🔑 查看哈希 (View Hash)` button. Clicking opens an intuitive modal comparing the workflow-embedded provenance hash and size against the local disk model's hash and size, with one-click clipboard copying.
* If a match is found, the frontend refreshes native ComfyUI combo definitions and requires the returned path to exist in that widget's native choices. Cross-category or otherwise invalid paths are rejected; the resolver must never append a foreign path to `widget.options.values` merely to report success.
* After native validation succeeds, it mutates the dropdown `value`, removes the red color, and flags it as `anomalous_auto_resolved`.
@@ -268,15 +276,15 @@ Scans all nodes in `app.graph._nodes` that are colored red.
* **Variable Naming Collisions**: When moving UI elements, search for ALL references to the old `const` declaration. A single duplicate `const` in the same scope kills the entire module at parse time.
* **Data Scaling & Payload Limits**: When passing arrays/lists (e.g. hundreds of selected files), NEVER append them to URL query parameters (`GET` requests), or you will trigger `414 URI Too Long`. Use `POST` with a `JSON Body`. Similarly, NEVER pass massive arrays directly to `subprocess.run` via CLI arguments, as it will crash silently due to OS character limits (8191 on Windows). Always dump massive data to an intermediate `.json` file for the subprocess to read.
> For a complete list of past mistakes and detailed post-mortems, refer to `.agents/logs/ai_lessons.md`. Engineering snapshots and handoff notes belong in `.agents/logs/ai_changelog.md`.
> For a complete list of past mistakes and detailed post-mortems, refer to `.agents/logs/ai_lessons.md`. Engineering history and change tracking are managed via Git commits and ARCHITECTURE.md.
## 7. Mandatory Change Snapshot Protocol (强制变更快照流程)
Every product-code change in this plugin must finish as one coherent local Git snapshot:
1. Run checks proportional to the touched behavior before committing.
2. Update `.agents/logs/ai_changelog.md` in the same change with the implementation summary, architectural decision, checks run, and handoff notes.
3. Update this `ARCHITECTURE.md` in the same change so directory ownership, data flow, API contracts, naming boundaries, and non-negotiable rules remain accurate. If the code does not alter an architectural boundary, record that the existing boundary is intentionally unchanged rather than inventing a new abstraction.
2. Update this `ARCHITECTURE.md` in the same change so directory ownership, data flow, API contracts, naming boundaries, and non-negotiable rules remain accurate. If the code does not alter an architectural boundary, record that the existing boundary is intentionally unchanged rather than inventing a new abstraction.
3. If critical bugs or architectural pitfalls were resolved, record lessons learned into `.agents/logs/ai_lessons.md`.
4. Create a local Git commit after verification. Keep unrelated work out of the snapshot and do not push unless the user explicitly requests it.
5. Leave a clean worktree for the next agent, or document every intentional uncommitted file in the handoff.
+16
View File
@@ -1,5 +1,21 @@
# 📈 Anomalous Model Browser Changelog
## v1.56 Beta (Model Doctor Intelligence & Smart Civitai Routing)
### 🚀 Major Improvements & Features
- **On-Demand Dynamic Hash Resolution (按需动态算哈希与智能认亲)**: Upgraded Model Doctor's fallback resolution engine. When a local candidate model matches the exact byte size of a missing reference but lacks local metadata (e.g., after renaming and deleting `.info` files), the backend dynamically calculates its SHA256 on demand. Upon cryptographic verification, the node is seamlessly resolved and an offline inferred `.info` record is saved locally.
- **Dedicated Hash Inspection Modal (🔑 查看哈希透视详情)**: Added a dedicated `🔑 查看哈希 (View Hash)` button on every model card in the Doctor Dashboard. Clicking it opens a crystal-clear inspection modal comparing the workflow-embedded provenance hash & size with local disk hash & size, featuring one-click clipboard copying.
- **Smart Civitai by-hash Routing & NSFW Domain Dispatch (C 站精准直达与 .red/.com 智能分流)**: Upgraded the "🌐 Civitai" action buttons across Model Doctor and Workflow Import. Instead of crude search bar queries, it now queries the official Civitai `by-hash` API to resolve exact Model IDs and Version IDs, automatically routing NSFW models to `civitai.red` and safe models to `civitai.com`.
- **UNET Default Scan Coverage (UNET 目录扫描补齐)**: Added `models/unet` to the default scanned model types, ensuring UNET / Diffusion Models are consistently indexed during background deep scans.
- **Parameter Notebook Renaming (参数笔记本重命名)**: Users can now rename saved parameter notebooks directly from both the recipe sidebar list and the detail heading via inline `✏️` action buttons and modal prompts, safely updating names with atomic writes without disrupting parameter provenance or canvas matching.
- **Cross-Platform Path Separator Normalization (跨平台斜杠全链路归一化)**: Fully unified Windows `\` and Unix `/` path handling across workflow serialization, hash retrieval, and node diagnostic matching, preventing lookup misses caused by cross-platform workflow exchange.
### 🐛 Bug Fixes & UX Polish
- Fixed a false-negative rejection issue where size-matching renamed models without local `.info` files were incorrectly rejected as `identity_conflict`.
- Fixed duplicate key icons on the Model Doctor Hash Inspection modal header.
- Fixed a loop duplication issue in the workflow import modal preflight inspection.
- Updated unit test suite with comprehensive coverage for dynamic hash resolution and unet folder discovery.
## v1.55 Beta (Workflow Recipes Preview)
> **Beta scope:** Workflow Recipes, Parameter Notebooks, and the recipe-powered Parameter Presets tab in Node Assistant are preview features. The Node Assistant's Actions tab—visual model replacement and LoRA insertion—is not part of this beta. Save the current canvas and back up `workflows/anomalous_recipes` plus `workflows/anomalous_parameters` before updating, importing, restoring, deleting, or applying recipe data. The stable target for these preview features is v1.6.
+112 -110
View File
@@ -1,159 +1,161 @@
# Anomalous Model Browser
<div align="center">
[![Changelog](https://img.shields.io/badge/📖_Read_the-Changelog-blue?style=for-the-badge)](CHANGELOG.md) [![ComfyUI Manager](https://img.shields.io/badge/ComfyUI-Manager-green?style=for-the-badge)](https://github.com/ltdrdata/ComfyUI-Manager)
# 🚀 Anomalous Model Browser
[English](#english) | [中文](#中文)
**A Comprehensive Creative Workspace & Model Manager for ComfyUI**
*零依赖 C 站元数据抓取 · 智能模型医生 · 可视化节点助手 · 工作流配方与参数笔记*
<br/>
[![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.56_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)
<br/>
[**English**](#-english) | [**中文说明**](#-中文)
</div>
---
<h2 id="english">🇬🇧 English</h2>
> A comprehensive Creative Workspace and Model Manager for ComfyUI. Features zero-dependency Civitai scraping, an auto-resolving Model Doctor, a visual Node Assistant, lossless Workflow Exchange, and reusable Workflow Recipes with Parameter Notebooks.
> **Anomalous Model Browser** is an all-in-one management suite for ComfyUI. It empowers your generative workflow with hash-based model scraping, one-click broken-path resolution (Model Doctor), visual LoRA insertion/replacement (Node Assistant), and immutable Workflow Recipes with Parameter Notebooks.
> [!WARNING]
> **Beta features and data protection:** Workflow Recipes and only the recipe-powered **Parameter Presets** tab inside Node Assistant are currently in testing; the Node Assistant's **Actions** tab is not part of this beta. Before updating the plugin, importing someone else's recipe, restoring or deleting recipe data, or applying saved parameters, save the current canvas and back up `workflows/anomalous_recipes` and `workflows/anomalous_parameters` inside your ComfyUI user directory. Recipe model-preview snapshots do not contain model files and are not model backups.
### 🎬 Video Walkthrough & Demos
* 📺 **YouTube**: [Watch Quick Walkthrough on YouTube](https://youtu.be/hAvsj7uiaCw)
* 📺 **Bilibili**: [Watch Video Demo on Bilibili (在哔哩哔哩观看)](https://www.bilibili.com/video/BV1a1bv68EuA/)
### 🌟 Core Capabilities
| 🚀 Feature | 💡 Description |
| :--- | :--- |
| **Comprehensive Model Management** | Automatically extract Base Model architectures, cover images, trigger words, and author info by scanning model hashes. Customize model names, notes, and covers. Safely delete unwanted models or quickly replace them visually with cover icons. |
| **Workflow Node Repair (🩺)** | As long as a workflow/image was exported with this plugin active, the **Model Doctor (🩺)** can auto-detect missing nodes and replace them with correct local paths. Stubborn models? Use "Deep Hash Scan" or get Civitai links. |
| **Node Assistant & Recipe Presets (🤖)** | Select a canvas node to visually replace a model, safely insert a LoRA into a compatible chain, or load same-type node parameters saved by Workflow Recipes. A saved KSampler note, for example, can apply sampler, scheduler, steps, CFG, and denoise in one click while volatile seeds are ignored. |
| **Workflow Recipes (🧰)** | Save a complete workflow with a cover, notes, tags, model identity, prompts, sampler settings, node parameters, matching output images, and version history. Append recipes to the canvas, compare output/version parameters, match foreign model references to local files, and import or export portable recipe packages. |
| **Smart Notebook (📑)** | Leveraging scanned local architectures, it offers rapid matching between Checkpoints, UNet, and compatible LoRAs. Features a built-in translation tool, auto-chunks prompts into tags, and supports 1-click canvas deployment. |
| **History Gallery (🖼️)** | Natively reads your local `output` folder. Supports 1-click viewing, deleting, and mouse-wheel zooming. Drag and drop any generated image directly onto the canvas to instantly load its embedded workflow! |
| **Additional Features** | Click the top-right `➕` of any model card to deploy it to the canvas. The UI supports side-docking to leave space for your canvas operations. |
| **Comprehensive Model Management** | Automatically extract Base Model architectures, cover images, trigger words, and author info via hash scanning. Customize names, notes, and covers with safe deletion & visual replacement. |
| **Workflow Repair (Model Doctor 🩺)** | Auto-detect missing model nodes when loading external workflows and swap them with matching local paths in one click. |
| **Node Assistant & Presets (🤖)** | Visually replace models or safely insert LoRAs into compatible chains directly from the canvas. Apply node parameters from Workflow Recipes transactionally. |
| **Workflow Recipes & Presets (🧰)** | Save complete workflows with covers, notes, tags, model identities, and parameter snapshots. Append to canvas, compare versions, and export portable packages. |
| **Smart Prompt Notebook (📑)** | Architecture-aware compatibility matching for Checkpoints and LoRAs, built-in translation, auto-tagging, and 1-click canvas deployment. |
| **History Gallery (🖼️)** | Native viewer for your `output` folder with mouse-wheel zoom, deletion, and direct drag-and-drop workflow reconstruction onto the canvas. |
### 📖 Step-by-Step Operating Guide
### 📦 Quick Installation
1. Open your terminal in the ComfyUI `custom_nodes` directory:
```bash
cd custom_nodes
git clone https://github.com/DemonGatanjieu/Anomalous_Model_Browser.git
```
2. Restart ComfyUI. *(Alternatively, install via **ComfyUI Manager** by searching for `Anomalous Model Browser`)*
<br/>
<details>
<summary><b>📖 Click to Expand: Step-by-Step Operating Guide</b></summary>
<br/>
#### 1. Initialization (Scan Wizard) 🔄
* **Location**: The Scan icon (**🔄**) at the bottom left of the plugin interface.
* **Operation**: This is the most important prerequisite. Open the Scan Wizard, select your configuration, and execute the scan. Ensure your network is connected.
* **Result**: This activates all core features of the plugin by building your local model database.
* **Location**: The Scan icon (**🔄**) at the bottom-left of the sidebar.
* **Operation**: Open the Scan Wizard, choose your configuration, and execute the scan with an active network connection to build your local model database.
#### 2. Workflow Repair (Model Doctor) 🩺
* **Location**: The second Stethoscope icon (**🩺**) from the bottom left.
* **Operation**: When you import someone else's workflow and the nodes turn red due to missing paths, click the Model Doctor. It will intelligently identify all broken nodes and replace them with your correct local paths.
* **Location**: The second Stethoscope icon (**🩺**) from the bottom-left.
* **Operation**: When importing foreign workflows with red broken model nodes, click Model Doctor to automatically identify and batch-replace them with local equivalents.
#### 3. Visual Swapping (Node Assistant) 🤖
* **Location**: The third Robot icon (**🤖**) from the bottom left.
* **Actions**: Select a node on the ComfyUI canvas. For model nodes, the Actions tab can visually replace the current model or insert a LoRA before/after a compatible MODEL + CLIP chain. Picker cards support previews, model-category/base-model badges, folders, full-path search, sorting, and safe handling of ambiguous branches.
* **Parameter Presets (Beta)**: Switch to **Parameter Presets** to read parameter notes associated with Workflow Recipes. Presets are grouped by recipe and contain saved values for the same node type as the selected node. Clicking a preset applies it transactionally to that one node; runtime-changing values such as seeds are ignored.
#### 3. Visual Swapping & Presets (Node Assistant) 🤖
* **Location**: The third Robot icon (**🤖**) from the bottom-left.
* **Actions**: Select a node on the canvas to visually replace models or insert LoRAs before/after a compatible MODEL + CLIP chain.
* **Parameter Presets (Beta)**: Switch to **Parameter Presets** to apply recipe-saved parameters to matching canvas nodes in one click (seeds and volatile values are safely ignored).
#### 4. Workflow Recipes & Parameter Notebooks 🧰
* **Location**: Open **Creative Workspace (📑)** and switch from **Prompt Notes** to **Workflow Recipes**.
* **Save and reuse**: Save the complete current graph with a cover, name, tags, notes, model references, prompts, sampler settings, and bounded node summaries. Recipes can be appended to the current canvas without replacing it.
* **Details**: Overview shows model composition and reproducibility information. Models can be matched to local files by hash, size, and model category rather than the author's path; Civitai origin names/links and local previews can be retained when available.
* **Parameter Notebook**: Browse the recipe's saved values, copy or expand long fields, create an edited parameter note, or read a fresh matching canvas into a new note. **Apply to Current Workflow** first checks that the recipe skeleton matches, then updates safe widgets; volatile values such as seeds are ignored. Stored notes can be deleted individually. Unknown third-party prompt roles can be labelled manually.
* **Gallery and Versions**: Find historical outputs with the same node structure, open an image to compare parameter differences, set an output as the recipe cover, compare recipe versions, or restore an older version while archiving the current one.
* **Sharing**: Export/import portable recipe packages with optional model-preview snapshots and history. Identity hashes can be removed during export. A preview snapshot is presentation data only—it never includes a model file.
* **Location**: Open **Creative Workspace (📑)** and switch to **Workflow Recipes**.
* **Features**: Save graphs with covers, notes, and model hashes. Append recipes to current canvas, inspect parameter differences against outputs, compare versions, and import/export portable packages.
#### 5. Settings Panel ⚙️
* **Location**: The Gear icon (**⚙️**) at the bottom left.
* **Operation**: Adjust the UI language and main UI font size. Open **Model Settings** to choose always-play or hover-play video covers and optimized card thumbnails or original covers. Detail pages continue to use the original cover. **Folder Manager** can hide folders from both the sidebar and background reads, and reorder them. **Help** contains the current safety and feature guide.
* **Location**: The Gear icon (**⚙️**) at the bottom-left.
* **Options**: Language toggle, UI font scale, hover/autoplay video covers, card thumbnails, and custom folder management.
#### 6. Top Navigation & Prompt Notes 📑
**Location**: The top tabs: **Models (📦)**, **Gallery (🖼️)**, **Creative Workspace (📑)**, and **Dock Side (◧)**.
**Prompt Notes Operation**:
1. Open **Creative Workspace (📑)**, choose **Prompt Notes**, then click the **➕ (New)** button and confirm.
2. Select a Base Model (e.g., SD1.5). You can then select compatible Main Models and LoRAs.
3. Click **📝 Edit Raw / Paste** to paste your prompts. Select the language at the top-left for bilingual translation.
4. After confirming, the plugin will automatically chunk your prompt into tags. You can find/replace or directly edit tags.
5. Click **🚀 Send to Canvas** to automatically wire and deploy the Checkpoint, LoRA, and CLIP Text Encode directly to your canvas!
---
### 📦 Installation
Open your terminal, go to your ComfyUI `custom_nodes` folder, and run:
```bash
cd custom_nodes
git clone https://github.com/DemonGatanjieu/Anomalous_Model_Browser.git
```
> **Note**: Restart ComfyUI after cloning. Alternatively, search for `Anomalous Model Browser` inside the ComfyUI Manager and click Install!
---
<h2 id="中文">🇨🇳 中文</h2>
> 一个综合性的 ComfyUI 创作工作台与模型管家。集成零依赖的 C 站信息抓取、支持自动修复的模型医生、可视化节点助手、无损工作流交互,以及支持参数复用的工作流配方与笔记本体系。
#### 6. Creative Workspace & Prompt Notes 📑
* **Location**: Top navigation tabs (**Models 📦**, **Gallery 🖼️**, **Workspace 📑**, **Dock Side ◧**).
* **Usage**: Select a Base Model architecture, attach compatible LoRAs, paste & auto-tag bilingual prompts, and click **Send to Canvas** to deploy directly.
> [!WARNING]
> **测试功能与数据保护:** 工作流配方以及节点助手中由配方驱动的“参数笔记本”目前属于测试功能;节点助手的“动作”页不在测试范围内。更新插件、导入他人配方、恢复或删除配方数据、应用已保存参数之前,请先保存当前画布,并备份 ComfyUI 用户目录中的 `workflows/anomalous_recipes` 与 `workflows/anomalous_parameters`。配方中的模型预览快照不包含模型文件,不能替代模型备份。
> **Beta Data Protection:** Workflow Recipes and Parameter Presets are currently in testing. Please back up `workflows/anomalous_recipes` and `workflows/anomalous_parameters` inside your ComfyUI user directory before updating.
### 🌟 核心功能特性
</details>
---
<h2 id="中文">🇨🇳 中文说明</h2>
> **Anomalous Model Browser** 是为 ComfyUI 量身打造的全能创作工作台与模型管家。告别复杂的模型长路径,集成基于哈希的零依赖 C 站元数据抓取、一键拯救爆红节点的“模型医生”、可视化模型替换与 LoRA 插入的“节点助手”,以及支持参数复用的“工作流配方与笔记本”体系。
### 🎬 视频演示与教程
* 📺 **哔哩哔哩 (Bilibili)**:[在 B 站观看快速上手与使用演示](https://www.bilibili.com/video/BV1a1bv68EuA/)
* 📺 **YouTube**:[在 YouTube 观看视频演示](https://youtu.be/hAvsj7uiaCw)
### 🌟 核心特性速览
| 🚀 功能板块 | 💡 详细说明 |
| :--- | :--- |
| **全方位模型管理** | 通过扫描哈希值,自动获取模型的基础架构、封面、提示词、作者简介等信息。支持自定义名称、备注和封面。点开模型即刻呈现封面图标,支持看图一键替换模型,彻底告别复杂的模型路径! |
| **节点智能修复 (🩺)** | 凡是由本插件导出的工作流和图片,在载入他人工作流时出现节点爆红,只需点开**模型医生 (🩺)**,即可自动识别正确的模型路径并一键替换。内置深度扫描与 C 站直达链接。 |
| **节点助手与配方参数 (🤖)** | 选中画布节点后,可视化替换模型、向兼容链路安全插入 LoRA,或读取工作流配方保存的同类型节点参数。例如保存过的 K 采样器笔记可一键应用采样器、调度器、步数、CFG 与降噪,同时忽略易变的种子。 |
| **工作流配方 (🧰)** | 保存完整工作流及封面、备注、标签、模型身份、提示词、采样参数、节点参数、匹配出图与版本历史。支持追加到画布、比较出图/版本参数、将他人的模型引用匹配到本地文件,以及导入导出便携配方包。 |
| **智能笔记本 (📑)** | 基于本地基础架构,提供快速的架构匹配功能,极速选择兼容的主模型与 LoRA。内置强大的翻译功能,自动将提示词分块打上标签,实现双语对照,并支持一键发布组装到工作流画布中。 |
| **原生图库管理 (🖼️)** | 无缝读取本地的 `output` 文件夹。支持鼠标滚轮缩放、一键查看和安全删除。更绝的是,您可以**直接将图片拖动到画布上,瞬间原地加载内嵌的工作流!** |
| **其它便捷功能** | 点击模型卡片右上角的 `➕` 号,快捷将节点发布到画布。支持侧边栏停靠 (Dock),将界面吸附在左侧,为您的画布留出充足的操作空间。 |
| **全方位模型管理** | 通过文件哈希自动提取基础架构、封面图、触发词与作者信息;支持看图一键替换模型、自定义备注与安全删除。 |
| **节点智能修复 (模型医生 🩺)** | 导入他人工作流发生节点爆红时,模型医生可自动识别缺失模型并一键替换为本地有效路径。 |
| **节点助手与参数预设 (🤖)** | 画布选中节点即可可视化选图换模型、向兼容链路插入 LoRA,或一键应用工作流配方中沉淀的节点参数(自动跳过易变种子)。 |
| **工作流配方 (🧰)** | 保存完整工作流及封面、标签、模型身份与参数快照;支持追加到画布、历史版本比对、参数差异分析及便携分享包导入导出。 |
| **智能提示词笔记 (📑)** | 架构级兼容性匹配(主模型+兼容 LoRA),内置双语分块翻译与标签编辑,支持一键打包发送至画布。 |
| **原生出图图库 (🖼️)** | 原生读取本地 `output` 文件夹,支持滚轮缩放与安全删除,**直接将图片拖拽至画布即可原地还原工作流**。 |
### 📖 标准操作指南
### 📦 快速安装
为了让插件发挥最大效能,请按照以下流程进行操作:
1. 在 ComfyUI 的 `custom_nodes` 目录下打开终端执行:
```bash
cd custom_nodes
git clone https://github.com/DemonGatanjieu/Anomalous_Model_Browser.git
```
2. 重启 ComfyUI 即可使用。(*也可以直接在 **ComfyUI Manager** 搜索 `Anomalous Model Browser` 点击安装*)
<br/>
<details>
<summary><b>📖 点击展开:标准操作指南(图文步骤)</b></summary>
<br/>
#### 1. 前置准备 (扫描向导) 🔄
* **具体位置**:界面左侧底部的 **扫描图标 (🔄)**。
* **操作步骤**:这是最重要的一步!请先打开扫描向导,根据需求选择扫描配置并执行扫描(切记保持网络畅通)。扫描完成后,您的本地数据库建立完毕,插件的各项核心功能全面激活。
* **入口位置**:侧边栏左下角 **扫描图标 (🔄)**。
* **操作步骤**:首次使用请先打开扫描向导,根据需要选择扫描模式并执行(请保持网络畅通)。扫描完成后即建立本地模型库,全面激活插件特性。
#### 2. 拯救爆红 (模型医生) 🩺
* **具体位置**:左侧底部第二个 **听诊器按钮 (🩺)**。
* **操作步骤**:当导入别人使用该插件输出的工作流或者图片时,若发现模型路径爆红报错,点击模型医生,它就能智能识别报错节点,并一键实现正确路径的替换。
* **入口位置**:侧边栏左下角第二个 **听诊器图标 (🩺)**。
* **操作步骤**:载入他人工作流或图片出现红框缺失报错时,点击模型医生即可智能识别缺失项并一键批量映射为本地正确路径。
#### 3. 选中交互 (节点助手) 🤖
* **具体位置**:左侧底部第三个 **机器人按钮 (🤖)**。
* **动作**:打开后在 ComfyUI 画布上选中节点。对于模型节点,“动作”页可以可视化替换当前模型,或在兼容的 MODEL + CLIP 链前后插入 LoRA。选择器提供模型预览、模型类别/基础模型标记、文件夹、完整路径搜索、排序与多分支安全保护。
* **参数笔记本(测试)**:切换到“参数笔记本”,可以读取与工作流配方关联的参数笔记。内容会按配方分组,只展示和当前选中节点类型相同的保存记录。点击一条记录即可事务式应用到该节点;种子等每次运行都会变化的值会被忽略。
#### 3. 选中交互与预设 (节点助手) 🤖
* **入口位置**:侧边栏左下角第三个 **机器人图标 (🤖)**。
* **动作功能**:在画布选中模型节点后,可在“动作”页可视化换模,或在兼容的 MODEL + CLIP 链前后插入 LoRA。
* **参数预设(测试)**:切换至“参数笔记本”页,可读取配方中同类型节点的保存参数并一键应用(自动忽略易变种子)。
#### 4. 工作流配方与参数笔记本 🧰
* **具体位置**:打开 **创作工作台 (📑)**,从“提示词笔记”切换到 **工作流配方**。
* **保存与复用**:保存当前完整节点图,并附带封面、名称、标签、备注、模型引用、提示词、采样设置与受限节点摘要。配方可以追加到当前画布,不会覆盖当前内容。
* **详细信息**:概览展示模型组成与复现信息。模型匹配根据哈希、文件大小与模型类别查找本地文件,不依赖原作者路径;可用时还会保留 C 站官方名称/链接与本地预览。
* **参数笔记本**:查看配方保存的参数,复制或展开长内容,新建可编辑参数笔记,或者从骨架匹配的当前画布读取全新参数。点击“应用到当前工作流”时会先检查配方骨架,再更新安全控件;种子等易变值会被忽略。保存的参数笔记可以单独删除;无法可靠识别的第三方提示词节点可以手动标注正负角色。
* **图库与版本**:查找节点结构相同的历史出图,打开图片比较参数差异,将出图设为配方封面,比较配方历史版本,或在自动归档当前版本后恢复旧版本。
* **分享**:导入导出便携配方包,可选择包含模型预览快照与历史版本,也可在导出时移除身份哈希。预览快照只是展示数据,绝不会包含模型文件。
* **入口位置**:顶部 **创作工作台 (📑)** ➔ 切换至 **工作流配方**。
* **主要功能**:完整保存当前节点图、封面、标签与参数快照;支持无损追加到当前画布、出图参数差异对比、版本回滚与便携包分享。
#### 5. 个性化配置 (设置面板) ⚙️
* **具体位置**:左侧底部的 **齿轮按钮 (⚙️)**。
* **操作步骤**:在此可以调节语言和主页面字体大小。进入 **模型设置** 后,可选择视频封面始终播放或悬停播放,也可选择卡片使用流畅缩略图或原始封面;模型详情页始终使用原始封面。**文件夹管理**可以让指定目录同时退出侧栏和后台读取,并支持调整顺序;**帮助**中会持续更新安全提醒与功能说明。
* **入口位置**:侧边栏左下角 **齿轮图标 (⚙️)**。
* **个性调节**:支持中英文界面切换、字体大小缩放、视频封面悬停/常开播放、缩略图优化与目录黑名单管理。
#### 6. 顶部导航与提示词笔记 📑
**具体位置**:右侧顶部的按钮分别为 **模型 (📦)**、**图库 (🖼️)**、**创作工作台 (📑)**、**停靠侧边栏 (◧)**。
#### 6. 创作工作台与提示词笔记 📑
* **入口位置**:顶部导航栏 (**模型 📦**、**图库 🖼️**、**工作台 📑**、**侧栏停靠 ◧**)。
* **提示词组装**:选择基础模型架构过滤兼容 LoRA,粘贴提示词并一键双语翻译,点击 **发送到画布** 即可自动连线布署。
**提示词笔记操作步骤**:
1. 打开**创作工作台 (📑)**,进入“提示词笔记”,点击新建 **➕** 号并确认。
2. 首先选择基础模型(例如 SD1.5),系统会过滤出兼容的主模型与对应的 LoRA 模型供你选择。
3. 点击下方的 **📝 纯文本/粘贴** 复制提示词进去,选择左上角的语言即可实现双语对照翻译。
4. 确认后,插件会自动生成标签 (Tags),支持查找替换,也支持直接双击修改单个标签。
5. 最后,点击下方的 **🚀 发送到画布**,系统会将打包好的主模型、LoRA 和 CLIP,一键全自动连线发布到画布!
> [!WARNING]
> **测试功能数据安全提醒:** 工作流配方与参数预设目前属于测试阶段,更新插件前建议备份 ComfyUI 用户目录下的 `workflows/anomalous_recipes` 与 `workflows/anomalous_parameters` 文件夹。
---
### 📦 安装指南
打开命令行终端,进入 ComfyUI 的 `custom_nodes` 文件夹,执行以下命令:
```bash
cd custom_nodes
git clone https://github.com/DemonGatanjieu/Anomalous_Model_Browser.git
```
> **注意**:克隆完成后,重启 ComfyUI 即可。您也可以直接在 ComfyUI Manager(管理器)中搜索 `Anomalous Model Browser` 并一键点击安装!
</details>
---
### 📝 License & Branding (开源与品牌声明)
**Code License (代码授权)**
The source code of this project is released under the [MIT License](LICENSE). You are free to use, modify, and distribute the code.
本项目的源代码基于 MIT 许可证开源,您可以自由地使用、修改和分发代码。
**Branding & Trademarks (品牌与商标保护)**
The MIT License covers the repository's code, documentation, stylesheets, and ordinary UI resources. The name **Anomalous Model Browser** and the official project logo identify official releases; the MIT License does not imply that a modified distribution is official or endorsed. Publicly distributed forks should use a distinct primary name and logo, while they may truthfully say that they are based on Anomalous Model Browser. See the [Trademark and Brand Policy](TRADEMARKS.md).
MIT 许可证适用于本仓库的代码、文档、样式表和普通 UI 资源。名称 **Anomalous Model Browser** 与官方项目 Logo 用于识别官方版本;采用 MIT 许可证并不表示修改后的发行版属于官方版本或获得认可。公开分发的修改版本应使用不同的主要名称与 Logo,但可以如实说明其基于 Anomalous Model Browser。详见[商标与品牌政策](TRADEMARKS.md)。
* **Code License (代码授权)**: The source code is released under the [MIT License](LICENSE). 本项目源代码基于 MIT 许可证开源,可自由使用、修改与分发。
* **Branding & Trademarks (商标与品牌保护)**: The name **Anomalous Model Browser** and the official logo identify official releases. Forks should use distinct names/branding. 详见 [Trademark and Brand Policy](TRADEMARKS.md)。
+1
View File
@@ -30,6 +30,7 @@ def setup_routes(app):
app.router.add_get('/anomalous/parameters', api_get_parameters)
app.router.add_get('/anomalous/parameters/by_node_type', api_get_parameters_by_type)
app.router.add_post('/anomalous/save_parameter', api_save_parameter)
app.router.add_post('/anomalous/rename_parameter', api_rename_parameter)
app.router.add_post('/anomalous/delete_parameter', api_delete_parameter)
app.router.add_get('/anomalous/parameter_gallery', api_get_parameter_gallery)
+139 -66
View File
@@ -11,6 +11,10 @@ from aiohttp import web
import folder_paths
import struct
from .utils import get_active_folder_types, get_folder_view_mode, get_active_physical_basenames, require_filename, resolve_folder_subdir, resolve_within
try:
from ..model_policies import is_physical_rename_protected, requires_hash_for_model_recovery
except ImportError:
from model_policies import is_physical_rename_protected, requires_hash_for_model_recovery
# Cover and sidecar lifecycle is intentionally bounded to exact candidate paths.
@@ -147,6 +151,7 @@ def _collect_folder_models(target_dir, folder_type, path_idx, rel_subfolder, pag
size_bytes = 0
models.append({
"filename": filename,
"file_path": os.path.abspath(file_path),
"size_mb": size_mb,
"size_bytes": size_bytes,
"metadata": metadata,
@@ -312,62 +317,51 @@ async def api_get_models(request):
)
return web.json_response(payload)
async def api_find_model(request):
search = request.query.get('search', '').lower()
if not search:
return web.json_response({"status": "error", "message": "No search query provided"})
for folder_type in folder_paths.folder_names_and_paths.keys():
paths = folder_paths.get_folder_paths(folder_type)
if not paths: continue
for path_idx, base_dir in enumerate(paths):
if not os.path.exists(base_dir): continue
for root, dirs, files in os.walk(base_dir):
for f in files:
if f.endswith('.safetensors') or f.endswith('.ckpt') or f.endswith('.pt') or f.endswith('.bin') or f.endswith('.sft'):
search_norm = search.replace(os.sep, '/')
abs_path_norm = os.path.join(root, f).replace(os.sep, '/').lower()
if search_norm in f.lower() or search_norm in abs_path_norm:
rel_subfolder = os.path.relpath(root, base_dir)
if rel_subfolder == '.': rel_subfolder = '/'
file_path = os.path.join(root, f)
meta = get_metadata(file_path)
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(folder_type)
q_idx = str(path_idx)
q_sub = urllib.parse.quote(rel_subfolder)
q_file = urllib.parse.quote(preview_file)
mtime = _cache_token(os.path.join(root, preview_file))
preview_url = f"/anomalous/image?type={q_type}&path_idx={q_idx}&subfolder={q_sub}&filename={q_file}&t={mtime}"
try: size_mb = round(os.path.getsize(file_path) / (1024 * 1024), 1)
except: size_mb = 0
modelData = {
"filename": f,
"size_mb": size_mb,
"metadata": meta,
"preview_url": preview_url
}
return web.json_response({
"status": "success",
"model": modelData,
"type": folder_type,
"path_idx": path_idx,
"subfolder": rel_subfolder
})
return web.json_response({"status": "error", "message": "Model not found"})
def _iter_search_models():
for folder_type in folder_paths.folder_names_and_paths.keys():
try:
paths = folder_paths.get_folder_paths(folder_type)
except Exception:
continue
for path_idx, base_dir in enumerate(paths):
if not os.path.isdir(base_dir):
continue
for root, _, files in os.walk(base_dir):
for filename in files:
if filename.lower().endswith(MODEL_EXTENSIONS + ('.sft',)):
yield folder_type, path_idx, base_dir, os.path.join(root, filename)
def _find_model_sync(search):
normalized_search = search.replace('\\', '/').lower()
for folder_type, path_idx, base_dir, file_path in _iter_search_models():
normalized_path = file_path.replace(os.sep, '/').lower()
if normalized_search in os.path.basename(file_path).lower() or normalized_search in normalized_path:
return _model_info_for_path(folder_type, path_idx, base_dir, file_path)
for folder_type, path_idx, base_dir, file_path in _iter_search_models():
metadata = get_metadata(file_path)
searchable_names = (metadata.get('custom_name', ''), metadata.get('name', ''))
if any(normalized_search in str(name).lower() for name in searchable_names if name):
return _model_info_for_path(folder_type, path_idx, base_dir, file_path)
return None
async def api_find_model(request):
search = request.query.get('search', '').strip()
if not search:
return web.json_response({"status": "error", "message": "No search query provided"})
result = await asyncio.to_thread(_find_model_sync, search)
if not result:
return web.json_response({"status": "error", "message": "Model not found"})
return web.json_response({
"status": "success",
"model": result,
"type": result["type"],
"path_idx": result["path_idx"],
"subfolder": result["subfolder"],
})
async def api_delete_model(request):
try:
@@ -541,7 +535,10 @@ async def api_base_models(request):
return web.json_response({"base_models": sorted(list(base_models))})
RESOLVABLE_MODEL_TYPES = ('checkpoints', 'loras', 'unet', 'diffusion_models', 'controlnet', 'vae')
RESOLVABLE_MODEL_TYPES = (
'checkpoints', 'loras', 'unet', 'diffusion_models', 'controlnet',
'vae', 'vae_approx', 'clip', 'text_encoders', 'clip_vision',
)
def _parse_resolution_types(expected_types_raw):
@@ -568,7 +565,7 @@ def _collect_resolution_candidates(types):
continue
for root, _, files in os.walk(base_dir):
for filename in files:
if not filename.lower().endswith(('.safetensors', '.ckpt', '.pt')):
if not filename.lower().endswith(MODEL_EXTENSIONS):
continue
file_path = os.path.join(root, filename)
real_path = os.path.realpath(file_path)
@@ -619,7 +616,36 @@ def _resolved_payload(candidate, **details):
return payload
def _resolve_from_candidates(candidates, target_hash="", target_size=None, filename_query=""):
def _compute_and_save_fallback_info(file_path, file_hash):
try:
base_path = os.path.splitext(file_path)[0]
info_path = base_path + ".info"
civitai_info_path = base_path + ".civitai.info"
if not os.path.exists(info_path) and not os.path.exists(civitai_info_path):
from scraper import infer_base_model_from_header
filename = os.path.basename(file_path)
inferred_base = infer_base_model_from_header(file_path) if file_path.lower().endswith('.safetensors') else ""
if inferred_base == 'Unknown':
inferred_base = ""
info_data = {
"id": -1,
"modelId": -1,
"name": os.path.splitext(filename)[0],
"baseModel": inferred_base,
"description": "<p>Automatically inferred by Anomalous Local Engine.</p>",
"model": {
"name": os.path.splitext(filename)[0],
"type": "LORA" if "lora" in file_path.lower() else "Checkpoint"
},
"files": [{"hashes": {"SHA256": file_hash.lower()}}]
}
with open(info_path, 'w', encoding='utf-8') as f:
json.dump(info_data, f, ensure_ascii=True, indent=4)
except Exception:
pass
def _resolve_from_candidates(candidates, target_hash="", target_size=None, filename_query="", require_hash=False):
target_hash = str(target_hash or "").strip().upper()
# A saved filename/path is not identity evidence. It is intentionally
# ignored here; exact path lookup for previews lives in the bounded
@@ -638,6 +664,26 @@ def _resolve_from_candidates(candidates, target_hash="", target_size=None, filen
if len(combined_matches) > 1:
return {"found": False, "ambiguous": True}
# If no combined match found from static cache, but there are size-matching candidates without hash metadata,
# dynamically compute their hash on-demand to test against target_hash
unhashed_size_matches = [c for c in size_matches if not _candidate_hashes(c)]
if unhashed_size_matches:
from scraper import calculate_sha256
for candidate in unhashed_size_matches:
try:
computed_hash = calculate_sha256(candidate["path"]).upper()
candidate.setdefault("hashes", set()).add(computed_hash)
if computed_hash == target_hash:
_compute_and_save_fallback_info(candidate["path"], computed_hash)
except Exception:
pass
dynamic_matches = [c for c in size_matches if target_hash in c.get("hashes", set())]
if len(dynamic_matches) == 1:
return _resolved_payload(dynamic_matches[0], matched_by_hash=True, matched_by_size=True)
if len(dynamic_matches) > 1:
return {"found": False, "ambiguous": True}
hash_matches = [candidate for candidate in candidates if target_hash in _candidate_hashes(candidate)]
if hash_matches:
return {"found": False, "identity_conflict": True}
@@ -650,6 +696,9 @@ def _resolve_from_candidates(candidates, target_hash="", target_size=None, filen
if len(hash_matches) > 1:
return {"found": False, "ambiguous": True}
if require_hash:
return {"found": False, "hash_required": True}
if target_size is not None:
if len(size_matches) == 1:
return _resolved_payload(size_matches[0], matched_by_size=True)
@@ -672,7 +721,13 @@ async def api_resolve_hash(request):
def resolve_one():
candidates = _collect_resolution_candidates(types)
return _resolve_from_candidates(candidates, target_hash, target_size, filename_query)
return _resolve_from_candidates(
candidates,
target_hash,
target_size,
filename_query,
require_hash=requires_hash_for_model_recovery(types),
)
return web.json_response(await asyncio.to_thread(resolve_one))
@@ -713,6 +768,7 @@ async def api_resolve_hash_batch(request):
candidate_groups[types],
item["hash"],
item["size"],
require_hash=requires_hash_for_model_recovery(types),
)
results.append({"key": item["key"], "result": result})
return results
@@ -740,7 +796,7 @@ async def api_get_all_hashes(request):
hashes.pop(key, None)
ambiguous_keys.add(key)
types = ['checkpoints', 'loras', 'unet', 'diffusion_models', 'controlnet', 'vae']
types = RESOLVABLE_MODEL_TYPES
seen_dirs = set()
for t in types:
try:
@@ -752,7 +808,7 @@ async def api_get_all_hashes(request):
if not os.path.exists(base_dir): continue
for root, dirs, files in os.walk(base_dir):
for file in files:
if file.endswith('.safetensors') or file.endswith('.ckpt') or file.endswith('.pt'):
if file.lower().endswith(MODEL_EXTENSIONS):
file_path = os.path.join(root, file)
try:
size_bytes = os.path.getsize(file_path)
@@ -788,7 +844,7 @@ async def api_update_metadata(request):
filename = data.get('filename', '')
custom_name = data.get('custom_name', '')
custom_notes = data.get('custom_notes', '')
physical_rename = data.get('physical_rename', False)
physical_rename_requested = data.get('physical_rename', False)
try: path_idx = int(data.get('path_idx', 0))
except: path_idx = 0
@@ -799,8 +855,14 @@ async def api_update_metadata(request):
except (ValueError, KeyError):
return web.json_response({"status": "error", "message": "Invalid request parameters"}, status=400)
if not os.path.exists(file_path):
return web.json_response({"status": "error", "message": "Model not found"})
if not os.path.exists(file_path):
return web.json_response({"status": "error", "message": "Model not found"})
physical_rename_skipped = physical_rename_requested and is_physical_rename_protected(
folder_type=folder_type,
folder_path=target_dir,
)
physical_rename = physical_rename_requested and not physical_rename_skipped
base_name = os.path.splitext(file_path)[0]
model_ext = os.path.splitext(file_path)[1]
@@ -854,7 +916,11 @@ async def api_update_metadata(request):
elif os.path.exists(new_file_path) and new_file_path != file_path:
return web.json_response({"status": "error", "message": "A file with the target physical name already exists."})
response_data = {"status": "success", "new_filename": new_filename}
response_data = {
"status": "success",
"new_filename": new_filename,
"physical_rename_skipped": physical_rename_skipped,
}
if reset_cover:
response_data.update({
"cover_reset": cover_reset,
@@ -997,11 +1063,18 @@ def _model_info_for_path(folder_type, path_idx, base_dir, file_path):
rel_subfolder = '/'
else:
rel_subfolder = '/' + rel_subfolder.replace(os.sep, '/')
try:
size_bytes = os.path.getsize(file_path)
except OSError:
size_bytes = 0
return {
"type": folder_type,
"path_idx": path_idx,
"subfolder": rel_subfolder,
"filename": os.path.basename(file_path),
"file_path": os.path.abspath(file_path),
"size_bytes": size_bytes,
"size_mb": round(size_bytes / (1024 * 1024), 2),
"preview_url": _preview_url_for_model(folder_type, path_idx, base_dir, file_path),
"metadata": get_metadata(file_path),
}
+46
View File
@@ -300,6 +300,52 @@ async def api_delete_parameter(request):
return web.json_response({"status": "success"})
async def api_rename_parameter(request):
try:
data = await request.json()
filename = require_filename(data.get("filename", ""))
if not filename.endswith(".json"):
raise ValueError("Invalid filename")
new_name = str(data.get("name", "")).strip()
if not new_name:
raise ValueError("Name cannot be empty")
if len(new_name) > 200:
new_name = new_name[:200]
except (ValueError, AttributeError, json.JSONDecodeError):
return web.json_response({"status": "error", "message": "Invalid parameter data"}, status=400)
try:
parameters_dir = get_parameters_dir()
file_path = resolve_within(parameters_dir, filename)
if not os.path.exists(file_path):
return web.json_response({"status": "error", "message": "Parameter notebook not found"}, status=404)
with open(file_path, "r", encoding="utf-8") as f:
nb_data = json.load(f)
if not isinstance(nb_data, dict):
return web.json_response({"status": "error", "message": "Invalid parameter file format"}, status=400)
nb_data["name"] = new_name
nb_data["timestamp"] = int(time.time() * 1000)
fd, temp_path = tempfile.mkstemp(prefix=".parameter-", suffix=".tmp", dir=parameters_dir)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(nb_data, f, ensure_ascii=False, separators=(",", ":"))
f.flush()
os.fsync(f.fileno())
os.replace(temp_path, file_path)
finally:
if os.path.exists(temp_path):
os.remove(temp_path)
_invalidate_parameter_notebooks_cache()
except OSError:
return web.json_response({"status": "error", "message": "Could not update parameter notebook"}, status=500)
return web.json_response({"status": "success", "filename": filename, "name": new_name})
async def api_get_parameter_gallery(request):
"""Find recent output PNGs whose embedded parameters match one parameter notebook."""
try:
+42 -14
View File
@@ -5,10 +5,28 @@ import urllib.parse
import subprocess
import threading
import asyncio
from aiohttp import web
import folder_paths
import struct
from aiohttp import web
import folder_paths
import struct
from .utils import get_active_folder_types, get_active_scan_paths, resolve_folder_subdir
try:
from ..model_policies import is_physical_rename_protected
except ImportError:
from model_policies import is_physical_rename_protected
def _protected_type_for_path(folder_path):
"""Resolve protected registered aliases for a global physical-folder scan."""
target = os.path.realpath(folder_path)
for folder_type in folder_paths.folder_names_and_paths.keys():
if not is_physical_rename_protected(folder_type=folder_type):
continue
try:
if any(os.path.realpath(path) == target for path in folder_paths.get_folder_paths(folder_type)):
return folder_type
except Exception:
continue
return ""
async def api_scan_status(request):
folder_type = request.query.get('type', 'checkpoints')
@@ -77,7 +95,11 @@ async def api_scan_folder(request):
offline_only = data.get("offline_only", False)
skip_rename = data.get("skip_rename", False)
virtual_rename = data.get("virtual_rename", False)
physical_rename = data.get("physical_rename", False)
physical_rename_requested = data.get("physical_rename", False)
physical_rename = physical_rename_requested and not is_physical_rename_protected(
folder_type=folder_type,
folder_path=target_dir,
)
force_overwrite = data.get("force_overwrite", False)
target_files_list = data.get("target_files", [])
@@ -100,7 +122,7 @@ async def api_scan_folder(request):
def run_bg():
try:
cmd = [sys.executable, scraper_path, target_dir]
cmd = [sys.executable, scraper_path, target_dir, "--folder-type", folder_type]
if offline_only:
cmd.append("--offline-only")
if skip_rename:
@@ -155,7 +177,7 @@ async def api_scan_all(request):
use_local_metadata = data.get("use_local_metadata", True)
skip_rename = data.get("skip_rename", True)
virtual_rename = data.get("virtual_rename", False)
physical_rename = data.get("physical_rename", False)
physical_rename = data.get("physical_rename", False)
force_overwrite = data.get("force_overwrite", False)
skip_media = data.get("skip_media", False)
@@ -168,20 +190,26 @@ async def api_scan_all(request):
def run_global_bg():
try:
paths_to_scan = get_active_scan_paths()
for base_dir in paths_to_scan:
if not os.path.exists(base_dir): continue
try:
print(f"[Anomalous Browser] Global scan processing: {base_dir}")
cmd = [sys.executable, scraper_path, base_dir]
paths_to_scan = get_active_scan_paths()
for base_dir in paths_to_scan:
if not os.path.exists(base_dir): continue
try:
print(f"[Anomalous Browser] Global scan processing: {base_dir}")
cmd = [sys.executable, scraper_path, base_dir]
protected_type = _protected_type_for_path(base_dir)
if protected_type:
cmd.extend(["--folder-type", protected_type])
if offline_only:
cmd.append("--offline-only")
if skip_rename:
cmd.append("--skip-rename")
if virtual_rename:
cmd.append("--virtual-rename")
if physical_rename:
cmd.append("--physical-rename")
if physical_rename and not is_physical_rename_protected(
folder_type=protected_type,
folder_path=base_dir,
):
cmd.append("--physical-rename")
if force_overwrite:
cmd.append("--force-overwrite")
if skip_media:
+1 -1
View File
@@ -515,7 +515,7 @@ def get_active_folder_types():
config_path = os.path.join(script_dir, "config.json")
all_types = list(folder_paths.folder_names_and_paths.keys())
default_types = ['checkpoints', 'loras', 'diffusion_models', 'controlnet', 'vae']
default_types = ['checkpoints', 'loras', 'diffusion_models', 'unet', 'controlnet', 'vae']
try:
if os.path.exists(config_path):
+47
View File
@@ -0,0 +1,47 @@
import os
from pathlib import Path
# These components are normally referenced by stable ecosystem filenames.
# Renaming them breaks existing workflows without providing meaningful value.
PHYSICAL_RENAME_PROTECTED_TYPES = frozenset({
"vae",
"vae_approx",
"clip",
"text_encoders",
"clip_vision",
})
# Foundation components may be recovered under a different local filename, but
# only when the workflow carries a cryptographic identity. File size alone is
# too weak for shared VAE/text-encoder libraries.
HASH_ONLY_RECOVERY_TYPES = PHYSICAL_RENAME_PROTECTED_TYPES
def is_physical_rename_protected(folder_type=None, folder_path=None):
"""Return True when a model category must keep its physical filename."""
normalized_type = str(folder_type or "").strip().lower()
if normalized_type in PHYSICAL_RENAME_PROTECTED_TYPES:
return True
if not folder_path:
return False
try:
parts = {
os.path.normcase(part).lower()
for part in Path(os.path.abspath(folder_path)).parts
}
except (OSError, TypeError, ValueError):
return False
return bool(parts.intersection(PHYSICAL_RENAME_PROTECTED_TYPES))
def requires_hash_for_model_recovery(folder_types):
"""Return True when every requested category requires a hash match."""
normalized = {
str(folder_type or "").strip().lower()
for folder_type in (folder_types or ())
if str(folder_type or "").strip()
}
return bool(normalized) and normalized.issubset(HASH_ONLY_RECOVERY_TYPES)
+17 -8
View File
@@ -1,6 +1,9 @@
import os
import sys
try:
import os
import sys
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
if _SCRIPT_DIR not in sys.path:
sys.path.insert(0, _SCRIPT_DIR)
try:
sys.stdout.reconfigure(encoding="utf-8")
except:
pass
@@ -18,6 +21,7 @@ import urllib.parse
import argparse
import shutil
from typing import Dict, Optional
from model_policies import is_physical_rename_protected
# Fixed tuples avoid rebuilding long extension lists for every scanned model.
@@ -219,13 +223,18 @@ def main():
parser.add_argument("--offline-only", action="store_true", help="跳过 Civitai 联网获取,强制使用本地脱机张量推断提取 Base Model")
parser.add_argument("--force-overwrite", action="store_true", help="强制覆盖已存在的信息文件")
parser.add_argument("--skip-local-metadata", action="store_true", help="忽略本地已有的.info / .json文件")
parser.add_argument("--target-files", type=str, default="", help="仅扫描逗号分隔的具体文件(相对路径)")
args = parser.parse_args()
parser.add_argument("--target-files", type=str, default="", help="仅扫描逗号分隔的具体文件(相对路径)")
parser.add_argument("--folder-type", default="", help="由 ComfyUI 传入的模型目录类型,用于执行安全策略")
args = parser.parse_args()
target_folder = args.folder
if not os.path.isdir(target_folder):
print(f"[-] 错误: 文件夹不存在 -> {target_folder}")
sys.exit(1)
if not os.path.isdir(target_folder):
print(f"[-] 错误: 文件夹不存在 -> {target_folder}")
sys.exit(1)
if args.physical_rename and is_physical_rename_protected(args.folder_type, target_folder):
print("[*] 基础组件目录受保护:VAE、CLIP/Text Encoder 与 CLIP Vision 不执行物理重命名。")
args.physical_rename = False
backup_log_path = os.path.join(target_folder, "backup_rename_log.json")
+94 -79
View File
@@ -1,5 +1,9 @@
import { app } from "../../scripts/app.js";
import { translate } from './modules/locales.js';
import {
inferModelFolderTypes,
requiresHashForModelRecovery,
} from './modules/model_policies.js';
// Global cache for hashes: filename -> hash
window.anomalous_hash_cache = window.anomalous_hash_cache || {};
@@ -17,17 +21,12 @@ window.anomalous_update_hash_cache = function (models) {
}
};
function inferExpectedModelTypes(node, widget) {
const widgetName = String(widget?.name || '').toLowerCase();
const nodeType = String(node?.type || '').toLowerCase();
const key = `${widgetName} ${nodeType}`;
function isModelFilename(value) {
return typeof value === 'string' && /\.(safetensors|ckpt|pt|bin)$/i.test(value);
}
if (widgetName.includes('vae') || nodeType === 'vaeloader' || key.includes('vae loader')) return 'vae';
if (widgetName.includes('lora') || key.includes('lora')) return 'loras';
if (widgetName.includes('control_net') || widgetName.includes('controlnet') || key.includes('controlnet')) return 'controlnet';
if (widgetName.includes('ckpt') || widgetName.includes('checkpoint') || key.includes('checkpoint')) return 'checkpoints';
if (widgetName.includes('unet') || widgetName.includes('diffusion_model') || key.includes('unet loader')) return 'diffusion_models,unet';
return '';
function inferExpectedModelTypes(node, widget) {
return inferModelFolderTypes(node, widget).join(',');
}
window.anomalous_resolve_all_missing_nodes = async function (is_manual = false, silent = false) {
@@ -44,7 +43,7 @@ window.anomalous_resolve_all_missing_nodes = async function (is_manual = false,
if (node.widgets) {
for (let i = 0; i < node.widgets.length; i++) {
const w = node.widgets[i];
if (typeof w.value === 'string' && (w.value.endsWith('.safetensors') || w.value.endsWith('.ckpt') || w.value.endsWith('.pt'))) {
if (isModelFilename(w.value)) {
if (w.options && w.options.values && !w.options.values.includes(w.value)) {
has_missing = true;
break;
@@ -67,13 +66,26 @@ window.anomalous_resolve_all_missing_nodes = async function (is_manual = false,
}
const hashes = (app.graph.extra && app.graph.extra.anomalous_hashes) || {};
const getWorkflowHash = (nodeId, val) => {
if (!hashes || typeof val !== 'string') return null;
const normVal = val.replace(/\\/g, '/');
const winVal = val.replace(/\//g, '\\');
return hashes[`${nodeId}_${val}`] ||
hashes[`${nodeId}_${normVal}`] ||
hashes[`${nodeId}_${winVal}`] ||
hashes[val] ||
hashes[normVal] ||
hashes[winVal] ||
null;
};
let needsGlobalHashRefresh = false;
provenanceCheck:
for (const node of app.graph._nodes) {
for (const widget of (node.widgets || [])) {
const value = widget.value;
if (typeof value !== 'string' || !(value.endsWith('.safetensors') || value.endsWith('.ckpt') || value.endsWith('.pt'))) continue;
if (!hashes[`${node.id}_${value}`]) {
if (!isModelFilename(value)) continue;
if (!getWorkflowHash(node.id, value)) {
needsGlobalHashRefresh = true;
break provenanceCheck;
}
@@ -97,11 +109,12 @@ window.anomalous_resolve_all_missing_nodes = async function (is_manual = false,
let fixed_count = 0;
const findHashData = (node, widget, value) => {
let hashData = hashes[`${node.id}_${value}`];
if (!hashData && window.anomalous_hash_cache) {
let hashData = getWorkflowHash(node.id, value);
if (!hashData && !requiresHashForModelRecovery(node, widget) && window.anomalous_hash_cache && typeof value === 'string') {
const parts = value.split(/[/\\]/);
const basename = parts[parts.length - 1];
const cacheData = window.anomalous_hash_cache[value] || window.anomalous_hash_cache[basename];
const normVal = value.replace(/\\/g, '/');
const cacheData = window.anomalous_hash_cache[value] || window.anomalous_hash_cache[normVal] || window.anomalous_hash_cache[basename];
if (cacheData) hashData = typeof cacheData === 'string' ? { hash: cacheData, size: "" } : cacheData;
}
return hashData;
@@ -116,7 +129,7 @@ window.anomalous_resolve_all_missing_nodes = async function (is_manual = false,
for (let i = 0; i < node.widgets.length; i++) {
const widget = node.widgets[i];
const value = widget.value;
if (typeof value !== 'string' || !(value.endsWith('.safetensors') || value.endsWith('.ckpt') || value.endsWith('.pt'))) continue;
if (!isModelFilename(value)) continue;
if (widget.options && widget.options.values && !widget.options.values.includes(value)) {
const normalized = value.replace(/\\/g, '/');
const nativeMatch = widget.options.values.find(v => typeof v === 'string' && v.replace(/\\/g, '/') === normalized);
@@ -159,7 +172,7 @@ window.anomalous_resolve_all_missing_nodes = async function (is_manual = false,
for (let i = 0; i < node.widgets.length; i++) {
const w = node.widgets[i];
const val = w.value;
if (typeof val === 'string' && (val.endsWith('.safetensors') || val.endsWith('.ckpt') || val.endsWith('.pt'))) {
if (isModelFilename(val)) {
// Native slash mismatch fix: if val isn't in options, but matches if we normalize slashes
if (w.options && w.options.values && !w.options.values.includes(val)) {
@@ -335,65 +348,67 @@ app.registerExtension({
window.anomalous_unscanned_models = [];
graphClass.prototype.__anomalousHashResolverPatched = true;
graphClass.prototype.serialize = function () {
const data = origSerialize.apply(this, arguments);
if (localStorage.getItem('anomalous_inject_hash') === 'false') {
return data;
}
// Clone extra to avoid mutating the live graph's extra object
const extraObj = data.extra ? JSON.parse(JSON.stringify(data.extra)) : {};
extraObj.anomalous_hashes = {};
let unscanned_models = [];
if (data.nodes) {
const liveNodes = this._nodes || [];
for (const node of data.nodes) {
const liveNode = liveNodes.find(n => n.id === node.id);
if (node.widgets_values && node.widgets_values.length > 0) {
for (const val of node.widgets_values) {
if (typeof val === 'string' && (val.endsWith('.safetensors') || val.endsWith('.ckpt') || val.endsWith('.pt'))) {
const parts = val.split(/[/\\]/);
const basename = parts[parts.length - 1];
let valIsMissing = false;
if (liveNode && liveNode.widgets) {
const matchingWidget = liveNode.widgets.find(w => w.value === val && w.type === "combo");
if (matchingWidget && matchingWidget.options && matchingWidget.options.values && !matchingWidget.options.values.includes(val)) {
valIsMissing = true;
}
}
if (!valIsMissing) {
const cache_data = window.anomalous_hash_cache[val] || window.anomalous_hash_cache[basename];
if (cache_data) {
if (typeof cache_data === 'string') {
extraObj.anomalous_hashes[`${node.id}_${val}`] = { hash: cache_data, size: "" };
} else {
extraObj.anomalous_hashes[`${node.id}_${val}`] = cache_data;
}
} else {
if (!unscanned_models.includes(basename)) {
unscanned_models.push(basename);
}
}
}
}
}
}
}
}
data.extra = extraObj;
data.extra = extraObj;
window.anomalous_unscanned_models = unscanned_models;
return data;
};
// Intercept loadGraphData to resolve missing models
const data = origSerialize.apply(this, arguments);
if (localStorage.getItem('anomalous_inject_hash') === 'false') {
return data;
}
// Clone extra to avoid mutating the live graph's extra object
const extraObj = data.extra ? JSON.parse(JSON.stringify(data.extra)) : {};
extraObj.anomalous_hashes = {};
let unscanned_models = [];
if (data.nodes) {
const liveNodes = this._nodes || [];
for (const node of data.nodes) {
const liveNode = liveNodes.find(n => n.id === node.id);
if (node.widgets_values && node.widgets_values.length > 0) {
for (const val of node.widgets_values) {
if (isModelFilename(val)) {
const parts = val.split(/[/\\]/);
const basename = parts[parts.length - 1];
let valIsMissing = false;
let matchingWidget = null;
if (liveNode && liveNode.widgets) {
matchingWidget = liveNode.widgets.find(w => w.value === val && w.type === "combo") || null;
if (matchingWidget && matchingWidget.options && matchingWidget.options.values && !matchingWidget.options.values.includes(val)) {
valIsMissing = true;
}
}
if (!valIsMissing) {
const normVal = val.replace(/\\/g, '/');
const cache_data = window.anomalous_hash_cache[val] || window.anomalous_hash_cache[normVal] || window.anomalous_hash_cache[basename];
if (cache_data) {
const hashObj = typeof cache_data === 'string' ? { hash: cache_data, size: "" } : cache_data;
if (matchingWidget && requiresHashForModelRecovery(liveNode, matchingWidget) && !hashObj.hash) {
if (!unscanned_models.includes(basename)) unscanned_models.push(basename);
continue;
}
extraObj.anomalous_hashes[`${node.id}_${val}`] = hashObj;
if (normVal !== val) {
extraObj.anomalous_hashes[`${node.id}_${normVal}`] = hashObj;
}
} else {
if (!unscanned_models.includes(basename)) {
unscanned_models.push(basename);
}
}
}
}
}
}
}
}
data.extra = extraObj;
window.anomalous_unscanned_models = unscanned_models;
return data;
};
// Intercept loadGraphData to resolve missing models
if (typeof app.loadGraphData === 'function') {
const origLoadGraphData = app.loadGraphData;
app.loadGraphData = function (graphData) {
@@ -401,5 +416,5 @@ app.registerExtension({
return origLoadGraphData.apply(this, arguments);
};
}
}
});
}
});
+121 -86
View File
@@ -299,98 +299,133 @@ class AnomalousBrowser {
detectedHeader.style.marginBottom = '10px';
detectedHeader.textContent = t('mainDetectedModels', { count: models.length });
resultArea.replaceChildren(detectedHeader);
for (const m of models) {
const val = m.value;
const parts = val.split(/[\\/]/);
const basename = parts[parts.length - 1];
let isHealthy = false;
for (const m of models) {
const val = m.value;
const parts = val.split(/[\\/]/);
const basename = parts[parts.length - 1];
let isHealthy = false;
let cacheHit = window.anomalous_hash_cache[val] || window.anomalous_hash_cache[basename];
const fetchRes = await fetch(`/anomalous/resolve_hash?hash=unknown&size=&filename=${encodeURIComponent(val)}`);
const fetchData = await fetchRes.json();
if (fetchData.found) {
isHealthy = true;
} else if (cacheHit) {
const resolveRes = await fetch(`/anomalous/resolve_hash?hash=${encodeURIComponent(cacheHit.hash || cacheHit)}&size=`);
const resolveData = await resolveRes.json();
if (resolveData.found) isHealthy = true;
}
const item = document.createElement('div');
item.style.display = 'flex';
item.style.alignItems = 'center';
item.style.justifyContent = 'space-between';
item.style.padding = '8px';
item.style.background = 'rgba(255,255,255,0.05)';
item.style.borderRadius = '4px';
const left = document.createElement('div');
left.style.display = 'flex';
left.style.alignItems = 'center';
left.style.gap = '8px';
const icon = document.createElement('span');
icon.innerText = isHealthy ? '✅' : '⚠️';
const text = document.createElement('span');
text.innerText = `[${m.nodeType}] ${basename}`;
text.style.color = isHealthy ? '#ccc' : '#ff6b6b';
text.style.fontSize = '14px';
left.appendChild(icon);
left.appendChild(text);
item.appendChild(left);
if (!isHealthy) {
const right = document.createElement('a');
const searchHash = cacheHit ? (typeof cacheHit === 'string' ? cacheHit : cacheHit.hash) : null;
const searchStr = searchHash || basename.replace('.safetensors', '').replace('.ckpt', '').replace('.pt', '').replace('.sft', '');
right.href = `https://civitai.com/search/models?sortBy=models_v9&query=${encodeURIComponent(searchStr)}`;
right.target = '_blank';
const fetchRes = await fetch(`/anomalous/resolve_hash?hash=unknown&size=&filename=${encodeURIComponent(val)}`);
const fetchData = await fetchRes.json();
if (fetchData.found) {
isHealthy = true;
} else if (cacheHit) {
const resolveRes = await fetch(`/anomalous/resolve_hash?hash=${encodeURIComponent(cacheHit.hash || cacheHit)}&size=`);
const resolveData = await resolveRes.json();
if (resolveData.found) isHealthy = true;
}
const item = document.createElement('div');
item.style.display = 'flex';
item.style.alignItems = 'center';
item.style.justifyContent = 'space-between';
item.style.padding = '8px';
item.style.background = 'rgba(255,255,255,0.05)';
item.style.borderRadius = '4px';
const left = document.createElement('div');
left.style.display = 'flex';
left.style.alignItems = 'center';
left.style.gap = '8px';
const icon = document.createElement('span');
icon.innerText = isHealthy ? '✅' : '⚠️';
const text = document.createElement('span');
text.innerText = `[${m.nodeType}] ${basename}`;
text.style.color = isHealthy ? '#ccc' : '#ff6b6b';
text.style.fontSize = '14px';
left.appendChild(icon);
left.appendChild(text);
item.appendChild(left);
if (!isHealthy) {
const right = document.createElement('a');
const normVal = val.replace(/\\/g, '/');
const winVal = val.replace(/\//g, '\\');
let workflowHash = null;
if (app.graph && app.graph.extra && app.graph.extra.anomalous_hashes) {
const hData = app.graph.extra.anomalous_hashes[`${m.nodeId || ''}_${val}`] ||
app.graph.extra.anomalous_hashes[`${m.nodeId || ''}_${normVal}`] ||
app.graph.extra.anomalous_hashes[`${m.nodeId || ''}_${winVal}`] ||
app.graph.extra.anomalous_hashes[val] ||
app.graph.extra.anomalous_hashes[normVal] ||
app.graph.extra.anomalous_hashes[winVal];
if (hData) workflowHash = typeof hData === 'string' ? hData : hData.hash;
}
const searchHash = workflowHash || (cacheHit ? (typeof cacheHit === 'string' ? cacheHit : cacheHit.hash) : null);
const searchStr = basename.replace('.safetensors', '').replace('.ckpt', '').replace('.pt', '').replace('.sft', '');
right.href = `https://civitai.com/search/models?sortBy=models_v9&query=${encodeURIComponent(searchHash || searchStr)}`;
right.target = '_blank';
right.textContent = t('mainDownloadCivitai');
right.style.color = '#1a73e8';
right.style.fontSize = '12px';
right.style.textDecoration = 'none';
item.appendChild(right);
} else {
const right = document.createElement('span');
right.style.color = '#1a73e8';
right.style.fontSize = '12px';
right.style.textDecoration = 'none';
right.style.cursor = 'pointer';
if (searchHash) {
right.onclick = async (e) => {
e.preventDefault();
const prevText = right.textContent;
right.textContent = '⏳...';
try {
const res = await fetch(`https://civitai.com/api/v1/model-versions/by-hash/${encodeURIComponent(searchHash)}`);
if (res.ok) {
const data = await res.json();
if (data && data.modelId) {
const nsfwLevel = data.nsfwLevel || 1;
const isNsfw = (data.model && data.model.nsfw) || (nsfwLevel > 1);
const domain = isNsfw ? 'civitai.red' : 'civitai.com';
let targetUrl = `https://${domain}/models/${data.modelId}`;
if (data.id) targetUrl += `?modelVersionId=${data.id}`;
window.open(targetUrl, '_blank');
right.textContent = prevText;
return;
}
}
} catch (err) {
console.warn("[Anomalous] Civitai by-hash lookup failed:", err);
}
right.textContent = prevText;
window.open(`https://civitai.com/search/models?sortBy=models_v9&query=${encodeURIComponent(searchStr)}`, '_blank');
};
}
item.appendChild(right);
} else {
const right = document.createElement('span');
right.textContent = t('mainReady');
right.style.color = '#28a745';
right.style.fontSize = '12px';
item.appendChild(right);
}
resultArea.appendChild(item);
}
} catch (e) {
right.style.color = '#28a745';
right.style.fontSize = '12px';
item.appendChild(right);
}
resultArea.appendChild(item);
}
} catch (e) {
alert(t('mainInvalidJson') + ' ' + e.message);
}
};
loadBtn.onclick = () => {
if (parsedGraphData && app) {
app.loadGraphData(parsedGraphData);
overlay.style.display = 'none';
}
};
btnRow.appendChild(closeBtn);
btnRow.appendChild(analyzeBtn);
btnRow.appendChild(loadBtn);
modal.appendChild(btnRow);
overlay.appendChild(modal);
document.body.appendChild(overlay);
}
};
loadBtn.onclick = () => {
if (parsedGraphData && app) {
app.loadGraphData(parsedGraphData);
overlay.style.display = 'none';
}
};
btnRow.appendChild(closeBtn);
btnRow.appendChild(analyzeBtn);
btnRow.appendChild(loadBtn);
modal.appendChild(btnRow);
overlay.appendChild(modal);
document.body.appendChild(overlay);
}
const textarea = document.getElementById('anomalous-import-textarea');
const resultArea = document.getElementById('anomalous-import-result');
const analyzeBtn = document.getElementById('anomalous-import-analyze-btn');
const loadBtn = document.getElementById('anomalous-import-load-btn');
if (textarea) {
textarea.value = '';
textarea.style.display = 'block';
+40
View File
@@ -121,10 +121,16 @@ export const i18n = {
detailUploadFailed: '上传失败: ',
detailModelInfo: '编辑模型信息',
detailFile: '文件:',
detailPhysicalFilename: '物理文件',
detailFullPath: '完整路径',
detailCopyPath: '复制路径',
detailPathCopied: '已复制',
detailPathCopyFailed: '复制失败',
detailCustomNamePlaceholder: '自定义名称 (留空则使用原名)',
detailNotesPlaceholder: '📓 专属备注... (支持多行)',
detailPhysicalRename: '同步物理重命名',
detailPhysicalRenameDesc: '若不勾选则仅虚拟重命名,安全不破坏原文件。',
detailPhysicalRenameProtectedDesc: 'VAE、CLIP/Text Encoder、CLIP Vision 等基础组件受保护,不允许物理重命名。',
detailResetAll: '重置设置',
detailResetConfirm: '确定要重置该模型的所有自定义信息(名称、封面、备注)吗?',
detailResetCoverPreserved: '未找到可恢复的 C 站备份或原始封面。为避免丢失唯一图片,当前封面已保留;自定义名称和备注仍已重置。',
@@ -178,12 +184,22 @@ export const i18n = {
assistantConnectOutputs: '接入节点输出端',
doctorTotal: '总模型',
doctorHealthy: '🟢 健康',
doctorIdentityWarning: '🟡 身份变化',
doctorMissing: '🔴 缺失',
doctorNoWorkflowModels: '当前工作流没有任何模型节点',
doctorAutoRedirected: '自动重定向',
doctorReady: '正常',
doctorIdentityChanged: '同名文件哈希不同,ComfyUI 将继续使用',
doctorMissingStatus: '丢失',
doctorCivitai: '🌐 C站',
doctorViewHash: '🔑 查看哈希',
doctorHashModalTitle: '模型哈希与溯源详情',
doctorWorkflowHash: '工作流注入哈希 (Provenance)',
doctorLocalHash: '本地当前哈希 (Local Model)',
doctorNoHashInWorkflow: '未包含(工作流导出时未注入哈希或处于未扫描状态)',
doctorNoLocalHash: '本地未扫描或文件丢失',
doctorCopied: '已复制哈希到剪贴板!',
doctorFileSize: '文件大小',
doctorDeepHashScan: '🔍 深度哈希扫描',
doctorScanStarting: '⏳ 正在启动扫描引擎...',
doctorScanning: '⏳ 扫描中 ({current}/{total}) {filename}',
@@ -307,6 +323,7 @@ export const i18n = {
sidebarVirtualRenameDesc: '仅将标准名称注入浏览器插件中,不修改硬盘上的真实文件名,随时可无损还原。',
sidebarPhysicalRename: '物理重命名',
sidebarPhysicalRenameDesc: '真实修改底层的 <code>.safetensors</code> 及其所有附属文件名,彻底告别乱码文件名。',
sidebarPhysicalRenameProtectedNotice: '基础组件保护已启用:VAE、VAE Approx、CLIP/Text Encoder 与 CLIP Vision 仍会扫描元数据,但不会修改物理文件名。',
sidebarStep3: 'Step 3. 工作流保障与修复',
sidebarProvenance: '模型溯源绑定',
sidebarProvenanceDesc: '全局生效。每次生成图片或保存工作流时,将模型的精确哈希值隐式写入到图像元数据与工作流 JSON 中,防止未来模型改名或换环境后报错找不到。',
@@ -641,6 +658,9 @@ export const i18n = {
recipeDetailNoPreview: '暂无预览',
recipeDetailSavedSnapshot: '已保存快照',
recipeParameterNotebooks: 'RECIPE PARAMETER NOTEBOOKS',
recipeParameterRename: '重命名',
recipeParameterRenamePrompt: '请输入参数笔记本的新名称:',
recipeParameterRenameError: '重命名参数笔记本失败',
assistantTabActions: '动作',
assistantTabPresets: '参数笔记本',
assistantRecipeGroup: '配方',
@@ -772,10 +792,16 @@ export const i18n = {
detailUploadFailed: 'Upload failed: ',
detailModelInfo: 'Edit Model Info',
detailFile: 'File:',
detailPhysicalFilename: 'Physical file',
detailFullPath: 'Full path',
detailCopyPath: 'Copy path',
detailPathCopied: 'Copied',
detailPathCopyFailed: 'Copy failed',
detailCustomNamePlaceholder: 'Custom Name (Leave empty to use original)',
detailNotesPlaceholder: '📓 Custom Notes...',
detailPhysicalRename: 'Physical Rename on Disk',
detailPhysicalRenameDesc: 'Virtual rename only if unchecked. Safe for originals.',
detailPhysicalRenameProtectedDesc: 'Foundation components such as VAE, CLIP/Text Encoder, and CLIP Vision are protected from physical renaming.',
detailResetAll: 'Reset All',
detailResetConfirm: 'Reset all custom info for this model?',
detailResetCoverPreserved: 'No recoverable Civitai backup or original cover was found. The current cover was kept to avoid losing the only image; the custom name and notes were still reset.',
@@ -829,12 +855,22 @@ export const i18n = {
assistantConnectOutputs: 'Connect to node outputs',
doctorTotal: 'Total',
doctorHealthy: '🟢 Healthy',
doctorIdentityWarning: '🟡 Identity changed',
doctorMissing: '🔴 Missing',
doctorNoWorkflowModels: 'No models found in this workflow',
doctorAutoRedirected: 'Auto-Redirected',
doctorReady: 'Ready',
doctorIdentityChanged: 'Same filename, different hash; ComfyUI will keep using it',
doctorMissingStatus: 'Missing',
doctorCivitai: '🌐 Civitai',
doctorViewHash: '🔑 View Hash',
doctorHashModalTitle: 'Model Hash & Provenance Details',
doctorWorkflowHash: 'Workflow Embedded Hash (Provenance)',
doctorLocalHash: 'Local Model Hash (Local Disk)',
doctorNoHashInWorkflow: 'Not available (Uninjected or unscanned during export)',
doctorNoLocalHash: 'No local record (Unscanned or file missing)',
doctorCopied: 'Hash copied to clipboard!',
doctorFileSize: 'File Size',
doctorDeepHashScan: '🔍 Deep Hash Scan',
doctorScanStarting: '⏳ Starting scan engine...',
doctorScanning: '⏳ Scanning ({current}/{total}) {filename}',
@@ -958,6 +994,7 @@ export const i18n = {
sidebarVirtualRenameDesc: 'Inject standard names into the browser only, without modifying actual files on disk. Safely reversible.',
sidebarPhysicalRename: 'Physical Rename',
sidebarPhysicalRenameDesc: 'Permanently rename the underlying <code>.safetensors</code> and associated files on disk.',
sidebarPhysicalRenameProtectedNotice: 'Foundation-component protection is active: VAE, VAE Approx, CLIP/Text Encoder, and CLIP Vision metadata will still be scanned, but their physical filenames will not change.',
sidebarStep3: 'Step 3. Workflow Protection & Fix',
sidebarProvenance: 'Model Provenance Binding',
sidebarProvenanceDesc: 'Global effect. Implicitly embed exact model hashes into generated images and saved workflows, ensuring missing models can always be auto-fixed.',
@@ -1292,6 +1329,9 @@ export const i18n = {
recipeDetailNoPreview: 'No Preview',
recipeDetailSavedSnapshot: 'Snapshot Saved',
recipeParameterNotebooks: 'RECIPE PARAMETER NOTEBOOKS',
recipeParameterRename: 'Rename',
recipeParameterRenamePrompt: 'Enter a new name for this parameter notebook:',
recipeParameterRenameError: 'Failed to rename parameter notebook',
assistantTabActions: 'Actions',
assistantTabPresets: 'Parameter Presets',
assistantRecipeGroup: 'Recipe',
+32
View File
@@ -0,0 +1,32 @@
const PHYSICAL_RENAME_PROTECTED_TYPES = new Set([
'vae',
'vae_approx',
'clip',
'text_encoders',
'clip_vision',
]);
export function isPhysicalRenameProtectedType(folderType) {
return PHYSICAL_RENAME_PROTECTED_TYPES.has(String(folderType || '').trim().toLowerCase());
}
export function inferModelFolderTypes(node, widget) {
const widgetName = String(widget?.name || '').toLowerCase();
const nodeType = String(node?.type || '').toLowerCase();
const key = `${widgetName} ${nodeType}`;
if (key.includes('clip_vision') || key.includes('clip vision') || nodeType.includes('clipvision')) return ['clip_vision'];
if (key.includes('vae_approx') || key.includes('vae approx')) return ['vae_approx'];
if (widgetName.includes('vae') || nodeType === 'vaeloader' || key.includes('vae loader')) return ['vae'];
if (widgetName.includes('lora') || key.includes('lora')) return ['loras'];
if (widgetName.includes('control_net') || widgetName.includes('controlnet') || key.includes('controlnet')) return ['controlnet'];
if (widgetName.includes('ckpt') || widgetName.includes('checkpoint') || key.includes('checkpoint')) return ['checkpoints'];
if (widgetName.includes('unet') || widgetName.includes('diffusion_model') || key.includes('unet loader')) return ['diffusion_models', 'unet'];
if (widgetName.includes('clip_name') || widgetName.includes('text_encoder') || nodeType.includes('cliploader')) return ['clip', 'text_encoders'];
return [];
}
export function requiresHashForModelRecovery(node, widget) {
const types = inferModelFolderTypes(node, widget);
return types.length > 0 && types.every(type => PHYSICAL_RENAME_PROTECTED_TYPES.has(type));
}
+48 -2
View File
@@ -6,9 +6,48 @@
import { app } from "../../../scripts/app.js";
import { translate } from './locales.js';
import { escapeHtml, setSafeRichHtml } from './safe_dom.js';
import { isPhysicalRenameProtectedType } from './model_policies.js';
const t = (key, params) => translate(key, params);
function createModelLocationCard(model) {
const card = document.createElement('div');
card.className = 'anomalous-model-location';
const appendValue = (labelKey, value) => {
const row = document.createElement('div');
row.className = 'anomalous-model-location-row';
const label = document.createElement('span');
label.className = 'anomalous-model-location-label';
label.textContent = t(labelKey);
const content = document.createElement('code');
content.textContent = value;
content.title = value;
row.append(label, content);
card.appendChild(row);
};
appendValue('detailPhysicalFilename', model.filename);
if (model.file_path) {
appendValue('detailFullPath', model.file_path);
const copyButton = document.createElement('button');
copyButton.type = 'button';
copyButton.className = 'anomalous-model-location-copy';
copyButton.textContent = t('detailCopyPath');
copyButton.onclick = async () => {
try {
await navigator.clipboard.writeText(model.file_path);
copyButton.textContent = t('detailPathCopied');
} catch (error) {
copyButton.textContent = t('detailPathCopyFailed');
}
setTimeout(() => { copyButton.textContent = t('detailCopyPath'); }, 1400);
};
card.appendChild(copyButton);
}
return card;
}
export function showDetail(model) {
@@ -412,6 +451,7 @@ export function showDetail(model) {
topRow.appendChild(editMetaBtn);
rightPanel.appendChild(topRow);
rightPanel.appendChild(createModelLocationCard(model));
// 1.5 Custom Notes Section (Google Material Card)
if (m.custom_notes) {
@@ -971,13 +1011,17 @@ export function showEditModal(model) {
physicalCheckbox.type = 'checkbox';
physicalCheckbox.id = 'anomalous-physical-rename-checkbox';
physicalCheckbox.style.cursor = 'pointer';
const physicalRenameProtected = isPhysicalRenameProtectedType(this.currentType);
physicalCheckbox.disabled = physicalRenameProtected;
physicalCheckbox.style.cursor = physicalRenameProtected ? 'not-allowed' : 'pointer';
const physicalLabel = document.createElement('label');
physicalLabel.htmlFor = 'anomalous-physical-rename-checkbox';
physicalLabel.textContent = t('detailPhysicalRename');
physicalLabel.style.color = '#e8eaed';
physicalLabel.style.fontSize = '0.9em';
physicalLabel.style.cursor = 'pointer';
physicalLabel.style.cursor = physicalRenameProtected ? 'not-allowed' : 'pointer';
physicalLabel.style.opacity = physicalRenameProtected ? '0.6' : '1';
physicalCheckboxWrapper.appendChild(physicalCheckbox);
physicalCheckboxWrapper.appendChild(physicalLabel);
@@ -986,7 +1030,9 @@ export function showEditModal(model) {
physicalDesc.style.fontSize = '0.8em';
physicalDesc.style.color = '#9aa0a6';
physicalDesc.style.marginLeft = '22px';
physicalDesc.textContent = t('detailPhysicalRenameDesc');
physicalDesc.textContent = t(physicalRenameProtected
? 'detailPhysicalRenameProtectedDesc'
: 'detailPhysicalRenameDesc');
physicalRow.appendChild(physicalCheckboxWrapper);
physicalRow.appendChild(physicalDesc);
+103
View File
@@ -150,3 +150,106 @@ export function anomalousConfirm(message, title = 'Anomalous', options = {}) {
cancelBtn.focus();
});
}
export function anomalousPrompt(message, defaultValue = '', title = 'Anomalous') {
return new Promise((resolve) => {
const overlay = document.createElement('div');
overlay.style.position = 'fixed';
overlay.style.inset = '0';
overlay.style.zIndex = '999999';
overlay.style.display = 'flex';
overlay.style.alignItems = 'center';
overlay.style.justifyContent = 'center';
overlay.style.background = 'rgba(0, 0, 0, 0.6)';
overlay.style.backdropFilter = 'blur(4px)';
overlay.style.padding = '20px';
overlay.style.boxSizing = 'border-box';
const dialog = document.createElement('div');
dialog.style.background = 'linear-gradient(145deg, rgba(48, 49, 55, 0.98), rgba(27, 28, 33, 0.98))';
dialog.style.border = '1px solid rgba(255, 255, 255, 0.12)';
dialog.style.borderRadius = '16px';
dialog.style.padding = '24px';
dialog.style.maxWidth = '420px';
dialog.style.width = '100%';
dialog.style.boxShadow = '0 24px 70px rgba(0, 0, 0, 0.55)';
dialog.style.color = '#f3f4f6';
dialog.style.display = 'flex';
dialog.style.flexDirection = 'column';
dialog.style.gap = '16px';
const heading = document.createElement('h3');
heading.textContent = title;
heading.style.margin = '0';
heading.style.fontSize = '1.25rem';
const text = document.createElement('p');
text.textContent = message;
text.style.margin = '0';
text.style.lineHeight = '1.5';
text.style.color = '#ccc';
const input = document.createElement('input');
input.type = 'text';
input.value = defaultValue;
input.maxLength = 200;
input.style.width = '100%';
input.style.padding = '10px 14px';
input.style.borderRadius = '8px';
input.style.border = '1px solid rgba(255, 255, 255, 0.18)';
input.style.background = 'rgba(0, 0, 0, 0.35)';
input.style.color = '#fff';
input.style.fontSize = '14px';
input.style.boxSizing = 'border-box';
input.style.outline = 'none';
input.onfocus = () => input.style.borderColor = '#1a73e8';
input.onblur = () => input.style.borderColor = 'rgba(255, 255, 255, 0.18)';
const footer = document.createElement('div');
footer.style.display = 'flex';
footer.style.justifyContent = 'flex-end';
footer.style.gap = '12px';
footer.style.marginTop = '8px';
const cancelBtn = document.createElement('button');
cancelBtn.textContent = t('dialogCancel');
cancelBtn.className = 'anomalous-btn-ghost';
cancelBtn.style.padding = '8px 24px';
const okBtn = document.createElement('button');
okBtn.textContent = t('dialogOk');
okBtn.className = 'anomalous-btn-primary';
okBtn.style.padding = '8px 24px';
const finish = (result) => {
overlay.remove();
resolve(result);
};
cancelBtn.onclick = () => finish(null);
okBtn.onclick = () => {
const val = input.value.trim();
finish(val);
};
input.onkeydown = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
okBtn.click();
} else if (e.key === 'Escape') {
e.preventDefault();
cancelBtn.click();
}
};
footer.append(cancelBtn, okBtn);
dialog.append(heading, text, input, footer);
overlay.appendChild(dialog);
document.body.appendChild(overlay);
setTimeout(() => {
input.focus();
input.select();
}, 50);
});
}
+232 -23
View File
@@ -12,6 +12,7 @@ import {
getBaseModelFamily,
inferPickerModelType,
} from './model_picker.js';
import { requiresHashForModelRecovery } from './model_policies.js';
/**
* ui_doctor.js
* Extracted Doctor Panel & Assistant Panel methods.
@@ -19,6 +20,42 @@ import {
const t = (key, params) => translate(key, params);
function workflowHashRecord(nodeId, value) {
const hashes = app.graph?.extra?.anomalous_hashes;
if (!hashes || typeof value !== 'string') return null;
const normalized = value.replace(/\\/g, '/');
const windowsPath = value.replace(/\//g, '\\');
return hashes[`${nodeId}_${value}`]
|| hashes[`${nodeId}_${normalized}`]
|| hashes[`${nodeId}_${windowsPath}`]
|| hashes[value]
|| hashes[normalized]
|| hashes[windowsPath]
|| null;
}
function localHashRecord(value) {
if (!window.anomalous_hash_cache || typeof value !== 'string') return null;
const normalized = value.replace(/\\/g, '/');
const basename = normalized.split('/').pop();
return window.anomalous_hash_cache[value]
|| window.anomalous_hash_cache[normalized]
|| window.anomalous_hash_cache[basename]
|| null;
}
function hashFromRecord(record) {
const value = typeof record === 'string' ? record : record?.hash;
return String(value || '').trim().toUpperCase();
}
function hasFoundationIdentityMismatch(node, widget, value) {
if (!requiresHashForModelRecovery(node, widget)) return false;
const workflowHash = hashFromRecord(workflowHashRecord(node.id, value));
const localHash = hashFromRecord(localHashRecord(value));
return Boolean(workflowHash && localHash && workflowHash !== localHash);
}
export function initDoctorPanel() {
this.doctorPanelInitialized = true;
this.doctorPanel.innerHTML = '';
@@ -71,6 +108,7 @@ export function initDoctorPanel() {
refreshBtn.disabled = true;
refreshBtn.style.opacity = '0.5';
if (app.refreshComboInNodes) await app.refreshComboInNodes();
if (window.anomalous_reload_hashes) await window.anomalous_reload_hashes();
if (app.lastNodeErrors) app.lastNodeErrors = null;
if (typeof app.clearErrors === 'function') app.clearErrors();
if (app.graph) {
@@ -388,7 +426,7 @@ export function renderGlobalDashboard() {
nodes = app.graph._nodes;
}
let total = 0, healthy = 0, missing = 0;
let total = 0, healthy = 0, missing = 0, identityWarnings = 0;
let missingNodesData = [];
let has_native_fixes = false;
@@ -425,9 +463,11 @@ export function renderGlobalDashboard() {
}
}
}
const identityMismatch = isHealthy && hasFoundationIdentityMismatch(node, w, val);
if (identityMismatch) identityWarnings++;
if (isHealthy) healthy++; else missing++;
missingNodesData.push({ node, w, val, isHealthy, exactMatch });
missingNodesData.push({ node, w, val, isHealthy, exactMatch, identityMismatch });
}
}
}
@@ -448,6 +488,7 @@ export function renderGlobalDashboard() {
statsRow.innerHTML = `
${createBadge(t('doctorTotal'), total, '#aaa', 'rgba(255,255,255,0.05)')}
${createBadge(t('doctorHealthy'), healthy, '#28a745', 'rgba(40, 167, 69, 0.1)')}
${identityWarnings ? createBadge(t('doctorIdentityWarning'), identityWarnings, '#ffc107', 'rgba(255, 193, 7, 0.1)') : ''}
${createBadge(t('doctorMissing'), missing, '#ff6b6b', 'rgba(220, 53, 69, 0.1)')}
`;
@@ -461,7 +502,7 @@ export function renderGlobalDashboard() {
// Render List
for (const data of missingNodesData) {
const { node, w, val, isHealthy, exactMatch } = data;
const { node, w, val, isHealthy, exactMatch, identityMismatch } = data;
const item = document.createElement('div');
item.style.cssText = `display:flex; flex-direction:column; padding:16px 20px; background:rgba(255,255,255,0.02); border-radius:12px; border:1px solid rgba(255,255,255,0.04); transition:all 0.2s; position:relative; overflow:hidden; flex-shrink:0;`;
@@ -470,7 +511,7 @@ for (const data of missingNodesData) {
// Accent bar
const accent = document.createElement('div');
accent.style.cssText = `position:absolute; left:0; top:0; bottom:0; width:4px; background:${isHealthy ? '#28a745' : '#ff6b6b'};`;
accent.style.cssText = `position:absolute; left:0; top:0; bottom:0; width:4px; background:${identityMismatch ? '#ffc107' : (isHealthy ? '#28a745' : '#ff6b6b')};`;
item.appendChild(accent);
const top = document.createElement('div');
@@ -494,7 +535,9 @@ for (const data of missingNodesData) {
const right = document.createElement('div');
right.style.cssText = 'display:flex; align-items:center; gap:12px;';
if (isHealthy && exactMatch && exactMatch !== val) {
if (identityMismatch) {
right.innerHTML = `<div style="color:#ffc107;font-size:13px;font-weight:bold;padding:6px 12px;background:rgba(255,193,7,0.1);border-radius:20px;">🟡 ${t('doctorIdentityChanged')}</div>`;
} else if (isHealthy && exactMatch && exactMatch !== val) {
right.innerHTML = `<div style="text-align:right;"><div style="color:#ffc107;font-size:13px;font-weight:bold;">🟡 ${t('doctorAutoRedirected')}</div><div style="color:rgba(255,255,255,0.4);font-size:11px;margin-top:4px;">${escapeHtml(exactMatch.split(/[\/]/).pop())}</div></div>`;
} else if (isHealthy) {
right.innerHTML = `<div style="color:#28a745;font-size:13px;font-weight:bold;padding:6px 12px;background:rgba(40,167,69,0.1);border-radius:20px;">🟢 ${t('doctorReady')}</div>`;
@@ -508,29 +551,63 @@ for (const data of missingNodesData) {
const actionRow = document.createElement('div');
actionRow.style.cssText = 'display:flex; gap:10px; margin-top:16px; margin-left:8px;';
const civitaiBtn = document.createElement('button');
civitaiBtn.textContent = t('doctorCivitai');
civitaiBtn.style.cssText = 'padding:8px 16px; background:rgba(255,255,255,0.1); color:#fff; border:none; border-radius:6px; cursor:pointer; font-weight:600; font-size:12px; transition:background 0.2s;';
civitaiBtn.onmouseover = () => civitaiBtn.style.background = 'rgba(255,255,255,0.2)';
civitaiBtn.onmouseout = () => civitaiBtn.style.background = 'rgba(255,255,255,0.1)';
civitaiBtn.onclick = () => {
civitaiBtn.onclick = async () => {
let searchHash = null;
if (app.graph && app.graph.extra && app.graph.extra.anomalous_hashes) {
const hData = app.graph.extra.anomalous_hashes[`${node.id}_${val}`];
const normVal = val.replace(/\\/g, '/');
const winVal = val.replace(/\//g, '\\');
const hData = app.graph.extra.anomalous_hashes[`${node.id}_${val}`] ||
app.graph.extra.anomalous_hashes[`${node.id}_${normVal}`] ||
app.graph.extra.anomalous_hashes[`${node.id}_${winVal}`] ||
app.graph.extra.anomalous_hashes[val] ||
app.graph.extra.anomalous_hashes[normVal] ||
app.graph.extra.anomalous_hashes[winVal];
if (hData) searchHash = typeof hData === 'string' ? hData : hData.hash;
}
if (!searchHash && window.anomalous_hash_cache) {
const normVal = val.replace(/\\/g, '/');
const basename = val.split(/[/\\]/).pop();
const cData = window.anomalous_hash_cache[basename] || window.anomalous_hash_cache[val];
const cData = window.anomalous_hash_cache[val] || window.anomalous_hash_cache[normVal] || window.anomalous_hash_cache[basename];
if (cData) searchHash = typeof cData === 'string' ? cData : cData.hash;
}
const searchStr = searchHash || val.split(/[/\\]/).pop().replace('.safetensors', '').replace('.ckpt', '').replace('.pt', '').replace('.sft', '');
if (searchHash) {
const prevText = civitaiBtn.textContent;
civitaiBtn.textContent = '⏳...';
try {
const res = await fetch(`https://civitai.com/api/v1/model-versions/by-hash/${encodeURIComponent(searchHash)}`);
if (res.ok) {
const data = await res.json();
if (data && data.modelId) {
const nsfwLevel = data.nsfwLevel || 1;
const isNsfw = (data.model && data.model.nsfw) || (nsfwLevel > 1);
const domain = isNsfw ? 'civitai.red' : 'civitai.com';
let targetUrl = `https://${domain}/models/${data.modelId}`;
if (data.id) {
targetUrl += `?modelVersionId=${data.id}`;
}
window.open(targetUrl, '_blank');
civitaiBtn.textContent = prevText;
return;
}
}
} catch (e) {
console.warn("[Anomalous Doctor] Failed to query Civitai by hash, fallback to query search:", e);
}
civitaiBtn.textContent = prevText;
}
const searchStr = val.split(/[/\\]/).pop().replace('.safetensors', '').replace('.ckpt', '').replace('.pt', '').replace('.sft', '');
const url = `https://civitai.com/search/models?sortBy=models_v9&query=${encodeURIComponent(searchStr)}`;
window.open(url, '_blank');
};
if (!isHealthy) {
if (!isHealthy) {
const deepScanBtn = document.createElement('button');
deepScanBtn.textContent = t('doctorDeepHashScan');
deepScanBtn.style.cssText = 'padding:8px 16px; background:#1a73e8; color:#fff; border:none; border-radius:6px; cursor:pointer; font-weight:600; font-size:12px; transition:background 0.2s;';
@@ -558,13 +635,13 @@ if (!isHealthy) {
if (!statusRes.ok) throw new Error(`HTTP ${statusRes.status}`);
const statusData = await statusRes.json();
if (statusData.scanning) {
if (statusData.scanning) {
let filename = statusData.filename || '';
if (filename.length > 20) filename = filename.substring(0, 10) + '...' + filename.substring(filename.length - 7);
deepScanBtn.textContent = t('doctorScanning', { current: statusData.current, total: statusData.total, filename });
setTimeout(pollStatus, 500);
} else {
if (statusData.error) {
if (statusData.error) {
alert(t('doctorScanError') + statusData.error);
deepScanBtn.textContent = t('doctorDeepHashScan');
deepScanBtn.disabled = false;
@@ -574,27 +651,28 @@ if (statusData.error) {
deepScanBtn.textContent = t('doctorMatching');
if (window.anomalous_reload_hashes) {
if (window.anomalous_reload_hashes) {
await window.anomalous_reload_hashes();
}
if (window.anomalous_resolve_all_missing_nodes) {
if (window.anomalous_resolve_all_missing_nodes) {
await window.anomalous_resolve_all_missing_nodes(true, false);
}
let stillMissing = false;
const normVal = w.value.replace(/\\/g, '/');
for (let i = 0; i < node.widgets.length; i++) {
const wi = node.widgets[i];
if (wi.name === w.name && wi.options && wi.options.values) {
const match = wi.options.values.find(v => typeof v === 'string' && v.replace(/\\/g, '/') === normVal);
if (!match) stillMissing = true;
}
const currentVal = w.value;
const currentNorm = typeof currentVal === 'string' ? currentVal.replace(/\\/g, '/') : '';
if (w.options && w.options.values) {
const match = w.options.values.find(v => typeof v === 'string' && v.replace(/\\/g, '/') === currentNorm);
if (!match) stillMissing = true;
}
if (node.has_errors || node.color === "#FF3333" || node.bgcolor === "#FF3333") {
stillMissing = true;
}
const scanInfo = t('doctorDeepScanSummary', { total: statusData.total });
if (stillMissing) {
if (stillMissing) {
alert(t('doctorNoLocalMatch', { summary: scanInfo }));
} else {
alert(t('doctorScanSuccess', { summary: scanInfo }));
@@ -640,6 +718,16 @@ if (stillMissing) {
item.appendChild(pathText);
}
const viewHashBtn = document.createElement('button');
viewHashBtn.textContent = t('doctorViewHash');
viewHashBtn.style.cssText = 'padding:8px 16px; background:rgba(255,255,255,0.1); color:#fff; border:none; border-radius:6px; cursor:pointer; font-weight:600; font-size:12px; transition:background 0.2s;';
viewHashBtn.onmouseover = () => viewHashBtn.style.background = 'rgba(255,255,255,0.2)';
viewHashBtn.onmouseout = () => viewHashBtn.style.background = 'rgba(255,255,255,0.1)';
viewHashBtn.onclick = () => {
openHashDetailDialog(node, w, val);
};
actionRow.appendChild(viewHashBtn);
actionRow.appendChild(civitaiBtn);
item.appendChild(actionRow);
@@ -647,6 +735,127 @@ if (stillMissing) {
}
}
function openHashDetailDialog(node, widget, val) {
let workflowHash = null;
let workflowSize = null;
if (app.graph && app.graph.extra && app.graph.extra.anomalous_hashes) {
const normVal = val.replace(/\\/g, '/');
const winVal = val.replace(/\//g, '\\');
const hData = app.graph.extra.anomalous_hashes[`${node.id}_${val}`] ||
app.graph.extra.anomalous_hashes[`${node.id}_${normVal}`] ||
app.graph.extra.anomalous_hashes[`${node.id}_${winVal}`] ||
app.graph.extra.anomalous_hashes[val] ||
app.graph.extra.anomalous_hashes[normVal] ||
app.graph.extra.anomalous_hashes[winVal];
if (hData) {
workflowHash = typeof hData === 'string' ? hData : (hData.hash || null);
workflowSize = typeof hData === 'object' ? (hData.size || null) : null;
}
}
let localHash = null;
let localSize = null;
if (window.anomalous_hash_cache) {
const normVal = val.replace(/\\/g, '/');
const basename = val.split(/[/\\]/).pop();
const cData = window.anomalous_hash_cache[val] || window.anomalous_hash_cache[normVal] || window.anomalous_hash_cache[basename];
if (cData) {
localHash = typeof cData === 'string' ? cData : (cData.hash || null);
localSize = typeof cData === 'object' ? (cData.size || null) : null;
}
}
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;top:0;left:0;width:100vw;height:100vh;background:rgba(0,0,0,0.75);backdrop-filter:blur(6px);z-index:9999999;display:flex;align-items:center;justify-content:center;font-family:Inter,-apple-system,sans-serif;';
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
const modal = document.createElement('div');
modal.style.cssText = 'background:#1a1c23;border:1px solid rgba(255,255,255,0.12);border-radius:14px;padding:24px;width:540px;max-width:92vw;max-height:85vh;overflow-y:auto;box-shadow:0 20px 50px rgba(0,0,0,0.6);display:flex;flex-direction:column;gap:18px;color:#fff;';
const headerRow = document.createElement('div');
headerRow.style.cssText = 'display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid rgba(255,255,255,0.08);padding-bottom:14px;';
headerRow.innerHTML = `<div style="font-size:16px;font-weight:700;display:flex;align-items:center;gap:8px;"><span>🔑</span><span>${escapeHtml(t('doctorHashModalTitle'))}</span></div>`;
const closeBtn = document.createElement('button');
closeBtn.textContent = '✕';
closeBtn.style.cssText = 'background:none;border:none;color:#aaa;font-size:18px;cursor:pointer;padding:4px 8px;border-radius:6px;transition:color 0.2s;';
closeBtn.onmouseover = () => closeBtn.style.color = '#fff';
closeBtn.onmouseout = () => closeBtn.style.color = '#aaa';
closeBtn.onclick = () => overlay.remove();
headerRow.appendChild(closeBtn);
modal.appendChild(headerRow);
const metaSection = document.createElement('div');
metaSection.style.cssText = 'display:flex;flex-direction:column;gap:6px;background:rgba(255,255,255,0.03);padding:12px 14px;border-radius:8px;border:1px solid rgba(255,255,255,0.05);';
metaSection.innerHTML = `
<div style="font-size:11px;color:#8ab4f8;font-weight:600;">#${escapeHtml(String(node.id))} · ${escapeHtml(node.title || node.type || 'Node')} · ${escapeHtml(widget?.name || '')}</div>
<div style="font-size:13px;font-weight:600;word-break:break-all;color:#e8eaed;">${escapeHtml(val)}</div>
`;
modal.appendChild(metaSection);
const formatBytes = (bytes) => {
if (!bytes || isNaN(bytes)) return '';
const b = parseInt(bytes);
if (b > 1024 * 1024 * 1024) return (b / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
return (b / (1024 * 1024)).toFixed(2) + ' MB';
};
const renderHashCard = (title, hash, size, badgeColor, isProvenance) => {
const card = document.createElement('div');
card.style.cssText = 'display:flex;flex-direction:column;gap:8px;padding:12px 14px;background:rgba(255,255,255,0.02);border:1px solid rgba(255,255,255,0.06);border-radius:10px;';
const cardHeader = document.createElement('div');
cardHeader.style.cssText = 'display:flex;align-items:center;justify-content:space-between;font-size:12px;';
cardHeader.innerHTML = `<span style="font-weight:600;color:${badgeColor};">${escapeHtml(title)}</span>${size ? `<span style="color:#aaa;font-size:11px;">${formatBytes(size)} (${size} B)</span>` : ''}`;
card.appendChild(cardHeader);
if (hash) {
const hashBox = document.createElement('div');
hashBox.style.cssText = 'display:flex;align-items:center;gap:8px;background:#0d0e12;padding:8px 10px;border-radius:6px;border:1px solid rgba(255,255,255,0.08);';
const hashText = document.createElement('code');
hashText.style.cssText = 'font-size:11px;color:#00ffcc;word-break:break-all;flex:1;font-family:Consolas,monospace;letter-spacing:0.5px;';
hashText.textContent = hash;
const copyBtn = document.createElement('button');
copyBtn.textContent = '📋';
copyBtn.title = 'Copy';
copyBtn.style.cssText = 'background:rgba(255,255,255,0.08);border:none;color:#fff;cursor:pointer;padding:4px 8px;border-radius:4px;font-size:12px;transition:background 0.2s;flex-shrink:0;';
copyBtn.onmouseover = () => copyBtn.style.background = 'rgba(255,255,255,0.18)';
copyBtn.onmouseout = () => copyBtn.style.background = 'rgba(255,255,255,0.08)';
copyBtn.onclick = () => {
navigator.clipboard.writeText(hash);
copyBtn.textContent = '✅';
setTimeout(() => copyBtn.textContent = '📋', 1500);
};
hashBox.append(hashText, copyBtn);
card.appendChild(hashBox);
} else {
const emptyTip = document.createElement('div');
emptyTip.style.cssText = 'font-size:12px;color:#888;font-style:italic;padding:4px 0;';
emptyTip.textContent = isProvenance ? t('doctorNoHashInWorkflow') : t('doctorNoLocalHash');
card.appendChild(emptyTip);
}
return card;
};
modal.appendChild(renderHashCard(t('doctorWorkflowHash'), workflowHash, workflowSize, '#8ab4f8', true));
modal.appendChild(renderHashCard(t('doctorLocalHash'), localHash, localSize, '#81c995', false));
const footer = document.createElement('div');
footer.style.cssText = 'display:flex;justify-content:flex-end;margin-top:4px;';
const okBtn = document.createElement('button');
okBtn.textContent = 'OK';
okBtn.style.cssText = 'padding:8px 24px;background:#1a73e8;color:#fff;border:none;border-radius:6px;cursor:pointer;font-weight:600;font-size:12px;';
okBtn.onclick = () => overlay.remove();
footer.appendChild(okBtn);
modal.appendChild(footer);
overlay.appendChild(modal);
document.body.appendChild(overlay);
}
export function initAssistantPanel() {
this.assistantPanelInitialized = true;
this.assistantPanel.innerHTML = '';
+17 -3
View File
@@ -13,6 +13,11 @@ export function cardPreviewUrl(previewUrl, thumbnailMode) {
return `${previewUrl}${previewUrl.includes('?') ? '&' : '?'}variant=card`;
}
function modelCardDisplayName(model) {
const metadata = model?.metadata || {};
return metadata.custom_name || model?.filename || '';
}
export async function loadModels() {
@@ -112,7 +117,6 @@ export async function loadModels() {
ph.innerHTML = `<div style="text-align:center;color:#666;margin-top:80px;">${t('noPreview')}</div><div style="font-size:0.8em;text-align:center;opacity:0.5;margin-top:5px">${t('clickScan')}</div>`;
card.appendChild(ph);
}
const title = document.createElement('div');
if (model.metadata && model.metadata.baseModel) {
const badge = document.createElement('div');
badge.innerText = model.metadata.baseModel;
@@ -130,9 +134,19 @@ export async function loadModels() {
badge.style.zIndex = '10';
card.appendChild(badge);
}
const labels = document.createElement('div');
labels.className = 'anomalous-card-labels';
const title = document.createElement('div');
title.className = 'anomalous-card-title';
title.innerText = model.filename;
card.appendChild(title);
title.textContent = modelCardDisplayName(model);
labels.appendChild(title);
const physicalName = document.createElement('div');
physicalName.className = 'anomalous-card-filename';
physicalName.textContent = model.filename;
physicalName.title = model.file_path || model.filename;
labels.appendChild(physicalName);
card.appendChild(labels);
card.onclick = () => {
this.recipeModelReturn = null;
+57 -2
View File
@@ -1,6 +1,6 @@
import { app } from '../../../scripts/app.js';
import { translate } from './locales.js';
import { anomalousAlert, anomalousConfirm } from './ui_dialog.js';
import { anomalousAlert, anomalousConfirm, anomalousPrompt } from './ui_dialog.js';
import {
deriveRecipeModelReferences,
formatIdentitySize,
@@ -390,7 +390,8 @@ function folderTypesForReference(reference) {
function resolutionTypesForReference(reference) {
return folderTypesForReference(reference).filter((type) => [
'checkpoints', 'unet', 'diffusion_models', 'loras', 'vae', 'controlnet',
'checkpoints', 'unet', 'diffusion_models', 'loras', 'vae', 'vae_approx',
'controlnet', 'clip', 'text_encoders', 'clip_vision',
].includes(type));
}
@@ -1835,6 +1836,35 @@ function renderRecipeParameters(content, owner, recipe, gallery, refreshGallery,
selectParameterTab?.();
};
const rename = button(row, '✏️', 'anomalous-recipe-parameter-notebook-rename');
rename.title = t('recipeParameterRename');
rename.setAttribute('aria-label', `${t('recipeParameterRename')}: ${notebookName}`);
rename.onclick = async () => {
const newName = await anomalousPrompt(t('recipeParameterRenamePrompt'), notebookName);
if (newName === null || !newName.trim() || newName.trim() === notebookName) return;
const trimmedName = newName.trim();
rename.disabled = true;
item.disabled = true;
row.classList.add('is-busy');
try {
const response = await fetch('/anomalous/rename_parameter', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: notebook.filename, name: trimmedName }),
});
const payload = await response.json();
if (!response.ok || payload.status !== 'success') throw new Error(payload.message || 'rename failed');
notebook.name = trimmedName;
await parameterState.refresh?.(true);
} catch (error) {
console.error('Could not rename parameter notebook:', error);
rename.disabled = false;
item.disabled = false;
row.classList.remove('is-busy');
await anomalousAlert(t('recipeParameterRenameError'));
}
};
const remove = button(row, '×', 'anomalous-recipe-parameter-notebook-delete');
remove.title = t('recipeParameterDelete');
remove.setAttribute('aria-label', `${t('recipeParameterDelete')}: ${notebookName}`);
@@ -1885,6 +1915,31 @@ function renderRecipeParameters(content, owner, recipe, gallery, refreshGallery,
const introHeading = document.createElement('div');
introHeading.className = 'anomalous-recipe-detail-section-heading';
appendText(introHeading, 'h4', t('recipeDetailParameters'));
if (parameterState?.selectedFilename) {
const currentNotebook = parameterState.notebooks?.find(nb => nb.filename === parameterState.selectedFilename);
const currentName = currentNotebook?.name || source?.name || t('recipeParameterUntitled');
const renameHeadingBtn = button(introHeading, '✏️ ' + t('recipeParameterRename'), 'anomalous-btn-ghost');
renameHeadingBtn.onclick = async () => {
const newName = await anomalousPrompt(t('recipeParameterRenamePrompt'), currentName);
if (newName === null || !newName.trim() || newName.trim() === currentName) return;
const trimmedName = newName.trim();
renameHeadingBtn.disabled = true;
try {
const response = await fetch('/anomalous/rename_parameter', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: parameterState.selectedFilename, name: trimmedName }),
});
const payload = await response.json();
if (!response.ok || payload.status !== 'success') throw new Error(payload.message || 'rename failed');
await parameterState.refresh?.(true);
} catch (error) {
console.error('Could not rename parameter notebook:', error);
renameHeadingBtn.disabled = false;
await anomalousAlert(t('recipeParameterRenameError'));
}
};
}
const applyButton = button(introHeading, t('recipeParameterApply'), 'anomalous-btn-primary');
const applyStatus = appendText(introHeading, 'small', '', 'anomalous-recipe-header-status');
applyButton.onclick = async () => {
+30 -9
View File
@@ -715,7 +715,13 @@ export function createDOM() {
};
const virtualSwitch = createMaterialSwitch(enableVirtualRename, (s) => enableVirtualRename = s);
const physicalSwitch = createMaterialSwitch(enablePhysicalRename, (s) => enablePhysicalRename = s);
let physicalProtectionNotice = null;
const physicalSwitch = createMaterialSwitch(enablePhysicalRename, (s) => {
enablePhysicalRename = s;
if (physicalProtectionNotice) {
physicalProtectionNotice.style.display = s ? 'block' : 'none';
}
});
const vContainer = document.createElement('div');
vContainer.style.display = 'flex';
@@ -755,6 +761,19 @@ export function createDOM() {
pContainer.appendChild(pRow);
pContainer.appendChild(pDesc);
physicalProtectionNotice = document.createElement('div');
physicalProtectionNotice.style.display = enablePhysicalRename ? 'block' : 'none';
physicalProtectionNotice.style.marginTop = '8px';
physicalProtectionNotice.style.padding = '9px 11px';
physicalProtectionNotice.style.border = '1px solid rgba(251, 188, 4, 0.45)';
physicalProtectionNotice.style.borderRadius = '6px';
physicalProtectionNotice.style.background = 'rgba(251, 188, 4, 0.08)';
physicalProtectionNotice.style.color = '#fdd663';
physicalProtectionNotice.style.fontSize = '0.8em';
physicalProtectionNotice.style.lineHeight = '1.45';
physicalProtectionNotice.textContent = t('sidebarPhysicalRenameProtectedNotice');
pContainer.appendChild(physicalProtectionNotice);
dualChannelRow.appendChild(vContainer);
dualChannelRow.appendChild(pContainer);
@@ -1152,7 +1171,7 @@ export function createDOM() {
const modelSettingsDialog = document.createElement('div');
modelSettingsDialog.className = 'anomalous-model-settings-dialog';
modelSettingsDialog.setAttribute('role', 'dialog');
modelSettingsDialog.setAttribute('aria-modal', 'true');
modelSettingsDialog.setAttribute('aria-modal', 'false');
const modelSettingsTitle = document.createElement('h2');
const modelSettingsDescription = document.createElement('p');
@@ -1218,11 +1237,15 @@ export function createDOM() {
};
refreshModelSettingsText();
const closeModelSettings = () => { modelSettingsOverlay.hidden = true; };
const setModelSettingsOpen = (isOpen) => {
modelSettingsOverlay.hidden = !isOpen;
modelSettingsDialog.setAttribute('aria-modal', String(isOpen));
};
const closeModelSettings = () => { setModelSettingsOpen(false); };
modelSettingsBtn.onclick = () => {
refreshModelSettingsText();
settingsHubModal.style.display = 'none';
modelSettingsOverlay.hidden = false;
setModelSettingsOpen(true);
videoSetting.select.focus();
};
modelSettingsClose.onclick = closeModelSettings;
@@ -1589,13 +1612,11 @@ export function createDOM() {
this.initDoctorPanel();
}
// Trigger auto hash-resolve when opening Doctor
if (window.anomalous_reload_hashes) await window.anomalous_reload_hashes();
if (window.anomalous_resolve_all_missing_nodes) {
window.anomalous_resolve_all_missing_nodes(true, false).then(() => {
if (app.graph && app.graph.extra && app.graph.extra.anomalous_hashes) {
this.renderGlobalDashboard(app.graph.extra.anomalous_hashes);
}
});
await window.anomalous_resolve_all_missing_nodes(true, false);
}
this.renderGlobalDashboard();
};
const assistantBtn = document.createElement('button');
+84 -9
View File
@@ -237,13 +237,36 @@
height: 300px;
}
.anomalous-card-title {
padding: 10px;
color: var(--fg-color, #fff);
font-size: 0.9em;
text-align: center;
word-break: break-all;
}
.anomalous-card-labels {
min-width: 0;
padding: 9px 11px 10px;
display: flex;
flex-direction: column;
gap: 4px;
text-align: center;
}
.anomalous-card-title {
color: var(--fg-color, #fff);
font-size: 0.9em;
font-weight: 650;
line-height: 1.25;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
overflow-wrap: anywhere;
}
.anomalous-card-filename {
color: rgba(255, 255, 255, 0.55);
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
font-size: 0.72em;
line-height: 1.25;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#anomalous-content {
display: flex;
@@ -374,7 +397,7 @@
background: #007bff;
}
.anomalous-split-right {
.anomalous-split-right {
width: 60%;
background: var(--bg-color, #222);
overflow: auto;
@@ -2144,6 +2167,49 @@ dialog.anomalous-gallery-viewer[open] {
overflow-y: auto;
}
.anomalous-model-location {
display: grid;
gap: 7px;
margin-bottom: 12px;
padding: 10px 12px;
border: 1px solid var(--border-color, #444);
border-radius: 7px;
background: rgba(255, 255, 255, 0.025);
}
.anomalous-model-location-row {
min-width: 0;
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
gap: 9px;
align-items: baseline;
}
.anomalous-model-location-label {
color: #9aa0a6;
font-size: 0.78em;
}
.anomalous-model-location code {
min-width: 0;
color: #d7dbe5;
font-size: 0.76em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.anomalous-model-location-copy {
justify-self: start;
padding: 4px 9px;
border: 1px solid rgba(138, 180, 248, 0.3);
border-radius: 5px;
background: rgba(138, 180, 248, 0.08);
color: #8ab4f8;
cursor: pointer;
font-size: 0.75em;
}
.anomalous-beta-notice {
display: flex;
min-width: 0;
@@ -2231,6 +2297,7 @@ dialog.anomalous-gallery-viewer[open] {
white-space: nowrap;
}
.anomalous-recipe-parameter-notebook-rename,
.anomalous-recipe-parameter-notebook-delete {
display: grid;
place-items: center;
@@ -2241,11 +2308,18 @@ dialog.anomalous-gallery-viewer[open] {
color: #9da5b1;
background: rgba(255, 255, 255, 0.025);
cursor: pointer;
font-size: 1.05rem;
font-size: 0.9rem;
line-height: 1;
transition: color 0.16s ease, border-color 0.16s ease, background 0.16s ease, transform 0.16s ease;
}
.anomalous-recipe-parameter-notebook-rename:hover,
.anomalous-recipe-parameter-notebook-rename:focus-visible {
border-color: rgba(96, 165, 250, 0.55);
color: #bfdbfe;
background: rgba(96, 165, 250, 0.12);
}
.anomalous-recipe-parameter-notebook-delete:hover,
.anomalous-recipe-parameter-notebook-delete:focus-visible {
border-color: rgba(248, 113, 113, 0.55);
@@ -2253,6 +2327,7 @@ dialog.anomalous-gallery-viewer[open] {
background: rgba(248, 113, 113, 0.12);
}
.anomalous-recipe-parameter-notebook-rename:active,
.anomalous-recipe-parameter-notebook-delete:active { transform: scale(0.92); }
.anomalous-recipe-detail-parameters {