Compare commits

...
268 Commits
Author SHA1 Message Date
DemonGatanjieuandClaude Opus 5.5 3ba16e874c fix(notebooks): make UNet notes runnable and list unlabeled models
Sending a Prompt Note with a UNet main model chained its LoRAs through
LoraLoader, whose clip input nothing could feed. UNet LoRAs now use
LoraLoaderModelOnly and the editor says to connect a text encoder.

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012RMuwbsWZHnx2eCWpxYqAB
2026-09-26 19:20:03 +00:00
DemonGatanjieu e788306291 docs(changelog): clean up version entry description in update guide 2026-09-23 22:58:07 +08:00
DemonGatanjieuandClaude Opus 5.5 eabff31c1c fix(ui): show the version in the update guide instead of the sidebar footer
The footer version sat right under the tool dock and invited misclicks.
The "!" update guide now opens with the current version and a
"Check for updates / switch version" button that closes the guide and
opens the version panel.

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeQEub5
2026-09-23 13:42:58 +00:00
DemonGatanjieu 39e99db53d docs: align README and CHANGELOG with live fixed toolbox dock layout 2026-09-23 20:55:03 +08:00
DemonGatanjieu 3768c8ec44 docs: align README with verified live browser UI structure 2026-09-23 20:48:14 +08:00
DemonGatanjieu dbb645feb5 docs: document Toolbox architecture, vector iconography, and gallery search in README and CHANGELOG 2026-09-23 19:39:16 +08:00
DemonGatanjieuandClaude Opus 5.5 e0776891c8 feat(gallery): turn search terms into visible, removable search blocks
Splitting the query on spaces hid how it was read and made phrases like
"cyberpunk city" impossible to search.

- ui_search_chips.js: Enter or a comma (also "," / "、" inserted by an IME)
  turns the typed text into a block; blocks keep their spaces and match as a
  phrase; hash-like and numeric blocks are labelled; × removes a block,
  Backspace in an empty box removes the last one, one button clears all; text
  still being typed joins the search after a short pause; IME composition
  never commits a block
- gallery_images accepts repeated `term` params (q still works)

Verified: tests/test_image_search.py (phrase blocks vs split query, 107 Python
tests in total) and a jsdom walkthrough of blocks, IME commas, backspace,
remove, clear and the request parameters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeqEub5
2026-09-23 10:00:05 +00:00
DemonGatanjieuandClaude Opus 5.5 1476fafd8a feat(gallery): search output images by prompt, model, seed and model hash
- api/image_search.py reads only the PNG text chunks before IDAT (ComfyUI
  prompt/workflow, A1111 parameters), builds one small record per image and
  caches it by mtime; all space-separated terms must match
- hex terms of 8+ characters also match SHA256 values recorded in the
  workflow (extra.anomalous_hashes) and local model files with that hash, so
  renamed models are still found; A1111 "Model hash" text matches as text
- gallery_images accepts q; the search box above the gallery debounces input,
  shows the match count, keeps paging/infinite scroll and the detail viewer on
  the filtered list, and lets a newer query supersede one still loading

On the local output (774 PNG) the first search reads ~10 MB of text chunks;
later searches use the cache.

Verified: tests/test_image_search.py (prompt, seed, AND terms, A1111, file
names, recorded and local-index hashes, cache, chunk reader), full Python suite
(106), jsdom walkthrough of the gallery search box.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeqEub5
2026-09-23 10:00:00 +00:00
DemonGatanjieuandClaude Opus 5.5 83bce45af6 fix(models): import MODEL_EXTENSIONS in model_resolution
Since 9cebdbd9 model_resolution.py used MODEL_EXTENSIONS without importing it.
/anomalous/all_hashes swallowed the NameError and always returned {}, and
resolve_hash / resolve_hash_batch (recipe model matching) failed while
collecting candidates. The all-hashes walk is now collect_model_hash_index()
so the output gallery search can reuse it; the endpoint behaves as before.

Verified: tests/test_image_search.py::ModelResolutionImportTests; on the local
library the index now returns 191 keys (176 with SHA256) instead of 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeqEub5
2026-09-23 09:59:44 +00:00
DemonGatanjieu 09b06d1dc7 fix(entry): resolve floating trigger position, boundary clamping, and refresh flicker 2026-09-21 02:02:50 +08:00
DemonGatanjieu dd1bb8639c build: remove .agents and tests directories from git tracking and ignore them 2026-09-19 20:58:23 +08:00
DemonGatanjieu 4e2110ad4b test: normalize line endings in css_bundle_order for cross-platform deterministic hashing 2026-09-19 20:52:21 +08:00
DemonGatanjieu 154c159bc0 Merge pull request #15 from DemonGatanjieu/feature/recipe-model-binding
feat(recipes, materials, studio): Recipe Studio, Material Library, and Polymorphic Drag
2026-09-19 20:50:05 +08:00
DemonGatanjieu 882db0a81c docs: update ARCHITECTURE.md reading map to include docs/audits/ 2026-09-19 20:36:06 +08:00
DemonGatanjieu 5c0a207f06 docs: track docs/audits directory and add index README 2026-09-19 20:35:45 +08:00
DemonGatanjieu d88c067183 docs: organize audit reports into docs/audits directory 2026-09-19 20:35:07 +08:00
DemonGatanjieu 7584c794e2 docs: update audit report with verification sign-off section 2026-09-19 20:32:46 +08:00
DemonGatanjieu 04b2eeff8f fix(materials, doctor, scan): polymorphic material drag, third-party prompt nodes, model resolution, and scan wizard improvements 2026-09-19 20:22:45 +08:00
DemonGatanjieu 2ff5e28ac0 docs: add E2E browser audit and bug report 2026-09-19 19:34:39 +08:00
DemonGatanjieu 0d1e25ade3 fix(recipes): harmonize and synchronize model readiness between cards and detail view 2026-09-19 19:04:07 +08:00
DemonGatanjieu 6411e591ca fix(recipes): fix duplicate hint icon, make hint dismissible, and enhance readiness pill contrast 2026-09-19 18:31:35 +08:00
DemonGatanjieu 23cb24c6fd feat(recipes): add canvas drag affordance, hint strip, and empty state onboarding blueprint 2026-09-19 18:20:57 +08:00
DemonGatanjieu 596750bd1a docs(arch): document material library UI and empty state onboarding in ARCHITECTURE.md 2026-09-19 16:12:26 +08:00
DemonGatanjieu 3e6b6e09a5 feat(materials): add empty state onboarding blueprint and canvas drag affordance 2026-09-19 16:11:43 +08:00
DemonGatanjieu bea49f08d3 feat(ui): adopt low-profile callout capsule for spotlight tour in update guide 2026-09-19 15:59:23 +08:00
DemonGatanjieu 63769026fb feat(ui): elevate spotlight tour button visibility with accent styling and breathing glow 2026-09-19 15:52:31 +08:00
DemonGatanjieu 579a8be4d3 docs(tour): refine spotlight tour wording to be concise and instructional 2026-09-19 15:41:23 +08:00
DemonGatanjieu 950990a19c feat(tour): implement interactive spotlight mask tour for key buttons and studios 2026-09-19 15:33:28 +08:00
DemonGatanjieu e4372720f8 feat(locales): emphasize material drag-to-canvas and prompt workbench assembly in update guide 2026-09-19 15:11:37 +08:00
DemonGatanjieu 8cdff4833a feat(guide): rewrite update guide to highlight milestone features over origin/main 2026-09-19 14:59:13 +08:00
DemonGatanjieu fc62523193 fix(scan): correctly unpack backend model wrapper to display factual base-model feedback 2026-09-19 14:39:43 +08:00
DemonGatanjieu 3018c17fc7 fix(scan): remove blocking alert and route model scan feedback to progress panel 2026-09-19 14:21:08 +08:00
DemonGatanjieu 25f7a10ba3 fix(scan): simplify completion feedback to be purely factual without speculative instructions 2026-09-19 14:11:13 +08:00
DemonGatanjieu f460ae916f fix(ui): correct toast text to refer to card edit button 2026-09-19 14:07:52 +08:00
DemonGatanjieu 024d0a0abd fix(ui,scan): remove misleading clickScan text and provide transparent scan feedback for non-Civitai models 2026-09-19 13:45:11 +08:00
DemonGatanjieu 14014a952c fix(sources,scan): resolve scope toggle deadlock and streamline direct model scanning 2026-09-19 13:31:22 +08:00
DemonGatanjieu 96fba93e80 fix(grid): restore card hover action buttons and one-click canvas publishing 2026-09-19 13:16:52 +08:00
DemonGatanjieu af162ef953 fix(translator): streamline compact button layout, elastic textarea flex and 460px sidebar width 2026-09-19 03:48:29 +08:00
DemonGatanjieu 79646b6d94 fix(translator): add live canvas selection sync, multi-tab graph support and Chinese widget extraction 2026-09-19 03:43:13 +08:00
DemonGatanjieu 0553f920be feat(translator): auto-load selected node prompt on open and remove read node button 2026-09-19 03:33:30 +08:00
DemonGatanjieu 35714084be fix(materials): optimize detail layout ratio and header wrapping for sidebar docked mode 2026-09-19 03:24:34 +08:00
DemonGatanjieu 1bf4946260 fix(materials): restore browse mode and shortcut 2026-09-19 03:05:23 +08:00
DemonGatanjieu b5d534df6b fix(assistant): restore model picker rendering 2026-09-19 02:50:14 +08:00
DemonGatanjieu f05c592c46 fix(recipes): optimize parameter preset layout ratio for sidebar docked mode 2026-09-19 02:42:02 +08:00
DemonGatanjieu b6acd09a1f fix(recipes): decouple modal close button to top-right and replace details with floating dropdown 2026-09-19 02:35:47 +08:00
DemonGatanjieu acbf0e39ae fix(ui): restore gallery zoom and source filtering 2026-09-19 02:19:24 +08:00
DemonGatanjieu 66fa0ae4a8 fix(sources): ignore offline Civitai sentinel links 2026-09-18 21:01:09 +08:00
DemonGatanjieu 2c5b883187 docs(plan): record gallery AI creative assistant concept 2026-09-18 19:03:39 +08:00
DemonGatanjieu 5c99c90053 docs(plan): record selected node material capture idea 2026-09-18 17:30:01 +08:00
DemonGatanjieu 2c992c6ca5 feat(sources): align library scope and fold components 2026-09-18 17:17:21 +08:00
DemonGatanjieu 2f1fd07f55 docs(plan): scope source hub folder alignment and component disclosure 2026-09-18 16:54:20 +08:00
DemonGatanjieu 1b2013fe69 fix(sources): include foundation components and distinguish missing links 2026-09-18 11:04:33 +08:00
DemonGatanjieu 200e3823d1 docs: bound source hub to links and defer multi-source enrichment 2026-09-18 10:50:34 +08:00
DemonGatanjieu e5cba40374 fix(notebooks): remove duplicated warning triangle in delete confirmation 2026-09-18 10:19:34 +08:00
DemonGatanjieu 24aea49f3b fix(notebooks): eliminate nested gallery scrollbars and replace details with floating more popover 2026-09-18 10:14:28 +08:00
DemonGatanjieu ad71dfdf71 fix(notebooks): adapt sidebar quick jump to docked mode with icon-only view and clean duplicate labels 2026-09-18 10:05:46 +08:00
DemonGatanjieu 44d2c824bc refactor(notebooks): optimize prompt notes UX with flat layout, sticky toolbar, quick jump navigation, and complete localization 2026-09-18 09:58:50 +08:00
DemonGatanjieu 4463f2406b docs: record completed structure refactor 2026-09-18 01:59:20 +08:00
DemonGatanjieu a63a615514 refactor(backend): split shared utility route families 2026-09-18 01:56:10 +08:00
DemonGatanjieu a0c6849878 refactor(materials): separate schema assets and storage 2026-09-18 01:51:00 +08:00
DemonGatanjieu 9cebdbd994 refactor(models): separate catalog recovery metadata and media 2026-09-18 01:43:09 +08:00
DemonGatanjieu 6ad975c1cd refactor(recipes): separate schema images and storage 2026-09-18 01:40:10 +08:00
DemonGatanjieu e8b50ecaf5 refactor(styles): split ordered stylesheet bundle 2026-09-18 01:36:13 +08:00
DemonGatanjieu 7c167444db docs(architecture): record remaining frontend splits 2026-09-18 01:33:57 +08:00
DemonGatanjieu 7afa7518bc refactor(notebooks): split editor and canvas creation 2026-09-18 01:32:38 +08:00
DemonGatanjieu 2951d610c5 refactor(materials): split catalog cards and detail 2026-09-18 01:30:59 +08:00
DemonGatanjieu 3e3e26d2b2 refactor(gallery): split image stage and inspector 2026-09-18 01:28:18 +08:00
DemonGatanjieu 12875f9053 refactor(detail): separate editor and model selector 2026-09-18 01:24:35 +08:00
DemonGatanjieu 3ad1eaacc5 docs(architecture): record doctor module ownership 2026-09-18 01:22:28 +08:00
DemonGatanjieu e6217bb557 refactor(doctor): separate assistant picker and presets 2026-09-18 01:20:42 +08:00
DemonGatanjieu e75beafd70 refactor(recipes): separate cards and dialogs 2026-09-17 16:59:30 +08:00
DemonGatanjieu 7f7e3cef71 docs(refactor): record recipe module split 2026-09-17 16:55:58 +08:00
DemonGatanjieu e5f3301971 refactor(recipes): extract parameter workspace 2026-09-17 16:54:53 +08:00
DemonGatanjieu 881570e887 refactor(recipes): extract overview view 2026-09-17 16:51:19 +08:00
DemonGatanjieu 56774a2d99 refactor(recipes): extract model matching and parameter utilities 2026-09-17 16:48:50 +08:00
DemonGatanjieu 413b876dfe refactor(recipes): extract catalog and media views 2026-09-17 16:45:36 +08:00
DemonGatanjieu 2978025cbe docs(refactor): record main module boundaries 2026-09-17 02:06:55 +08:00
DemonGatanjieu a984ee4a31 refactor(main): separate entry and interface settings 2026-09-17 02:06:13 +08:00
DemonGatanjieu 67d5be6277 refactor(main): extract browser composition 2026-09-17 01:59:39 +08:00
DemonGatanjieu d9ce967de7 docs: record recipe detail split progress 2026-09-17 00:59:09 +08:00
DemonGatanjieu d8691d8811 refactor(recipes): extract detail history and gallery 2026-09-17 00:58:33 +08:00
DemonGatanjieu aed9501b34 docs: record structure refactor progress 2026-09-17 00:55:08 +08:00
DemonGatanjieu 1e42033ac0 refactor(sidebar): separate settings toolbox and navigation 2026-09-17 00:53:52 +08:00
DemonGatanjieu 2a53e3edf5 refactor(sidebar): extract scan and support dialogs 2026-09-17 00:45:48 +08:00
DemonGatanjieu c39701773a docs: plan staged code structure refactor for Sol 2026-09-17 00:36:05 +08:00
DemonGatanjieu 29b14d2c71 refactor(sources): lock links by default with explicit edit toggle and dynamic save-local button 2026-09-17 00:13:14 +08:00
DemonGatanjieu 52259acd09 refactor(recipes): remove unused recipe package import button from topbar 2026-09-16 23:44:01 +08:00
DemonGatanjieu e588392e14 fix(sources): auto-resolve workflow model URLs from local metadata and add cross-folder update fallback 2026-09-16 21:58:36 +08:00
DemonGatanjieu 6d25e4aa74 style(toolbox): add 3-row grid height and enhance popover prominence 2026-09-16 21:27:14 +08:00
DemonGatanjieu d596af293c fix(notebooks): resolve TDZ ReferenceError on updateModelsSummary and empty state rendering 2026-09-16 21:18:23 +08:00
DemonGatanjieu f84379c0ae feat(notebooks): move companion models to top & reorganize editor layout 2026-09-16 21:10:25 +08:00
DemonGatanjieu be96077b3c feat(recipe-detail): default expand parameter nodes & integrate prompt notes into toolbox 2026-09-16 21:01:46 +08:00
DemonGatanjieu 174c82dcc5 fix(recipe-detail): overhaul parameter presets layout, resolve occlusion and typography bugs 2026-09-16 20:28:11 +08:00
DemonGatanjieu 143f076751 fix(ui): remove beta footer text and add popover anchor caret with smooth spring transition 2026-09-16 19:53:30 +08:00
DemonGatanjieu a165921889 style(ui): streamline toolbox modal with frameless compact 40px tiles and clean 216px footprint 2026-09-16 19:46:32 +08:00
DemonGatanjieu 9e5cd77148 fix(ui): suppress bottom action tooltips and labels completely while toolbox modal is open 2026-09-16 19:42:30 +08:00
DemonGatanjieu 05c6bd95ec style(ui): rebalance icon hierarchy with 20px bottom toolbar and 14.5px compact toolbox cards 2026-09-16 19:03:45 +08:00
DemonGatanjieu 35f02a1122 feat(ui): optimize toolbox proportions with balanced 200px height, expansion placeholders, and click text suppression 2026-09-16 18:55:11 +08:00
DemonGatanjieu 72212f1e6a style(ui): scale down toolbox modal, tiles and icons for compact visual harmony with bottom bar 2026-09-16 18:48:42 +08:00
DemonGatanjieu cd1ea0ee2e feat(ui): redesign toolbox into spacious 3-column grid with moderate icons and 9-item capacity 2026-09-16 18:44:24 +08:00
DemonGatanjieu 0d8d163081 feat(ui): restore colorful emoji icons and expand toolbox layout into spacious 2x2 grid 2026-09-16 18:39:08 +08:00
DemonGatanjieu e78b46ba36 fix(ui): revert toolbox to native icons, disable drag-and-drop, and exclude outside tools 2026-09-16 18:26:56 +08:00
DemonGatanjieu 9b4b87a879 fix(ui): eliminate sidebar action button hover flicker and stabilize pointer hit-testing 2026-09-16 14:44:32 +08:00
DemonGatanjieu e370bedf02 feat: implement customizable shortcut bar and toolbox drag-and-drop reorganization 2026-09-16 13:02:47 +08:00
DemonGatanjieu 0fe1827f3f docs: consolidate frontend design brief and Gemini handoff 2026-09-16 12:46:52 +08:00
DemonGatanjieu 3f71ddfcb7 docs(plan): define customizable toolbox shortcuts and hover timing
Specify pinning and removal semantics, fixed anchors, drag cancellation, accessible alternatives, tooltip timing and bounds, state ownership, persistence and staged validation. Planning only; runtime behavior is unchanged.
2026-09-16 11:52:27 +08:00
DemonGatanjieu 77ae7ce888 fix(ui): keep update guide manual and clean sidebar styles
Keep a persistent header guide button, restore upward label transitions, remove duplicate CSS and obsolete notice events, and move replay styling to CSS.

Document proportional UX checks and interruption recovery checkpoints. Validated update_guide.mjs, 48-module parse/link check, diff check, and live ComfyUI manual open, Escape, replay, Help and sidebar keyboard labels. No backend behavior changed; mobile and alternate themes were not exercised.
2026-09-16 11:27:19 +08:00
DemonGatanjieu 705cdf0a7f fix(ui): widen sidebar tooltips to prevent vertical wrapping and switch update guide to clean monochrome 2026-09-16 11:03:43 +08:00
DemonGatanjieu 476c34e79d fix(ui): wire sidebar action hover labels, unclip tooltips, and polish update guide modal 2026-09-16 10:54:27 +08:00
DemonGatanjieu fe27e7f2e3 feat(guide): replace automatic popup with restrained top-right amber notice badge 2026-09-16 10:36:20 +08:00
DemonGatanjieu 276ad766a6 fix(transfers): keep toolbox share center and pause recipe imports 2026-09-16 10:03:54 +08:00
DemonGatanjieu 0f592dc77f feat(toolbox): lock panel to original dimensions while removing unused buttons 2026-09-15 21:59:14 +08:00
DemonGatanjieu e052a7ae39 revert(toolbox): restore toolbox tools layout and original panel dimensions 2026-09-15 21:47:30 +08:00
DemonGatanjieu bf2764239f feat(toolbox): clean up unlit placeholder tools and refine source hub icon 2026-09-15 21:39:17 +08:00
DemonGatanjieu b37204fd68 fix(exports): pause workflow share codes across library entry points 2026-09-15 20:57:55 +08:00
DemonGatanjieu 8bd3130fe7 fix(exports): remove prompt JSON export and pause recipe packages 2026-09-15 20:50:58 +08:00
DemonGatanjieu 45958356f1 feat(toolbox): add unified model source hub with workflow sync and note node generation 2026-09-15 16:17:52 +08:00
DemonGatanjieu 02f0230c08 fix(prompt-studio): refine popover safe-zone corridor and blank-space dismiss guards 2026-09-15 14:27:04 +08:00
DemonGatanjieu d664826093 style(prompt-studio): prevent role and category badges from wrapping 2026-09-15 11:09:20 +08:00
DemonGatanjieu a4b34100de feat(prompt-studio): unify positive/negative indicators and auto-tail cross-role blocks with tags 2026-09-15 11:03:50 +08:00
DemonGatanjieu 43c994cc34 fix(prompt-studio): implement global pointer safe zone to prevent popover closing during transit to buttons 2026-09-15 10:44:08 +08:00
DemonGatanjieu fd16be87ce fix(prompt-studio): resolve intermittent popover disappearance with 100ms open-debounce, 380ms buffer, 28px bridge and pin support 2026-09-15 10:13:12 +08:00
DemonGatanjieu 109565fa00 fix(prompt-studio): enable interactive card preview popover with 220ms debounce bridge and copy/add actions 2026-09-15 10:06:13 +08:00
DemonGatanjieu 5ecdef15e2 fix(prompt-studio): ensure more dropdown is hidden by default and correctly toggles via .is-open class 2026-09-15 09:52:23 +08:00
DemonGatanjieu 8c0b65385c fix(prompt-studio): resolve stacking context z-index bug causing workbench buttons to pierce and obscure more dropdown menu 2026-09-15 09:49:48 +08:00
DemonGatanjieu 04182578dc Add development rules and Gemini reading entry 2026-09-15 09:38:49 +08:00
DemonGatanjieu de02f5dd81 refactor: split prompt studio views and unify UI lifecycle 2026-09-15 09:32:18 +08:00
DemonGatanjieu 6dd7f94c0e feat(doctor,materials): overhaul parameter schemes, hover popover, explicit apply buttons, gallery naming, and drag text previews 2026-09-14 22:42:14 +08:00
DemonGatanjieu 5ce15dfe30 style(assistant): auto-collapse folder sidebar on node assistant to maximize workspace 2026-09-14 22:13:13 +08:00
DemonGatanjieu 9f653a30c3 style(prompt-studio): replace native browser title tooltip with eye-catching obsidian glass card preview popover and category borders 2026-09-14 21:53:01 +08:00
DemonGatanjieu a9c4e3d961 fix(prompt-studio): suppress global trigger button overlaying studio drawer 2026-09-14 21:46:50 +08:00
DemonGatanjieu 96c340b79b style(prompt-studio): default to left dock, auto-orient track facing canvas, widen cards column to 190px and eliminate topbar wrapping 2026-09-14 21:44:05 +08:00
DemonGatanjieu e1f8bf39d9 feat(prompt-studio): redesign Prompt Studio as minimalist sidebar drawer with expanded assembly track, interactive weight wheel, zero-height floating dock, and popout inspector modal 2026-09-14 21:34:52 +08:00
DemonGatanjieu 3df69ba7f4 fix(translator): streamline header layout to guarantee always-visible close button and move language selector to mid-bar 2026-09-14 14:29:19 +08:00
DemonGatanjieu fafad3de75 fix(translator): add canvas focus mode auto-collapse, dock-side toggle, and fix header layout compression 2026-09-14 14:18:12 +08:00
DemonGatanjieu 4e6e1050b4 feat(translator): add dedicated sidebar drawer mode with canvas passthrough and resizable width 2026-09-14 11:23:59 +08:00
DemonGatanjieu 46a67c7760 feat(translator): add direct source write-back, swap button, and translate-to-EN-and-write node integration 2026-09-14 11:12:28 +08:00
DemonGatanjieu abc34f3f8b fix(translator): support Chinese enumeration marks in tag splitting and add tag normalization 2026-09-14 10:37:27 +08:00
DemonGatanjieu 1294e30e3c fix(translate): resolve Google Translate 429 rate limit with dict-chrome-ex/mymemory fallback and fix false-positive error caching 2026-09-14 10:24:39 +08:00
DemonGatanjieu 2b0393723d feat(recipes-translation): decouple workflow recipes from dual-tabs and integrate atomic prompt translation 2026-09-14 10:15:26 +08:00
DemonGatanjieu db09ef5dcc feat(prompt-studio): enhance card tactile affordance and auto-snap drop dock 2026-09-12 23:03:40 +08:00
DemonGatanjieu dfa208b44c refactor(prompt-studio): redesign sidebar mode with unified dual-zone flow and streamlined controls 2026-09-12 17:25:51 +08:00
DemonGatanjieu ad14a596e5 refactor(materials): completely remove prompt studio from material library 2026-09-11 23:45:15 +08:00
DemonGatanjieu f7e3348e3d refactor(prompt-studio): reclaim vertical workspace and display final assembled text on-demand 2026-09-11 22:50:36 +08:00
DemonGatanjieu 0a0f2f6bcf style(prompt-studio): strip gaudy effects and adapt to docked sidebar mode 2026-09-11 22:31:46 +08:00
DemonGatanjieu e568e94fac feat(toolbox): integrate Prompt Studio into toolbox palette with ready status and direct invocation 2026-09-11 22:12:43 +08:00
DemonGatanjieu fcb1d84e7f fix(ui): eliminate ugly scrollbars and prevent overflow in toolbox modal 2026-09-11 21:57:22 +08:00
DemonGatanjieu c2f04e3cc9 feat(ui): rename workflows tab, restore card action buttons, and redesign toolbox as bottom-left icon grid 2026-09-11 21:54:03 +08:00
DemonGatanjieu f1edb2163e fix(ui): keep workspace overlay above model sidebar 2026-09-11 21:32:48 +08:00
DemonGatanjieu 3142dace09 feat(ui): implement extensible toolbox hub panel and sidebar action entry 2026-09-11 21:21:30 +08:00
DemonGatanjieu 770822a5b6 fix(ui): normalize settings hub help icon to 13px inline glyph and replace confusing ruler with reset arrow 2026-09-11 21:05:55 +08:00
DemonGatanjieu 8ffa222128 style(ui): harmonize header navigation tabs with 28px height, 13px icons, and persistent text labels 2026-09-11 17:59:48 +08:00
DemonGatanjieu 44aa09e67c fix(ui): replace cryptic nested-hexagon icon with clean 3D isometric model asset cube 2026-09-11 17:46:27 +08:00
DemonGatanjieu 3798d9f9b4 feat(ui): elevate compact layout to primary default with 48px slim industrial header 2026-09-11 17:39:39 +08:00
DemonGatanjieu bd4abead45 style(ui): eliminate cool-blue cast and switch to neutral dark charcoal obsidian palette 2026-09-11 01:01:43 +08:00
DemonGatanjieu 31d8b3737c feat(ui): implement restrained design language with standard, compact, and aesthetic modes 2026-09-11 00:45:57 +08:00
DemonGatanjieu 6eaf4fce05 fix(ui): mount settings hub to container to break stacking context trap 2026-09-11 00:28:23 +08:00
DemonGatanjieu 8464b7c983 fix(ui): resolve settings hub clip, isolate doctor/assistant backdrops, and refine restrained iconography 2026-09-11 00:24:02 +08:00
DemonGatanjieu f3985005a4 style(ui): redesign bottom sidebar action icons to be vivid and intuitive 2026-09-11 00:00:32 +08:00
DemonGatanjieu 1b985baf29 style(ui): redesign floating entry trigger icon with Anomalous Hypercube Core and frosted glass orb 2026-09-10 23:52:24 +08:00
DemonGatanjieu 852d9e19ed style(ui): unify sidebar and header with full-window anime archive frosted glass blend 2026-09-10 23:35:18 +08:00
DemonGatanjieu 93bb46cef9 style(ui): make anime archive background vivid and clear with runic blueprints and transparent grid 2026-09-10 23:27:46 +08:00
DemonGatanjieu 896cd3d20d fix(ui): restore recipe cards and remove decorative control glows 2026-09-10 23:14:22 +08:00
DemonGatanjieu 731cf89101 style(ui): unify design tokens, overhaul 3-tier button system, add anime archive library backdrop, and streamline workbench layout 2026-09-10 23:05:21 +08:00
DemonGatanjieu ffa6f56d08 feat(theme): rework dark matter watermark in normal mode and velvet inner shadows in abyssal mode 2026-09-10 22:31:51 +08:00
DemonGatanjieu a36c8dfc50 fix(theme): isolate abyssal scarlet easter egg and restore clean neutral dark normal mode 2026-09-10 21:39:37 +08:00
DemonGatanjieu 4b283565f6 fix(ui): resolve header click swallow, eliminate cyan badges for gothic palette, and add ambient illumination 2026-09-10 21:16:44 +08:00
DemonGatanjieu ea64877bb9 fix(ui): emergency remediation - restore dock button, tighten glow to 2-4px, add backdrop fallbacks, and purge cold slate tones 2026-09-10 20:56:00 +08:00
DemonGatanjieu 79ac441585 style(ui): refine tooltips, condense button glows, upgrade concept backdrops, and unify button geometry 2026-09-10 20:36:55 +08:00
DemonGatanjieu 38c75f3fc0 fix(core): repair syntax error in ui_doctor.js and restore floating trigger button presentation 2026-09-10 19:50:31 +08:00
DemonGatanjieu fc40dead3d fix(ui): visual correction audit - return to gothic vampire aesthetics, sweep emojis, center header, and fix tooltip overlap 2026-09-10 19:06:44 +08:00
DemonGatanjieu b72a7a5a5f feat(ui): implement global design system with semantic colors, island sidebar, and hover reveal 2026-09-10 18:06:03 +08:00
DemonGatanjieu aa3b49a3ee feat(ui): implement Task 9 master container geometric chamfers, accent energy dividers, empty space watermarks, and micro-floating tooltips 2026-09-10 17:50:10 +08:00
DemonGatanjieu 0628183c74 feat(ui): overhaul iconography with pure-code vector SVGs and break boxy layouts with asymmetric chamfers and depth shadows 2026-09-10 17:15:45 +08:00
DemonGatanjieu 8a9a91b3d2 fix(ui): distinguish scan wizard from reset layout with vector radar scanner and distinct layout icon 2026-09-10 16:50:14 +08:00
DemonGatanjieu 2370ad86b4 style(ui): overhaul button morphology with asymmetric chamfers, micro-rune glyphs, dynamic inflow hover bars, and satin glow strokes 2026-09-10 16:35:32 +08:00
DemonGatanjieu 2c7a79885d feat(ui): introduce Abyssal Scarlet anime gothic theme with domain expansion easter egg and corruption animation 2026-09-10 16:20:49 +08:00
DemonGatanjieu 1c2df86a90 style(ui): elevate core action buttons and active pills with AI micro-texture underlays 2026-09-10 03:14:56 +08:00
DemonGatanjieu 1213a5e825 feat: integrate AI concept art, cyber atmospheric backgrounds, and default blind-box thumbnails 2026-09-10 02:51:05 +08:00
DemonGatanjieu ae7a5bd1c8 style: overhaul frontend presentation and DOM rendering across prompt studio, recipes and notebooks 2026-09-10 02:31:30 +08:00
DemonGatanjieu 462f8287d3 fix(prompts): resolve 9 workbench defects, ensure lossless roundtrip, card persistence and role isolation 2026-09-10 01:44:27 +08:00
DemonGatanjieu e2d4387de7 feat(prompt-workbench): expand right mixer stage, compact lego cards, and clarify import label 2026-09-10 00:41:06 +08:00
DemonGatanjieu 9f307b06d4 feat(prompt-workbench): add one-click node prompt extraction and material library sync 2026-09-10 00:28:04 +08:00
DemonGatanjieu 828c4d5488 feat(prompt-workbench): implement dual-column prompt studio with source cards library and drag-to-assemble stage 2026-09-10 00:23:01 +08:00
DemonGatanjieu 888330de6a fix(ui-prompt-composer): resolve multiline regex syntax error restoring extension entry icon 2026-09-10 00:15:19 +08:00
DemonGatanjieu 200e6e4d6b feat(prompt-mixer): deliver Prompt Mixer Deck with Lego block reordering, smart sort and canvas drag 2026-09-09 23:43:26 +08:00
DemonGatanjieu 07c1bbe89c fix(studio): connect filters and prompt import contracts 2026-09-09 23:25:08 +08:00
DemonGatanjieu af2e223dc1 feat(recipe-detail): transform prompt cards into full-width streamlined decks, remove awkward buttons, and add active pulse dot for presets 2026-09-09 23:08:51 +08:00
DemonGatanjieu d86032ff58 feat(recipe-detail): overhaul internal studio layout, hide redundant topbar, replace bulky copy button with micro-copy, and redesign parameters into modern Bento matrix 2026-09-09 22:58:24 +08:00
DemonGatanjieu 00c884b517 fix(recipes): add card-level error isolation and document lesson 65 2026-09-09 22:38:43 +08:00
DemonGatanjieu dac700337a fix(recipes): import bindMaterialDrag in ui_recipes.js to fix card rendering error 2026-09-09 22:36:51 +08:00
DemonGatanjieu b4b86eb572 feat(recipes): overhaul studio topbar, uniform bento cards with readiness indicator and canvas drag-drop 2026-09-09 22:33:19 +08:00
DemonGatanjieu 0f24498704 feat(materials): studio topbar with split prompt studio and uniform grid/list views 2026-09-09 22:10:36 +08:00
DemonGatanjieu ea8284b482 fix(ui): replace ugly pink toolbox emoji with elegant vector asset cube SVG across sidebar and materials 2026-09-09 21:58:46 +08:00
DemonGatanjieu 39a7d7094a feat(ui): redesign prompt composer with paper-like note cards, active drag, canvas reveal, and material import drawer 2026-09-09 21:53:28 +08:00
DemonGatanjieu 2c51befe66 fix(materials): separate library entry and simplify canvas drops 2026-09-09 21:40:04 +08:00
DemonGatanjieu 975863bf0f feat(materials): integrate node application and prompt combinations 2026-09-09 11:13:24 +08:00
DemonGatanjieu f0079ad457 fix(ui): relocate node selection checkbox to row-start with dark-mode styling 2026-09-09 00:22:35 +08:00
DemonGatanjieu ed3f1774b3 perf(frontend): improve card differentiation, add inline title editing, and optimize CSS transitions/blurs 2026-09-09 00:16:55 +08:00
DemonGatanjieu 9e2bdbe8cb Use ComfyUI node titles on material cards 2026-09-09 00:00:58 +08:00
DemonGatanjieu 8f8063615d style(gallery): redesign parameter inspect button, workbench actions and seed copy interaction 2026-09-08 23:51:54 +08:00
DemonGatanjieu 393c4e9ade Connect prompt notes to materials and simplify capture pages 2026-09-08 21:36:40 +08:00
DemonGatanjieu 01dfa18d12 Reduce material detail rendering and payloads 2026-09-07 23:20:48 +08:00
DemonGatanjieu f8d159dc11 Improve material library browsing and reuse 2026-09-07 22:47:27 +08:00
DemonGatanjieu a7104fa507 Cache material summaries and output gallery listings 2026-09-07 15:17:15 +08:00
DemonGatanjieu 346860ff00 Make notebook and material persistence recoverable 2026-09-07 15:11:53 +08:00
DemonGatanjieu ef9c8b4519 Fix full-file model identity evidence 2026-09-07 15:07:17 +08:00
DemonGatanjieu a6d2705b9d feat(materials): refactor material library for card-click navigation and default-expanded prompt cards 2026-09-04 18:49:17 +08:00
DemonGatanjieu b6adb0a0bc refactor(materials): add focused library inspector 2026-09-04 15:57:49 +08:00
DemonGatanjieu 585b751c57 feat(materials): expand saved parameter details 2026-09-04 15:37:13 +08:00
DemonGatanjieu 6bdb1e072f refactor(materials): save nodes from their cards 2026-09-04 15:11:35 +08:00
DemonGatanjieu f6311ff8f1 refactor(workbench): standardize on windowed modal design and remove fullscreen mode 2026-09-04 14:59:14 +08:00
DemonGatanjieu 809c6124b7 feat(workbench): expand prompt cards by default with collapsible toggle 2026-09-04 14:50:28 +08:00
DemonGatanjieu 3c29cc0142 style(workbench): move filmstrip to left rail and enlarge thumbnail cards 2026-09-04 14:44:11 +08:00
DemonGatanjieu 57b480da79 fix(ui_gallery_detail): remove duplicate clipboard emoji from copy prompt button 2026-09-04 14:31:39 +08:00
DemonGatanjieu 43d7901d0d feat(materials): save selected gallery nodes efficiently 2026-09-04 14:15:33 +08:00
DemonGatanjieu 9b124333c0 feat(gallery): refactor image detail into fullscreen studio workbench with multi-dimensional navigation and bento inspector 2026-09-04 13:58:17 +08:00
DemonGatanjieu 628cacfbe9 feat(materials): expose detailed gallery node parameters 2026-09-03 23:57:41 +08:00
DemonGatanjieu 35d6abfa48 feat(materials): uncap expansion limits, wrap resolution metrics, and categorize models by role with LoRA strength details 2026-09-03 23:46:57 +08:00
DemonGatanjieu e6088fc2a1 refactor(materials): upgrade image snapshot inspector to wide studio modal with direct PNG workflow parser and dedicated scroll container 2026-09-03 22:42:06 +08:00
DemonGatanjieu cbb6b9adb5 feat(materials): introduce progressive disclosure for image material details with metric tiles and collapsible model/node accordions 2026-09-03 22:26:51 +08:00
DemonGatanjieu b357380fa1 feat(gallery): expose material snapshot details 2026-09-03 22:16:40 +08:00
DemonGatanjieu b55a23b529 feat(materials): add image workflow snapshot library 2026-09-03 22:11:25 +08:00
DemonGatanjieu 932555c448 fix(ui): remove duplicate summary::before gear icon and add missing recipeRawNodesCount localization string 2026-09-03 20:42:28 +08:00
DemonGatanjieu 54e8e1f6cf style(dark-mode): tone down bright saturated colors to clean monochromatic slate for eye-comfort 2026-09-03 20:39:40 +08:00
DemonGatanjieu 6f6cd6a4fc style(recipe-presets): eliminate raw bootstrap-style buttons, fix broken card layout, and replace awkward hero image with sleek compact gallery deck 2026-09-03 20:34:09 +08:00
DemonGatanjieu 1fde428db1 feat(recipe-studio): overhaul recipe detail to modern studio layout with plain language and zero-overhead performance 2026-09-03 20:27:44 +08:00
DemonGatanjieu 81463e70a8 fix(recipe-detail): fix ReferenceError on resDisplay breaking parameter notebook tab 2026-09-03 20:19:16 +08:00
DemonGatanjieu 69afcb834f fix(recipe-ui): fix display none conflict on recipe list and resolve resolution object display 2026-09-03 18:48:35 +08:00
DemonGatanjieu 7c8876f08c feat(recipe-ui): redesign workflow recipes with bento cards, health indicators, and lazy-loading 2026-09-03 18:43:01 +08:00
DemonGatanjieu 61c0918498 checkpoint: save recipe model lifecycle and provenance baseline 2026-09-03 18:34:12 +08:00
DemonGatanjieu 780f60af35 Add scope-aware recipes and model verification 2026-09-03 16:55:11 +08:00
DemonGatanjieu b106111430 Fix canvas focus after browser close 2026-09-03 16:07:55 +08:00
DemonGatanjieu 6eec3b82c3 Split architecture documentation 2026-08-27 16:14:06 +08:00
DemonGatanjieu 039d982316 Fix live language switching 2026-08-27 14:34:33 +08:00
DemonGatanjieu 7c94cb9fea Add shortcut control to interface settings 2026-08-26 21:36:27 +08:00
DemonGatanjieu 4b828e8cb4 Add configurable browser shortcut 2026-08-26 21:07:49 +08:00
DemonGatanjieu e7719a76e6 feat(ui): refine native interface preferences 2026-08-26 18:51:33 +08:00
DemonGatanjieu cd813c778a fix(ui): make browser entry modes exclusive 2026-08-26 13:24:34 +08:00
DemonGatanjieu cbd18181fc feat(ui): customize model browser entries 2026-08-26 13:12:41 +08:00
DemonGatanjieu ec308da9e9 feat(ui): add optional floating browser entry 2026-08-26 12:07:04 +08:00
DemonGatanjieu 141b5f8271 Fix interrupted scans and show progress 2026-08-25 22:29:02 +08:00
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
171 changed files with 50517 additions and 15383 deletions
+12 -1
View File
@@ -24,6 +24,10 @@ temp/
*.pid
*.tmp
*.temp
*.scan_in_progress
*.scan_progress.json
.global_scan_in_progress
.global_scan_progress.json
*.bak
*.backup
*.sqlite
@@ -52,7 +56,13 @@ api/notebooks/
# AI Dev Scripts & Temporary text dumps
scripts/
doctor_code.txt
docs/
docs/*
!docs/architecture/
!docs/architecture/*.md
!docs/decisions/
!docs/decisions/*.md
!docs/audits/
!docs/audits/*.md
AI_SYSTEM_TEMPLATE.md
# AI Agents Directory
@@ -66,3 +76,4 @@ tests/
# Local diagnostic/test artifacts
grep_doctor.txt
test_folders.json
*.bat
+117
View File
@@ -0,0 +1,117 @@
# 开发与维护规范
适用于本插件的所有修改,包括 Gemini、Codex 和人工维护。目标是让 UX 持续改善,同时让代码职责、状态来源和验证方式保持清楚。
用户明确的当前要求优先;确需偏离本规范时,采用最小例外,在交付说明中写明原因、影响和验证。常规实现选择自行处理,不因本规范增加无必要的确认步骤。
## 1. 修改前先定位责任
1. 查看 `git status` 和相关差异,保留已有工作;不覆盖、回退或提交他人的无关修改。
2. 阅读 [ARCHITECTURE.md](ARCHITECTURE.md),再按阅读地图打开与任务有关的专题,避免每次加载全部文档。
3. 搜索目标函数的调用方、相关状态、CSS 选择器及已有辅助函数,再确定修改位置。
4. 简短说明本次行为变化和负责它的模块。一个修复优先落在能解释问题的最小责任范围内。
UX 请求默认允许调整布局、交互和文案,不代表允许改变文件存储位置、接口协议、工作流语义、模型识别依据或用户数据。
### 小型 UX 修改快速流程
仅调整文案、图标、颜色、间距或局部交互时:查看相关差异 → 定位负责规则 → 最小修改 → 验证受影响界面 → 本地提交。
同一任务已读过且未变化的规范不反复通读;只读相关专题,不做全仓审查,不顺手重构。
按第 7 节选择相关检查,不默认运行全部 JS/Python 测试。检查通过后,除非又改了代码或出现新问题,不重复运行。
没有改变职责、接口或持久化契约时,不更新架构文档或新增经验记录。提交说明由 AI 根据实际差异编写,无需用户代写。
### 任务中断后的接续与快照
- 开工先检查状态和差异。工作区干净时直接继续,不制造空提交。
- 上次因额度或其他原因中断,存在属于该任务的改动时,先确认文件范围,再选择性暂存这些文件;不得使用无差别的 `git add .` 收入用户数据、凭据、临时文件或无关工作。
- 已完成且有验证记录的改动可补正式提交;尚未验证或未完成的改动可先保存 `chore(checkpoint): <实际改动摘要>` 恢复快照,提交正文明确“未完成/验证待完成”、已做检查和下一步。快照是恢复点,不代表验收通过。
- 后续修改和验证另作正式提交,不自动 amend、reset 或改写快照历史。若无法区分遗留文件归属,保留原状并说明,不猜测打包。
- 长任务按可独立验证的完整阶段提交,避免把所有 Git 收尾留到最后。测试范围内检查完成后及时提交,不为额外润色拖延快照。
- 上述快照和正式提交均只保存在本地,未经明确授权不推送。
## 2. 模块职责与规模
- `web/main.js` 负责注册和接线;`ui_sidebar.js` 的 `createDOM` 负责外壳装配。新工具的完整实现放在自己的模块,不继续塞进这两个入口。
- 界面模块负责交互和渲染;组合、转换、校验等纯逻辑放在对应领域模块。底层模块不能反向导入上层界面,也不能读取 DOM 或选中节点来暗中决定业务行为。
- 一个模块应有可用一句话解释的主要职责。按“谁拥有状态、谁负责关闭、谁发起请求”拆分,不按随意的行号切割。
- 复用现有 `ui_lifecycle.js`、`ui_dom.js`、`translation_service.js`、`node_material_actions.js` 等接口。不要为同一行为再复制一套实现。
- 通用文件不能成为杂物箱。不要为了省一个文件,把新领域逻辑塞进 `utils`、素材详情渲染器或浏览器主对象。
- 不引入没有当前调用方的管理器、适配层、配置项、事件总线或依赖。一次性的简单逻辑可以留在其责任模块内。
**规模审查阈值:** 普通逻辑文件超过约 600 行,或单函数超过约 100 行时,新增功能前必须检查能否提取独立职责。继续扩展时,在交付说明中解释为什么仍属于同一职责。已有超大模块的小修复不要求顺手重写全文件;语言目录、数据表和样式表不按这个行数阈值拆分。不能通过空壳包装、压缩代码或碎片化文件绕过审查。
## 3. 状态、关闭与异步
- 每份可变状态只有一个权威来源。预览、计数、序列化结果从它派生,不维护几份会互相覆盖的数据。
- 子视图状态默认留在子视图。跨界面确需保留的状态才放到 `owner`;新增共享字段时说明拥有者、初始化、重置和释放时机。
- 父子视图传容器、必要数据和明确回调,不让子模块随意修改父视图的私有 DOM 和字段。
- 新建临时窗口优先使用 `createViewScope()`。按钮、Esc、遮罩、替换窗口、父窗口关闭必须走同一个清理入口;重复关闭应安全。
- 视图创建的全局事件、观察器、定时任务、拖拽监听和临时预览都要有对应释放路径。禁止仅 `element.remove()` 就当作关闭完成。
- 拖拽中关闭也必须释放鼠标监听,恢复此前的 cursor 和 user-select。子窗口消费 Esc 时,不能顺带关闭父窗口。
- 视图读取请求使用其生命周期的 `AbortSignal`;重新查询时取消或隔离旧请求。请求返回后检查当前视图和请求身份,不能让旧结果覆盖新内容。
- 异步写入画布前捕获原 graph/node/widget/value,返回后复核身份及输入;不能在返回后重新找“当前选中节点”然后直接写。
- 已发出的保存可能在窗口关闭后完成。不要假称取消了服务端写入,也不要让完成回调重新打开或修改已关闭的界面。
提示词工坊的具体状态约定见 [frontend.md](docs/architecture/frontend.md)。不要恢复已删除的嵌入式工坊、旧导入抽屉或第二套草稿来源。
## 4. CSS 和 UX 迭代
- 改样式前搜索同名选择器及主题、响应式覆盖,修改实际负责该属性的规则。禁止每轮都在文件末尾追加“最终修复 / override v2”覆盖包。
- 变更后删除本次取代的重复声明、失效选择器和旧布局分支;不能仅依靠更高 specificity 让旧实现暂时失效。
- 优先使用现有 `--amb-*` 设计变量。新增变量必须有实际用途和清楚的归属,不为每个常量包装一层变量。
- 不新增无解释的 `!important`。确因宿主样式或既有层叠边界需要时,在附近注释说明冲突对象与原因,并验证普通模式和受影响主题。
- 静态布局和外观放在 CSS。行内样式用于实际运行时值,例如拖动宽度、定位和数据决定的颜色,不用 JavaScript 重写整套固定样式。
- 保持插件选择器作用域,避免影响 ComfyUI 的其他按钮、弹窗和输入框。
- 添加功能时检查窄宽度、长中文、空列表、加载中、失败和禁用状态,以及焦点、键盘访问和嵌套窗口层级。
- 不为了视觉调整改变按钮的写入、覆盖、删除含义。成功提示必须来自真实成功结果,失败和取消不能显示“已应用”。
## 5. 文案、数据与文件
- 新增固定界面文案放入 `locales.js`,同步中英文;不继续增加散落的 `lang === 'zh' ? ... : ...` 分支。
- 用户输入、文件名、模型名和元数据通过 `textContent` 或已验证的转义工具进入 DOM;不要拼进未转义的 `innerHTML`。
- 列表摘要不能当作详情。正文通过对应详情接口读取;分页、去重、空结果和取消语义必须明确。
- 路径与文件名在后端使用现有边界校验;不能只检查 `..`。前端校验不能替代后端校验。
- 修改模型、封面、附属文件或导入包前先完整校验和暂存。失败时保留旧数据,避免先删除旧文件再尝试保存新文件。
- 修改多文件发布流程要覆盖中途失败和冲突。单个 JSON 的原子写入不能冒充整组文件的事务。
- 大目录遍历、哈希和大量磁盘 I/O 使用已有工作线程/进程路径,不阻塞 aiohttp 事件循环。
- UI 适配不能偷偷迁移用户数据、修改全局配置或改变现有路由与存储格式。确需改协议时同步调用方、兼容策略和测试。
## 6. 清理旧实现
- 替换一条路径时搜索它的导入、导出、调用、字段、事件和选择器;确认无用后一起移除。
- 不保留注释掉的大段旧代码、`EXTRACTED` 占位、无人使用的备选实现或“以后可能需要”的分支。
- 拆模块后检查移动函数引用的闭包变量、导入、回调参数和返回值。语法通过不代表按钮点击不会出现未定义变量。
- 不保留两个相互覆盖的 `onclick` 或两条完成同一请求的路径;让所有入口调用同一个实现。
- 不用空 `catch`、伪造成功值或任意 optional chaining 掩盖结构错误。可选宿主能力才允许局部降级,并给出准确结果。
## 7. 按风险验证
从插件根目录运行相关检查。选择与变化相称的验证,不为纯文案、文档或低风险样式改动制造镜像测试。
| 修改范围 | 至少验证 |
| --- | --- |
| 导入、导出、模块拆分 | JS 语法、模块链接,以及受影响按钮的真实执行路径 |
| 工坊状态与生命周期 | `node --experimental-vm-modules tests/prompt_ui_lifecycle.mjs` |
| 词卡读取、分页 | `node tests/prompt_material_source.mjs` |
| 翻译桥接 | `node tests/translation_service_contracts.mjs`,异步节点写入另跑工坊生命周期测试 |
| 素材应用、笔记隔离 | `node --experimental-vm-modules tests/material_workspace_flow.mjs` |
| 素材提示词协议、配方筛选、拖拽清理 | `node --experimental-vm-modules tests/studio_contracts.mjs` |
| CSS、布局、交互 | 实际浏览器验证受影响状态;涉及宿主行为时验证 ComfyUI |
| 文件修改、导入、存储 | 对应 Python 测试,至少覆盖成功、冲突和中途失败保留旧数据 |
| 仅文档 | 检查链接、命令、实际代码一致性和 Git 差异 |
- 模拟测试尽量加载实际模块,只替换外部系统边界;不要把待验证的业务函数也 mock 成成功。
- 修复故障时,优先补充能复现原故障的行为测试。不能只通过源码字符串“存在某段代码”来证明修复。
- UX 明确替换旧交互时,更新测试到新的用户流程,并保留原本要保护的行为;不要仅删除失败断言或重新塞回废弃功能。
- 新增应随仓库交付的回归测试要检查 `.gitignore`,确认确实进入版本控制。
- 历史本地 Python 测试可能不在 Git 中。使用合适环境运行相关测试,例如 `python -m unittest discover -s tests -p 'test_*.py'`,核对实际发现的数量;零项测试不算通过。
- 无法运行真实宿主、网络服务或完整测试时,准确说明缺口;不要将模拟画布、语法检查或已有通过数量称为本轮宿主验证。
## 8. 文档与交付
- 架构文档只记录职责、数据流、契约和关键约束。颜色、像素值及审美描述以 CSS 为准,不每次 UX 更新都往架构入口追加长篇设计说明。
- 改动架构时更新最窄的相关专题。修改规范时以本文件为唯一正文,`GEMINI.md` 只保留阅读入口,避免两套要求漂移。
- 完成前审查差异、死代码、临时文件和空白错误;保留现有文件编码及换行风格。
- 验证后按仓库约定形成一个连贯的本地提交,只包含本任务文件;没有明确授权不推送 GitHub。
- 交付说明包含:改了什么与原因、验证结果与限制、剩余问题、本地提交。仅在触发规模阈值或规则例外时额外解释取舍。
**交付自查:** 新职责放对位置了吗?是否只有一个状态来源?每条关闭路径会释放资源吗?迟到请求会写错对象吗?旧代码和旧 CSS 清掉了吗?新文案已本地化吗?用户文件失败时还在吗?测试验证了实际行为吗?
+240 -328
View File
@@ -1,353 +1,265 @@
# Anomalous Model Browser - Architecture Summary (架构指南)
# Anomalous Model Browser Architecture
This document provides a high-level overview of the Anomalous Model Browser plugin for ComfyUI. It is designed to quickly onboard new AI agents or developers to the project's structure, design philosophy, and critical subsystems.
This is the short entry point for maintainers and AI agents. It describes the
system map, ownership boundaries, and rules that apply across subsystems. Read
only the linked topic document relevant to the task; do not load every document
by default.
Workspace lifecycle note: reopening an already initialized Workspace must restore the visible notebook body and reset the hidden recipe body before refreshing data. Hiding the overlay alone is not enough because the next open reuses the existing DOM tree.
## Reading map
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.
Before implementation, follow [AGENTS.md](AGENTS.md), the canonical development
and maintenance rules. [GEMINI.md](GEMINI.md) is the Gemini reading entry point.
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.
The prompt studio reads material prompt payloads through `web/modules/material_prompt_data.js`;
list summaries do not contain prompt bodies. See the Material Library contract below.
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.
| When changing... | Read... |
| --- | --- |
| Python routes, storage, paths, metadata, covers, or scan state | [`docs/architecture/backend.md`](docs/architecture/backend.md) |
| Browser lifecycle, UI state, localization, media, or graph edits | [`docs/architecture/frontend.md`](docs/architecture/frontend.md) |
| Update-guide content/versioning or sidebar hover labels | [`docs/architecture/update-guide.md`](docs/architecture/update-guide.md) |
| Workflow Recipes, packages, galleries, Parameter Notebooks, or prompt roles | [`docs/architecture/recipes.md`](docs/architecture/recipes.md) |
| Material Library snapshots, image parameter details, or reusable node blocks | [`docs/architecture/material-library.md`](docs/architecture/material-library.md) |
| Model Doctor, provenance hashes, missing-model recovery, or deep scanning | [`docs/architecture/model-resolution.md`](docs/architecture/model-resolution.md) |
| Browser audits, E2E functional bug reports, or verification sign-offs | [`docs/audits/README.md`](docs/audits/README.md) |
| Why a current product boundary exists | [`docs/decisions/README.md`](docs/decisions/README.md) |
| Recurring implementation mistakes and post-mortems | [`.agents/logs/ai_lessons.md`](.agents/logs/ai_lessons.md) |
Recipe common-parameter summaries use a readable minimum-width responsive grid and an explicit label/value row; labels stay horizontal, while long summary values use a single-line ellipsis with copy preserving the full value. Full expansion belongs to detailed parameter rows, not to narrow summary cards.
`README.md` is user-facing documentation and `CHANGELOG.md` is user-facing
release history. Neither is the source of truth for internal architecture.
Availability refresh actions expose immediate busy feedback, an accessible `aria-busy` state, and a recoverable error state; a disabled button without visible progress is not considered sufficient interaction feedback.
## System shape
Identity help uses a click-to-expand inline explanation attached to the badge. The explanation stays in normal card flow with bounded width and wrapping, so it cannot be duplicated by a native `title` tooltip or cover adjacent flex content.
Recipe origin editing has two supported update paths: complete recipe updates for metadata/workflow edits, and a refresh-only request containing `filename` plus `refreshIdentities`. The latter must clone the stored recipe before enrichment so the refresh action cannot be rejected by complete-recipe validation or accidentally discard the saved workflow.
Origin edit dialogs locate a saved model reference by stable provenance fields (`node_id`, `widget_index`, category, and saved value), not by display-only fields such as `type`. Localized action labels must use keys that exist in the shared locale catalog; a missing key must never make a visible dialog action appear inert.
Recipe import matching is a separate, explicit local-recovery action. The recipe detail entry point sends all unresolved references to the hash-resolution batch endpoint, constrained by the reference's model category; the saved author path and filename are never identity evidence. A found local candidate is presentation-only until the user clicks `Apply match`, which updates the corresponding workflow widget through the full-recipe update contract and archives the prior recipe. Exact saved-path availability checks remain a separate lightweight preview operation.
## Current recipe interaction correction (2026-08-03)
The recipe detail view keeps Overview, Parameters, and Versions tabs. The Overview contains the model composition blocks; each resolved model name or preview is an explicit entry into the browser model detail view. Model previews are demand-loaded when Overview opens, and returning from model detail restores the recipe detail view. The model browser stores the active grid folder and scroll coordinates before opening detail and restores them when returning to the grid. List-card Append actions await the shared canvas transaction before reporting success or restoring the button state. Export confirmation uses three states (cancel, exclude, include); cancellation must stop the export flow rather than silently becoming an exclusion.
The Workspace close action is a state restoration boundary. Opening the Workspace records which main-browser panel was visible; closing it hides recipe/notebook content and restores that panel, falling back to the model grid when the previous state is unavailable. All Workspace close affordances use the same cleanup function.
## Recipe result gallery (2026-08-04)
Recipe detail has an on-demand Gallery tab for reusing the user's own generated results. Gallery discovery uses a separate `sha256-node-types-v1` signature: it compares the sorted node class composition and count, deliberately ignoring seeds, prompts, model values, and other parameters so ordinary variations still appear. The original `sha256-structural-v1` fingerprint remains the recipe/history integrity fingerprint. Opening a detail view scans at most the newest 200 PNG files below ComfyUI's output directory and reads only bounded embedded `workflow` or API `prompt` metadata; there is no background polling or persistent output index. Clicking a result loads bounded node parameters and shows recipe-versus-image differences; the match itself remains node-only. The main output Gallery refreshes on each open and has no persistent toolbar refresh strip. A chosen result becomes a recipe-owned, compressed WebP cover in `.assets/<recipe-stem>/`, so it survives output cleanup and follows the recipe during package export/import. The original output image remains a local convenience link, never package data or identity evidence.
## Recipe Parameters & Parameter Notebooks (2026-08-04)
The "Parameter Notebook" system has been fully integrated into the Workflow Recipe detail view, replacing the static parameters presentation. Rather than existing as a disjoint global list in the sidebar, Parameter Notebooks are now intrinsically linked to their parent Workflow Recipe.
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.
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.
## Node Assistant & Parameter Notebook Integration (2026-08-06)
The Parameter Notebook system has been extended to act as a contextual preset library within the Node Assistant (`ui_doctor.js`). When a user selects a specific node (e.g., `KSampler`), the Node Assistant automatically queries the backend for all historical Parameter Notebooks across all recipes, filtering specifically for saved nodes that match the selected `type`.
The backend (`api/parameters.py`) implements a lightweight in-memory cache for notebook parsing to ensure instantaneous UI updates without heavy disk I/O, which can be bypassed via `?refresh=1` for immediate UI synchronization. The filtered presets are rendered hierarchically (Recipe -> Notebook -> Parameter Summary). Applying a preset invokes `applyLocalNodeParameters`, which performs a targeted, single-node widget replacement while preserving the rest of the canvas and safely triggering all required ComfyUI widget callbacks.
### Topological Prompt Tracing and Dynamic Role Injection
Prompt tagging (Positive vs Negative) leverages a robust Topological Backward-Tracing engine (`recipe_parser.js`) rather than fragile regex matching on node titles. During recipe save, the engine starts from known conditioning ports on samplers (including `positive`, `negative`, `conditioning`, `cond`, `uncond`, `model_cond`, etc.) and traces backward through the graph to explicitly tag upstream `CLIPTextEncode` nodes.
These topological `role` properties are baked into the recipe's `metadata.nodes`. To seamlessly bridge this structural knowledge to the Node Assistant, `api/parameters.py` performs Dynamic Role Injection—reading the bounded recipe's metadata by `node_id` and injecting the `role` back into the Parameter Notebook response. This ensures the UI accurately displays `[🟢 正面]` or `[🔴 负面]` prefixes without guessing.
## 1. Project Philosophy (设计理念)
* **Zero Frameworks**: No React, Vue, or build tools. Everything is Vanilla JS and CSS for maximum compatibility, minimum overhead, and zero compilation steps.
* **Non-Intrusive Integration**: Operates as a floating Gemini-style popover (`#anomalous-container`) mounted via ComfyUI's standard UI extension system. It avoids altering the native ComfyUI canvas except when explicit interaction is required. The floating trigger is mounted before browser panels are initialized and retries initialization on click, so a panel regression or missing optional browser API cannot hide the plugin entry point.
* **Strict DOM Obliteration**: Rather than caching complex DOM structures (like `display: none` for large grids), the UI strictly employs `innerHTML = ''` when navigating between folders. This enforces immediate Garbage Collection, crucial for performance when users have thousands of models.
* **Offline-First Resilience**: Model metadata is parsed from local `.info` and `.civitai.info` files or extracted directly from `.safetensors` headers via Python `struct`. API calls to Civitai are explicit, user-initiated actions.
## 1.5 Internationalization and Licensing Boundaries (2026-08-06)
The extension is a UI-only ComfyUI integration: `NODE_CLASS_MAPPINGS` is intentionally empty, and the project does not provide or install custom nodes. Its runtime is split between Vanilla JS/CSS frontend modules and Python `aiohttp` routes hosted by ComfyUI. The repository currently has no bundled font, image, video, vendor library, dependency manifest, or license file; Civitai, optional DeepL/Google translation endpoints, ComfyUI host APIs, and Pillow/aiohttp are runtime/environment integrations rather than bundled project assets.
`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.
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.
The third migration snapshot extends the same contract to `ui_detail.js`, `ui_doctor.js`, and `ui_notebooks.js`. Detail editing, model selection, Doctor diagnostics, the Node Assistant, the model picker, and Notebook validation now resolve user-facing strings through `translate` with parameter interpolation for counts, paths, model names, and scan status. Dynamic model/path/node data remains outside the locale dictionaries; rich UI state is composed with DOM nodes where translated text replaced previously formatted status HTML.
The fourth migration snapshot applies the contract to `web/main.js` for preflight workflow import and workflow share-code export/import. These modal surfaces use shared keys for labels, status messages, and count interpolation; the remaining locale conditional in `translateText` only maps the active locale to the backend service's `zh-CN` or `en` code and is not a UI copy branch. `ui_sidebar.js` remains a separate pending snapshot because its scan wizard, settings hub, language toggle, and folder manager require their own key extraction review.
The fifth migration snapshot applies the contract to `web/modules/ui_sidebar.js`. Sidebar navigation, scan wizard cards, metadata/hash scan controls, model settings, language-toggle refreshes, layout controls, and folder management now resolve user-facing copy through `translate`. Wizard descriptions that intentionally contain trusted formatting remain dictionary-owned rich text; dynamic file names, counts, API errors, and folder data remain runtime values. Locale checks used only for the language CSS class and toggle direction remain functional state logic rather than inline UI copy.
Licensing and community-delivery work are intentionally deferred. The current local MIT `LICENSE` change is owned by the user's separate Gemini workflow and is not part of the i18n snapshots. Russian localization begins only after the contributor submits a PR. Remote pushes and Issue #9 synchronization remain disabled until the user explicitly authorizes them after other feature work is complete.
The sixth migration snapshot completes the Stage 2 UI i18n pass for `ui_recipes.js`, `ui_recipe_detail.js`, the supporting Recipe modules, and `web/hash_resolver.js`. Recipe modules now use the shared `translate` contract rather than importing the dictionary directly; the hash resolver uses the same contract for its manual repair summary with a dynamic repaired-count parameter. Recipe workflow/model data remains runtime data and is not added to locale dictionaries.
## 2. Directory Structure (核心文件结构)
Anomalous Model Browser is a UI-only ComfyUI extension. It registers no custom
nodes (`NODE_CLASS_MAPPINGS` is empty). ComfyUI loads the Vanilla JavaScript/CSS
frontend from `web/`; the Python package registers `/anomalous/` `aiohttp`
routes and performs local filesystem, metadata, recipe, and scan operations.
```text
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"
├── api/ # Backend Python API (Modularized)
│ ├── __init__.py # Appends all modular routes to server
│ ├── config.py # Configuration and paths setup
│ ├── metadata.py # Model metadata extraction and parsing
│ ├── models.py # Core model listing and routing (API endpoints)
│ ├── notebooks.py # Notebooks management logic (Prompt Notes)
│ ├── parameters.py # Parameter Notebook CRUD and Gallery logic
│ ├── recipes.py # Local Workflow Recipe CRUD and validation
│ ├── recipe_packages.py # Bounded ZIP inspection, export, staging, and import commit
│ ├── scanner.py # Scanning engine for hash resolution and caching
│ └── utils.py # Shared backend utilities and safe path boundary helpers
├── scraper.py # Async HTTP client logic & web scraping (Root Level)
├── CHANGELOG.md # Version history
├── web/
│ ├── main.js # Frontend Vanilla JS Entry (Under 1000 lines, mounts modules to prototype)
│ ├── 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
│ ├── ui_sidebar.js # Sidebar and folder tree logic
│ ├── ui_detail.js # Model detail panel
│ ├── ui_doctor.js # Model Doctor and Assistant panel
│ ├── ui_notebooks.js # Prompt Notes editor
│ ├── ui_recipes.js # Workflow Recipe cards, search/filter, save dialog, and actions
│ ├── recipe_parser.js # Read-only LiteGraph recipe metadata extraction
│ ├── recipe_identity.js # Pure recipe model-reference and identity normalization
│ ├── recipe_diff.js # Pure bounded semantic version comparison
│ ├── recipe_actions.js # Transactional canvas-append actions
│ ├── ui_recipe_detail.js # Demand-loaded recipe detail panel and tab rendering
│ ├── ui_gallery.js # Fullscreen Gallery Viewer
│ ├── ui_grid.js # Main grid and model loading
│ ├── graph_splice.js # Transactional MODEL/CLIP chain insertion and rollback
│ ├── locales.js # Dedicated multi-language dictionary (i18n)
│ └── safe_dom.js # HTML escaping and allowlisted rich-text sanitization
├── tests/ # Backend security and path-boundary regression tests
├── docs/ # Supplemental documentation
ComfyUI frontend
web/main.js
-> web/modules/* UI, graph integration, localization
-> /anomalous/* JSON and bounded media requests
ComfyUI Python server
__init__.py
-> api/__init__.py route registration
-> api/*.py storage and domain operations
-> scraper.py explicit metadata/hash enrichment
User-owned data
ComfyUI model folders models and sidecars
user/.../anomalous_recipes workflow recipes and recipe assets
user/.../anomalous_parameters immutable parameter snapshots
user/.../anomalous_materials curated image/workflow material bundles
user/.../anomalous_notebooks prompt notes, with legacy originals preserved
```
## 3. Backend Architecture (Python API)
Modularized into the `api/` package. Registered with ComfyUI's internal `aiohttp` server.
All endpoints are prefixed with `/anomalous/`.
The frontend and backend communicate through narrow JSON contracts. The
frontend must not infer filesystem authority, and the backend must not depend on
DOM or live LiteGraph state.
### Python Rules of Engagement:
1. **Never block the event loop**: File I/O should ideally be offloaded or fast. Large files are handled via `struct` (reading only the first 64KB for metadata), avoiding full reads.
2. **Handle missing dependencies gracefully**: Avoid 3rd-party pip requirements if possible.
3. **Always Restart ComfyUI**: Any changes to the `api/` package **REQUIRE** a full ComfyUI server restart to take effect.
4. **Never use hardcoded backslash strings**: When writing path manipulation code, use `os.sep` or `os.path.normpath()` instead of `replace('\\', '/')`. Editing tools may corrupt escaped backslashes silently.
5. **Dynamic Folder Resolution (Config-Driven)**: Never hardcode folder types (like `checkpoints`, `loras`). Always use `api.utils.get_active_folder_types()` to enforce whitelist logic. Folders disabled by the user in `config.json` are completely skipped during `os.walk`, ensuring zero I/O overhead for hidden directories.
6. **Strict Path Containment**: Every request path must go through `resolve_folder_subdir()`, `resolve_within()`, and `require_filename()` as appropriate. Checking only for `..` is forbidden: absolute Windows paths, UNC paths, alternate separators, and symlinks can otherwise escape the configured model/output/notebook directory. File-serving endpoints must also enforce an explicit media-extension allowlist.
7. **Canonical Configuration**: Runtime UI settings and newly saved API keys live in `api/config.json`. `scraper.py` reads that file first and falls back to the legacy root `config.json` only for backward compatibility. API keys must never be persisted in browser `localStorage`.
8. **Atomic Background State**: Set scan state or create the exclusive marker file before launching a worker thread/process. Folder scans use `.scan_in_progress`; global quick scans use `.global_scan_in_progress`; deep missing-model scans use `GLOBAL_SCAN_STATE`, exposed by `/anomalous/scan_missing_models_status`.
9. **Private Development Tests**: The local `tests/` directory contains backend and frontend regression tests for maintainers only. It is intentionally ignored by `.gitignore` and must not be included in the installable/plugin Git distribution. Keep these files locally for validation; do not import them from runtime code or move them into the shipped extension bundle.
10. **Workflow Recipes Are User Data**: Recipes live only in `ComfyUI/user/default/workflows/anomalous_recipes`, never in the extension repository. `GET /anomalous/recipes` returns card metadata only; the graph is fetched separately through `GET /anomalous/recipe_full`. Recipe writes are bounded, validate local-only data-image thumbnails and contained output-image references, validate graph node IDs/widget bounds/link endpoints before writing, and use atomic replacement. Successful save, update, and restore responses include a compact integrity receipt (node/link/group counts and the persisted workflow fingerprint). A bound output stores a compressed cover plus a local source reference; it never copies an arbitrary path supplied by the browser. Export packages strip machine-local `source_image` paths, include the union of current and selected-history snapshot assets, and preserve imported model identity records independently from transient local availability. Replacement import stages recipe/assets/history and restores the prior set if the commit fails. `recipe_packages.py` accepts only bounded ZIP packages with `manifest.json`, `recipe.json`, declared contained WebP assets, and optional bounded history. Import is inspect-then-commit, rejects traversal/symlinks/archive bombs/checksum failures, never installs code or dependencies, and stages before atomic rename.
## Ownership map
### NON-NEGOTIABLE: Model Sidecar & Cover Lifecycle / 模型伴生文件与封面生命周期死规矩
* `.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.
* 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.
### Backend
### NON-NEGOTIABLE: Lossless Performance Boundaries / 无损性能优化死规矩
* Metadata and embedded safetensors-header hashes may be cached only with a bounded cache whose key includes the real path and physical file signatures (`size`, `mtime_ns`, and `ctime_ns`) for the model and both metadata sidecars. Callers receive independent copies; cached mutable dictionaries/lists must never be exposed directly.
* Preview URLs use the preview file's stable nanosecond modification time as their cache version. Never append `Date.now()` or another per-request random token during ordinary listing: doing so disables the browser cache and redownloads unchanged media. A changed cover must still produce a changed URL.
* Folder listing must inventory a directory once with `os.scandir()` and preserve the established preview priority (`.preview.*` before bare media). Do not perform one `exists()` sequence per model when the directory inventory already contains the answer.
* Recursive directory walks, metadata parsing, and other potentially large disk operations must run through `asyncio.to_thread()` rather than blocking ComfyUI's aiohttp event loop. This improves responsiveness without skipping files.
* Large grids keep the complete ordered result set but create cards in bounded animation-frame chunks. Images use native lazy loading/async decoding; videos delay their source until near the viewport and retain the configured autoplay/hover behavior once activated. A new folder request must cancel the previous request and stale render generation.
* Card-quality choice is deliberately user-facing but bounded to two understandable modes: `balanced` requests a derived longest-edge-512px WebP for static grid covers, while `original` serves the source cover. Detail pages continue to use the original in both modes. Derived thumbnails live only in ComfyUI's temporary area, are keyed by the source real path plus physical signatures, and are capped at 256 MiB with oldest-accessed eviction. They MUST NOT modify, replace, rename, or sit beside the user's source cover; unsupported/animated images and any generation failure fall back to the original.
* Closing the browser immediately aborts listing work, disconnects observers, pauses all media, and releases grid video/audio sources. Lightweight card DOM may remain warm for 90 seconds for a smooth reopen, after which cards and retained model payloads are released automatically. Folder replacement must stop old media before removing DOM nodes. Non-grid panels retain their established UI state, so their paused media is rebuilt only by the panel's existing lifecycle rather than being left visibly broken on reopen.
* Model Doctor batch resolution is an I/O optimization only. Provenance-rich workflows skip the redundant full filename-to-hash cache refresh; legacy workflows missing injected provenance still refresh it for compatibility. Requests are grouped by the exact required model-type tuple so each group is scanned once, while every item still uses the same cryptographic hash, exact byte-size, category constraint, conflict rejection, and ambiguity rules as `/anomalous/resolve_hash`. Batch failure must fall back to the single-item endpoint; batching must never introduce filename/path evidence.
* Preview resolution should try contained exact relative paths first and walk the library only for unresolved basename fallbacks. Exact-path lookup here locates a preview for a model value already supplied by ComfyUI; it is not Model Doctor identity discovery.
- `api/config.py` owns configured paths and active model-folder types.
- `api/path_utils.py` owns containment, filename validation, atomic JSON writes, and resolving
a saved model value inside the model folders;
`api/utils.py` is a compatibility export surface.
- `api/metadata.py` owns sidecar and safetensors metadata extraction.
- `api/model_catalog.py`, `api/model_resolution.py`, `api/model_metadata.py`, and
`api/model_media.py` own model listing, identity recovery, mutation, and covers;
`api/models.py` is a compatibility facade.
- `api/scanner.py` and `scraper.py` own scan orchestration and enrichment.
- `api/workflow_schema.py`, `api/recipe_schema.py`, `api/recipe_images.py`, and
`api/recipe_store.py` own recipe validation/shaping, images, CRUD, history, and
integrity receipts; `api/recipes.py` is the HTTP facade.
- `api/recipe_packages.py` owns bounded inspect-stage-commit package handling.
- `api/parameters.py` owns Parameter Notebook persistence and lookup.
- `api/notebooks.py` owns Prompt Note persistence and recoverable legacy copying.
- `api/material_schema.py`, `api/material_assets.py`, and `api/material_store.py`
own curated material shaping, private assets, persistence/cache, search and
lifecycle; `api/materials.py` owns HTTP mapping and compatibility entry points.
- `api/media_routes.py`, `api/gallery_routes.py`, `api/translation_routes.py`, and
`api/folder_types.py` own the formerly mixed utility route families.
- `model_policies.py` owns shared backend rename and protected-category policy.
- `model_identity.py` owns file SHA-256 evidence shared with the standalone scanner.
- `api/image_search.py` powers the output gallery search (`gallery_images?q=`):
it reads only the PNG text chunks before the pixel data (ComfyUI prompt and
workflow, A1111 `parameters`), caches one record per image by mtime, and
matches all query terms; hex terms of 8+ characters also match recorded model
SHA256 values and, via `collect_model_hash_index` in `model_resolution.py`,
local model files with that hash. Terms arrive as repeated `term` params, one
phrase each. `ui_search_chips.js` is the reusable search-block input (Enter or a
comma commits a block); `ui_gallery.js` places it above the gallery.
- `api/version_manager.py` owns the plugin's own version: the installed tag/branch
(`/anomalous/version`, local only), published releases fetched only on request
(GitHub releases API, falling back to `git ls-remote` tags; drafts skipped,
pre-releases never counted as latest), switching to a published tag (detached
checkout), returning to the default branch (fast-forward only), and undoing the
last switch (recorded in the ignored `.anomalous_version.local.json`). Every
switch refuses to run over modified tracked files. `ui_version_manager.js` is
the version line (shown in the update guide opened by the header "!" button) and panel; nothing goes online until "check for updates"
is clicked, and restarts go through ComfyUI Manager's reboot route when present.
## 4. Frontend Architecture (Vanilla JS)
Located in `web/main.js` (Under 1000 lines, fully refactored into ES Modules). Wraps its logic inside `app.registerExtension({ name: "Anomalous.ModelBrowser", ... })`.
- `web/main.js` coordinates extension registration (with `?v=...` versioned module imports busting aggressive browser ES Module caching and unconditional legacy storage key purging). `browser.js` owns the shared
browser class and extracted-method wiring; `browser_entry.js` owns the single
browser instance plus floating/topbar/menu entry behavior (with Dual-Binding PointerEvents drag capture, `lostpointercapture` fail-safe listeners, zero-drift viewport boundary clamping, flicker-free pre-mount coordinate binding and `anomalous-trigger-initializing` smooth opacity fade reveal, default safe placement in the top-left canvas area at `top: 80px; left: 80px;` gracefully avoiding the left sidebar dock, and versioned `anomalous_trigger_pos_v3` atomic JSON coordinate persistence); `entry_controls.js`
owns entry mode, trigger sizing/styling normalization, mathematical viewport-safe boundary
clamping (`clampFloatingTriggerPosition` with minimum safe boundary `minX=70` preventing left sidebar dock entrapment), clean `loadSavedTriggerPosition`/`saveTriggerPosition` storage drivers, and non-distorting coordinate validation
(`isValidSavedTriggerPosition`); `api/__init__.py` injects an aiohttp no-cache middleware (`Cache-Control: no-cache, no-store, must-revalidate`) for extension static files to eliminate browser memory/disk cache desynchronization across regular page refreshes; and `interface_settings.js` owns language and theme preferences.
- `ui_sidebar.js` creates the browser shell and folder navigation.
`ui_settings_hub.js` owns settings and model-card preferences;
`ui_toolbox.js` owns the tool catalog, fixed shortcut bar, and tool dispatch;
`ui_browser_navigation.js` owns shared panel hiding/cleanup and workspace return.
Scan-wizard launch, single-model precision scans (`triggerDirectModelScan` with strictly factual Civitai vs non-Civitai feedback reporting inferred base-model or match status directly within the bottom-right progress panel and toasts without blocking browser alerts), modal lifecycle ergonomics (backdrop click and Escape key dismissal with listener detachment, scrollable content area with sticky footer actions), post-scan frontend hash and native combo refreshes (`app.refreshComboInNodes()`, `window.anomalous_reload_hashes()`), and polling live in `ui_scan_wizard.js`; folder visibility/order lives in
`ui_folder_manager.js`; and help content lives in `ui_help.js`.
- `ui_model_sources.js` owns the Model Sources Hub, managing workflow-model and global-library source detection, Civitai/HuggingFace URL attribution, sidecar persistence, and resilient scope switching between active workflow and full local library (with cached library state preservation and reliable re-rendering).
- `ui_materials.js`, `ui_material_cards.js`, and `ui_material_application.js` own the Material Library UI, category navigation, card presentation (with grab cursor affordances, explicit drag tooltips, and polymorphic card dragging via `bindPolymorphicMaterialCardDrag`), context-aware drag guidance, relaxed third-party node prompt widget sniffing and injection, and the structured empty state onboarding blueprint guiding users through collection, canvas drag, and prompt studio mixing. Drag precedence prioritizes node hits over blank canvas drops; blank canvas drops auto-instantiate `CLIPTextEncode` nodes for prompt materials with standard colors or open full workflows.
- `ui_update_guide.js` and `update_guide_data.js` own the non-intrusive update guide modal (accessible via header button `#anomalous-update-notice-btn` and Help modal; version ID `2026-09-recipes-and-studios`), presenting a 4-step milestone walkthrough (Workflow Recipe Studio, Material Library & Prompt Studio, Model Sources Hub, and Precision Direct Scan with canvas addition) with full bilingual localization. `ui_spotlight_tour.js` provides the interactive spotlight mask tour (`startSpotlightTour`), gliding smooth focal box highlights across topbar workspaces and bottom dock actions with directional tooltip cards and keyboard navigation.
- `sidebar_actions.js` owns the sidebar bottom action hover-reveal short labels (100ms), singleton dynamic DOM tooltip bubbles (`#anomalous-sidebar-tooltip-bubble`, 600ms), click/pointerdown instant text/tooltip suppression guards, `isBottomModalOpen` tooltip occlusion guards, and anti-flicker pointer stability.
- `tool_registry.js` centralizes metadata, SVG icons (enlarged 20px crisp vector outlines with 2px stroke, #cbd5e1 contrast), and stable IDs for the 9 catalog tools (including Prompt Notes / 提示词笔记) and 2 fixed anchors (Toolbox and Settings).
- `shortcut_layout.js` provides tool layout utilities and fallbacks. The bottom shortcut bar maintains the clean fixed 4-tool setup (`scan`, `doctor`, `assistant`, `materials`) plus two anchors (`toolbox`, `settings`) housed in prominent 36px buttons with full click/active text suppression and `.is-active` toggled styling.
- `ui_toolbox.js`'s Toolbox modal strictly filters out all tools already present on the bottom bar, presenting a sleek 216px 3-row utility catalog with compact, frameless 44px tiles (providing an elevated silhouette with breathing room for catalog discovery), downward anchor caret pointing to the toolbox trigger button, 0.18s smooth spring pop-in animation, clean click action execution, and zero obstructive text or beta footers.
- `ui_grid.js` and model-detail modules own model presentation: `ui_grid.js` manages chunked card rendering,
card placeholder ergonomics (eliminating misleading unclickable text in favor of pure centered icon and status),
card action buttons (one-click canvas addition with plus icon, model metadata editor, direct precision scanner without wizard modal popups)
with absolute positioning cascades immune to tooltip target conflicts, vibrant hover affordance,
safe docked sidebar preservation upon node addition, and multi-type node dispatch; `ui_detail.js`
coordinates detail display, `ui_model_editor.js` owns metadata editing, and
`ui_model_selector.js` owns advanced selection. `ui_gallery.js` and
`ui_gallery_detail.js` own generated-image browsing and workbench lifecycle,
with stage interaction in `ui_image_stage.js` and metadata tabs in
`ui_image_inspector.js`.
- `ui_recipes.js` / `ui_recipe_detail.js`, `ui_notebooks.js`, and `ui_materials.js`
own their respective workspace surfaces and persistence flows. `ui_recipes.js` owns
the Workflow Recipe studio catalog workspace with search/filter tags, grid/list layout toggle,
dedicated top-right modal close anchor (permanently decoupled from the tool button row to prevent wrapping displacement),
streamlined action header (preserving active workflow saving while pruning unfinished package
import entrypoints), and card browsing. `ui_notebooks.js` owns Prompt Note catalog,
sidebar dual-group management (note list + floor quick jump anchor navigation with scrollspy active tracking and tooltip hints),
and persistence; `ui_notebook_editor.js` owns unfolded card editing (modularized into single-responsibility
sub-functions adhering to the 50-line rule: sticky top action toolbar with floating More popover dropdown and timed two-step delete safety guard,
unfolded companion models card with unconstrained multi-column tile flow eliminating nested gallery scrollbars,
prompt composer with dynamic field-sizing and compact inline find & replace toolbar, flat material library archiving card with
clean single-icon feedback, and unified dark slim scrollbar ergonomics with complete bilingual dictionary coverage in `locales.js`), and
`notebook_canvas.js` owns LiteGraph creation. Prompt Notes are integrated as a standard tool in the Toolbox
with defensive workspace return state restoration, TDZ-safe summary initialization, and responsive empty-state fallback rendering. `ui_recipe_detail.js`
coordinates the Workflow Recipe detail session and model composition. `ui_recipe_overview.js`
owns the Overview prompt showcase (with `entry.text` fallback, guarded non-shrinking primary action CTA, and floating Popover More dropdown menu), and `ui_recipe_parameters.js`
owns the responsive Parameter Presets workspace (featuring default-expanded raw node parameter inspection,
a `clamp(230px, 24vw, 290px)` sidebar with guarded card actions, uncluttered console action bars with deferred status feedback,
`minmax(130px, 1fr)` Bento Grid with universal click-to-copy, LoRA cards with flexbox truncation guards,
and sticky editor headers).
`ui_materials.js` owns Material Library discovery and pagination,
`ui_material_cards.js` owns catalog cards, `ui_material_detail.js` owns the
full detail surface, and `ui_material_application.js` owns selected-node
tracking and explicit material application.
Within recipe detail, `ui_recipe_versions.js` owns history comparison/restore,
`ui_recipe_gallery.js` owns result cards and direct Image Detail Workbench handoff,
`ui_recipe_model_matching.js` owns preview resolution and explicit local replacement,
`ui_recipe_metadata.js` owns inline persistence, and `ui_recipe_detail_dom.js` owns
the DOM/copy helpers shared by detail subviews. `ui_recipe_catalog.js` owns recipe
filters, navigation, dismissible topbar drag guidance strip with localStorage persistence, the 3-step empty-state onboarding blueprint (`renderRecipeEmptyGuide`), and background catalog-wide model readiness resolution (`resolveCatalogRecipeReadiness`), `ui_recipe_cards.js` owns cards and card actions
(including `grab` drag affordance, cover `可拖拽` badge, harmonized multi-state model readiness pill with `getRecipeReadiness` synchronizing available, missing, and pending matches with detail overview, and direct canvas drag-and-drop), `ui_recipe_dialogs.js` owns save/edit dialogs, and `ui_recipe_media.js` owns shared cover helpers. Detail sessions synchronize detected model availability back to `owner.recipeRecords` via `syncRecipeReferencesToCatalog`.
- `ui_prompt_composer.js` owns the standalone Prompt Studio drawer. Its child
views are `ui_prompt_source_deck.js`, `ui_prompt_workbench.js`, and
`ui_prompt_inspector.js`. Assembly plan data, track-vs-role separation, and
cross-role tail smart-sorting are owned by `prompt_composition.js` and `prompt_studio_data.js`.
`ui_prompt_source_deck.js` owns the card preview popover with narrow bridging corridors,
differentiated hide timers, and fast dismissal when hovering or clicking library blank space.
- `ui_prompt_translator.js` owns the standalone Prompt Translator, featuring robust multilingual/Chinese node prompt extraction (`extractPromptFromNode`), real-time canvas selection synchronization (`app.canvas.onNodeSelected`), automatic prompt injection on open/docked mode, on-demand read/sync controls, guarded selection writeback across single and multi-tab workflows, compact streamlined button ergonomics preventing multi-row wrapping, elastic vertical flex textareas maximizing canvas-side vertical space, and an expanded 460px default sidebar width with automatic backward-compatible width migration. Both translator and
studio use `ui_lifecycle.js` for global listeners, request cancellation and
resize cleanup. Translation requests go through `translation_service.js`.
- `ui_dom.js` provides small DOM/JSON helpers; `material_inspector.js` owns
material-specific metadata and parameter rendering.
- `ui_doctor.js` owns diagnostics and global scans; `ui_node_assistant.js` owns
selected-node assistant history, `ui_node_model_picker.js` owns native combo
replacement, and `ui_node_presets.js` owns parameter preset rendering and
application. `model_picker.js`, `node_material_actions.js`, and `graph_splice.js`
own the remaining explicit graph changes.
- `ui_model_sources.js` owns the Model Source Hub (模型来源统一中控中心), providing dual-scope
(Workflow and Library) source inspection, external platform jumping, canvas `Note` node generation,
`workflow.extra.anomalous_model_sources` metadata synchronization, automated asynchronous model
metadata resolution (`resolveWorkflowModelsMetadata`) via `/anomalous/resolve_paths_to_previews` with
local sidecar priority detection, and protected read-only link display with deliberate edit-mode
unlocking and dirty-state dynamic local persistence (hiding redundant `[Save Local]` buttons until links are modified).
- `locales.js` is the shared runtime string catalog. Existing inline bilingual
UI strings remain migration debt; new strings belong in the catalog.
- `styles.css` is the ordered import manifest for `web/styles/*.css`, which own
presentation and theme overrides. Color values, dimensions
and visual design descriptions are not duplicated as architectural contracts.
### The Modular Extraction Strategy (模块化拆分架构)
We have successfully transitioned from a monolithic `main.js` to a modular ES architecture.
Instead of fragmenting the class scope and losing context, we extracted all UI panels into `web/modules/` and bound them back to the `AnomalousBrowser.prototype`.
1. **Modules**: All major UI components (`ui_sidebar.js`, `ui_detail.js`, `ui_doctor.js`, `ui_notebooks.js`, `ui_gallery.js`, `ui_grid.js`) are decoupled ES modules.
2. **Shared State**: Everything continues to live on the `AnomalousBrowser` class instance (`this.xxx`). No complex context passing required.
3. **TOC (Table of Contents)**: The top of `main.js` contains a TOC mapping out the extracted module bindings.
## Cross-system invariants
### 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.
* **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.
* **Recipe interaction extensions (`recipe_diff.js`, `recipe_actions.js`)**: Recipe cards filter only lightweight name/notes/tags metadata. Version comparison fetches one validated historical JSON and returns bounded semantic changes without instantiating or mutating either graph. Append clones serialized nodes into the live graph with collision-free IDs, transactional link remapping, placement, selection, and rollback. Card and detail actions use a shared busy/restore discipline so a network or canvas failure cannot leave a button permanently disabled or trigger duplicate mutations. Open/Append UI actions share the same exported handlers from `ui_recipe_detail.js`. All Quick Queue functionality was removed to enforce a single execution path through the canvas.
* **Recipe detail presentation**: Copy actions use visible temporary success/failure feedback. Name, notes, and tags can be edited inline from the loaded detail view and are persisted through the existing full-recipe update contract, preserving the serialized workflow and history behavior. Workflow fingerprints and model SHA-256 values are advanced information shown only inside collapsed disclosure panels; card and detail surfaces use bounded glass-style cards, responsive grids, and restrained hover transitions. The Overview separates base model and each LoRA into one full-width composition block; sampling values remain in a compact key-value grid.
* **Doctor Panel (`ui_doctor.js`)**: Diagnoses model health for individual nodes or the entire workflow. Includes "View Profile" functionality.
These rules are intentionally summarized here and specified in the linked topic
documents.
### NON-NEGOTIABLE: Recipe Presentation & Model Preview Boundaries / 配方展示与模型预览边界
* Recipe cover, current local model preview, and optional frozen model-preview snapshot are three different concepts. None is model identity evidence.
* The authoritative `workflow` field contains only the serialized ComfyUI graph. Recipe covers, preview descriptors, and future snapshot asset IDs belong to additive recipe presentation data; never inject preview bytes or local preview URLs into the graph.
* Recipe cards perform no per-model preview lookup. Current previews are demand-loaded only after the Models & reproducibility tab opens and must use a bounded exact-reference/category lookup. Detail opening must not recursively walk model folders.
* Do not persist preview URLs as truth: they are current-machine cache locators and may change after rename or cover replacement. Display precedence is future frozen snapshot, then current local preview, then category placeholder.
* Frozen preview snapshots are schema-v4 presentation data. The save flow uses them by default and does not expose a per-recipe checkbox; recipes with an explicitly stored `false` value remain compatible until a future Workflow Recipe global setting owns this preference. This default matters because an export can include only snapshots that were saved first. Assets live in a contained recipe-owned `.assets/<recipe-stem>/` directory, are immutable/content-addressed, and are served only through `GET /anomalous/recipe_asset`. Snapshot generation accepts static local preview files only, writes WebP thumbnails no larger than 320 px or 96 KiB each, is capped at 12 images and 1.25 MiB per save/update, and never copies videos or original full-resolution media. Deleting a recipe removes only its contained assets after the recipe file is deleted; retained history may share an asset ID. Export still asks whether to include these bounded presentation assets in the package.
* Preview hashes, filenames, paths, images, and visual similarity MUST NEVER create a Verified identity badge or participate in Model Doctor matching. Workflow fingerprints also exclude covers and preview presentation.
* Recipe model cards may display the basename and current local preview by default, with full saved paths and hashes behind Advanced information. Overview and compact recipe summaries must use the basename only; a filesystem path is never a model display name. Exact local references are used only for current-machine preview lookup and temporary navigation into the browser detail panel. Imported or unresolved references remain visually inactive until the user explicitly requests matching through the existing hash/size/category resolver; filename or path alone can never activate a match. A successful match activates only the current detail view and does not rewrite the authoritative workflow path.
* Navigating from a resolved recipe model reference to the model browser detail panel must preserve the main browser modal. The recipe workspace child overlay is hidden after the detail panel is shown; it must not call the browser-wide `close()` lifecycle, because that lifecycle also hides and releases the target detail panel. The navigation stores a lightweight recipe return token (recipe payload, active tab, and scroll position); the model detail Back action consumes that token, hides/cleans the model panel, and restores the recipe detail view rather than falling back to the model grid.
* Local model-preview media may include image, MP4, or WebM files and uses hover playback for video. Recipe save/export never embeds original video media: a bound output video may contribute only a bounded static first-frame thumbnail, while package assets remain contained WebP snapshots.
* Detail readability is not card-summary readability: a compact card may use bounded summaries, but the detail panel may resolve the corresponding safe widget value from the already-loaded contained `workflow` by node ID and widget index. Sensitive/button widgets remain excluded. Long values and positive/negative prompts must use a visible collapse/expand control plus a copy action; never silently truncate a detail value. Overview may show normalized positive/negative prompt summaries, while Parameters preserves native node names and renders each saved `CLIPTextEncode` text widget separately. If the bounded summary omits an old native CLIP node, Parameters must recover it from the authoritative serialized workflow's `widgets_values` without changing the saved graph, then keep native CLIP rows together in the presentation order.
1. **Identity is provenance, not naming.** Model Doctor may use a cryptographic
hash, exact byte size under the allowed category policy, and target category.
Paths, filenames, display names, previews, and fuzzy similarity are never
identity evidence.
2. **Filesystem input is untrusted.** Backend request paths must pass the shared
containment and filename helpers. Checking only for `..` is insufficient on
Windows and in the presence of alternate separators, UNC paths, or symlinks.
3. **User files are changed transactionally and conservatively.** Recipe writes,
imports, graph mutations, and sidecar operations validate before mutation and
either complete coherently or restore the prior state.
4. **The event loop stays responsive.** Recursive walks, hashing, metadata
parsing, and other potentially large disk operations run off the aiohttp
event loop. UI rendering and media loading are bounded and cancellable.
5. **Host state is preserved.** Browser panels are mutually exclusive,
Workspace/model-detail transitions are recoverable, and graph edits use
ComfyUI's change/callback contracts.
6. **Runtime strings are localized safely.** User-visible copy comes from
`locales.js`; dynamic values stay outside dictionaries and enter the DOM as
text. Only allowlisted rich content goes through `safe_dom.js`.
7. **Optional integrations fail locally.** Missing graph APIs, metadata, network
availability, or an optional resolver may disable that capability but must
not make the main extension disappear.
8. **Presentation data is not authority.** Covers, thumbnails, summaries,
cached names, and workflow fingerprints never replace the authoritative
serialized workflow or model provenance record.
### NON-NEGOTIABLE: Recipe Action Boundaries / 配方动作边界
* `Open in Canvas` replaces the live graph only after an explicit dirty-canvas confirmation and never queues automatically. `Append to Canvas` clones saved nodes, remaps IDs/links, validates the complete insertion, and rolls back all inserted nodes on failure. Detail actions use one busy/settle path so a successful canvas action resolves the detail controller and a failed action restores the button state.
* Append must not call `loadGraphData` on the live graph. It must not mutate the saved recipe, existing nodes, existing links, or external model files. Unsupported/missing node types produce a visible error instead of a destructive fallback.
* Append accepts both LiteGraph tuple links and object-shaped link records, treats groups as first-class inserted items, rejects subgraph definitions until a dedicated ID-remapping path exists, and removes every item created by the action when any insertion step fails.
* `recipe_diff.js` compares bounded semantic records only. It must not expose newly-unfiltered sensitive widget values, render a full graph diff, instantiate saved nodes for display, or mutate either recipe.
## Data and compatibility boundaries
### Graph Splicing and Picker Context (`graph_splice.js`, `model_picker.js`)
Manual LoRA insertion is an explicit canvas mutation and is isolated from Model Doctor identity recovery. The graph helper analyzes ports by their declared `MODEL` and `CLIP` types, never by fixed slot indexes or display names. It supports inserting a compatible loader before a node with connected MODEL/CLIP inputs, or after a node with MODEL/CLIP outputs. Downstream fan-out is rejected as ambiguous until the user can choose a concrete branch.
- Runtime settings and newly saved API keys live in `api/config.json`.
`scraper.py` may read the legacy root `config.json` only as a compatibility
fallback. API keys are not stored in browser `localStorage`.
- Recipes, Parameter Notebooks, and Material Library records/assets are user data outside the extension directory.
They are never bundled with or silently migrated into the plugin source.
- The extension may integrate with Civitai and optional translation services
only through explicit product behavior. External content and dependencies keep
their own terms.
- Project code and documentation use the MIT license. `TRADEMARKS.md` separately
defines the project-name and official-branding boundary.
- Internal property names and established routes may remain stable when a
user-facing surface is renamed. Do not churn compatibility contracts merely
to match presentation wording.
Graph edits are transactional: validate the complete topology before mutation, wrap the operation in `graph.beforeChange()` / `graph.afterChange()`, and restore every original connection if node creation or any link operation fails. A successful insertion must be one undoable graph change and must dirty the canvas. The helper must not move, delete, or rewrite existing nodes or widgets, and model choices supplied by the UI must remain constrained to ComfyUI's native combo values for the inserted node.
## Change and snapshot protocol
The Node Assistant owns the corresponding picker UI. Replacement actions live in the selected-node toolbar, while insert-before/insert-after actions are enabled only when graph analysis reports a safe topology. The picker derives its complete candidate set from the target widget's native combo values, then provides client-side folder browsing, full-path search, and sorting. It must not broaden the list with models from another category or require metadata scanning merely to select a filename.
Every product-code change should end as one coherent local Git snapshot:
`model_picker.js` owns pure picker-type inference, base-model family normalization, and bounded upstream MODEL/CLIP traversal used to discover the nearest non-LoRA main model. Picker presentation metadata is resolved in one batch through `/anomalous/resolve_paths_to_previews`. The request supplies the native folder-type boundary inferred from the target widget, and the response may include preview URLs, model category, and cached sidecar metadata for each requested path. Picker cards expose category and `baseModel` badges using text-only DOM assignment. LoRA compatibility filtering uses the connected main model's `baseModel` metadata, falling back to the current LoRA's metadata only when the main model cannot be identified. This is a user-visible browsing filter, not Model Doctor identity evidence; missing metadata must remain accessible through an explicit unfiltered option.
1. Run checks proportional to the changed behavior.
2. Update architecture documentation **only** when the change modifies a module
owner, data flow, public/internal interface contract, persistence format,
security boundary, or critical invariant.
3. When architecture changes, update the narrowest relevant topic document.
Update this entry point only if the system map, cross-system invariants, or
reading map changed.
4. Do not add an architecture entry merely to say that existing boundaries were
unchanged. Ordinary fixes belong in code, tests, Git history, and—when useful
to users—`CHANGELOG.md`.
5. Record a durable lesson in `.agents/logs/ai_lessons.md` only for a recurring
trap or a critical failure mode, not as a turn-by-turn work log.
6. Create a local commit after verification. Keep unrelated work out of the
snapshot and do not push without explicit user authorization.
7. Leave a clean worktree, or identify every intentional uncommitted file in the
handoff.
### JavaScript Rules of Engagement:
1. **Strict Localization (双语)**:
- Never use hardcoded UI strings.
- Always use the ternary operator bounded to the global state: `window.anomalous_browser_lang === 'zh' ? '中文' : 'English'`.
- Never use variables like `currentLang === 'zh'` for DOM rendering, because they fail when closure scopes diverge from the global state.
2. **Avoid Global Scope Pollution**: Scope all IDs and classes with `anomalous-`.
3. **Z-Index Tiers & Overlap Prevention (层叠规范)**:
- Base UI: `10000`
- Overlay Modals: `10001` to `999999`
- *Critical Rule*: Never arbitrarily assign z-indexes. A child modal MUST have a strictly higher z-index to prevent the parent from consuming clicks.
4. **DOM ID Conflicts with CSS**: When assigning an `id` to an element dynamically, always `grep_search` `styles.css` first.
5. **Untrusted Text and Rich HTML**: Filenames, folder names, notebook names, workflow values, and metadata are untrusted. Use `textContent` or `escapeHtml()` for normal text. Only Civitai description/notes fields may retain formatting, and they must be inserted with `setSafeRichHtml()` from `safe_dom.js`; direct metadata assignment to `innerHTML` is forbidden.
## 5. Hash Resolver Subsystem & Scanning Engine (`hash_resolver.js` & `scraper.py`)
A highly complex subsystem responsible for automatically resolving missing/broken model references in workflows, and aggressively scanning models to build local caches.
### 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.
* 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.
* This boundary is part of the plugin's native product contract. Do not weaken, reinterpret, or bypass it without explicit user authorization.
### The Global Hash Cache (`window.anomalous_hash_cache`)
* This is the absolute core of the resolution engine. It maps physical filenames to their exact SHA256 hashes.
* It is fetched on startup by the optional resolver via `fetch('/anomalous/all_hashes')`. If the current ComfyUI frontend does not expose a compatible graph serialization API, the resolver disables itself with a warning; this must never prevent the main browser extension and its visible entry point from loading.
* Whenever a scan finishes (Scan Wizard or Deep Hash Scan), `window.anomalous_reload_hashes()` MUST be called to synchronize the frontend dictionary with the newly generated `.info` files on the disk.
* The cache exposes relative-path and basename aliases only when they are unambiguous. If two local models share a key but have different hash/size values, that alias is omitted instead of silently choosing one. Frontend lookups must prefer the full widget value before falling back to a basename.
### 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.
* 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`)
When "Deep Hash Scan" is triggered, it runs in a background thread to prevent UI lockup. It uses a triple fallback to identify models:
1. **Fallback 1 (Header Hash Match)**: Extracts `modelspec.hash.sha256` or Blake3/AutoV2 directly from the `.safetensors` header (O(1) speed). Hits Civitai API to fetch official metadata.
2. **Fallback 2 (Full File SHA256)**: If the header has no hash, it brutally calculates the full file SHA256 (Slow) and hits Civitai.
3. **Fallback 3 (Offline Inference)**: If Civitai returns 404, it reads the Tensor Fingerprints (keys like `cond_stage_model`) from the header to guess the Base Model (SDXL, SD 1.5, Flux, SD3) and builds a local `.info` file.
* **Offline hash injection**: After a fallback succeeds, the engine injects the discovered hash into the generated JSON. Consumers must still associate that entry with the current physical file using size/name matching; array position is not an identity guarantee.
* Physical rename conflicts are non-destructive. When the target filename already exists, both files must be hashed; deletion is permitted only when their complete SHA256 values match. Different files with the same generated display name must both be preserved.
### 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.
* 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`.
## 6. Known Gotchas & Critical Context (新接手必读)
* **Metadata Override Danger**: If you write to a `.civitai.info` file, ALWAYS read it first and update the dictionary. Never just dump `{custom_name: 'foo'}`.
* **Search Context Trap**: If the user searches using the sidebar while a detail panel is open, you MUST explicitly hide the detail panel and show the grid.
* **Media Leakage**: Destroying a DOM element that contains a playing `<video>` or `<audio>` does **not** stop the audio. You must `.pause()` it before doing `innerHTML = ''`.
* **Video Cover Compatibility & Interaction**: Any frontend component displaying model preview covers MUST check if the file is `.mp4`/`.webm`. The model-card setting owns the grid behavior: `always` may autoplay only while the card is near/in the viewport; `hover` loads/plays on pointer hover and pauses on leave. Both modes use muted/loop/playsinline and release their source when the browser closes. Single main covers may keep their established autoplay behavior. **CRITICAL GOTCHA**: Do NOT use `new URL(url).pathname` to check extensions! Our backend serves images via query parameters (e.g. `?filename=cover.mp4`), so `pathname` strips the filename. Always test the full URL using regex like `/\.(mp4|webm)(?:$|\?|&|#)/i`.
* **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`.
## 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.
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.
`CHANGELOG.md` is public user-facing release communication. Update it only for intentional user-visible release notes; never use it for internal design decisions, implementation details, test output, or agent handoff. Planning-only documentation may be committed separately, but proposals must be labeled clearly and must not be described as implemented runtime behavior.
## 66. Recipe Canvas Actions Use Append as the Safe Composition Path
Recipe cards and detail views intentionally expose only “Append to Canvas”. The removed “Open to canvas” action was ambiguous across ComfyUI versions: the legacy API replaced the current graph, while the current workflow-tab API opens a new temporary tab unless an internal active-workflow object is supplied. Keeping only append avoids both meanings and preserves the user's current canvas.
The separate structural-edit action may still load a recipe into a new canvas for editing; it is not a recipe composition action and remains explicitly confirmed. Successful append actions continue to hide the notebook body, recipe view, recipe modal panel, and outer modal before invoking the existing close/media cleanup.
## 67. Recipe Save Does Not Ask for Presentation Pins or Snapshot Policy
The save dialog records recipe identity, notes, tags, and cover/source image. It does not render the expandable “Choose key parameters” section or a model-preview snapshot policy checkbox: the complete serialized workflow remains the source of truth, and these implementation choices should not burden users who only want to save/share a recipe. The current bounded snapshot default is internal and can later move behind a Workflow Recipe global setting. Existing `params.pinned` data is preserved when editing an older recipe so removing the control is not a destructive metadata migration; new saves use an empty pin list.
## 68. Parameter Notebook Switches Must Identify the Right-Hand State
The Parameters tab is a two-pane history browser, so highlighting only the left notebook list is insufficient feedback. The right pane must show the active notebook name and timestamp in a prominent selection banner and animate the panel on a genuine notebook switch. Parameter-gallery requests carry a selection token and must discard stale responses after the user switches notebooks.
Saved parameter notes are individually removable from the notebook sidebar. The row uses sibling selection and delete buttons rather than nested interactive controls, asks for explicit confirmation, and posts only the validated notebook filename to `/anomalous/delete_parameter`. Deleting the active note clears its selection, invalidates in-flight parameter-gallery requests, reloads the notebook list, and selects the newest remaining note. The current recipe parameter baseline is not a stored notebook row and therefore cannot be deleted through this control.
## 69. Docked Notebook Controls Must Shrink as a Unit
The notebook create row contains a text input and a confirmation button. In the 660px docked browser, the notebook sidebar is reduced to 80px, so the input must have `min-width: 0` and the confirmation button must remain a non-shrinking fixed-size item. Otherwise the input's placeholder intrinsic width pushes the check button outside the clickable sidebar.
The output gallery is a user-refreshed view of ComfyUI's output directory. Opening the gallery performs one page-one scan, and the toolbar's Refresh button explicitly repeats that scan on demand. There is no background polling; the grid preserves scroll position when manually refreshed.
## 70. Keep the main browser entry module parseable
The floating trigger is created by `web/main.js`, but ComfyUI loads every JavaScript file in the custom node's `WEB_DIRECTORY` as an ES module. A syntax or duplicate top-level declaration in any imported module prevents `main.js` from registering and leaves no visible plugin icon, even when the backend routes and the optional hash resolver are healthy.
When a trigger disappears, verify the browser extension registry and load the main entry in isolation before changing CSS or z-index values. In particular, do not duplicate helper declarations while merging recipe UI changes; shared helpers in `ui_recipes.js` must have one definition per module. `node --check` is required for the affected module, and a real ComfyUI runtime check must confirm that `#anomalous-trigger-btn` is created.
## 71. Recipe and Node Assistant Integration Must Fail Loudly and Preserve Host State
The Node Assistant consumes parameter notebooks by node type, while the Recipe detail remains the authoritative source for full-workflow skeleton matching. The assistant's preset endpoint may cache notebook discovery briefly, but save/delete operations invalidate that cache and an explicit assistant refresh requests a fresh read.
Applying a node preset is a transactional live-widget operation: volatile seed slots are ignored, values are cloned safely, widget callbacks and ComfyUI's four-argument `onWidgetChanged(index, value, previousValue, liveWidget)` hook are invoked only when callable, and callback failures roll back the node before the UI reports failure. A successful write marks both graph and canvas dirty and emits `graphChanged` so recipe and assistant surfaces can refresh.
Prompt roles are derived only from an explicit allowlist of ComfyUI prompt nodes, consumers, and conditioning pass-through nodes. Link storage may be an array, object, or `Map`, and every linked role input is inspected. Unknown third-party nodes are opaque boundaries: neither their title nor their type name may turn an unresolved text into a positive or negative prompt. Saved role metadata is joined to assistant records using string-safe node IDs; legacy recipes use exact, unambiguous full-value recovery and otherwise remain unknown.
The assistant must not call removed doctor-panel methods from canvas selection hooks. Existing host selection callbacks are preserved, and the assistant hook only updates its own panel.
## 72. Legacy Prompt Roles Are Recovered from Full Prompt Values
Older recipes may contain correct `params.promptPositive` and `params.promptNegative` arrays but no per-node `role` field. When the Node Assistant prepares a node-type preset response, the backend compares the node's complete saved text widget value against those arrays and injects a role only when the match is unambiguous. An identical value present in both arrays remains unclassified rather than being guessed; explicit metadata roles always take precedence.
## 73. Prompt Roles Are Conservative and User-Correctable
The first supported automatic contract is intentionally narrow: ComfyUI's native `CLIPTextEncode` can receive a role only through allowlisted official sampler/Guider inputs and allowlisted conditioning pass-through nodes. An unresolved native prompt is `unknown`; a third-party text candidate is also `unknown` unless the user labels it. The detail panel states this limitation directly and never uses node titles, prompt content, or a default-positive rule as semantic evidence.
Manual labels are recipe-skeleton metadata stored in `params.promptRoleOverrides`, keyed by string-safe node ID and guarded by the saved node type. Supported values are `positive`, `negative`, `both`, `ignored`, and `unknown`; removing an override restores the saved automatic result. Recipe updates retain an override only while node ID and type still match. The Parameters view and Node Assistant must consume the recipe override before automatic or legacy metadata, so all surfaces show the same role. Future third-party adapters may expand the allowlist, but unknown nodes must remain conservative until explicitly supported.
## 74. MIT Copyright Permission and Project Branding Are Separate
`LICENSE` remains the unmodified standard MIT license and is the copyright license for repository code, documentation, stylesheets, and ordinary UI resources unless a file expressly says otherwise. Do not append project-specific restrictions to that license or describe ordinary UI implementation as proprietary while simultaneously licensing the repository under MIT.
`TRADEMARKS.md` owns the separate source-identification boundary for the name **Anomalous Model Browser**, stylized presentations of that name, and any graphic explicitly designated as the official logo. It permits truthful references, links, compatibility statements, unmodified redistribution, and clear “based on” attribution. Public modified distributions use a distinct primary name/logo and must not imply official status or endorsement. README summarizes and links to both documents; legal-policy changes must be intentional and must not be hidden in engineering changelogs.
## 75. Beta Workflow Features Carry Localized Data-Safety Guidance
Workflow Recipes and the Node Assistant's recipe-powered parameter presets are explicitly presented as beta features until a stable release decision removes that status. The Node Assistant's Actions tab (visual model replacement and LoRA insertion) is mature and outside that beta boundary. The Recipe list renders its compact notice at library level, while the assistant notice belongs inside the hidden-by-default Parameter Presets container and appears only when that tab is active; no warning sentence is hardcoded in a UI module. The Settings Help content and bilingual README repeat the operational boundary and identify `workflows/anomalous_recipes` plus `workflows/anomalous_parameters` under the active ComfyUI user directory as the data to back up.
Documentation distinguishes the two application scopes: Node Assistant presets update the currently selected same-type node and ignore volatile values, while a Parameter Notebook's “Apply to Current Workflow” verifies the complete recipe skeleton before updating safe widgets. Model-preview snapshots are presentation assets, never model-file backups. README and Help must remain bilingual and describe the same behavior whenever these workflows change.
`locales.js` is the authoritative source for every user-visible runtime string in this feature. Chinese and English dictionaries must have identical key sets, no duplicate keys, and all statically referenced `t('key')` calls must resolve in both dictionaries. Language parity and static-key coverage are release checks for changes to Recipe, Assistant, Settings, or Help surfaces.
Persistent beta notices carry `data-anomalous-i18n-key` metadata. The sidebar language switch refreshes those live nodes in place and rebuilds the Node Assistant around the current selection, so changing language does not require closing either surface and cannot leave its warning, placeholder, or selected-node content in the previous language. Beta notices must be scoped to the exact experimental interaction rather than a stable parent surface. Any future persistent localized notice should use the same contract.
## 76. Recipe-to-Model Navigation Is an Exclusive, Recoverable Transition
Opening a local model from Recipe Overview temporarily replaces every main content surface: grid, Gallery, Model Doctor, Node Assistant, parameter panel, and Workspace are hidden before the model detail becomes visible. The recipe payload, active tab, scroll position, and original `workspaceReturnState` remain available across the transition, while `recipeModelReturn` exclusively owns reconstruction of the recipe detail; model detail and Node Assistant must never be visible together.
There are two valid ways to end this transition. The model detail Back action (or reopening Workspace and choosing Workflow Recipes) consumes `recipeModelReturn` and reconstructs the exact recipe detail. Navigating directly to another main panel or closing Workspace abandons it: stale payload/scroll state is cleared, detail media and DOM are released, and the recipe list plus action bar are restored. `showRecipes()` also normalizes those list surfaces defensively whenever no detail is active, so a lost callback cannot produce a blank Recipe body. Opening Workspace while a recipe-model return is pending must not overwrite the earlier `workspaceReturnState`, otherwise closing the reconstructed recipe could restore an empty model-detail panel.
Decision records explain enduring choices; they are not a chronological diary.
Git history is the authoritative record of implementation changes. Planning-only
documents must be clearly labeled as proposals and must not describe unshipped
behavior as current architecture.
+153 -1
View File
@@ -1,5 +1,153 @@
# 📈 Anomalous Model Browser Changelog
## v1.57.2 Beta (Hotfix: Canvas Performance, Saving Workflows) — 2026-09-27
### 💾 Saving workflows and Prompt Notes (保存工作流与提示词笔记)
- **Saving a workflow works again (保存工作流失败)**: since v1.57, **Save Current Workflow** failed with "Could not save this Workflow Recipe" for any workflow with a model loader, because splitting the backend into smaller files left the save path calling a function that was no longer imported. The same split also made model cards load full-size cover images instead of thumbnails (slow on big folders), and made editing details of a model that had moved fail.
- **Prompt Notes with UNet models (提示词笔记与 UNet 模型)**: sending a note with a UNet / diffusion model to the canvas built a graph that could not run, because its LoRAs waited for a CLIP the UNet does not have. LoRAs of a UNet now load model-only, and the note says to connect your own text encoder to the two prompt nodes. Models whose metadata names no base model (most models not downloaded from Civitai) were missing from every group; they now appear under **No base model listed**. Listing models no longer blocks ComfyUI while it reads the folders.
- Installed v1.57.2 before 2026-09-27 through the version panel? Pick v1.57.2 there once more to get this build; updating through ComfyUI Manager gets it as usual.
### ⚡ Smooth canvas dragging again (画布拖动卡顿修复)
- **Dragging and zooming the canvas no longer slows down with this plugin installed (安装插件后拖动画布掉帧)**: one stylesheet rule, which only hid the sidebar tooltip while the Toolbox or Settings popup was open, made the browser re-check the whole page every time any element's inline style changed. ComfyUI moves every text-box widget on every frame while you pan or zoom, so each frame paid for it, even with the browser closed. Measured on a 300-node workflow in ComfyUI: style work while panning drops from about 11× the no-plugin cost back to the same as without the plugin.
- **Large workflows with ComfyUI Prompt Assistant (与提示词小助手同时安装时的严重卡顿)**: the cost grew with the number of page elements, and Prompt Assistant adds a row of buttons to every text box, so together style work took about 19 ms per frame (more than a whole 60 fps frame): single-digit frame rates or freezes on big workflows. It is now back to the level of Prompt Assistant alone. The two plugins never conflicted otherwise.
- The tooltip still stays hidden while the Toolbox or Settings popup is open; the popups already hide it when they open and it does not reappear until they close.
### 🧩 Material Library with a node selected (选中节点时素材库卡片错位)
- **One apply button per card (每张卡片只有一个应用按钮)**: with a node selected, the Material Library showed every card's **Apply Scheme** button twice, each with a doubled ⚡ icon.
- **No more overlapping text (卡片文字不再重叠)**: the extra button squeezed the badges, date, node types and tags on top of each other. Cards in apply mode now get a taller row with the button at the bottom; applying works as before.
## v1.57.1 Beta (Version Panel: Update, Switch & Roll Back) — 2026-09-23
### 🏷️ Version & Updates Panel (版本号与一键更新 / 版本切换)
- **Version entry in the update guide (更新引导顶部的版本号与更新入口)**: the installed version (e.g. `v1.57.1-beta`) and a **Check for updates / switch version** button now appear at the top of the update guide (opened via the header **!** button), avoiding accidental clicks near the tool dock or crowding the header in docked/scaled layouts. Reading it is local only; nothing goes online.
- **Manual "Check for updates" (手动检查更新)**: the plugin never checks by itself. Clicking **Check for updates / switch version** opens the panel, and only the **Check for updates** button contacts GitHub. Each published release shows its notes, date and a GitHub link; pre-releases are hidden by default and are never offered as the latest version.
- **One-click update to published releases (一键更新到正式发布版本)**: updates only target releases published on GitHub, not in-progress commits, so what you install is a released build.
- **Switch or roll back (切换 / 回退版本)**: any published release can be installed from the list. Rolling back warns that older versions may not read settings or recipes saved by newer ones; nothing is deleted, and it all works again after returning.
- **Back to latest & undo (回到最新版 / 撤销上次切换)**: **Back to latest** returns to the default branch and pulls the newest code; **Undo last switch** returns to exactly where you were before. Versions older than v1.57.1 have no panel, so from those use **Update** for this plugin in ComfyUI Manager to come back.
- **Safe by default (安全保护)**: every switch is refused while plugin files have local modifications (the files are listed), and a local branch with unpublished commits is never overwritten.
- **Restart (重启生效)**: after a switch the panel offers to restart ComfyUI through ComfyUI Manager; without Manager it asks you to restart by hand.
## v1.57.0 Beta (Toolbox Architecture, Gallery Search & Polymorphic Drag) — 2026-09-23
### 🎨 Pure Vector Iconography Overhaul (纯代码矢量图标体系全面升级)
- **Universal Code-Native SVG Icons (全套规范化纯代码矢量 SVG 图标)**:
- Completely phased out legacy emojis and pixel icons in favor of lightweight, mathematically crisp 24x24 code-native vector SVGs (`stroke-width: 2`, rounded joins/caps).
- Redesigned the floating entry trigger orb with the **Anomalous Hypercube Core** (`1b985ba`) and frosted glass lighting effect, maintaining smooth rendering and subtle hover micro-interactions.
- Sidebar and toolbox action buttons upgraded with distinct vector geometry: 3D Asset Cube for Toolbox, Radar Pulse for Scan Wizard, Stethoscope for Model Doctor, Four-Point Spark for Node Assistant, Layered Sheets for Material Library, and Obsidian Gear for Settings.
- Eliminated icon flicker, CSS pseudo-element clipping, and alignment shifts across all display scales.
### 🔍 Multi-Dimensional Gallery Search (历史出图图库多维检索与标签块)
- **Multi-Field Real-Time Search (提示词/模型/种子/哈希多维检索)**:
- Upgraded the native output gallery header with an intelligent search bar capable of parsing prompts, model file names, exact numeric seeds, and cryptographic SHA-256 model hashes simultaneously (`1476faf`).
- Search terms are dynamically converted into visually distinct, standalone **Search Blocks (搜索标签块)** (`e077689`).
- Each search block clearly displays its matched category and provides an accessible `×` button for one-click removal, enabling flexible compound filtering without retyping.
- Backed by optimized chunk parsing (`api/image_search.py`) that reads only header metadata prior to pixel buffers, ensuring zero-lag gallery navigation.
### 🚀 Polymorphic Canvas Drag & Drop (全域多态画布拖拽架构)
- **Universal Canvas Drag (全域多态画布拖拽)**:
- Dragging workflow materials onto empty canvas automatically reconstructs and opens the full workflow.
- Dragging prompt-only materials onto empty canvas auto-creates native `CLIPTextEncode` nodes with pure prompt text, bilingual titles, and standard ComfyUI dark theme colors (`#532323` for negative / `#235327` for positive).
- Dragging materials onto existing canvas nodes prioritizes the target node, injecting matching widget values and prompt text via semantic widget sniffing while strictly filtering out model file paths and loader blocks.
- **Floating Trigger Pre-Mount & Anti-FOUC (悬浮入口预挂载防闪烁与安全吸附)**:
- Completely eliminated FOUC flicker on page refresh via pre-mount positioning and smooth opacity reveal (`53c9f1b`).
- Hardened PointerEvents persistence, safe coordinate validation, and boundary clamping to prevent trigger entrapment or collapsing to the viewport origin (`09b06d1`).
### 🧰 Toolbox Catalog & Dedicated Bottom Dock (实用工具箱与底部固定快捷栏架构)
- **Dedicated Bottom Action Bar (结构化底部快捷栏)**:
- Streamlined the lower-left sidebar into a dedicated 6-button utility bar:
1. **Toolbox (🧰 实用工具箱)**: Anchored as the primary launcher on the left to access secondary tools.
2. **Scan Wizard (🔄 扫描向导)**: Direct shortcut to launch model library scanning.
3. **Model Doctor (🩺 模型医生)**: Direct shortcut to diagnose and auto-heal missing workflow models.
4. **Node Assistant (🤖 节点助手)**: Direct shortcut for canvas node inspection, model swapping, and parameter injection.
5. **Material Library (✨ 素材库)**: Direct shortcut to manage curated material bundles and polymorphic canvas drag-and-drop.
6. **Settings (⚙️ 设置)**: Fixed anchor on the right for interface preferences.
- **Toolbox Modal Catalog (实用工具箱浮窗面板)**:
- Clicking the first icon (**🧰 实用工具箱**) opens a dedicated popup panel housing 5 secondary tools not on the main dock:
1. **Workflow Transfer Center (导入导出)**: Lossless AMB format workflow share code import and export.
2. **Prompt Studio (提示词工坊)**: Modular prompt mixer deck for lego-block prompt extraction, reordering, and assembly.
3. **Prompt Translator (翻译助手)**: Bilingual real-time English/Chinese prompt translation.
4. **Model Source Hub (模型来源)**: Online model source URL inspector with instant canvas Note node generation.
5. **Prompt Notes (提示词笔记)**: Lightweight notebook for drafting and organizing reusable prompt snippets (`workflows/anomalous_notebooks`).
- **Enhanced Sidebar Tooltips & Reveal Timing (侧边栏操作提示与动画优化)**:
- 100ms quick hover text replacement on shortcut buttons and 600ms rich singleton floating tooltip bubble (`#anomalous-sidebar-tooltip-bubble`) with bold title and muted explanation, cleanly avoiding CSS pseudo-element clipping issues.
- Clicking or pressing any action button immediately suppresses hover text and floating tooltips.
- Maintained elegant obsidian glass / dark monochromatic aesthetics without harsh or distracting bright colors.
### Navigation and update guide
- Sidebar icons reveal compact text labels on hover or keyboard focus while keeping their existing button size and actions. Detailed hints explain each tool; scan-state and language changes retain labels. Unclipped tooltips ensure explanations are fully readable.
- Update guide opens only from the persistent top-right exclamation button or Help. Dismissing never removes the entry. Sidebar labels slide upward on hover/focus; duplicate source-panel and obsolete notice styles were removed. See `docs/architecture/update-guide.md`.
### Model Source Hub (模型来源中控中心)
- Added a unified Model Source Hub under Toolbox for dual-scope (current workflow and full library) model source URL inspection, one-click browser navigation to Civitai/HuggingFace/Liblib/ModelScope, custom source editing, workflow metadata synchronization (`extra.anomalous_model_sources`), and instant canvas `Note` node generation for non-intrusive workflow documentation.
### Export availability
- Removed Prompt Studio's JSON export action and closed the Material Library file-import entry; copying prompts and local saves remain available.
- Recipe package import/export is disabled in the UI and API, including both upload inspection and import commit. The verified AMB0/AMB1 workflow Import / Export Center is restored in Toolbox as the only active import/export center. Ordinary downloads and image/workflow hash metadata are unchanged.
- Studio source cards automatically follow library additions, edits and deletions, display their origin, and cannot be deleted from the studio. Failed synchronization preserves existing cards; mixer copies remain editable.
### Material Library
- Fixed grouped material filters, Recipe scope/tag filters, and prompt studio imports using actual detail payloads. Fixed side-studio draft reset/return state and guarded recipe drops against canvas changes. Recipe model status no longer treats saved hash verification as current availability.
- Separated the lower-left Material Library from the Recipes/Prompt Notes workspace. Added canvas drag-and-drop: parameter materials replace matching node values, while the simplified prompt panel inserts text at the beginning or end, with undo.
- Material Library now opens from the lower-left control; Workflow Recipes stays at the upper right. The workflow Import / Export Center is available in Toolbox.
- Added Prompt Combinations: ordered general/specific fragments, enable switches, separate positive/negative content, and saved snapshots.
- Library cards can apply matching parameters to one selected node through the same transaction as Node Assistant, with seed preservation, native-option validation and guarded undo. Prompt combinations support explicit text replacement or append.
- Material details initially expand prompt cards only; other node parameters render on first expansion. Details omit the complete source workflow, which loads separately when opening a full snapshot. Selected-node materials never return their hidden source graph.
- Material capture supports PNG workflows, Recipe parameters and Prompt Notes. Applying reference images to LoadImage remains future work.
- Added search by name, tag, or node type, type/tag filters, and pagination. Material names and tags can be edited in place without changing their workflow.
- Saving the same source image and node selection now prompts before creating another copy. Save labels distinguish full image/workflow snapshots, node parameters, and Prompt Notes.
- Shared parameter inspection between the library and image workbench, improved keyboard card access and clipboard failure feedback, and fixed confirmation dialogs being covered by the workbench.
### Reliability
- Material browsing reuses signature-validated summaries and reads full workflows only for matching node types. Gallery pagination shares a short-lived directory snapshot; refresh and deletion invalidate it immediately.
- Prompt Notes now use the ComfyUI user directory with recoverable legacy copying, atomic saves, and visible save failures. Material saves stage their files and roll back new assets if persistence fails.
- File identity now uses full-file SHA-256 only. BLAKE3 and tensor/header digests cannot become verified file identities. Older offline metadata is reverified on demand while preserving personal notes.
### 🩺 Model Doctor
- **Manual size fallback (模型大小手动兜底)**: A unique same-size local model is now presented for explicit confirmation instead of being applied automatically. Background checks never apply it, while hash/size identity conflicts are rejected and shown in the Doctor dashboard.
### 🧰 Workflow Recipes
- **Scope-aware recipes (局部/整体配方)**: Graphs with open required connections or no runnable output are saved as partial recipes and append to the current canvas. Independently runnable graphs are saved as complete recipes and open in a new workflow canvas.
- **Optional save-time model verification (保存时可选模型核验)**: Recipe saving now performs a lightweight cached-identity check. Recognized model references missing identity are listed in the save dialog, while full SHA-256 calculation remains explicitly opt-in. This is especially useful for hash-only Model Doctor categories such as VAE and text encoders.
- **Model lifecycle consistency (模型生命周期一致性)**: Partial append remaps and merges node-scoped model Hash records, while applying a local model match updates the workflow value, structured model reference, base-model summary, and Hash index together.
- **Recipe-scoped model notes (配方模型备注)**: Each model reference can keep a bounded personal note. Package export asks whether to include these notes, and version comparison reports note and identity changes.
- **Material Library MVP (统一素材库 MVP)**: Main Gallery and Recipe gallery images now expose a non-intrusive two-column parameter detail view and can be saved as one curated bundle containing the original image, exact embedded workflow, model provenance, and reusable node blocks. Core metrics remain concise, while a lazy two-level inspector exposes every serialized node widget value, node properties, mode, and volatile-parameter marker on demand. Every node card now has a direct “Save to Library” action; checked nodes share a colocated multi-save action above the list, while the footer is reserved for the full image/workflow snapshot. One generation-settings action saves the sampler and size nodes together, and prompt cards expose topology-based positive/negative detection, manual role overrides, and direct node saving. Saved Material details preserve those overrides and allow returning to automatic detection. Workflow Recipe parameters and saved Parameter Notebooks can now publish all widget-bearing nodes, one node, or an explicit node selection as image-free Recipe Parameter materials. The ambiguous plain-text share action has been removed in favor of a visible Material Library save entry. Focused node materials feed Node Assistant without exposing a misleading full-workflow action. Saved Material cards stay compact and open a focused in-library inspector with the uncropped reference image on the left and scoped model/node parameters on the right; returning releases the full response and detailed DOM. Detail navigation bounds parsed metadata to a 16-entry LRU, cancels obsolete reads, avoids a second full-PNG browser allocation, removes duplicated widget payloads, and defers local-model preview resolution until the Models tab opens. Main Gallery keeps its existing click, drag, delete, and cover-selection behavior. The Workspace Material Library opens complete snapshots in a new canvas, while Node Assistant filters matching blocks by the selected node type, skips volatile seeds, and hands remapped Hash evidence to Model Doctor.
### ✨ Material capture and simpler pages
- 图片、配方参数与提示词笔记均可保存到素材库,保存后直接查看。提示词笔记支持整份组合或仅提示词,并可从素材载入为新笔记。
- 图片保存表单、用途调整、节点技术信息、笔记模型区与配方多选改为按需展开;配方参数页去掉重复数值,统一使用“参数方案”名称。
### ✨ Interface & Access
- **Native Configurable Shortcut (原生可配置快捷键)**: Added `Ctrl + Shift + M` as the default shortcut for opening the browser. The plugin's Interface settings show the currently active binding and open ComfyUI's native key recorder for customization. Conflict detection, overwrite confirmation, removal, reserved-key guidance, and modal guards therefore remain centralized without a separate global keyboard listener.
### 🐛 Bug Fixes
- **Prompt Studio Preview Popover Safe Zone (提示词预览浮窗全域安全区与防消失机制)**: 解决鼠标从词卡划向右侧浮窗按钮过程中浮窗提前消失的问题。引入全局动态指针安全区判定(`isPointInSafeZone`),将卡片、浮窗(向外各扩展 40px)与空间通道全部纳入安全驻留区,光标移动即实时取消关闭计时;并在定时器回调中执行终审坐标校验;结合 100ms 展开防抖与 `[📌 固定]` 钉住功能,确保用户可从容点击复制与添加按钮。
- **Live Language Switching (语言即时切换)**: Changing the plugin language now immediately re-translates the open native Interface settings—including labels, tooltips, and combo choices—as well as the browser UI, without refreshing ComfyUI.
## v1.56.1 Beta (Interface Preferences & Scan Reliability) — 2026-08-26
### ✨ Interface & Access
- **Native Interface Preferences (原生界面偏好)**: Added one native ComfyUI entry-mode setting with three strictly exclusive choices: floating button, action-bar button beside the run controls, or Extensions menu only. Floating size/style controls now appear only when floating mode is selected. A colocated language preference follows ComfyUI by default or applies a plugin-only Chinese/English override, and the existing in-browser language shortcut updates that same native setting. Entry selection remains authoritative during action-bar redraws, and every entry reuses the same browser instance and open path.
### 🐛 Reliability & Scan Feedback
- **Interrupted Scan Recovery (异常扫描自动恢复)**: Closing ComfyUI during a folder or global scan no longer leaves the browser permanently locked. Versioned scan ownership records distinguish live workers from stale or legacy markers, clean interrupted state automatically, and allow an immediate retry without reinstalling the extension.
- **Real Scan Progress (真实扫描进度)**: Folder, custom-selection, and global scans now show a non-blocking localized progress panel backed by the scanner's actual folder/model counts and current filename. Reopening the browser reconnects to the running task through the existing status polling contract.
## 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.
@@ -115,7 +263,11 @@
- **Offline Base Model Inference**: Added a zero-API local inference engine! For models downloaded purely from HuggingFace (or private unreleased models) that return a 404 on Civitai, the scanner no longer gives up. It now forcibly parses the .safetensors structure and uses **Tensor Fingerprinting** (e.g., detecting double_blocks.0.img_attn for Flux) to accurately deduce the underlying base architecture with 100% precision.
- **Universal UI Integration**: Successfully inferred offline models are dynamically assigned a virtual .info payload (ID: -1). This instantly grants them full VIP access to the frontend ecosystem—they seamlessly appear in the Cross-Folder Radar, interact perfectly with the bilingual Notebook, and support one-click Auto-Inject loaders, all completely completely offline!
# 📈 Anomalous Model Browser Changelog
## v2.0.0 (The Workflow & UI Evolution Update)
### 🚀 Major Features
+11
View File
@@ -0,0 +1,11 @@
# Gemini 工作入口
在本插件中修改任何文件前,先阅读并遵守根目录的 [AGENTS.md](AGENTS.md)。它是本仓库开发与维护规范的唯一正文,适用于 UX 调整、修复和重构。
随后阅读 [ARCHITECTURE.md](ARCHITECTURE.md),按其中的阅读地图加载与当前任务有关的专题。
不要只凭当前任务描述直接追加实现。先确认现有责任模块、状态来源、关闭清理和样式规则;完成后按规范验证并报告结果。
如果当前工具没有加载本文件,任务发起者应明确要求 Gemini 阅读 `GEMINI.md` 和 `AGENTS.md`。文件的存在不代表工具已实际读取,更不代表检查已自动执行。
前端设计任务另读 [前端设计与协作方案](docs/plans/frontend-design-brief.md);具体功能计划按该文链接进入,不使用历史视觉建议作为当前任务单。
+174 -122
View File
@@ -1,159 +1,211 @@
# 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.57.2_Beta-blue?style=for-the-badge)](CHANGELOG.md)
[![Bilibili Video](https://img.shields.io/badge/Bilibili-视频演示-00A1D6?style=for-the-badge&logo=bilibili)](https://www.bilibili.com/video/BV1a1bv68EuA/)
[![YouTube Video](https://img.shields.io/badge/YouTube-Video_Demo-red?style=for-the-badge&logo=youtube)](https://youtu.be/hAvsj7uiaCw)
<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 a comprehensive creative workspace and model management suite for ComfyUI. It integrates hash-based metadata indexing, workflow healing (Model Doctor), canvas visual model swapping (Node Assistant), a unified Material Library with polymorphic canvas drag-and-drop, and immutable Workflow Recipes.
> [!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 |
| 🚀 Primary View | 💡 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. |
| **Model Browser (📦 Models)** | Browse models categorized by folder (checkpoints, unet, loras). Displays Civitai metadata, triggers, architectures, and high-res covers. Supports visual swapping, renaming, and notes. |
| **History Gallery (🖼️ Gallery)** | Native viewer for the ComfyUI `output` directory. Features multi-dimensional search (prompt, model, seed, hash) with removable filter blocks, mouse-wheel zoom, and drag-to-canvas workflow reconstruction. |
| **Workflow Recipes (🪡 Workflows)** | Save full workflows or partial subgraphs. Displays live model readiness status (e.g. all models ready). Open recipes in a new canvas or drag cards directly onto the canvas to load. |
| **Toolbox & Bottom Dock (🧰)** | Lower-left navigation bar featuring the Toolbox as the first icon. Directly hosts 4 frequent tools (Scan, Doctor, Assistant, Material Library) plus Settings. Clicking the Toolbox opens a panel containing 5 secondary utilities (Workflow Transfer, Prompt Studio, Prompt Translator, Model Source Hub, Prompt Notes). |
| **Workflow Repair (Model Doctor 🩺)** | Automatically detects broken nodes in external workflows and reconnects them to local models by comparing SHA-256 hashes and file sizes. |
| **Node Assistant & Presets (🤖)** | Canvas-linked sidebar. Select any node on the canvas to inspect covers/triggers, visually swap models, or apply recipe parameters transactionally. |
| **Material Library & Polymorphic Drag (✨)** | Save curated bundles from output PNGs (workflow, provenance, node parameters). Drop onto canvas to open workflow, drop prompts to auto-create native CLIP nodes, or drop onto existing nodes to inject parameters. |
### 📖 Step-by-Step Operating Guide
### 📦 Quick Installation
#### 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.
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`)*
3. **Updating**: click the **!** button in the browser header; the current version is shown at the top, and **Check for updates / switch version** opens the version panel. From there you can update to the newest published release, roll back to an earlier one, or return to the latest version, then restart ComfyUI. The plugin never checks for updates on its own.
#### 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.
Open the browser via **Ctrl + Shift + M**, the floating canvas orb (**📦**), or **Extensions → Anomalous Model Browser** in ComfyUI's menu. **ComfyUI Settings → Anomalous Model Browser → Interface** lets you customize the shortcut, configure the UI language, and select an entry mode (floating button, action-bar button, or Extensions menu only).
#### 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.
<br/>
#### 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.
<details>
<summary><b>📖 Click to Expand: Step-by-Step Operating Guide</b></summary>
#### 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.
<br/>
#### 6. Top Navigation & Prompt Notes 📑
**Location**: The top tabs: **Models (📦)**, **Gallery (🖼️)**, **Creative Workspace (📑)**, and **Dock Side (◧)**.
#### 1. Top Navigation Views (三大顶栏视图)
The header provides 3 primary workspaces:
* **Models (📦)**: Main model catalog. The left panel shows folder trees (`checkpoints`, `unet`, `loras`), while the center grid displays model cards with previews, trigger tags, and metadata.
* **Gallery (🖼️)**: Historical output browser. The top search bar filters outputs by prompt, model name, seed, filename, or model hash; each search keyword forms an independent block with a `×` button for instant removal. Dragging an image onto the canvas reconstructs its embedded workflow.
* **Workflows (🪡)**: Workflow Recipe studio. Filter by `[All]`, `[Full Workflows]`, or `[Subgraphs]`. Click `Save Current Workflow` to save the active canvas, or drag any recipe card directly onto the canvas to load it.
**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!
#### 2. Bottom Navigation & Toolbox Catalog (🧰 第一个图标:实用工具箱)
* **Dock Layout**: The lower-left navigation bar hosts 6 permanent buttons in a fixed layout:
1. **Toolbox (🧰)**: First anchor on the left. Opens the Toolbox panel for secondary tools.
2. **Scan Wizard (🔄)**: Direct shortcut to launch model library scanning.
3. **Model Doctor (🩺)**: Direct shortcut to diagnose and repair missing workflow nodes.
4. **Node Assistant (🤖)**: Direct shortcut to inspect canvas node models and inject parameter presets.
5. **Material Library (✨)**: Direct shortcut to manage curated bundles and drag assets to the canvas.
6. **Settings (⚙️)**: Fixed anchor on the far right for global preferences.
* **Toolbox Catalog**: Clicking the first icon (**🧰 实用工具箱**) opens a dedicated popup panel containing 5 secondary utilities:
1. **Workflow Transfer Center (⇄ 导入导出)**: Lossless AMB format workflow share code import and export. Allows importing workflows from text codes or exporting the current canvas with embedded hashes.
2. **Prompt Studio (🎛️ 提示词工坊)**: Modular prompt mixer deck. Extract prompt cards from history or materials, reorder segments, and assemble positive/negative prompts.
3. **Prompt Translator (🌐 翻译助手)**: Built-in bilingual prompt translation between Chinese and English without leaving the interface.
4. **Model Source Hub (🔗 模型来源)**: Dual-scope (workflow and library) download URL inspector. Navigates to Civitai, HuggingFace, Liblib, or ModelScope, and generates non-intrusive canvas Note nodes.
5. **Prompt Notes (📑 提示词笔记)**: Lightweight notebook for drafting, editing, and managing reusable prompt snippets saved locally in the user directory (`workflows/anomalous_notebooks`).
---
#### 3. Scan Wizard (🔄 扫描向导)
* **Location**: Direct shortcut on the bottom dock (2nd icon).
* **Operation**: Scan configured model directories to compute file hashes, download Civitai covers, tags, and architectures, and build the local offline database.
### 📦 Installation
#### 4. Model Doctor (🩺 模型医生)
* **Location**: Direct shortcut on the bottom dock (3rd icon).
* **Operation**: When loading an external workflow with missing red nodes, Model Doctor inspects embedded provenance hashes and file sizes to reconnect local matches automatically. The "View Hash" button opens a side-by-side SHA256 comparison modal.
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!
#### 5. Node Assistant & Parameter Presets (🤖 节点助手)
* **Location**: Direct shortcut on the bottom dock (4th icon).
* **Actions**: Select a model node on the canvas to view high-res previews and trigger words, or swap models visually. Switch to **Parameter Presets** to inject recipe-saved node values (e.g., KSampler steps, CFG, denoise) into matching canvas nodes with safe seed preservation.
---
#### 6. Material Library & Polymorphic Canvas Drag (✨ 素材库与多态拖拽)
* **Location**: Direct shortcut on the bottom dock (5th icon).
* **Capture**: In Gallery or Workflows, open an image's parameter inspector to save its image, workflow, and node blocks as a curated material bundle (`workflows/anomalous_materials`).
* **Polymorphic Canvas Drag**:
- Dragging a workflow material to an empty canvas area opens the full workflow.
- Dragging a prompt-only material to an empty canvas auto-creates native `CLIPTextEncode` nodes with distinct negative (`#532323`) and positive (`#235327`) styling.
- Dragging materials onto existing canvas nodes injects matching parameter values directly.
<h2 id="中文">🇨🇳 中文</h2>
> 一个综合性的 ComfyUI 创作工作台与模型管家。集成零依赖的 C 站信息抓取、支持自动修复的模型医生、可视化节点助手、无损工作流交互,以及支持参数复用的工作流配方与笔记本体系。
#### 7. Global Settings (⚙️ 设置)
* **Location**: Direct shortcut on the far right of the lower-left navigation bar (6th icon).
* **Options**: Interface language, font scale, thumbnail rendering, video cover hover behavior, and model folder blacklist.
> [!WARNING]
> **测试功能与数据保护:** 工作流配方以及节点助手中由配方驱动的“参数笔记本”目前属于测试功能;节点助手的“动作”页不在测试范围内。更新插件、导入他人配方、恢复或删除配方数据、应用已保存参数之前,请先保存当前画布,并备份 ComfyUI 用户目录中的 `workflows/anomalous_recipes` 与 `workflows/anomalous_parameters`。配方中的模型预览快照不包含模型文件,不能替代模型备份。
> **Beta Data Protection:** Workflow Recipes, Material Library, and Parameter Presets are currently in active preview. Please back up `workflows/anomalous_recipes`, `workflows/anomalous_materials`, and `workflows/anomalous_parameters` inside your ComfyUI user directory before updating.
### 🌟 核心功能特性
| 🚀 功能板块 | 💡 详细说明 |
| :--- | :--- |
| **全方位模型管理** | 通过扫描哈希值,自动获取模型的基础架构、封面、提示词、作者简介等信息。支持自定义名称、备注和封面。点开模型即刻呈现封面图标,支持看图一键替换模型,彻底告别复杂的模型路径! |
| **节点智能修复 (🩺)** | 凡是由本插件导出的工作流和图片,在载入他人工作流时出现节点爆红,只需点开**模型医生 (🩺)**,即可自动识别正确的模型路径并一键替换。内置深度扫描与 C 站直达链接。 |
| **节点助手与配方参数 (🤖)** | 选中画布节点后,可视化替换模型、向兼容链路安全插入 LoRA,或读取工作流配方保存的同类型节点参数。例如保存过的 K 采样器笔记可一键应用采样器、调度器、步数、CFG 与降噪,同时忽略易变的种子。 |
| **工作流配方 (🧰)** | 保存完整工作流及封面、备注、标签、模型身份、提示词、采样参数、节点参数、匹配出图与版本历史。支持追加到画布、比较出图/版本参数、将他人的模型引用匹配到本地文件,以及导入导出便携配方包。 |
| **智能笔记本 (📑)** | 基于本地基础架构,提供快速的架构匹配功能,极速选择兼容的主模型与 LoRA。内置强大的翻译功能,自动将提示词分块打上标签,实现双语对照,并支持一键发布组装到工作流画布中。 |
| **原生图库管理 (🖼️)** | 无缝读取本地的 `output` 文件夹。支持鼠标滚轮缩放、一键查看和安全删除。更绝的是,您可以**直接将图片拖动到画布上,瞬间原地加载内嵌的工作流!** |
| **其它便捷功能** | 点击模型卡片右上角的 `➕` 号,快捷将节点发布到画布。支持侧边栏停靠 (Dock),将界面吸附在左侧,为您的画布留出充足的操作空间。 |
### 📖 标准操作指南
为了让插件发挥最大效能,请按照以下流程进行操作:
#### 1. 前置准备 (扫描向导) 🔄
* **具体位置**:界面左侧底部的 **扫描图标 (🔄)**。
* **操作步骤**:这是最重要的一步!请先打开扫描向导,根据需求选择扫描配置并执行扫描(切记保持网络畅通)。扫描完成后,您的本地数据库建立完毕,插件的各项核心功能全面激活。
#### 2. 拯救爆红 (模型医生) 🩺
* **具体位置**:左侧底部第二个 **听诊器按钮 (🩺)**。
* **操作步骤**:当导入别人使用该插件输出的工作流或者图片时,若发现模型路径爆红报错,点击模型医生,它就能智能识别报错节点,并一键实现正确路径的替换。
#### 3. 选中交互 (节点助手) 🤖
* **具体位置**:左侧底部第三个 **机器人按钮 (🤖)**。
* **动作**:打开后在 ComfyUI 画布上选中节点。对于模型节点,“动作”页可以可视化替换当前模型,或在兼容的 MODEL + CLIP 链前后插入 LoRA。选择器提供模型预览、模型类别/基础模型标记、文件夹、完整路径搜索、排序与多分支安全保护。
* **参数笔记本(测试)**:切换到“参数笔记本”,可以读取与工作流配方关联的参数笔记。内容会按配方分组,只展示和当前选中节点类型相同的保存记录。点击一条记录即可事务式应用到该节点;种子等每次运行都会变化的值会被忽略。
#### 4. 工作流配方与参数笔记本 🧰
* **具体位置**:打开 **创作工作台 (📑)**,从“提示词笔记”切换到 **工作流配方**。
* **保存与复用**:保存当前完整节点图,并附带封面、名称、标签、备注、模型引用、提示词、采样设置与受限节点摘要。配方可以追加到当前画布,不会覆盖当前内容。
* **详细信息**:概览展示模型组成与复现信息。模型匹配根据哈希、文件大小与模型类别查找本地文件,不依赖原作者路径;可用时还会保留 C 站官方名称/链接与本地预览。
* **参数笔记本**:查看配方保存的参数,复制或展开长内容,新建可编辑参数笔记,或者从骨架匹配的当前画布读取全新参数。点击“应用到当前工作流”时会先检查配方骨架,再更新安全控件;种子等易变值会被忽略。保存的参数笔记可以单独删除;无法可靠识别的第三方提示词节点可以手动标注正负角色。
* **图库与版本**:查找节点结构相同的历史出图,打开图片比较参数差异,将出图设为配方封面,比较配方历史版本,或在自动归档当前版本后恢复旧版本。
* **分享**:导入导出便携配方包,可选择包含模型预览快照与历史版本,也可在导出时移除身份哈希。预览快照只是展示数据,绝不会包含模型文件。
#### 5. 个性化配置 (设置面板) ⚙️
* **具体位置**:左侧底部的 **齿轮按钮 (⚙️)**。
* **操作步骤**:在此可以调节语言和主页面字体大小。进入 **模型设置** 后,可选择视频封面始终播放或悬停播放,也可选择卡片使用流畅缩略图或原始封面;模型详情页始终使用原始封面。**文件夹管理**可以让指定目录同时退出侧栏和后台读取,并支持调整顺序;**帮助**中会持续更新安全提醒与功能说明。
#### 6. 顶部导航与提示词笔记 📑
**具体位置**:右侧顶部的按钮分别为 **模型 (📦)**、**图库 (🖼️)**、**创作工作台 (📑)**、**停靠侧边栏 (◧)**。
**提示词笔记操作步骤**:
1. 打开**创作工作台 (📑)**,进入“提示词笔记”,点击新建 **➕** 号并确认。
2. 首先选择基础模型(例如 SD1.5),系统会过滤出兼容的主模型与对应的 LoRA 模型供你选择。
3. 点击下方的 **📝 纯文本/粘贴** 复制提示词进去,选择左上角的语言即可实现双语对照翻译。
4. 确认后,插件会自动生成标签 (Tags),支持查找替换,也支持直接双击修改单个标签。
5. 最后,点击下方的 **🚀 发送到画布**,系统会将打包好的主模型、LoRA 和 CLIP,一键全自动连线发布到画布!
</details>
---
### 📦 安装指南
<h2 id="中文">🇨🇳 中文说明</h2>
打开命令行终端,进入 ComfyUI 的 `custom_nodes` 文件夹,执行以下命令:
```bash
cd custom_nodes
git clone https://github.com/DemonGatanjieu/Anomalous_Model_Browser.git
```
> **注意**:克隆完成后,重启 ComfyUI 即可。您也可以直接在 ComfyUI Manager(管理器)中搜索 `Anomalous Model Browser` 并一键点击安装!
> **Anomalous Model Browser** 是为 ComfyUI 设计的综合创作工作台与模型管理套件。系统集成了基于文件哈希的元数据提取、工作流缺失模型自愈(模型医生)、画布节点可视化换模与 LoRA 插入(节点助手)、支持多态画布拖拽的统一素材库,以及工作流配方体系。
### 🎬 视频演示与教程
* 📺 **哔哩哔哩 (Bilibili)**:[在 B 站观看快速上手与使用演示](https://www.bilibili.com/video/BV1a1bv68EuA/)
* 📺 **YouTube**:[在 YouTube 观看视频演示](https://youtu.be/hAvsj7uiaCw)
### 🌟 核心特性速览
| 🚀 核心视图与工具 | 💡 详细说明 |
| :--- | :--- |
| **模型浏览 (📦 模型)** | 本地模型管理网格。左侧树状目录分类(checkpoints、unet、loras),卡片展示 C 站元数据、触发词、基础架构与高清封面,支持看图换模与自定义备注。 |
| **出图图库 (🖼️ 图库)** | 原生读取本地 `output` 文件夹,支持滚轮缩放与安全删除;顶部搜索栏支持按提示词、模型名、LoRA、seed、文件名或模型哈希多维检索,生成可点击独立移除的搜索标签块;拖拽图片原地还原工作流。 |
| **工作流配方 (🪡 工作流)** | 工作流配方工坊。支持保存完整工作流或局部子图,实时显示模型就绪状态(如“全部模型就绪”);支持一键在新画布打开或直接拖拽卡片到画布空白处载入。 |
| **实用工具箱与底栏 (🧰)** | 侧边栏左下角第一个图标为工具箱,底栏直达 4 大常用工具(扫描向导、模型医生、节点助手、素材库)与全局设置;点击工具箱弹出浮窗面板,提供 5 大扩展工具(导入导出、提示词工坊、翻译助手、模型来源、提示词笔记)。 |
| **节点智能修复 (模型医生 🩺)** | 导入他人工作流发生节点爆红时,自动比对本地模型 SHA256 哈希与字节大小,一键替换为有效路径,支持哈希透视比对。 |
| **节点助手与参数预设 (🤖)** | 画布选中节点即可可视化选图换模型、向兼容链路插入 LoRA,或一键应用工作流配方中沉淀的节点参数(自动跳过易变种子)。 |
| **统一素材库与多态拖拽 (✨)** | 原生 PNG 资产一键打包保存(完整工作流、模型血缘与节点参数);多态拖拽:拖至空白画布还原工作流或生成原生红绿提示词节点,拖至已有节点智能注入参数。 |
### 📦 快速安装
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` 点击安装*)
3. **更新插件**:点击顶栏的 **!** 按钮,顶部会显示当前版本,点 **检查更新 / 切换版本** 进入版本面板。可以一键更新到最新发布版本、回退到以前的版本,或回到最新版,之后重启 ComfyUI 即可。插件不会自己联网检查更新。
可以按 **Ctrl + Shift + M**、点击画布上的悬浮 **📦**,或从 ComfyUI 顶部菜单 **扩展 → Anomalous Model Browser** 打开。**ComfyUI 设置 → Anomalous Model Browser → 界面** 会显示当前快捷键,并可直接打开 ComfyUI 原生录入窗口修改,因此冲突与保留按键检查仍然只有一套。该页面还可以让插件语言跟随 ComfyUI 或单独固定为中文/English,并严格三选一显示悬浮入口、运行按钮旁的原生顶部入口或仅使用扩展菜单。只有选择悬浮入口时才显示大小和样式选项。顶部栏重绘不会擅自改变所选模式,扩展菜单命令则始终保留,便于恢复设置。
<br/>
<details>
<summary><b>📖 点击展开:标准操作指南(图文步骤)</b></summary>
<br/>
#### 1. 顶部主工作区导航 (三大顶栏视图)
插件顶部居中提供 3 个核心工作区入口:
* **模型 (📦)**:主模型浏览器。左侧为模型文件夹分类树(`checkpoints`、`unet`、`loras` 等),中间展示模型卡片网格、封面图预览与触发词。
* **图库 (🖼️)**:出图历史浏览器。原生读取本地 `output` 目录。顶部搜索栏支持输入提示词、模型名、LoRA、seed、文件名或模型哈希,回车即可生成独立的搜索标签块,点击标签块上的 `×` 可快速移除该过滤项;直接将图片拖拽至 ComfyUI 画布可原地恢复内嵌工作流。
* **工作流 (🪡)**:工作流配方工坊。提供 `[全部]`、`[完整工作流]`、`[局部子图]` 子过滤器与标签筛选;点击右上角 `保存当前工作流` 即可将当前画布打包为配方;直接按住配方卡片拖拽到 ComfyUI 画布空白处释放即可立即载入。
#### 2. 底部导航与实用工具箱 (🧰 第一个图标:实用工具箱)
* **布局结构**:侧边栏左下角常驻 6 个固定图标按钮:
1. **实用工具箱 (🧰)**:左侧首个图标,点击展开工具箱浮窗面板。
2. **扫描向导 (🔄)**:直达本地模型扫描与哈希/元数据建立。
3. **模型医生 (🩺)**:直达工作流爆红诊断与缺失模型自动自愈。
4. **节点助手 (🤖)**:直达画布选中节点模型信息查看、可视化换模与参数预设注入。
5. **素材库 (✨)**:直达素材资产管理与多态画布拖拽。
6. **全局设置 (⚙️)**:最右侧齿轮图标,直达全局偏好设置。
* **实用工具箱浮窗**:点击首个图标(**🧰 实用工具箱**)打开工具箱窗口,集中收纳了未在底栏常驻的 5 大扩展工具:
1. **导入导出 (⇄)**:无损 AMB 格式工作流分享码导入与导出,支持从文本分享码原地解析还原画布,或将当前画布打包生成带校验哈希的分享码。
2. **提示词工坊 (🎛️)**:模块化提示词积木组装台,支持从出图历史或素材中抽取词卡并分块混音、正负词排序与组合。
3. **翻译助手 (🌐)**:内置中英双向提示词互译工具,无需离开 ComfyUI 即可完成提示词中英翻译。
4. **模型来源 (🔗)**:检视当前工作流或模型库中模型的在线下载来源(Civitai、HuggingFace、Liblib、ModelScope),支持一键在画布生成包含模型来源信息的 Note 说明节点。
5. **提示词笔记 (📑)**:保存在 ComfyUI 用户目录(`workflows/anomalous_notebooks`)下的轻量提示词草稿本,用于随时记录、整理与复用常用提示词片段。
#### 3. 扫描向导 (🔄 扫描向导)
* **入口位置**:底部快捷栏第 2 个图标。
* **操作步骤**:首次使用或添加新模型后打开,选择扫描范围(全部路径、仅缺失元数据或自定义目录)并执行,系统将自动建立本地模型库并拉取 C 站封面、标签与架构信息。
#### 4. 拯救爆红 (🩺 模型医生)
* **入口位置**:底部快捷栏第 3 个图标。
* **操作步骤**:载入他人工作流或图片出现红框缺失报错时,点击模型医生即可自动比对本地模型哈希与字节大小,一键批量映射为本地正确路径。卡片上的“查看哈希”可展开内嵌指纹与磁盘文件的 SHA256 逐项比对面板。
#### 5. 选中交互与预设 (🤖 节点助手)
* **入口位置**:底部快捷栏第 4 个图标。
* **动作功能**:在画布选中模型节点后,可在“动作”页查看高清预览图与触发词,支持看图一键替换模型或在兼容链路前后插入 LoRA。
* **参数预设**:切换至“参数预设”页,可读取工作流配方中同类型节点的保存参数并一键注入画布(自动保留种子等易变数值)。
#### 6. 统一素材库与多态画布拖拽 (✨ 素材库与多态拖拽)
* **入口位置**:底部快捷栏第 5 个图标。
* **保存素材**:在出图图库或配方详情中打开任意生成 PNG 的参数面板,可一键将图片、完整工作流、模型依赖与可复用节点参数打包收藏。
* **多态画布拖拽**:
- 将工作流素材直接拖拽至空白画布,即可原地恢复并打开完整工作流。
- 将纯提示词素材拖拽至空白画布,会自动创建带有正负区分色(深红 `#532323` 为负向 / 墨绿 `#235327` 为正向)的原生 `CLIPTextEncode` 节点。
- 将素材拖拽至画布已有节点上时,会自动通过语义探针将匹配参数注入目标节点中。
* **本地存储**:素材存储于 ComfyUI 用户目录下的 `workflows/anomalous_materials`,支持原子写入。
#### 7. 全局设置面板 (⚙️ 设置)
* **入口位置**:侧边栏左下角最右侧第 6 个图标(**齿轮图标 ⚙️**)。
* **个性调节**:支持中英文界面切换、字体大小缩放、视频封面悬停/常开播放、缩略图优化与目录黑名单管理。
> [!WARNING]
> **测试功能数据安全提醒:** 工作流配方、素材库与参数预设目前属于活跃测试阶段,更新插件前建议备份 ComfyUI 用户目录下的 `workflows/anomalous_recipes`、`workflows/anomalous_materials` 与 `workflows/anomalous_parameters` 文件夹。
</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)。
+72 -43
View File
@@ -1,27 +1,41 @@
from .models import *
from . import (
folder_types, gallery_routes, materials, media_routes, model_catalog,
model_media, model_metadata, model_resolution, recipe_packages, recipes,
translation_routes, version_manager,
)
from .scanner import *
from .config import *
from .notebooks import *
from .parameters import *
from .recipes import *
from .recipe_packages import *
from .utils import *
from aiohttp import web
@web.middleware
async def no_cache_extension_middleware(request, handler):
response = await handler(request)
if request.path.startswith('/extensions/Anomalous_Model_Browser'):
response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate, max-age=0'
response.headers['Pragma'] = 'no-cache'
response.headers['Expires'] = '0'
return response
def setup_routes(app):
app.router.add_get('/anomalous/folders', api_get_folders)
app.router.add_get('/anomalous/all_folder_types', api_get_all_folder_types)
app.router.add_get('/anomalous/models', api_get_models)
app.router.add_get('/anomalous/all_scan_models', api_get_all_scan_models)
app.router.add_get('/anomalous/batch_select', api_batch_select)
app.router.add_get('/anomalous/image', api_serve_image)
if hasattr(app, 'middlewares') and no_cache_extension_middleware not in app.middlewares:
app.middlewares.append(no_cache_extension_middleware)
app.router.add_get('/anomalous/folders', model_catalog.api_get_folders)
app.router.add_get('/anomalous/all_folder_types', folder_types.api_get_all_folder_types)
app.router.add_get('/anomalous/models', model_catalog.api_get_models)
app.router.add_get('/anomalous/all_scan_models', model_catalog.api_get_all_scan_models)
app.router.add_get('/anomalous/batch_select', model_catalog.api_batch_select)
app.router.add_get('/anomalous/image', media_routes.api_serve_image)
app.router.add_post('/anomalous/scan', api_scan_folder)
app.router.add_get('/anomalous/scan_status', api_scan_status)
app.router.add_get('/anomalous/find_model', api_find_model)
app.router.add_get('/anomalous/find_model', model_catalog.api_find_model)
app.router.add_get('/anomalous/config', api_get_config)
app.router.add_post('/anomalous/save_config', api_save_config)
app.router.add_post('/anomalous/delete_model', api_delete_model)
app.router.add_post('/anomalous/delete_model', model_metadata.api_delete_model)
app.router.add_post('/anomalous/clean_civitai_info', api_clean_civitai_info)
app.router.add_get('/anomalous/compatible_models', api_compatible_models)
app.router.add_get('/anomalous/compatible_models', model_catalog.api_compatible_models)
app.router.add_get('/anomalous/notebooks', api_get_notebooks)
app.router.add_post('/anomalous/save_notebook', api_save_notebook)
app.router.add_post('/anomalous/delete_notebook', api_delete_notebook)
@@ -30,42 +44,57 @@ 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)
# Recipe Routes
app.router.add_get('/anomalous/recipes', api_get_recipes)
app.router.add_get('/anomalous/recipe_full', api_get_recipe_full)
app.router.add_get('/anomalous/recipe_asset', api_get_recipe_asset)
app.router.add_get('/anomalous/recipe_gallery', api_get_recipe_gallery)
app.router.add_get('/anomalous/recipe_parameter_gallery', api_get_recipe_parameter_gallery)
app.router.add_get('/anomalous/recipe_gallery_compare', api_get_recipe_gallery_compare)
app.router.add_post('/anomalous/save_recipe', api_save_recipe)
app.router.add_post('/anomalous/update_recipe', api_update_recipe)
app.router.add_post('/anomalous/set_recipe_gallery_cover', api_set_recipe_gallery_cover)
app.router.add_post('/anomalous/delete_recipe', api_delete_recipe)
app.router.add_get('/anomalous/recipe_history', api_get_recipe_history)
app.router.add_get('/anomalous/recipe_version', api_get_recipe_version)
app.router.add_post('/anomalous/restore_recipe_version', api_restore_recipe_version)
app.router.add_post('/anomalous/refresh_recipe_identity', api_refresh_recipe_identity)
app.router.add_post('/anomalous/export_recipe_package', api_export_recipe_package)
app.router.add_post('/anomalous/import_recipe_package_inspect', api_import_recipe_package_inspect)
app.router.add_post('/anomalous/import_recipe_package_commit', api_import_recipe_package_commit)
app.router.add_get('/anomalous/recipes', recipes.api_get_recipes)
app.router.add_get('/anomalous/recipe_full', recipes.api_get_recipe_full)
app.router.add_get('/anomalous/recipe_asset', recipes.api_get_recipe_asset)
app.router.add_get('/anomalous/recipe_gallery', recipes.api_get_recipe_gallery)
app.router.add_get('/anomalous/recipe_parameter_gallery', recipes.api_get_recipe_parameter_gallery)
app.router.add_get('/anomalous/recipe_gallery_compare', recipes.api_get_recipe_gallery_compare)
app.router.add_post('/anomalous/save_recipe', recipes.api_save_recipe)
app.router.add_post('/anomalous/update_recipe', recipes.api_update_recipe)
app.router.add_post('/anomalous/set_recipe_gallery_cover', recipes.api_set_recipe_gallery_cover)
app.router.add_post('/anomalous/delete_recipe', recipes.api_delete_recipe)
app.router.add_get('/anomalous/recipe_history', recipes.api_get_recipe_history)
app.router.add_get('/anomalous/recipe_version', recipes.api_get_recipe_version)
app.router.add_post('/anomalous/restore_recipe_version', recipes.api_restore_recipe_version)
app.router.add_post('/anomalous/refresh_recipe_identity', recipes.api_refresh_recipe_identity)
app.router.add_post('/anomalous/export_recipe_package', recipe_packages.api_export_recipe_package)
app.router.add_post('/anomalous/import_recipe_package_inspect', recipe_packages.api_import_recipe_package_inspect)
app.router.add_post('/anomalous/import_recipe_package_commit', recipe_packages.api_import_recipe_package_commit)
app.router.add_post('/anomalous/translate', api_translate)
app.router.add_get('/anomalous/base_models', api_base_models)
app.router.add_get('/anomalous/gallery_images', api_get_gallery_images)
app.router.add_post('/anomalous/delete_gallery_image', api_delete_gallery_image)
app.router.add_get('/anomalous/resolve_hash', api_resolve_hash)
app.router.add_post('/anomalous/resolve_hash_batch', api_resolve_hash_batch)
app.router.add_get('/anomalous/all_hashes', api_get_all_hashes)
# Curated Material Library Routes
app.router.add_get('/anomalous/materials', materials.api_get_materials)
app.router.add_get('/anomalous/material_full', materials.api_get_material_full)
app.router.add_get('/anomalous/material_asset', materials.api_get_material_asset)
app.router.add_get('/anomalous/materials/by_node_type', materials.api_get_materials_by_node_type)
app.router.add_post('/anomalous/inspect_image_material', materials.api_inspect_image_material)
app.router.add_post('/anomalous/save_image_material', materials.api_save_image_material)
app.router.add_post('/anomalous/save_parameter_material', materials.api_save_parameter_material)
app.router.add_post('/anomalous/save_prompt_note_material', materials.api_save_prompt_note_material)
app.router.add_post('/anomalous/save_prompt_plan', materials.api_save_prompt_plan)
app.router.add_post('/anomalous/delete_material', materials.api_delete_material)
app.router.add_post('/anomalous/update_material', materials.api_update_material)
app.router.add_post('/anomalous/translate', translation_routes.api_translate)
app.router.add_get('/anomalous/base_models', model_catalog.api_base_models)
app.router.add_get('/anomalous/gallery_images', gallery_routes.api_get_gallery_images)
app.router.add_post('/anomalous/delete_gallery_image', gallery_routes.api_delete_gallery_image)
app.router.add_get('/anomalous/resolve_hash', model_resolution.api_resolve_hash)
app.router.add_post('/anomalous/resolve_hash_batch', model_resolution.api_resolve_hash_batch)
app.router.add_get('/anomalous/all_hashes', model_resolution.api_get_all_hashes)
app.router.add_post('/anomalous/scan_all', api_scan_all)
app.router.add_get('/anomalous/global_scan_status', api_global_scan_status)
app.router.add_get('/anomalous/scan_missing_models_status', api_scan_missing_models_status)
app.router.add_post('/anomalous/clear_cache', api_clear_cache)
app.router.add_post('/anomalous/update_metadata', api_update_metadata)
app.router.add_post('/anomalous/set_custom_cover', api_set_custom_cover)
app.router.add_post('/anomalous/upload_custom_cover', api_upload_custom_cover)
app.router.add_get('/anomalous/model_images', api_get_model_images)
app.router.add_post('/anomalous/resolve_paths_to_previews', api_resolve_paths_to_previews)
app.router.add_post('/anomalous/clear_cache', media_routes.api_clear_cache)
app.router.add_post('/anomalous/update_metadata', model_metadata.api_update_metadata)
app.router.add_post('/anomalous/set_custom_cover', model_media.api_set_custom_cover)
app.router.add_post('/anomalous/upload_custom_cover', model_media.api_upload_custom_cover)
app.router.add_get('/anomalous/model_images', media_routes.api_get_model_images)
app.router.add_post('/anomalous/resolve_paths_to_previews', model_catalog.api_resolve_paths_to_previews)
app.router.add_post('/anomalous/scan_missing_models', api_scan_missing_models)
version_manager.register_routes(app)
+244
View File
@@ -0,0 +1,244 @@
"""Configured model-folder visibility and scan-scope helpers."""
import json
import os
from aiohttp import web
import folder_paths
DEFAULT_PHYSICAL_FOLDER_NAMES = {
'checkpoints', 'loras', 'unet', 'diffusion_models', 'controlnet', 'vae'
}
def get_folder_view_mode():
script_dir = os.path.dirname(os.path.abspath(__file__))
config_path = os.path.join(script_dir, "config.json")
try:
if os.path.exists(config_path):
with open(config_path, 'r', encoding='utf-8') as f:
cfg = json.load(f)
return cfg.get("folder_view_mode", "abstract")
except:
pass
return "abstract"
def get_all_physical_basenames():
basenames = set()
for t in folder_paths.folder_names_and_paths.keys():
try:
paths = folder_paths.get_folder_paths(t)
if not paths: continue
for p in paths:
bn = os.path.basename(os.path.normpath(p))
if bn: basenames.add(bn)
except:
pass
return list(basenames)
def get_active_physical_basenames():
script_dir = os.path.dirname(os.path.abspath(__file__))
config_path = os.path.join(script_dir, "config.json")
all_bns = get_all_physical_basenames()
active = []
configured = set()
try:
if os.path.exists(config_path):
with open(config_path, 'r', encoding='utf-8') as f:
cfg = json.load(f)
pfc = cfg.get("physical_folders_config")
if pfc and isinstance(pfc, list):
for item in pfc:
bn = item.get("type")
if bn in all_bns:
configured.add(bn)
if item.get("visible", True):
active.append(bn)
for bn in all_bns:
if bn not in configured and bn in DEFAULT_PHYSICAL_FOLDER_NAMES:
active.append(bn)
return active
except:
pass
return [bn for bn in all_bns if bn in DEFAULT_PHYSICAL_FOLDER_NAMES]
def get_active_model_roots():
"""Return the folder-manager-visible roots with stable model locators.
This is intentionally separate from the scanner scope. The source hub only
needs directory discovery and must preserve ``type``/``path_idx`` so later
metadata writes resolve back to the exact ComfyUI root.
"""
mode = get_folder_view_mode()
active_types = set(get_active_folder_types()) if mode != "physical" else None
active_basenames = (
set(get_active_physical_basenames()) if mode == "physical" else None
)
roots = []
seen_realpaths = set()
for folder_type in folder_paths.folder_names_and_paths.keys():
if active_types is not None and folder_type not in active_types:
continue
try:
paths = folder_paths.get_folder_paths(folder_type) or []
except Exception:
continue
for path_idx, base_dir in enumerate(paths):
if active_basenames is not None:
basename = os.path.basename(os.path.normpath(base_dir))
if basename not in active_basenames:
continue
if not os.path.isdir(base_dir):
continue
real_dir = os.path.realpath(base_dir)
real_key = os.path.normcase(real_dir)
if real_key in seen_realpaths:
continue
seen_realpaths.add(real_key)
roots.append({
"type": folder_type,
"path_idx": path_idx,
"base_dir": real_dir,
})
return roots
def get_active_scan_paths():
mode = get_folder_view_mode()
paths = set()
if mode == "physical":
active_bns = set(get_active_physical_basenames())
for t in folder_paths.folder_names_and_paths.keys():
try:
ps = folder_paths.get_folder_paths(t)
if not ps: continue
for p in ps:
if not os.path.exists(p): continue
bn = os.path.basename(os.path.normpath(p))
if bn in active_bns:
paths.add(os.path.realpath(p))
except:
pass
else:
active_types = get_active_folder_types()
for t in active_types:
try:
ps = folder_paths.get_folder_paths(t)
if not ps: continue
for p in ps:
if not os.path.exists(p): continue
paths.add(os.path.realpath(p))
except:
pass
return list(paths)
def get_active_folder_types():
script_dir = os.path.dirname(os.path.abspath(__file__))
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', 'unet', 'controlnet', 'vae']
try:
if os.path.exists(config_path):
with open(config_path, 'r', encoding='utf-8') as f:
cfg = json.load(f)
ftc = cfg.get("folder_types_config")
if ftc and isinstance(ftc, list):
active = []
configured_types = set()
for item in ftc:
t = item.get("type")
if t in all_types:
configured_types.add(t)
if item.get("visible", True):
active.append(t)
# For any newly registered types not in config, do not show them by default
# unless they are in default_types
for t in all_types:
if t not in configured_types and t in default_types:
active.append(t)
return active
except Exception:
pass
# Fallback if no config exists
active = [t for t in default_types if t in all_types]
return active
async def api_get_all_folder_types(request):
mode = get_folder_view_mode()
script_dir = os.path.dirname(os.path.abspath(__file__))
config_path = os.path.join(script_dir, "config.json")
result = []
if mode == "physical":
all_bns = get_all_physical_basenames()
configured = set()
try:
if os.path.exists(config_path):
with open(config_path, 'r', encoding='utf-8') as f:
cfg = json.load(f)
pfc = cfg.get("physical_folders_config")
if pfc and isinstance(pfc, list):
for item in pfc:
bn = item.get("type")
if bn in all_bns:
configured.add(bn)
result.append({
"type": bn,
"visible": item.get("visible", True)
})
except:
pass
for bn in all_bns:
if bn not in configured:
result.append({
"type": bn,
"visible": bn in DEFAULT_PHYSICAL_FOLDER_NAMES
})
else:
# Abstract mode
all_types = list(folder_paths.folder_names_and_paths.keys())
default_types = ['checkpoints', 'loras', 'diffusion_models', 'controlnet', 'vae']
configured_types = set()
try:
if os.path.exists(config_path):
with open(config_path, 'r', encoding='utf-8') as f:
cfg = json.load(f)
ftc = cfg.get("folder_types_config")
if ftc and isinstance(ftc, list):
for item in ftc:
t = item.get("type")
if t in all_types:
configured_types.add(t)
result.append({
"type": t,
"visible": item.get("visible", True)
})
except:
pass
for t in all_types:
if t not in configured_types:
result.append({
"type": t,
"visible": t in default_types
})
return web.json_response({"folder_types": result, "folder_view_mode": mode})
+104
View File
@@ -0,0 +1,104 @@
"""Generated-output gallery indexing and mutation routes."""
import asyncio
import os
import threading
import time
from aiohttp import web
import folder_paths
from .image_search import filter_images
from .path_utils import require_filename, resolve_within
GALLERY_SNAPSHOT_SECONDS = 10
MAX_CACHED_GALLERY_IMAGES = 50_000
_gallery_snapshot_lock = threading.Lock()
_gallery_snapshot = {"root": None, "expires": 0, "images": []}
def _collect_gallery_images(output_dir):
images = []
for root, _, files in os.walk(output_dir):
for filename in files:
if os.path.splitext(filename)[1].lower() not in {'.png', '.jpg', '.jpeg', '.webp', '.gif'}:
continue
try:
mtime = os.path.getmtime(os.path.join(root, filename))
except OSError:
continue
subfolder = os.path.relpath(root, output_dir)
images.append({
"filename": filename,
"subfolder": "" if subfolder == "." else subfolder.replace(os.sep, '/'),
"type": "output", "mtime": mtime,
})
images.sort(key=lambda item: (item['mtime'], item['subfolder'], item['filename']), reverse=True)
return images
def _gallery_images(output_dir, refresh=False):
root = os.path.realpath(output_dir)
with _gallery_snapshot_lock:
if not refresh and _gallery_snapshot['root'] == root and time.monotonic() < _gallery_snapshot['expires']:
return _gallery_snapshot['images']
images = _collect_gallery_images(root)
_gallery_snapshot.update(root=root, expires=time.monotonic() + GALLERY_SNAPSHOT_SECONDS,
images=images if len(images) <= MAX_CACHED_GALLERY_IMAGES else [])
if len(images) > MAX_CACHED_GALLERY_IMAGES:
_gallery_snapshot['expires'] = 0
return images
def _invalidate_gallery_snapshot():
with _gallery_snapshot_lock:
_gallery_snapshot.update(root=None, expires=0, images=[])
def _gallery_listing(output_dir, refresh, query):
images = _gallery_images(output_dir, refresh)
return filter_images(os.path.realpath(output_dir), images, query) if query else images
async def api_get_gallery_images(request):
"""GET /anomalous/gallery_images?page&limit&refresh&term=..&term=.. (or q) - Output images, optionally searched by embedded generation info."""
try:
page = max(1, int(request.query.get('page', 1)))
limit = min(200, max(1, int(request.query.get('limit', 50))))
# Repeated `term` params keep phrases whole; `q` is split on whitespace.
terms = [term for term in request.query.getall('term', []) if term.strip()]
query = terms or request.query.get('q', '').strip()
images = await asyncio.to_thread(_gallery_listing, folder_paths.get_output_directory(), request.query.get('refresh') == '1', query)
total = len(images)
start = (page - 1) * limit
return web.json_response({"images": images[start:start + limit], "total": total,
"page": page, "pages": (total + limit - 1) // limit})
except (TypeError, ValueError):
return web.json_response({"error": "Invalid pagination"}, status=400)
except OSError:
return web.json_response({"error": "Could not list output images"}, status=500)
async def api_delete_gallery_image(request):
try:
data = await request.json()
filename = data.get("filename")
subfolder = data.get("subfolder", "")
output_dir = folder_paths.get_output_directory()
try:
filename = require_filename(filename)
target_dir = resolve_within(output_dir, subfolder)
file_path = resolve_within(target_dir, filename)
except ValueError:
return web.json_response({"status": "error", "message": "Invalid parameters"}, status=400)
if os.path.exists(file_path):
os.remove(file_path)
_invalidate_gallery_snapshot()
return web.json_response({"status": "success"})
else:
return web.json_response({"status": "error", "message": "File not found"})
except Exception as e:
return web.json_response({"status": "error", "message": str(e)}, status=500)
+202
View File
@@ -0,0 +1,202 @@
"""Search output images by the generation info embedded in them.
Reads only the PNG text chunks before the pixel data (ComfyUI `prompt` /
`workflow`, A1111/Forge `parameters`), caches one small record per file keyed by
its mtime, and matches every query term against it. Terms that look like a
hash also match model SHA256 values recorded in the workflow and, through the
scanned model index, the local model files that carry that hash.
"""
import json
import os
import re
import struct
import threading
import time
import zlib
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
MAX_CHUNK_BYTES = 32 * 1024 * 1024
MAX_CACHED_RECORDS = 60_000
MODEL_INDEX_SECONDS = 120
HASH_TERM = re.compile(r"^[0-9a-f]{8,64}$", re.IGNORECASE)
MODEL_FILE = re.compile(r"\.(safetensors|ckpt|pt|pth|bin|sft|gguf)$", re.IGNORECASE)
NUMERIC_KEYS = {"seed", "noise_seed", "steps", "cfg", "denoise", "width", "height"}
_record_cache = {}
_cache_lock = threading.Lock()
_model_index = {"expires": 0, "entries": {}}
_model_index_lock = threading.Lock()
def _decode_text(raw):
"""tEXt is Latin-1 by spec, but ComfyUI/PIL may store UTF-8 bytes in it."""
try:
return raw.decode("utf-8")
except UnicodeDecodeError:
return raw.decode("latin-1", "replace")
def read_png_text(path):
"""Text chunks that precede IDAT, as {keyword: text}. Pixel data is never read."""
chunks = {}
with open(path, "rb") as handle:
if handle.read(8) != PNG_SIGNATURE:
return chunks
while True:
header = handle.read(8)
if len(header) < 8:
break
length, kind = struct.unpack(">I4s", header)
if kind in (b"IDAT", b"IEND") or length > MAX_CHUNK_BYTES:
break
data = handle.read(length)
handle.seek(4, os.SEEK_CUR)
try:
if kind == b"tEXt":
key, _, value = data.partition(b"\x00")
chunks[key.decode("latin-1")] = _decode_text(value)
elif kind == b"zTXt":
key, _, rest = data.partition(b"\x00")
chunks[key.decode("latin-1")] = _decode_text(zlib.decompress(rest[1:]))
elif kind == b"iTXt":
key, _, rest = data.partition(b"\x00")
compressed, rest = rest[0], rest[2:]
_, _, rest = rest.partition(b"\x00") # language tag
_, _, text = rest.partition(b"\x00") # translated keyword
chunks[key.decode("latin-1")] = (zlib.decompress(text) if compressed else text).decode("utf-8", "replace")
except (zlib.error, IndexError, ValueError):
continue
return chunks
def _normalise_model_ref(value):
ref = str(value).replace("\\", "/").strip().lower()
return {ref, ref.rsplit("/", 1)[-1]}
def build_record(path):
"""Searchable text, recorded model hashes and referenced model files for one image."""
name = os.path.basename(path)
texts = [name]
hashes = set()
models = set()
if path.lower().endswith(".png"):
try:
chunks = read_png_text(path)
except OSError:
chunks = {}
prompt = _load_json(chunks.get("prompt"))
if isinstance(prompt, dict):
for node in prompt.values():
if not isinstance(node, dict):
continue
texts.append(str(node.get("class_type", "")))
for key, value in (node.get("inputs") or {}).items():
if isinstance(value, str):
texts.append(value)
if MODEL_FILE.search(value):
models |= _normalise_model_ref(value)
elif isinstance(value, (int, float)) and not isinstance(value, bool) and key in NUMERIC_KEYS:
texts.append(f"{key}:{value} {value}")
workflow = _load_json(chunks.get("workflow"))
recorded = (workflow or {}).get("extra", {}).get("anomalous_hashes") if isinstance(workflow, dict) else None
if isinstance(recorded, dict):
for key, entry in recorded.items():
sha = entry.get("hash") if isinstance(entry, dict) else entry
if isinstance(sha, str) and sha:
hashes.add(sha.upper())
model_name = str(key).split("_", 1)[-1]
if MODEL_FILE.search(model_name):
models |= _normalise_model_ref(model_name)
if chunks.get("parameters"):
texts.append(chunks["parameters"])
return {"text": "\n".join(texts).lower(), "hashes": hashes, "models": models}
def _load_json(raw):
if not raw:
return None
try:
return json.loads(raw)
except ValueError:
return None
def cached_record(path, mtime=None):
"""Record for one image; `mtime` from the gallery listing avoids a stat per file."""
if mtime is None:
try:
mtime = os.stat(path).st_mtime
except OSError:
return None
key = mtime
with _cache_lock:
cached = _record_cache.get(path)
if cached and cached[0] == key:
return cached[1]
if not os.path.isfile(path):
return None
record = build_record(path)
with _cache_lock:
if len(_record_cache) >= MAX_CACHED_RECORDS:
_record_cache.clear()
_record_cache[path] = (key, record)
return record
def _local_models_for_hash(term):
"""Model file refs whose scanned SHA256 starts with `term` (cached briefly)."""
# Imported lazily: api.utils re-exports gallery_routes, which imports this module.
from .model_resolution import collect_model_hash_index
with _model_index_lock:
if time.monotonic() >= _model_index["expires"]:
_model_index["entries"] = collect_model_hash_index()
_model_index["expires"] = time.monotonic() + MODEL_INDEX_SECONDS
entries = _model_index["entries"]
wanted = term.upper()
refs = set()
for key, value in entries.items():
sha = str((value or {}).get("hash") or "").upper()
if sha and sha.startswith(wanted):
refs |= _normalise_model_ref(key)
return refs
def parse_query(query):
"""Search terms: a list keeps each entry as one phrase; a string splits on whitespace."""
raw = query if isinstance(query, (list, tuple)) else str(query or "").split()
terms = []
for term in raw:
term = " ".join(str(term).lower().split())
if term and term not in terms:
terms.append(term)
return terms
def filter_images(output_dir, images, query):
"""Keep gallery entries whose record matches every term (phrases may contain spaces)."""
terms = parse_query(query)
if not terms:
return images
hash_refs = {term: _local_models_for_hash(term) for term in terms if HASH_TERM.match(term)}
matched = []
for image in images:
record = cached_record(os.path.join(output_dir, image.get("subfolder", ""), image["filename"]), image.get("mtime"))
if record is None:
continue
if all(_term_matches(term, record, hash_refs) for term in terms):
matched.append(image)
return matched
def _term_matches(term, record, hash_refs):
if term in record["text"]:
return True
if term in hash_refs:
upper = term.upper()
if any(sha.startswith(upper) for sha in record["hashes"]):
return True
return bool(hash_refs[term] & record["models"])
return False
+81
View File
@@ -0,0 +1,81 @@
"""Image inspection and asset storage for curated materials."""
import hashlib
import os
import shutil
import tempfile
from .recipe_images import _embedded_workflow_payload, _output_source_path, _recipe_cover_webp_bytes
from .recipe_schema import _build_model_references, _normalise_source_image
from .workflow_schema import _validate_workflow
from .material_schema import _node_blocks, _suggested_name
from .utils import require_filename, resolve_within
MAX_SOURCE_IMAGE_BYTES = 64 * 1024 * 1024
def _material_assets_dir(materials_dir, filename, create=False):
stem = os.path.splitext(require_filename(filename))[0]
assets_dir = resolve_within(materials_dir, ".assets", stem)
if create:
os.makedirs(assets_dir, exist_ok=True)
return assets_dir
def _inspect_source_image(source_image):
source = _normalise_source_image(source_image)
if not source["filename"].lower().endswith(".png"):
raise ValueError("Only PNG output images can contain reusable workflow metadata")
source_path = _output_source_path(source)
payload = _embedded_workflow_payload(source_path)
workflow = payload.get("workflow") if isinstance(payload, dict) else None
if not isinstance(workflow, dict):
raise ValueError("Image has no reusable UI workflow")
_validate_workflow(workflow)
blocks = _node_blocks(workflow, include_values=False)
references = _build_model_references({"workflow": workflow, "params": {}}, verify_identities=False)
suggested_name = _suggested_name(workflow, source_path, references)
return source, source_path, workflow, blocks, references, suggested_name
def _store_assets(materials_dir, filename, source_path):
source_size = os.path.getsize(source_path)
if source_size > MAX_SOURCE_IMAGE_BYTES:
raise ValueError("Source image is too large")
digest = hashlib.sha256()
with open(source_path, "rb") as source_file:
for block in iter(lambda: source_file.read(1024 * 1024), b""):
digest.update(block)
assets_dir = _material_assets_dir(materials_dir, filename, create=True)
source_asset = f"source-{digest.hexdigest()}.png"
source_target = resolve_within(assets_dir, source_asset)
if not os.path.exists(source_target):
fd, temp_path = tempfile.mkstemp(prefix=".source-", suffix=".tmp", dir=assets_dir)
os.close(fd)
try:
shutil.copyfile(source_path, temp_path)
os.replace(temp_path, source_target)
finally:
if os.path.exists(temp_path):
os.remove(temp_path)
preview = _recipe_cover_webp_bytes(source_path)
preview_asset = None
if preview:
data, width, height = preview
preview_asset = f"preview-{hashlib.sha256(data).hexdigest()}.webp"
preview_target = resolve_within(assets_dir, preview_asset)
if not os.path.exists(preview_target):
with open(preview_target, "wb") as preview_file:
preview_file.write(data)
else:
width = height = None
return {
"source_asset_id": source_asset,
"preview_asset_id": preview_asset,
"preview_width": width,
"preview_height": height,
"source_sha256": digest.hexdigest(),
"source_size": source_size,
}
+507
View File
@@ -0,0 +1,507 @@
"""Validation and record-shaping helpers for curated materials."""
import copy
import hashlib
import json
import os
import re
from .notebooks import MAX_NOTEBOOK_BYTES
from .recipe_schema import _build_model_references
from .workflow_schema import _parameter_signature, _volatile_widget_indexes, _workflow_fingerprint
MAX_MATERIAL_NAME_LENGTH = 120
MODEL_PRIORITY = {"checkpoint": 0, "unet": 1, "lora": 2}
_PROMPT_NODE_TYPES = {"cliptextencode"}
_PROMPT_CONSUMERS = {
"ksampler": {"positive": "positive", "negative": "negative"},
"ksampleradvanced": {"positive": "positive", "negative": "negative"},
"cfgguider": {"positive": "positive", "negative": "negative"},
"basicguider": {"conditioning": "positive"},
"dualcfgguider": {"cond1": "positive", "cond2": "positive", "negative": "negative"},
}
_PROMPT_PASSTHROUGH = {
"conditioningaverage", "conditioningcombine", "conditioningconcat",
"conditioningmultiply", "conditioningsetarea", "conditioningsetareapercentage",
"conditioningsetareastrength", "conditioningsetmask", "conditioningsettimesteprange",
"conditioningzeroout",
}
_PROMPT_ROLES = {"positive", "negative", "both", "ignored", "unknown"}
_MAX_PROMPT_UPSTREAM = 96
def _node_blocks(workflow, include_values=True, selected_ids=None):
blocks = []
occurrences = {}
for node in workflow.get("nodes", []):
if not isinstance(node, dict):
continue
node_type = str(node.get("type") or "").strip()
if not node_type:
continue
occurrences[node_type] = occurrences.get(node_type, 0) + 1
if selected_ids is not None and str(node.get("id")) not in selected_ids:
continue
block = {
"node_id": node.get("id"),
"type": node_type,
"title": str(node.get("title") or node_type),
"occurrence": occurrences[node_type],
"widget_count": len(node.get("widgets_values") or []),
"volatile_widget_indexes": list(_volatile_widget_indexes(node)),
}
if include_values:
block["widgets_values"] = copy.deepcopy(node.get("widgets_values") or [])
block["properties"] = copy.deepcopy(node.get("properties") or {})
if node.get("mode") is not None:
block["mode"] = node.get("mode")
blocks.append(block)
return blocks
def _normalise_selected_node_ids(workflow, raw_node_ids):
"""Validate an optional node selection while preserving workflow ID types."""
if raw_node_ids is None:
return None
if not isinstance(raw_node_ids, list) or not raw_node_ids:
raise ValueError("Selected material nodes are required")
workflow_nodes = [node for node in workflow.get("nodes", []) if isinstance(node, dict)]
existing = {str(node.get("id")): node.get("id") for node in workflow_nodes if node.get("id") is not None}
selected = []
seen = set()
for raw_node_id in raw_node_ids:
key = str(raw_node_id)
if key not in existing:
raise ValueError("Selected material node does not exist")
if key in seen:
continue
seen.add(key)
selected.append(existing[key])
if not selected:
raise ValueError("Selected material nodes are required")
return selected
def _material_node_ids(material):
selection = material.get("selection") if isinstance(material, dict) else None
if not isinstance(selection, dict) or selection.get("scope") != "nodes":
return None
node_ids = selection.get("node_ids")
if not isinstance(node_ids, list):
return None
return {str(node_id) for node_id in node_ids}
def _material_node_blocks(material, include_values=True):
if material.get("kind") in ("prompt_note_bundle", "prompt_text", "prompt_plan"):
return []
return _node_blocks(material.get("workflow") or {}, include_values=include_values,
selected_ids=_material_node_ids(material))
def _prompt_excerpt(workflow):
for node in workflow.get("nodes", []):
if not isinstance(node, dict) or "cliptextencode" not in str(node.get("type") or "").lower():
continue
for value in node.get("widgets_values") or []:
if isinstance(value, str) and value.strip():
compact = re.sub(r"\s+", " ", value).strip()
return compact[:20]
return ""
def _extract_workflow_params(workflow):
params = {}
prompts = []
if not isinstance(workflow, dict):
return params, prompts
for node in workflow.get("nodes", []):
if not isinstance(node, dict):
continue
ntype = str(node.get("type") or "").strip()
ntype_lower = ntype.lower()
widgets = node.get("widgets_values") or []
if ntype_lower in ("ksampler", "ksampleradvanced") and isinstance(widgets, (list, tuple)):
is_adv = ntype_lower == "ksampleradvanced"
offset = 1 if is_adv else 0
if len(widgets) > offset and widgets[offset] is not None:
params.setdefault("seed", widgets[offset])
if len(widgets) > 2 + offset and widgets[2 + offset] is not None:
params.setdefault("steps", widgets[2 + offset])
if len(widgets) > 3 + offset and widgets[3 + offset] is not None:
params.setdefault("cfg", widgets[3 + offset])
if len(widgets) > 4 + offset and widgets[4 + offset] is not None:
params.setdefault("sampler_name", widgets[4 + offset])
if len(widgets) > 5 + offset and widgets[5 + offset] is not None:
params.setdefault("scheduler", widgets[5 + offset])
if len(widgets) > 6 + offset and widgets[6 + offset] is not None:
params.setdefault("denoise", widgets[6 + offset])
elif ntype_lower == "emptylatentimage" and isinstance(widgets, (list, tuple)):
if len(widgets) >= 2 and widgets[0] and widgets[1]:
params.setdefault("resolution", f"{widgets[0]}×{widgets[1]}")
if "cliptextencode" in ntype_lower and isinstance(widgets, (list, tuple)):
for val in widgets:
if isinstance(val, str) and val.strip():
cleaned = val.strip()
if cleaned not in prompts:
prompts.append(cleaned)
return params, prompts
def _norm_node_name(value):
return str(value or "").strip().lower()
def _is_prompt_node_type(node_type):
name = _norm_node_name(node_type)
return name in _PROMPT_NODE_TYPES or "cliptextencode" in name
def _first_prompt_text(node):
for value in node.get("widgets_values") or []:
if isinstance(value, str) and value.strip():
return value.strip()
return ""
def _title_prompt_role(node):
title = str(node.get("title") or "").lower()
if "neg" in title or "负向" in title or "反向" in title:
return "negative"
if "pos" in title or "正向" in title:
return "positive"
return None
def _workflow_link_origin(workflow, link_id):
if link_id is None:
return None
links = workflow.get("links")
if isinstance(links, list):
for link in links:
if isinstance(link, (list, tuple)) and len(link) > 1 and link[0] == link_id:
return link[1]
if isinstance(link, dict) and link.get("id") == link_id:
return link.get("origin_id")
elif isinstance(links, dict):
link = links.get(link_id)
if link is None:
link = links.get(str(link_id))
if isinstance(link, dict):
return link.get("origin_id")
if isinstance(link, (list, tuple)) and len(link) > 1:
return link[1]
return None
def _collect_prompt_nodes(workflow, nodes_by_id, start_id):
queue = [start_id]
visited = set()
found = []
while queue and len(visited) < _MAX_PROMPT_UPSTREAM:
node_id = queue.pop(0)
if node_id is None or node_id in visited:
continue
visited.add(node_id)
node = nodes_by_id.get(str(node_id))
if not isinstance(node, dict):
continue
if _is_prompt_node_type(node.get("type")):
if node not in found:
found.append(node)
continue
if _norm_node_name(node.get("type")) not in _PROMPT_PASSTHROUGH:
continue
for inbound in node.get("inputs") or []:
if not isinstance(inbound, dict):
continue
input_type = _norm_node_name(inbound.get("type"))
input_name = _norm_node_name(inbound.get("name"))
if input_type != "conditioning" and "conditioning" not in input_name:
continue
origin_id = _workflow_link_origin(workflow, inbound.get("link"))
if origin_id is not None and origin_id not in visited:
queue.append(origin_id)
return found
def _normalise_prompt_role_overrides(raw):
if not isinstance(raw, dict):
return {}
result = {}
for key, value in list(raw.items())[:80]:
if not isinstance(value, dict):
continue
role = value.get("role")
if role not in _PROMPT_ROLES:
continue
node_type = value.get("nodeType")
if node_type is not None and (not isinstance(node_type, str) or len(node_type) > 200):
continue
result[str(key)] = {
"role": role,
"nodeType": node_type if isinstance(node_type, str) else None,
"source": "manual",
}
return result
def _override_prompt_role(node, overrides):
if not isinstance(overrides, dict) or not isinstance(node, dict):
return None
entry = overrides.get(str(node.get("id")))
if not isinstance(entry, dict) or entry.get("role") not in _PROMPT_ROLES:
return None
expected = entry.get("nodeType")
if expected and expected != node.get("type"):
return None
return entry["role"]
def _prompt_roles_for_workflow(workflow, overrides=None):
roles = {}
if not isinstance(workflow, dict):
return roles
nodes = [node for node in workflow.get("nodes") or [] if isinstance(node, dict)]
nodes_by_id = {str(node.get("id")): node for node in nodes if node.get("id") is not None}
positive_ids = set()
negative_ids = set()
for node in nodes:
mapping = _PROMPT_CONSUMERS.get(_norm_node_name(node.get("type")))
if not mapping:
continue
for input_name, role in mapping.items():
for inbound in node.get("inputs") or []:
if not isinstance(inbound, dict) or _norm_node_name(inbound.get("name")) != input_name:
continue
origin_id = _workflow_link_origin(workflow, inbound.get("link"))
for prompt_node in _collect_prompt_nodes(workflow, nodes_by_id, origin_id):
node_id = str(prompt_node.get("id"))
if role == "negative":
negative_ids.add(node_id)
else:
positive_ids.add(node_id)
for node in nodes:
if not _is_prompt_node_type(node.get("type")):
continue
node_id = str(node.get("id"))
in_positive = node_id in positive_ids
in_negative = node_id in negative_ids
if in_positive and in_negative:
automatic_role, automatic_source = "both", "topology"
elif in_positive:
automatic_role, automatic_source = "positive", "topology"
elif in_negative:
automatic_role, automatic_source = "negative", "topology"
else:
titled = _title_prompt_role(node)
automatic_role = titled or "unknown"
automatic_source = "title" if titled else "unresolved"
override = _override_prompt_role(node, overrides)
roles[node_id] = {
"role": override or automatic_role,
"source": "manual" if override else automatic_source,
"automatic_role": automatic_role,
}
return roles
def _prompt_groups_from_roles(workflow, roles, selected_ids=None):
positive = []
negative = []
if not isinstance(workflow, dict) or not isinstance(roles, dict):
return {"positive": positive, "negative": negative}
for node in workflow.get("nodes") or []:
if not isinstance(node, dict):
continue
node_id = str(node.get("id"))
if selected_ids is not None and node_id not in selected_ids:
continue
info = roles.get(node_id)
if not isinstance(info, dict):
continue
text = _first_prompt_text(node)
if not text:
continue
role = info.get("role")
if role in ("positive", "both") and text not in positive:
positive.append(text)
if role in ("negative", "both") and text not in negative:
negative.append(text)
return {"positive": positive, "negative": negative}
def _display_model_name(reference):
value = str(reference.get("saved_value") or "").replace("\\", "/").split("/")[-1]
return re.sub(r"\.(?:safetensors|ckpt|pt|bin|sft)$", "", value, flags=re.IGNORECASE)
def _suggested_name(workflow, source_path, references=None):
stem = os.path.splitext(os.path.basename(source_path))[0] if source_path else ""
if stem:
return f"{stem} · 快照"[:MAX_MATERIAL_NAME_LENGTH]
if references is None:
references = _build_model_references({"workflow": workflow, "params": {}}, verify_identities=False)
references.sort(key=lambda item: MODEL_PRIORITY.get(item.get("category"), 99))
model_name = _display_model_name(references[0]) if references else "工作流快照"
return f"{model_name} · 快照"[:MAX_MATERIAL_NAME_LENGTH]
def _recipe_link_fingerprint(recipe):
if not isinstance(recipe, dict):
return ""
fingerprint = recipe.get("workflow_fingerprint")
workflow_hash = fingerprint.get("value") if isinstance(fingerprint, dict) else ""
if not workflow_hash and isinstance(recipe.get("workflow"), dict):
workflow_hash = (_workflow_fingerprint(recipe["workflow"]) or {}).get("value") or ""
params = recipe.get("params") if isinstance(recipe.get("params"), dict) else {}
overrides = params.get("promptRoleOverrides") if isinstance(params.get("promptRoleOverrides"), dict) else {}
payload = json.dumps(
{"workflow": workflow_hash, "promptRoleOverrides": overrides},
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
return hashlib.sha256(payload).hexdigest()
def _material_summary(filename, material):
image = material.get("image") or {}
source = material.get("source") if isinstance(material.get("source"), dict) else {}
blocks = _material_node_blocks(material, include_values=False)
summary = {
"filename": filename,
"id": material.get("id"),
"name": material.get("name") or "未命名素材",
"kind": material.get("kind"),
"tags": _normalise_material_tags(material.get("tags") or []),
"source_sha256": image.get("source_sha256", ""),
"timestamp": material.get("timestamp", 0),
"node_count": len(blocks),
"node_types": sorted({block["type"] for block in blocks}),
"selection": copy.deepcopy(material.get("selection")),
"image": {
"preview_asset_id": image.get("preview_asset_id"),
"source_asset_id": image.get("source_asset_id"),
"width": image.get("preview_width"),
"height": image.get("preview_height"),
},
"capabilities": list(material.get("capabilities") or []),
"source": {
"type": source.get("type"),
"label": str(source.get("parameter_name") or source.get("recipe_name") or source.get("notebook_name") or "").strip(),
},
"source_fingerprint": str(source.get("parameter_signature") or image.get("source_sha256") or ""),
}
if source.get("recipe_filename"):
summary["source_recipe"] = {
"filename": source.get("recipe_filename"),
"name": str(source.get("recipe_name") or "").strip(),
"fingerprint": str(source.get("recipe_fingerprint") or ""),
}
return summary
def _workflow_hashes_for_blocks(workflow, blocks):
hashes = (workflow.get("extra") or {}).get("anomalous_hashes") if isinstance(workflow, dict) else None
if not isinstance(hashes, dict):
return {}
prefixes = tuple(f"{block.get('node_id')}_" for block in blocks)
return {
key: copy.deepcopy(value)
for key, value in hashes.items()
if isinstance(key, str) and key.startswith(prefixes)
}
def _normalise_material_tags(tags):
if not isinstance(tags, list) or len(tags) > 20:
raise ValueError("Invalid material tags")
result = []
seen = set()
for tag in tags:
if not isinstance(tag, str) or not tag.strip() or len(tag.strip()) > 60:
raise ValueError("Invalid material tag")
tag = tag.strip()
if tag.casefold() not in seen:
result.append(tag)
seen.add(tag.casefold())
return result
def _normalise_prompt_note(data, prompt_only=False):
if not isinstance(data, dict):
raise ValueError("Invalid prompt note")
if len(json.dumps(data, ensure_ascii=False, allow_nan=False).encode("utf-8")) > MAX_NOTEBOOK_BYTES:
raise ValueError("Prompt note is too large")
result = {}
for key in ("promptEn", "promptZh", "targetLang"):
value = data.get(key, "")
if not isinstance(value, str):
raise ValueError("Invalid prompt text")
result[key] = value
translations = data.get("translations", {})
if not isinstance(translations, dict) or any(not isinstance(value, str) for value in translations.values()):
raise ValueError("Invalid prompt translations")
result["translations"] = copy.deepcopy(translations)
if not prompt_only:
base = data.get("baseModel", "")
main = data.get("mainModel")
loras = data.get("loras", [])
if not isinstance(base, str) or (main is not None and not isinstance(main, dict)):
raise ValueError("Invalid prompt models")
if not isinstance(loras, list) or len(loras) > 100 or any(not isinstance(model, dict) for model in loras):
raise ValueError("Invalid prompt LoRAs")
for model in ([main] if main is not None else []) + loras:
if not isinstance(model.get("filename"), str) or not model["filename"]:
raise ValueError("Invalid prompt model filename")
result.update(baseModel=base, mainModel=copy.deepcopy(main), loras=copy.deepcopy(loras))
return result
def _normalise_prompt_plan(plan):
if not isinstance(plan, dict) or len(json.dumps(plan, ensure_ascii=False, allow_nan=False).encode("utf-8")) > MAX_NOTEBOOK_BYTES:
raise ValueError("Invalid prompt plan")
parts = plan.get("parts", [])
if not isinstance(parts, list) or len(parts) > 100:
raise ValueError("Invalid prompt parts")
result = {"parts": []}
if "version" in plan and isinstance(plan["version"], int):
result["version"] = plan["version"]
for key in ("positive", "negative"):
if not isinstance(plan.get(key, ""), str):
raise ValueError("Invalid prompt text")
result[key] = plan.get(key, "")
for part in parts:
if not isinstance(part, dict) or part.get("category") not in ("general", "specific", "base", "style", "subject", "trigger") or not isinstance(part.get("enabled", True), bool):
raise ValueError("Invalid prompt part")
item = {"category": part["category"], "enabled": part.get("enabled", True)}
if "role" in part and part["role"] in ("positive", "negative"):
item["role"] = part["role"]
if "id" in part and isinstance(part["id"], str) and len(part["id"]) <= 120:
item["id"] = part["id"]
for key in ("name", "positive", "negative"):
if not isinstance(part.get(key, ""), str):
raise ValueError("Invalid prompt part text")
item[key] = part.get(key, "")
if len(item["name"]) > 120:
raise ValueError("Prompt part name is too long")
result["parts"].append(item)
return result
def _material_category(material):
if material.get("kind") == "image_workflow_snapshot":
return "workflow"
if material.get("kind") in ("prompt_text", "prompt_note_bundle", "prompt_plan"):
return "prompts"
types = material.get("node_types", [])
if types and all(_is_prompt_node_type(value) for value in types):
return "prompts"
return "params"
+297
View File
@@ -0,0 +1,297 @@
"""Single-owner persistence, cache, and locking for curated materials."""
import copy
import json
import os
import shutil
import tempfile
import threading
from functools import lru_cache
import folder_paths
from .material_assets import _material_assets_dir, _store_assets
from .material_schema import (
_is_prompt_node_type,
_material_category,
_material_summary,
_normalise_prompt_note,
_normalise_prompt_plan,
_recipe_link_fingerprint,
)
from .parameters import get_parameters_dir
from .recipe_constants import MAX_RECIPE_BYTES
from .recipe_store import _read_recipe, get_recipes_dir
from .utils import atomic_write_json as _atomic_write_json, require_filename, resolve_within
from .workflow_schema import _parameter_signature, _validate_workflow
_material_write_lock = threading.RLock()
def get_materials_dir():
user_dir = (
folder_paths.get_user_directory()
if hasattr(folder_paths, "get_user_directory")
else None
)
if not user_dir:
user_dir = os.path.join(folder_paths.base_path, "user", "default")
materials_dir = os.path.join(user_dir, "workflows", "anomalous_materials")
os.makedirs(materials_dir, exist_ok=True)
return materials_dir
def _read_material(path):
if os.path.getsize(path) > MAX_RECIPE_BYTES:
raise ValueError("Material snapshot is too large")
with open(path, "r", encoding="utf-8") as material_file:
value = json.load(material_file)
if not isinstance(value, dict):
raise ValueError("Invalid material snapshot")
if value.get("kind") == "prompt_plan":
_normalise_prompt_plan(value.get("plan"))
elif value.get("kind") in ("prompt_note_bundle", "prompt_text"):
_normalise_prompt_note(value.get("note"), value["kind"] == "prompt_text")
elif not isinstance(value.get("workflow"), dict):
raise ValueError("Invalid material snapshot")
return value
def _read_parameter_source(recipe_filename, parameter_filename=None):
recipe_filename = require_filename(recipe_filename)
if not recipe_filename.endswith(".json") or recipe_filename.startswith("."):
raise ValueError("Invalid recipe filename")
recipe_path = resolve_within(get_recipes_dir(), recipe_filename)
if os.path.getsize(recipe_path) > MAX_RECIPE_BYTES:
raise ValueError("Recipe source is too large")
recipe = _read_recipe(recipe_path)
source = recipe
if parameter_filename:
parameter_filename = require_filename(parameter_filename)
if not parameter_filename.endswith(".json") or parameter_filename.startswith("."):
raise ValueError("Invalid parameter filename")
parameter_path = resolve_within(get_parameters_dir(), parameter_filename)
if os.path.getsize(parameter_path) > MAX_RECIPE_BYTES:
raise ValueError("Parameter notebook is too large")
with open(parameter_path, "r", encoding="utf-8") as parameter_file:
source = json.load(parameter_file)
if not isinstance(source, dict) or source.get("recipe_filename") != recipe_filename:
raise ValueError("Parameter notebook does not belong to recipe")
workflow = source.get("workflow") if isinstance(source, dict) else None
if not isinstance(workflow, dict):
raise ValueError("Parameter source has no workflow")
_validate_workflow(workflow)
return recipe_filename, parameter_filename, recipe, source, workflow
def _parameter_source_record(recipe_filename, parameter_filename, recipe, source, workflow):
record = {
"type": "recipe_parameter_notebook" if parameter_filename else "workflow_recipe",
**_snapshot_recipe_source(recipe_filename),
"parameter_signature": (_parameter_signature(workflow) or {}).get("value") or "",
}
if parameter_filename:
record["parameter_filename"] = parameter_filename
record["parameter_name"] = str(source.get("name") or "").strip()[:200]
elif isinstance(recipe, dict):
record["parameter_name"] = str(recipe.get("name") or "").strip()[:200]
return record
def _snapshot_recipe_source(recipe_filename):
if not recipe_filename:
return {}
record = {"recipe_filename": recipe_filename}
try:
path = resolve_within(get_recipes_dir(), require_filename(recipe_filename))
recipe = _read_recipe(path)
except (OSError, ValueError, json.JSONDecodeError, TypeError):
return record
if not isinstance(recipe, dict):
return record
name = str(recipe.get("name") or "").strip()
if name:
record["recipe_name"] = name[:120]
fingerprint = _recipe_link_fingerprint(recipe)
if fingerprint:
record["recipe_fingerprint"] = fingerprint
return record
def _live_recipe_source(source):
if not isinstance(source, dict):
return None
filename = source.get("recipe_filename")
if not isinstance(filename, str) or not filename:
return None
info = {
"filename": filename,
"name": str(source.get("recipe_name") or "").strip(),
"status": "missing",
}
try:
path = resolve_within(get_recipes_dir(), require_filename(filename))
if not os.path.isfile(path):
return info
recipe = _read_recipe(path)
except (OSError, ValueError, json.JSONDecodeError, TypeError):
return info
if isinstance(recipe, dict):
live_name = str(recipe.get("name") or "").strip()
if live_name:
info["name"] = live_name[:120]
snapshot = str(source.get("recipe_fingerprint") or "")
current = _recipe_link_fingerprint(recipe)
if snapshot and current and snapshot != current:
info["status"] = "modified"
else:
info["status"] = "current"
return info
@lru_cache(maxsize=4096)
def _cached_material_summary(path, signature):
return _material_summary(os.path.basename(path), _read_material(path))
def _with_live_recipe_source(summary):
snapshot = summary.get("source_recipe")
if not isinstance(snapshot, dict) or not snapshot.get("filename"):
return summary
summary["source_recipe"] = _live_recipe_source({
"recipe_filename": snapshot.get("filename"),
"recipe_name": snapshot.get("name"),
"recipe_fingerprint": snapshot.get("fingerprint"),
})
return summary
def _summary_for_path(path, stat=None):
stat = stat or os.stat(path)
signature = (stat.st_size, stat.st_mtime_ns, stat.st_ctime_ns)
return _with_live_recipe_source(copy.deepcopy(_cached_material_summary(path, signature)))
def _list_materials(materials_dir):
result = []
try:
materials_dir = os.path.realpath(materials_dir)
entries = os.scandir(materials_dir)
except OSError:
return result
with entries:
for entry in entries:
if not entry.is_file() or not entry.name.endswith(".json") or entry.name.startswith("."):
continue
try:
path = resolve_within(materials_dir, entry.name) if entry.is_symlink() else entry.path
result.append(copy.deepcopy(_summary_for_path(path, entry.stat())))
except (OSError, ValueError):
continue
result.sort(key=lambda item: (item.get("timestamp", 0), item["filename"]), reverse=True)
return result
def _persist_material(materials_dir, filename, source_path, material, allow_duplicate=False):
with _material_write_lock:
return _persist_material_files(materials_dir, filename, source_path, material, allow_duplicate)
def _persist_parameter_material(materials_dir, filename, material, allow_duplicate=False):
with _material_write_lock:
target = resolve_within(materials_dir, filename)
if os.path.exists(target):
raise ValueError("Material already exists")
if not allow_duplicate:
selection = sorted(str(value) for value in (material.get("selection") or {}).get("node_ids", []))
fingerprint = str((material.get("source") or {}).get("parameter_signature") or "")
for summary in _list_materials(materials_dir):
candidate_selection = sorted(str(value) for value in (summary.get("selection") or {}).get("node_ids", []))
if (summary.get("kind") == material.get("kind")
and summary.get("source_fingerprint") == fingerprint
and candidate_selection == selection):
return summary
_atomic_write_json(target, material)
return None
def _persist_material_files(materials_dir, filename, source_path, material, allow_duplicate):
target = resolve_within(materials_dir, filename)
assets_target = _material_assets_dir(materials_dir, filename)
if os.path.exists(target) or os.path.exists(assets_target):
raise ValueError("Material already exists")
with tempfile.TemporaryDirectory(prefix=".save-", dir=materials_dir) as staging:
material["image"] = _store_assets(staging, filename, source_path)
if not allow_duplicate:
selection = sorted(str(value) for value in (material.get("selection") or {}).get("node_ids", []))
for summary in _list_materials(materials_dir):
candidate_selection = sorted(str(value) for value in (summary.get("selection") or {}).get("node_ids", []))
if summary.get("source_sha256") == material["image"]["source_sha256"] and summary.get("kind") == material.get("kind") and candidate_selection == selection:
return summary
staged_json = resolve_within(staging, filename)
_atomic_write_json(staged_json, material)
os.makedirs(os.path.dirname(assets_target), exist_ok=True)
os.replace(_material_assets_dir(staging, filename), assets_target)
try:
os.replace(staged_json, target)
except OSError:
shutil.rmtree(assets_target)
raise
def _query_materials(materials_dir, query):
materials = _list_materials(materials_dir)
all_tags = sorted({tag for material in materials for tag in material.get("tags", [])}, key=str.casefold)
search = query.get("q", "").strip().casefold()
tag = query.get("tag", "").strip().casefold()
kind = query.get("kind", "")
category = query.get("category", "all")
if category not in ("all", "workflow", "params", "prompts"):
raise ValueError("Invalid material category")
node_type = query.get("node_type", "")
if len(node_type) > 200:
raise ValueError("Invalid node type")
if len(search) > 200 or len(tag) > 60 or kind not in (
"", "image_workflow_snapshot", "image_node_selection", "recipe_parameter_selection", "prompt_note_bundle", "prompt_text", "prompt_plan"
):
raise ValueError("Invalid material filter")
materials = [material for material in materials
if (not search or search in " ".join([material["name"], *material["node_types"], *material.get("tags", [])]).casefold())
and (not tag or tag in [value.casefold() for value in material.get("tags", [])])
and (not kind or material["kind"] == kind)
and (category == "all" or _material_category(material) == category)
and (not node_type or node_type in material["node_types"])]
total = len(materials)
limit = min(100, max(1, int(query.get("limit", 48))))
pages = max(1, (total + limit - 1) // limit)
page = min(pages, max(1, int(query.get("page", 1))))
# Existing non-paginated callers retain their response; the library requests pages.
if "page" in query or "limit" in query:
materials = materials[(page - 1) * limit:page * limit]
return {"status": "success", "materials": materials, "total": total,
"page": page, "pages": pages, "tags": all_tags}
def _update_material_details(materials_dir, filename, name, tags, prompt_role_overrides=None,
update_prompt_roles=False):
with _material_write_lock:
path = resolve_within(materials_dir, filename)
material = _read_material(path)
material.update(name=name, tags=tags)
if update_prompt_roles:
if prompt_role_overrides:
material["promptRoleOverrides"] = prompt_role_overrides
else:
material.pop("promptRoleOverrides", None)
_atomic_write_json(path, material)
return _with_live_recipe_source(_material_summary(filename, material))
def _delete_material(materials_dir, filename, path):
with _material_write_lock:
_read_material(path)
os.remove(path)
assets_dir = _material_assets_dir(materials_dir, filename)
if os.path.isdir(assets_dir):
shutil.rmtree(assets_dir)
+574
View File
@@ -0,0 +1,574 @@
"""HTTP routes and compatibility facade for curated material modules."""
import asyncio
import hashlib
import json
import os
import time
import uuid
from aiohttp import web
import folder_paths
from . import material_schema as _schema
from . import material_store as _store
from .material_assets import _inspect_source_image, _material_assets_dir, _store_assets
from .material_schema import (
MAX_MATERIAL_NAME_LENGTH,
_extract_workflow_params,
_is_prompt_node_type,
_material_category,
_material_node_blocks,
_material_node_ids,
_material_summary,
_node_blocks,
_normalise_material_tags,
_normalise_prompt_role_overrides,
_normalise_selected_node_ids,
_parameter_signature,
_prompt_excerpt,
_prompt_groups_from_roles,
_prompt_roles_for_workflow,
_workflow_hashes_for_blocks,
)
from .notebooks import MAX_NOTEBOOK_BYTES
from .parameters import get_parameters_dir
from .recipe_constants import MAX_RECIPE_BYTES
from .recipe_schema import _build_model_references
from .recipe_store import get_recipes_dir
from .utils import atomic_write_json as _atomic_write_json, require_filename, resolve_within
MATERIAL_SCHEMA_VERSION = 1
_store_get_materials_dir = _store.get_materials_dir
_store_read_material = _store._read_material
_store_read_parameter_source = _store._read_parameter_source
_store_parameter_source_record = _store._parameter_source_record
_store_snapshot_recipe_source = _store._snapshot_recipe_source
_store_live_recipe_source = _store._live_recipe_source
_store_with_live_recipe_source = _store._with_live_recipe_source
_store_summary_for_path = _store._summary_for_path
_store_list_materials = _store._list_materials
_store_persist_material = _store._persist_material
_store_persist_parameter_material = _store._persist_parameter_material
_store_query_materials = _store._query_materials
_store_update_material_details = _store._update_material_details
_store_delete_material = _store._delete_material
def get_materials_dir():
return _store_get_materials_dir()
def _sync_store_compatibility():
"""Keep historical monkeypatch points effective while storage has one owner."""
_store._atomic_write_json = _atomic_write_json
_store._read_material = _read_material
_store._material_summary = _material_summary
_store._normalise_prompt_note = _normalise_prompt_note
_store._normalise_prompt_plan = _normalise_prompt_plan
_store._material_assets_dir = _material_assets_dir
_store._store_assets = _store_assets
_store.get_materials_dir = get_materials_dir
def _normalise_prompt_note(data, prompt_only=False):
_schema.MAX_NOTEBOOK_BYTES = MAX_NOTEBOOK_BYTES
return _schema._normalise_prompt_note(data, prompt_only)
def _normalise_prompt_plan(plan):
_schema.MAX_NOTEBOOK_BYTES = MAX_NOTEBOOK_BYTES
return _schema._normalise_prompt_plan(plan)
def _read_material(path):
_store._normalise_prompt_note = _normalise_prompt_note
_store._normalise_prompt_plan = _normalise_prompt_plan
return _store_read_material(path)
def _read_parameter_source(recipe_filename, parameter_filename=None):
_store.get_recipes_dir = get_recipes_dir
_store.get_parameters_dir = get_parameters_dir
return _store_read_parameter_source(recipe_filename, parameter_filename)
def _parameter_source_record(recipe_filename, parameter_filename, recipe, source, workflow):
return _store_parameter_source_record(recipe_filename, parameter_filename, recipe, source, workflow)
def _snapshot_recipe_source(filename):
_store.get_recipes_dir = get_recipes_dir
return _store_snapshot_recipe_source(filename)
def _live_recipe_source(source):
_store.get_recipes_dir = get_recipes_dir
return _store_live_recipe_source(source)
_cached_material_summary = _store._cached_material_summary
def _with_live_recipe_source(summary):
_store._live_recipe_source = _live_recipe_source
return _store_with_live_recipe_source(summary)
def _summary_for_path(path, stat=None):
_sync_store_compatibility()
_store._with_live_recipe_source = _with_live_recipe_source
return _store_summary_for_path(path, stat)
def _list_materials(materials_dir):
_sync_store_compatibility()
_store._summary_for_path = _summary_for_path
return _store_list_materials(materials_dir)
def _persist_material(materials_dir, filename, source_path, material, allow_duplicate=False):
_sync_store_compatibility()
_store._list_materials = _list_materials
return _store_persist_material(materials_dir, filename, source_path, material, allow_duplicate)
def _persist_parameter_material(materials_dir, filename, material, allow_duplicate=False):
_sync_store_compatibility()
_store._list_materials = _list_materials
return _store_persist_parameter_material(materials_dir, filename, material, allow_duplicate)
def _query_materials(materials_dir, query):
_sync_store_compatibility()
_store._list_materials = _list_materials
return _store_query_materials(materials_dir, query)
def _update_material_details(materials_dir, filename, name, tags, prompt_role_overrides=None,
update_prompt_roles=False):
_sync_store_compatibility()
_store._with_live_recipe_source = _with_live_recipe_source
return _store_update_material_details(
materials_dir, filename, name, tags, prompt_role_overrides, update_prompt_roles
)
def _delete_material(materials_dir, filename, path):
_sync_store_compatibility()
return _store_delete_material(materials_dir, filename, path)
async def api_inspect_image_material(request):
try:
payload = await request.json()
source, source_path, workflow, blocks, references, suggested_name = await asyncio.to_thread(
_inspect_source_image, payload.get("source_image")
)
except (AttributeError, ValueError, json.JSONDecodeError):
return web.json_response({"status": "error", "message": "Image has no reusable UI workflow"}, status=400)
except FileNotFoundError:
return web.json_response({"status": "error", "message": "Output image not found"}, status=404)
except OSError:
return web.json_response({"status": "error", "message": "Could not inspect output image"}, status=500)
sampling_params, prompts = _extract_workflow_params(workflow)
prompt_roles = _prompt_roles_for_workflow(workflow)
return web.json_response({
"status": "success",
"source_image": source,
"suggested_name": suggested_name,
"node_count": len(blocks),
"node_blocks": blocks,
"workflow": workflow,
"model_references": references,
"prompt_excerpt": _prompt_excerpt(workflow),
"params": sampling_params,
"prompts": prompts,
"prompt_roles": prompt_roles,
"prompt_groups": _prompt_groups_from_roles(workflow, prompt_roles),
})
async def api_save_image_material(request):
try:
payload = await request.json()
source, source_path, workflow, blocks, references, suggested_name = await asyncio.to_thread(
_inspect_source_image, payload.get("source_image")
)
name = payload.get("name") or suggested_name
if not isinstance(name, str) or not (name := name.strip()) or len(name) > MAX_MATERIAL_NAME_LENGTH:
raise ValueError("Invalid material name")
tags = _normalise_material_tags(payload.get("tags", []))
allow_duplicate = payload.get("allow_duplicate", False)
if not isinstance(allow_duplicate, bool):
raise ValueError("Invalid duplicate preference")
selected_node_ids = _normalise_selected_node_ids(workflow, payload.get("selected_node_ids"))
selected_id_keys = {str(node_id) for node_id in selected_node_ids or []}
if selected_node_ids is not None:
blocks = [block for block in blocks if str(block.get("node_id")) in selected_id_keys]
references = [
reference for reference in references
if str(reference.get("node_id")) in selected_id_keys
]
material_id = uuid.uuid4().hex
filename = f"material_{int(time.time())}_{material_id}.json"
materials_dir = get_materials_dir()
now = int(time.time() * 1000)
source_record = {"type": "generated_image", "image": source}
recipe_filename = payload.get("recipe_filename")
if isinstance(recipe_filename, str) and recipe_filename.strip():
try:
recipe_filename = require_filename(recipe_filename.strip())
except (AttributeError, TypeError, ValueError):
recipe_filename = ""
if recipe_filename.endswith(".json"):
source_record.update(_snapshot_recipe_source(recipe_filename))
prompt_role_overrides = _normalise_prompt_role_overrides(payload.get("promptRoleOverrides"))
material = {
"schema_version": MATERIAL_SCHEMA_VERSION,
"id": material_id,
"kind": "image_node_selection" if selected_node_ids is not None else "image_workflow_snapshot",
"name": name,
"tags": tags,
"timestamp": now,
"source": source_record,
"workflow": workflow,
"model_references": references,
"capabilities": (
["apply_node_parameters", "reference_image"]
if selected_node_ids is not None
else ["open_workflow", "apply_node_parameters", "reference_image"]
),
}
if selected_node_ids is not None:
material["selection"] = {"scope": "nodes", "node_ids": selected_node_ids}
if prompt_role_overrides:
material["promptRoleOverrides"] = prompt_role_overrides
duplicate = await asyncio.to_thread(_persist_material, materials_dir, filename, source_path, material, allow_duplicate)
if duplicate:
return web.json_response({"status": "duplicate", "filename": duplicate["filename"], "name": duplicate["name"]}, status=409)
except (AttributeError, ValueError, json.JSONDecodeError):
return web.json_response({"status": "error", "message": "Could not save image material"}, status=400)
except FileNotFoundError:
return web.json_response({"status": "error", "message": "Output image not found"}, status=404)
except OSError:
return web.json_response({"status": "error", "message": "Could not save image material"}, status=500)
return web.json_response({
"status": "success",
"filename": filename,
"material": _with_live_recipe_source(_material_summary(filename, material)),
"node_blocks": blocks,
})
async def api_save_parameter_material(request):
try:
payload = await request.json()
recipe_filename, parameter_filename, recipe, source, workflow = await asyncio.to_thread(
_read_parameter_source,
payload.get("recipe_filename", ""),
payload.get("parameter_filename"),
)
source_name = str(source.get("name") or recipe.get("name") or "参数素材").strip()
name = payload.get("name") or source_name
if not isinstance(name, str) or not (name := name.strip()) or len(name) > MAX_MATERIAL_NAME_LENGTH:
raise ValueError("Invalid material name")
tags = _normalise_material_tags(payload.get("tags", recipe.get("tags") or []))
allow_duplicate = payload.get("allow_duplicate", False)
if not isinstance(allow_duplicate, bool):
raise ValueError("Invalid duplicate preference")
reusable_ids = [
node.get("id") for node in workflow.get("nodes", [])
if isinstance(node, dict) and node.get("id") is not None
and isinstance(node.get("widgets_values"), list) and node.get("widgets_values")
]
raw_selection = payload.get("selected_node_ids")
selected_node_ids = _normalise_selected_node_ids(
workflow,
reusable_ids if raw_selection is None else raw_selection,
)
reusable_keys = {str(value) for value in reusable_ids}
if any(str(value) not in reusable_keys for value in selected_node_ids):
raise ValueError("Selected node has no reusable parameters")
selected_keys = {str(value) for value in selected_node_ids}
blocks = [
block for block in _node_blocks(workflow, include_values=False)
if str(block.get("node_id")) in selected_keys
]
references = [
reference for reference in _build_model_references(source, verify_identities=False)
if str(reference.get("node_id")) in selected_keys
]
source_record = _parameter_source_record(
recipe_filename, parameter_filename, recipe, source, workflow
)
material_id = uuid.uuid4().hex
filename = f"material_{int(time.time())}_{material_id}.json"
material = {
"schema_version": MATERIAL_SCHEMA_VERSION,
"id": material_id,
"kind": "recipe_parameter_selection",
"name": name,
"tags": tags,
"timestamp": int(time.time() * 1000),
"source": source_record,
"workflow": workflow,
"model_references": references,
"capabilities": ["apply_node_parameters"],
"selection": {"scope": "nodes", "node_ids": selected_node_ids},
}
params = source.get("params") if isinstance(source.get("params"), dict) else {}
prompt_role_overrides = _normalise_prompt_role_overrides(params.get("promptRoleOverrides"))
if prompt_role_overrides:
material["promptRoleOverrides"] = prompt_role_overrides
materials_dir = get_materials_dir()
duplicate = await asyncio.to_thread(
_persist_parameter_material, materials_dir, filename, material, allow_duplicate
)
if duplicate:
return web.json_response({
"status": "duplicate",
"filename": duplicate["filename"],
"name": duplicate["name"],
}, status=409)
except (AttributeError, TypeError, ValueError, json.JSONDecodeError):
return web.json_response({"status": "error", "message": "Could not save parameter material"}, status=400)
except FileNotFoundError:
return web.json_response({"status": "error", "message": "Parameter source not found"}, status=404)
except OSError:
return web.json_response({"status": "error", "message": "Could not save parameter material"}, status=500)
return web.json_response({
"status": "success",
"filename": filename,
"material": _with_live_recipe_source(_material_summary(filename, material)),
"node_blocks": blocks,
})
async def api_save_prompt_note_material(request):
try:
payload = await request.json()
scope = payload.get("scope", "note")
if scope not in ("note", "prompt"):
raise ValueError("Invalid prompt scope")
source_filename = require_filename(payload.get("notebook_filename", ""))
if not source_filename.endswith(".json") or source_filename.startswith("."):
raise ValueError("Invalid notebook filename")
name = payload.get("name", "")
if not isinstance(name, str) or not name.strip() or len(name.strip()) > MAX_MATERIAL_NAME_LENGTH:
raise ValueError("Invalid material name")
note = _normalise_prompt_note(payload.get("note"), scope == "prompt")
if scope == "prompt" and not note["promptEn"].strip():
raise ValueError("Prompt is empty")
tags = _normalise_material_tags(payload.get("tags", []))
allow_duplicate = payload.get("allow_duplicate", False)
if not isinstance(allow_duplicate, bool):
raise ValueError("Invalid duplicate preference")
signature = hashlib.sha256(json.dumps(note, ensure_ascii=False, sort_keys=True, allow_nan=False).encode("utf-8")).hexdigest()
material_id = uuid.uuid4().hex
filename = f"material_{int(time.time())}_{material_id}.json"
material = {
"schema_version": MATERIAL_SCHEMA_VERSION, "id": material_id,
"kind": "prompt_text" if scope == "prompt" else "prompt_note_bundle",
"name": name.strip(), "tags": tags, "timestamp": int(time.time() * 1000),
"source": {"type": "prompt_note", "notebook_filename": source_filename,
"notebook_name": name.strip(), "parameter_signature": signature},
"note": note, "selection": {"scope": scope},
"capabilities": ["copy_prompt", "restore_prompt_note"],
}
duplicate = await asyncio.to_thread(
_persist_parameter_material, get_materials_dir(), filename, material, allow_duplicate
)
if duplicate:
return web.json_response({"status": "duplicate", "filename": duplicate["filename"],
"name": duplicate["name"]}, status=409)
return web.json_response({"status": "success", "filename": filename,
"material": _material_summary(filename, material)})
except (AttributeError, TypeError, ValueError):
return web.json_response({"status": "error", "message": "Invalid prompt note material"}, status=400)
except OSError:
return web.json_response({"status": "error", "message": "Could not save prompt note material"}, status=500)
async def api_save_prompt_plan(request):
try:
payload = await request.json()
name = payload.get("name", "")
if not isinstance(name, str) or not name.strip() or len(name.strip()) > MAX_MATERIAL_NAME_LENGTH:
raise ValueError("Invalid plan name")
plan = _normalise_prompt_plan(payload.get("plan"))
tags = _normalise_material_tags(payload.get("tags", []))
allow_duplicate = payload.get("allow_duplicate", False)
if not isinstance(allow_duplicate, bool):
raise ValueError("Invalid duplicate preference")
signature = hashlib.sha256(json.dumps(plan, ensure_ascii=False, sort_keys=True).encode("utf-8")).hexdigest()
material_id = uuid.uuid4().hex
filename = f"material_{int(time.time())}_{material_id}.json"
material = {"schema_version": MATERIAL_SCHEMA_VERSION, "id": material_id,
"kind": "prompt_plan", "name": name.strip(), "tags": tags,
"timestamp": int(time.time() * 1000), "plan": plan,
"source": {"type": "prompt_plan", "parameter_signature": signature},
"capabilities": ["compose_prompt"], "selection": {"scope": "prompt_plan"}}
duplicate = await asyncio.to_thread(_persist_parameter_material, get_materials_dir(), filename, material, allow_duplicate)
if duplicate:
return web.json_response({"status": "duplicate", "filename": duplicate["filename"], "name": duplicate["name"]}, status=409)
return web.json_response({"status": "success", "filename": filename, "material": _material_summary(filename, material)})
except (AttributeError, TypeError, ValueError):
return web.json_response({"status": "error", "message": "Invalid prompt plan"}, status=400)
except OSError:
return web.json_response({"status": "error", "message": "Could not save prompt plan"}, status=500)
async def api_get_materials(request):
try:
payload = await asyncio.to_thread(_query_materials, get_materials_dir(), dict(request.query))
return web.json_response(payload)
except (TypeError, ValueError):
return web.json_response({"status": "error", "message": "Invalid material filter"}, status=400)
except OSError:
return web.json_response({"status": "error", "message": "Could not list materials"}, status=500)
async def api_update_material(request):
try:
payload = await request.json()
filename = require_filename(payload.get("filename", ""))
name = payload.get("name", "")
if not filename.endswith(".json") or filename.startswith(".") or not isinstance(name, str) or not name.strip() or len(name.strip()) > MAX_MATERIAL_NAME_LENGTH:
raise ValueError("Invalid material details")
tags = _normalise_material_tags(payload.get("tags", []))
update_prompt_roles = "promptRoleOverrides" in payload
prompt_role_overrides = (
_normalise_prompt_role_overrides(payload.get("promptRoleOverrides"))
if update_prompt_roles else None
)
material = await asyncio.to_thread(
_update_material_details,
get_materials_dir(),
filename,
name.strip(),
tags,
prompt_role_overrides,
update_prompt_roles,
)
return web.json_response({"status": "success", "material": material})
except (AttributeError, TypeError, ValueError):
return web.json_response({"status": "error", "message": "Invalid material details"}, status=400)
except FileNotFoundError:
return web.json_response({"status": "error", "message": "Material not found"}, status=404)
except OSError:
return web.json_response({"status": "error", "message": "Could not update material"}, status=500)
def _material_detail_response(path, include_workflow):
material = _read_material(path)
can_open = (material.get("kind") == "image_workflow_snapshot"
and (material.get("selection") or {}).get("scope") != "nodes"
and "open_workflow" in material.get("capabilities", []))
if include_workflow == "1" and not can_open:
return web.json_response({"status": "error", "message": "Material cannot open a full workflow"}, status=403)
workflow = material.get("workflow") or {}
prompt_roles = _prompt_roles_for_workflow(workflow, material.get("promptRoleOverrides"))
prompt_groups = _prompt_groups_from_roles(workflow, prompt_roles, _material_node_ids(material))
blocks = _material_node_blocks(material, include_values=True) if include_workflow != "1" else []
if include_workflow == "0" or not can_open:
material.pop("workflow", None)
return web.json_response({
"status": "success",
"data": material,
"source_recipe": _live_recipe_source(material.get("source") or {}),
"node_blocks": blocks,
"workflow_hashes": _workflow_hashes_for_blocks(workflow, blocks),
"prompt_roles": prompt_roles,
"prompt_groups": prompt_groups,
})
async def api_get_material_full(request):
try:
filename = require_filename(request.query.get("filename", ""))
if not filename.endswith(".json"):
raise ValueError("Invalid material filename")
include_workflow = request.query.get("include_workflow")
if include_workflow not in (None, "0", "1"):
raise ValueError("Invalid workflow inclusion flag")
return await asyncio.to_thread(_material_detail_response,
resolve_within(get_materials_dir(), filename), include_workflow)
except (AttributeError, ValueError, json.JSONDecodeError):
return web.json_response({"status": "error", "message": "Invalid material"}, status=400)
except FileNotFoundError:
return web.json_response({"status": "error", "message": "Material not found"}, status=404)
except OSError:
return web.json_response({"status": "error", "message": "Could not read material"}, status=500)
async def api_get_materials_by_node_type(request):
node_type = request.query.get("type", "")
if not isinstance(node_type, str) or not node_type.strip() or len(node_type) > 200:
return web.json_response({"status": "error", "message": "Invalid node type"}, status=400)
materials_dir = get_materials_dir()
def collect():
matches = []
for summary in _list_materials(materials_dir):
if node_type not in summary["node_types"]:
continue
try:
material = _read_material(resolve_within(materials_dir, summary["filename"]))
except (OSError, ValueError, json.JSONDecodeError):
continue
blocks = [block for block in _material_node_blocks(material, include_values=True) if block["type"] == node_type]
if blocks:
matches.append({
"filename": summary["filename"],
"name": summary["name"],
"kind": summary["kind"],
"timestamp": summary["timestamp"],
"blocks": blocks,
"workflow_hashes": _workflow_hashes_for_blocks(material["workflow"], blocks),
})
return matches
matches = await asyncio.to_thread(collect)
return web.json_response({"status": "success", "materials": matches})
async def api_get_material_asset(request):
try:
filename = require_filename(request.query.get("filename", ""))
asset_id = require_filename(request.query.get("asset", ""))
if not filename.endswith(".json") or not asset_id.lower().endswith((".png", ".webp")):
raise ValueError("Invalid material asset")
materials_dir = get_materials_dir()
await asyncio.to_thread(_summary_for_path, resolve_within(materials_dir, filename))
asset_path = resolve_within(_material_assets_dir(materials_dir, filename), asset_id)
if not os.path.isfile(asset_path):
raise FileNotFoundError
except (AttributeError, ValueError, json.JSONDecodeError):
return web.json_response({"status": "error", "message": "Invalid material asset"}, status=400)
except FileNotFoundError:
return web.json_response({"status": "error", "message": "Material asset not found"}, status=404)
except OSError:
return web.json_response({"status": "error", "message": "Could not read material asset"}, status=500)
return web.FileResponse(asset_path)
async def api_delete_material(request):
try:
payload = await request.json()
filename = require_filename(payload.get("filename", ""))
if not filename.endswith(".json"):
raise ValueError("Invalid material filename")
materials_dir = get_materials_dir()
path = resolve_within(materials_dir, filename)
await asyncio.to_thread(_delete_material, materials_dir, filename, path)
except (AttributeError, ValueError, json.JSONDecodeError):
return web.json_response({"status": "error", "message": "Invalid material"}, status=400)
except FileNotFoundError:
return web.json_response({"status": "error", "message": "Material not found"}, status=404)
except OSError:
return web.json_response({"status": "error", "message": "Could not delete material"}, status=500)
return web.json_response({"status": "success"})
+278
View File
@@ -0,0 +1,278 @@
"""Model cover, thumbnail-cache, and generated-image lookup routes."""
import asyncio
import hashlib
import json
import os
import struct
import tempfile
import threading
import time
import urllib.parse
from aiohttp import web
import folder_paths
from .path_utils import require_filename, resolve_folder_subdir, resolve_within
CARD_THUMBNAIL_EDGE = 512
CARD_THUMBNAIL_CACHE_LIMIT = 256 * 1024 * 1024
CARD_THUMBNAIL_STATIC_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.webp', '.avif'}
_thumbnail_cleanup_lock = threading.Lock()
_thumbnail_generation_slots = threading.BoundedSemaphore(2)
_thumbnail_last_cleanup = 0.0
_thumbnail_created_since_cleanup = 0
def _thumbnail_cache_directory():
try:
base_dir = folder_paths.get_temp_directory()
except Exception:
base_dir = None
if not isinstance(base_dir, (str, os.PathLike)):
base_dir = tempfile.gettempdir()
cache_dir = os.path.join(base_dir, "anomalous_model_browser", "card_thumbnails")
os.makedirs(cache_dir, exist_ok=True)
return cache_dir
def _prune_thumbnail_cache(cache_dir, created=False):
"""Occasionally cap the derived thumbnail cache without touching source covers."""
global _thumbnail_last_cleanup, _thumbnail_created_since_cleanup
now = time.monotonic()
with _thumbnail_cleanup_lock:
if created:
_thumbnail_created_since_cleanup += 1
now = time.monotonic()
if (
now - _thumbnail_last_cleanup < 3600
and _thumbnail_created_since_cleanup < 64
):
return
_thumbnail_last_cleanup = now
_thumbnail_created_since_cleanup = 0
entries = []
total_size = 0
try:
with os.scandir(cache_dir) as iterator:
for entry in iterator:
if not entry.is_file() or not entry.name.endswith('.webp'):
continue
try:
stat = entry.stat()
except OSError:
continue
total_size += stat.st_size
entries.append((stat.st_mtime_ns, stat.st_size, entry.path))
except OSError:
return
if total_size <= CARD_THUMBNAIL_CACHE_LIMIT:
return
target_size = int(CARD_THUMBNAIL_CACHE_LIMIT * 0.8)
for _, size, path in sorted(entries):
try:
os.remove(path)
total_size -= size
except OSError:
pass
if total_size <= target_size:
break
def _build_card_thumbnail_impl(source_path):
"""Return a cached 512px WebP card image, or the original on any safe fallback."""
try:
from PIL import Image, ImageOps
source_stat = os.stat(source_path)
cache_key = "\0".join((
os.path.realpath(source_path),
str(source_stat.st_size),
str(source_stat.st_mtime_ns),
str(getattr(source_stat, 'st_ctime_ns', 0)),
str(CARD_THUMBNAIL_EDGE),
))
digest = hashlib.sha256(cache_key.encode('utf-8', errors='surrogatepass')).hexdigest()
cache_dir = _thumbnail_cache_directory()
cached_path = os.path.join(cache_dir, f"{digest}.webp")
if os.path.isfile(cached_path):
try:
os.utime(cached_path, None)
except OSError:
pass
_prune_thumbnail_cache(cache_dir)
return cached_path
with Image.open(source_path) as image:
if getattr(image, 'is_animated', False):
return source_path
image = ImageOps.exif_transpose(image)
if max(image.size) <= CARD_THUMBNAIL_EDGE:
return source_path
resampling = getattr(Image, 'Resampling', Image).LANCZOS
image.thumbnail((CARD_THUMBNAIL_EDGE, CARD_THUMBNAIL_EDGE), resampling)
if image.mode not in ('RGB', 'RGBA'):
image = image.convert('RGBA' if 'transparency' in image.info else 'RGB')
temp_path = (
f"{cached_path}.{os.getpid()}.{threading.get_ident()}.tmp"
)
try:
image.save(temp_path, format='WEBP', quality=84, method=4)
os.replace(temp_path, cached_path)
finally:
if os.path.exists(temp_path):
try:
os.remove(temp_path)
except OSError:
pass
_prune_thumbnail_cache(cache_dir, created=True)
return cached_path
except Exception:
return source_path
def _build_card_thumbnail(source_path):
# Bound simultaneous decodes so opening a large folder cannot monopolize CPU/RAM.
with _thumbnail_generation_slots:
return _build_card_thumbnail_impl(source_path)
async def api_serve_image(request):
"""Dedicated image serving endpoint for model preview images."""
folder_type = request.query.get('type', 'checkpoints')
try:
path_idx = int(request.query.get('path_idx', 0))
except:
path_idx = 0
subfolder = request.query.get('subfolder', '')
filename = request.query.get('filename', '')
try:
filename = require_filename(filename)
_, target_dir = resolve_folder_subdir(folder_type, path_idx, subfolder)
file_path = resolve_within(target_dir, filename)
except (ValueError, KeyError):
return web.Response(status=400, text='Invalid request')
if not os.path.exists(file_path) or not os.path.isfile(file_path):
return web.Response(status=404, text='Image not found')
ext = os.path.splitext(filename)[1].lower()
content_types = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.webp': 'image/webp',
'.gif': 'image/gif',
'.avif': 'image/avif',
'.mp4': 'video/mp4',
'.webm': 'video/webm',
'.mov': 'video/quicktime',
'.avi': 'video/x-msvideo'
}
content_type = content_types.get(ext)
if content_type is None:
return web.Response(status=415, text='Unsupported media type')
served_path = file_path
if (
request.query.get('variant') == 'card'
and ext in CARD_THUMBNAIL_STATIC_EXTENSIONS
):
served_path = await asyncio.to_thread(_build_card_thumbnail, file_path)
if served_path != file_path:
content_type = 'image/webp'
headers = {'Content-Type': content_type}
if request.query.get('t'):
headers['Cache-Control'] = 'public, max-age=31536000, immutable'
return web.FileResponse(served_path, headers=headers)
async def api_clear_cache(request):
try:
from .metadata import clear_metadata_cache
if hasattr(folder_paths, "filename_list_cache"):
folder_paths.filename_list_cache.clear()
if hasattr(folder_paths, "cache_helper") and hasattr(folder_paths.cache_helper, "clear"):
folder_paths.cache_helper.clear()
clear_metadata_cache()
return web.json_response({"status": "success"})
except Exception as e:
return web.json_response({"status": "error", "message": str(e)})
def read_png_text_fast(path):
try:
with open(path, 'rb') as f:
signature = f.read(8)
if signature != b'\x89PNG\r\n\x1a\n':
return None
while True:
length_bytes = f.read(4)
if not length_bytes: break
length = struct.unpack('>I', length_bytes)[0]
chunk_type = f.read(4)
if chunk_type == b'tEXt':
data = f.read(length)
keyword, text = data.split(b'\0', 1)
if keyword == b'prompt':
return text.decode('utf-8', errors='ignore')
else:
f.seek(length, 1)
f.seek(4, 1)
except Exception:
pass
return None
async def api_get_model_images(request):
model_name = request.rel_url.query.get('model_name', '')
if not model_name:
return web.json_response({'images': []})
base_target = os.path.basename(model_name).lower()
# We will search the output directory
output_dir = folder_paths.get_output_directory()
if not os.path.exists(output_dir):
return web.json_response({'images': []})
def find_images():
matched_images = []
for root, _, files in os.walk(output_dir):
for file in files:
if not file.lower().endswith('.png'):
continue
full_path = os.path.join(root, file)
prompt_text = read_png_text_fast(full_path)
if not prompt_text:
continue
try:
prompt_data = json.loads(prompt_text)
matched = any(
os.path.basename(v).lower() == base_target
for node in prompt_data.values()
if isinstance(node, dict) and 'class_type' in node
for v in node.get('inputs', {}).values()
if isinstance(v, str)
)
if not matched:
continue
rel_path = os.path.relpath(root, output_dir).replace('\\', '/')
if rel_path == '.':
rel_path = ''
url = f'/view?filename={urllib.parse.quote(file)}&type=output'
if rel_path:
url += f'&subfolder={urllib.parse.quote(rel_path)}'
matched_images.append({'url': url, 'mtime': os.path.getmtime(full_path)})
except (OSError, ValueError, TypeError, json.JSONDecodeError):
pass
matched_images.sort(key=lambda x: x['mtime'], reverse=True)
return matched_images
matched_images = await asyncio.to_thread(find_images)
return web.json_response({'images': matched_images})
+72 -100
View File
@@ -10,45 +10,10 @@ from functools import lru_cache
from aiohttp import web
import folder_paths
import struct
def _read_safetensors_hash(file_path):
"""Extract hash from safetensors header metadata (O(1) fast read, no full-file SHA256)."""
try:
with open(file_path, "rb") as f:
header_size_bytes = f.read(8)
if len(header_size_bytes) < 8:
return None
header_size = struct.unpack('<Q', header_size_bytes)[0]
if header_size > 100 * 1024 * 1024:
return None
header_json_bytes = f.read(header_size)
header_str = header_json_bytes.decode('utf-8')
header_json = json.loads(header_str)
metadata = header_json.get('__metadata__', {})
if not metadata:
return None
if 'modelspec.hash.sha256' in metadata:
return metadata['modelspec.hash.sha256']
if 'modelspec.hash.blake3' in metadata:
return metadata['modelspec.hash.blake3']
except Exception:
pass
return None
@lru_cache(maxsize=4096)
def _extract_safetensors_hash_cached(file_path, signature):
return _read_safetensors_hash(file_path)
def _extract_safetensors_hash(file_path):
normalized_path = os.path.realpath(file_path)
return _extract_safetensors_hash_cached(normalized_path, _file_signature(normalized_path))
try:
from ..model_identity import sidecar_file_hash
except ImportError:
from model_identity import sidecar_file_hash
def _select_info_file(data, file_path):
"""Select the Civitai file entry that actually describes ``file_path``.
@@ -106,82 +71,90 @@ def _select_info_hash(data, file_path):
hashes = selected.get("hashes", {})
return str(hashes.get("SHA256", "")) if isinstance(hashes, dict) else ""
def _positive_civitai_id(value):
"""Return a real Civitai identifier, excluding offline sentinel values."""
if isinstance(value, bool):
return None
try:
parsed = int(value)
except (TypeError, ValueError, OverflowError):
return None
return parsed if parsed > 0 else None
def _read_metadata(file_path):
base_path = os.path.splitext(file_path)[0]
metadata = {
"name": os.path.basename(base_path),
"description": "",
"notes": "",
"trainedWords": [],
"baseModel": "",
"civitai_url": "",
base_path = os.path.splitext(file_path)[0]
metadata = {
"name": os.path.basename(base_path),
"description": "",
"notes": "",
"trainedWords": [],
"baseModel": "",
"civitai_url": "",
"source_url": "",
"hash": "",
"hash_algorithm": "sha256",
"hash_scope": "file",
"hash_source": "",
"custom_name": "",
"custom_notes": ""
}
info_files = [f"{base_path}.info", f"{base_path}.civitai.info"]
for info_file in info_files:
if os.path.exists(info_file):
try:
with open(info_file, 'r', encoding='utf-8') as f:
data = json.load(f)
name = data.get("name", "")
if "model" in data and isinstance(data["model"], dict):
model_name = data["model"].get("name", "")
name = f"{model_name} - {name}".strip(' -')
description = data.get("description", "") or ""
if not description and "model" in data and isinstance(data["model"], dict):
description = data["model"].get("description", "")
notes = data.get("notes", "")
trained_words = data.get("trainedWords", [])
base_model = data.get("baseModel", "")
model_id = data.get("modelId", "")
version_id = data.get("id", "")
civitai_url = ""
if model_id:
# Handle Civitai's new mature content policy
nsfw_level = data.get("nsfwLevel", 1)
is_nsfw = False
if "model" in data and isinstance(data["model"], dict):
is_nsfw = data["model"].get("nsfw", False)
domain = "civitai.red" if (nsfw_level > 1 or is_nsfw) else "civitai.com"
civitai_url = f"https://{domain}/models/{model_id}"
if version_id:
civitai_url += f"?modelVersionId={version_id}"
selected_file = _select_info_file(data, file_path)
hash_val = ""
if selected_file:
hashes = selected_file.get("hashes", {})
if isinstance(hashes, dict):
hash_val = str(hashes.get("SHA256", ""))
"custom_notes": ""
}
info_files = [f"{base_path}.info", f"{base_path}.civitai.info"]
for info_file in info_files:
if os.path.exists(info_file):
try:
with open(info_file, 'r', encoding='utf-8') as f:
data = json.load(f)
name = data.get("name", "")
if "model" in data and isinstance(data["model"], dict):
model_name = data["model"].get("name", "")
name = f"{model_name} - {name}".strip(' -')
description = data.get("description", "") or ""
if not description and "model" in data and isinstance(data["model"], dict):
description = data["model"].get("description", "")
notes = data.get("notes", "")
trained_words = data.get("trainedWords", [])
base_model = data.get("baseModel", "")
model_id = _positive_civitai_id(data.get("modelId"))
version_id = _positive_civitai_id(data.get("id"))
civitai_url = ""
if model_id is not None:
# Handle Civitai's new mature content policy
nsfw_level = data.get("nsfwLevel", 1)
is_nsfw = False
if "model" in data and isinstance(data["model"], dict):
is_nsfw = data["model"].get("nsfw", False)
domain = "civitai.red" if (nsfw_level > 1 or is_nsfw) else "civitai.com"
civitai_url = f"https://{domain}/models/{model_id}"
if version_id is not None:
civitai_url += f"?modelVersionId={version_id}"
selected_file = _select_info_file(data, file_path)
hash_val, hash_source = sidecar_file_hash(data, file_path, selected_file)
if hash_val:
metadata["hash_source"] = hash_source
if name: metadata["name"] = name
if description: metadata["description"] = description
if notes: metadata["notes"] = notes
if trained_words: metadata["trainedWords"] = trained_words
if base_model: metadata["baseModel"] = base_model
if civitai_url: metadata["civitai_url"] = civitai_url
if model_id: metadata["model_id"] = model_id
if version_id: metadata["version_id"] = version_id
if model_id is not None: metadata["model_id"] = model_id
if version_id is not None: metadata["version_id"] = version_id
if hash_val: metadata["hash"] = hash_val
if "anomalous_custom_name" in data and data["anomalous_custom_name"]: metadata["custom_name"] = data["anomalous_custom_name"]
if "anomalous_custom_notes" in data and data["anomalous_custom_notes"]: metadata["custom_notes"] = data["anomalous_custom_notes"]
if "anomalous_source_url" in data and data["anomalous_source_url"]: metadata["source_url"] = data["anomalous_source_url"]
except Exception:
pass
# Fallback: if no hash found from .info files, try extracting from safetensors header directly
if not metadata["hash"] and file_path.endswith('.safetensors'):
header_hash = _extract_safetensors_hash(file_path)
if header_hash:
metadata["hash"] = header_hash
return metadata
@@ -220,7 +193,6 @@ def get_metadata(file_path):
def clear_metadata_cache():
_get_metadata_cached.cache_clear()
_extract_safetensors_hash_cached.cache_clear()
def get_metadata_cache_info():
+692
View File
@@ -0,0 +1,692 @@
"""Model folder discovery, listing, search, and presentation lookup."""
import asyncio
import json
import os
import urllib.parse
from aiohttp import web
import folder_paths
from .metadata import get_metadata
from .model_constants import MEDIA_EXTENSIONS, MODEL_EXTENSIONS, PREVIEW_SUFFIXES
from .model_media import _cache_token, _preview_url_for_model
from .folder_types import (
get_active_folder_types, get_active_model_roots,
get_active_physical_basenames, get_folder_view_mode,
)
from .path_utils import resolve_folder_subdir
def _collect_folder_models(target_dir, folder_type, path_idx, rel_subfolder, page, limit):
"""Collect one folder in a worker thread with a single directory listing."""
try:
with os.scandir(target_dir) as iterator:
file_entries = {}
for entry in iterator:
try:
if entry.is_file():
file_entries[entry.name] = entry
except OSError:
continue
except OSError:
return {"models": [], "total": 0, "page": page, "limit": limit}
valid_files = sorted(
(name for name in file_entries if name.endswith(('.safetensors', '.ckpt', '.pt'))),
key=str.lower,
)
total = len(valid_files)
if limit > 0:
start = max(0, (page - 1) * limit)
sliced = valid_files[start:start + limit]
else:
sliced = valid_files
q_type = urllib.parse.quote(folder_type)
q_idx = str(path_idx)
q_sub = urllib.parse.quote(rel_subfolder.strip('/')) if rel_subfolder and rel_subfolder != '/' else ""
models = []
for filename in sliced:
file_path = os.path.join(target_dir, filename)
metadata = get_metadata(file_path)
base_name = os.path.splitext(filename)[0]
preview_file = next(
(base_name + suffix for suffix in PREVIEW_SUFFIXES + MEDIA_EXTENSIONS if base_name + suffix in file_entries),
None,
)
preview_url = ""
if preview_file:
q_file = urllib.parse.quote(preview_file)
try:
preview_version = file_entries[preview_file].stat().st_mtime_ns
except OSError:
preview_version = 0
preview_url = (
f"/anomalous/image?type={q_type}&path_idx={q_idx}&subfolder={q_sub}"
f"&filename={q_file}&t={preview_version}"
)
try:
model_stat = file_entries[filename].stat()
size_bytes = model_stat.st_size
size_mb = round(size_bytes / (1024 * 1024), 2)
except OSError:
size_mb = 0
size_bytes = 0
models.append({
"filename": filename,
"file_path": os.path.abspath(file_path),
"size_mb": size_mb,
"size_bytes": size_bytes,
"metadata": metadata,
"preview_url": preview_url,
"type": folder_type,
"path_idx": path_idx,
"subfolder": rel_subfolder,
})
return {"models": models, "total": total, "page": page, "limit": limit}
def _collect_folders():
mode = get_folder_view_mode()
result = []
seen_dirs = set()
if mode == "physical":
active_bns = get_active_physical_basenames()
all_paths_info = []
for t in folder_paths.folder_names_and_paths.keys():
try:
paths = folder_paths.get_folder_paths(t)
if not paths: continue
for path_idx, base_dir in enumerate(paths):
if not os.path.exists(base_dir): continue
real_path = os.path.realpath(base_dir)
if real_path in seen_dirs: continue
seen_dirs.add(real_path)
bn = os.path.basename(os.path.normpath(base_dir))
all_paths_info.append({
"t": t,
"path_idx": path_idx,
"base_dir": base_dir,
"bn": bn
})
except:
pass
for target_bn in active_bns:
matched = [p for p in all_paths_info if p["bn"] == target_bn]
if not matched: continue
for item in matched:
base_dir = item["base_dir"]
tree = {}
for root, dirs, files in os.walk(base_dir):
has_models = any(f.endswith(('.safetensors', '.ckpt', '.pt')) for f in files)
rel = os.path.relpath(root, base_dir)
if rel == '.':
rel = '/'
else:
rel = '/' + rel.replace('\\', '/')
tree[rel] = {
"path": rel,
"name": os.path.basename(root) if rel != '/' else '[Root]',
"has_models": has_models,
"model_count": sum(1 for f in files if f.endswith(('.safetensors', '.ckpt', '.pt')))
}
label = item["bn"]
if len(matched) > 1:
label += f" ({item['path_idx'] + 1})"
result.append({
"type": item["t"],
"path_idx": item["path_idx"],
"label": label,
"folders": tree
})
else:
types = get_active_folder_types()
for t in types:
try:
paths = folder_paths.get_folder_paths(t)
except Exception:
continue
if not paths:
continue
for path_idx, base_dir in enumerate(paths):
if not os.path.exists(base_dir):
continue
real_path = os.path.realpath(base_dir)
if real_path in seen_dirs:
continue
seen_dirs.add(real_path)
tree = {}
for root, dirs, files in os.walk(base_dir):
has_models = any(f.endswith(('.safetensors', '.ckpt', '.pt')) for f in files)
rel = os.path.relpath(root, base_dir)
if rel == '.':
rel = '/'
else:
rel = '/' + rel.replace('\\', '/')
tree[rel] = {
"path": rel,
"name": os.path.basename(root) if rel != '/' else '[Root]',
"has_models": has_models,
"model_count": sum(1 for f in files if f.endswith(('.safetensors', '.ckpt', '.pt')))
}
try:
folder_basename = os.path.basename(os.path.normpath(base_dir))
if not folder_basename:
folder_basename = t
except:
folder_basename = t
label = folder_basename
basenames = []
try:
basenames = [os.path.basename(os.path.normpath(p)) for p in paths]
except:
pass
if basenames.count(folder_basename) > 1:
label += f" ({path_idx + 1})"
result.append({
"type": t,
"path_idx": path_idx,
"label": label,
"folders": tree
})
return {"folders": result}
async def api_get_folders(request):
return web.json_response(await asyncio.to_thread(_collect_folders))
async def api_get_models(request):
folder_type = request.query.get('type', 'checkpoints')
subfolder = request.query.get('subfolder', '/')
page = int(request.query.get('page', 1))
limit = int(request.query.get('limit', 0))
try:
path_idx = int(request.query.get('path_idx', 0))
except:
path_idx = 0
try:
paths = folder_paths.get_folder_paths(folder_type)
except Exception:
return web.json_response({"models": [], "total": 0})
try:
base_dir, target_dir = resolve_folder_subdir(folder_type, path_idx, subfolder)
except (ValueError, KeyError):
return web.Response(status=400, text='Invalid subfolder')
rel_subfolder = "" if subfolder == '/' else subfolder.strip('/\\')
if not os.path.exists(target_dir):
return web.json_response({"models": [], "total": 0})
payload = await asyncio.to_thread(
_collect_folder_models,
target_dir,
folder_type,
path_idx,
rel_subfolder,
page,
limit,
)
return web.json_response(payload)
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"],
})
# Prompt Notes ask for this base model to list models whose metadata names none.
UNLABELED_BASE_MODEL = "__unlabeled__"
def _normalised_base(value):
return str(value or "").strip().lower().replace(" ", "")
def _walk_model_files(target_types):
"""Yield (folder_type, path_idx, base_dir, root, filename, metadata) once per real file."""
seen_files = set()
for folder_type in target_types:
try:
paths = folder_paths.get_folder_paths(folder_type)
except Exception:
continue
for path_idx, base_dir in enumerate(paths or []):
if not os.path.exists(base_dir):
continue
for root, _, files in os.walk(base_dir):
for filename in files:
if not filename.lower().endswith(MODEL_EXTENSIONS):
continue
file_path = os.path.join(root, filename)
real_path = os.path.realpath(file_path)
if real_path in seen_files:
continue
seen_files.add(real_path)
yield folder_type, path_idx, base_dir, root, filename, get_metadata(file_path)
def _base_matches(requested, metadata):
model_base = _normalised_base(metadata.get("baseModel"))
if requested == UNLABELED_BASE_MODEL:
return not model_base
wanted = _normalised_base(requested)
return bool(wanted and model_base and (wanted in model_base or model_base in wanted))
def _compatible_models_sync(base_model, target_types):
compatible_models = []
for folder_type, path_idx, base_dir, root, filename, meta in _walk_model_files(target_types):
if not _base_matches(base_model, meta):
continue
rel_subfolder = os.path.relpath(root, base_dir)
rel_subfolder = '/' if rel_subfolder == '.' else '/' + rel_subfolder.replace('\\', '/')
base_name = os.path.splitext(filename)[0]
preview_file = None
for ext in PREVIEW_SUFFIXES + MEDIA_EXTENSIONS:
if os.path.exists(os.path.join(root, base_name + ext)):
preview_file = base_name + ext
break
preview_url = ""
if preview_file:
q_sub = urllib.parse.quote(rel_subfolder.strip('/')) if rel_subfolder != '/' else ""
preview_url = (
f"/anomalous/image?type={urllib.parse.quote(folder_type)}&path_idx={path_idx}"
f"&subfolder={q_sub}&filename={urllib.parse.quote(preview_file)}"
)
try:
size_bytes = os.path.getsize(os.path.join(root, filename))
except OSError:
size_bytes = 0
compatible_models.append({
"type": folder_type,
"path_idx": path_idx,
"subfolder": rel_subfolder,
"filename": filename,
"size_mb": round(size_bytes / (1024 * 1024), 1),
"size_bytes": size_bytes,
"preview_url": preview_url,
"metadata": meta,
})
return compatible_models
async def api_compatible_models(request):
base_model = request.query.get('base_model', '')
if not base_model:
return web.json_response({"models": []})
target_types = [t.strip() for t in request.query.get('target_type', 'loras').split(',') if t.strip()]
models = await asyncio.to_thread(_compatible_models_sync, base_model, target_types)
return web.json_response({"models": models})
def _base_models_sync(target_types):
base_models = set()
unlabeled = False
for *_, meta in _walk_model_files(target_types):
label = str(meta.get("baseModel") or "").strip()
if label:
base_models.add(label)
else:
unlabeled = True
return sorted(base_models), unlabeled
async def api_base_models(request):
base_models, unlabeled = await asyncio.to_thread(_base_models_sync, get_active_folder_types())
return web.json_response({"base_models": base_models, "unlabeled": unlabeled})
def _model_info_for_path(folder_type, path_idx, base_dir, file_path):
root = os.path.dirname(file_path)
rel_subfolder = os.path.relpath(root, base_dir)
if rel_subfolder == '.':
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),
}
def _allowed_folder_types(requested_types=None):
available = list(folder_paths.folder_names_and_paths.keys())
if requested_types is None:
return available
if not isinstance(requested_types, list):
return []
allowed = set(available)
return list(dict.fromkeys(
folder_type
for folder_type in requested_types
if isinstance(folder_type, str) and folder_type in allowed
))
def _resolve_paths_to_model_info_sync(paths, folder_types=None, exact_only=False):
requested = [
(path, path.replace('\\', '/').lower(), path.replace('\\', '/'))
for path in paths
if isinstance(path, str)
]
exact_results = {}
roots = []
for folder_type in _allowed_folder_types(folder_types):
try:
folder_dirs = folder_paths.get_folder_paths(folder_type)
except Exception:
continue
for path_idx, base_dir in enumerate(folder_dirs or []):
if not os.path.isdir(base_dir):
continue
real_base_dir = os.path.realpath(base_dir)
roots.append((folder_type, path_idx, real_base_dir))
for original, _, relative_path in requested:
relative = relative_path.replace('/', os.sep)
candidate = os.path.realpath(os.path.join(real_base_dir, relative))
try:
if os.path.commonpath((real_base_dir, candidate)) != real_base_dir:
continue
except ValueError:
continue
if os.path.isfile(candidate) and candidate.lower().endswith(('.safetensors', '.ckpt', '.pt', '.bin', '.sft')):
exact_results[original] = _model_info_for_path(folder_type, path_idx, base_dir, candidate)
unresolved = [(original, normalized) for original, normalized, _ in requested if original not in exact_results]
if exact_only or not unresolved:
return exact_results
wanted_relpaths = {normalized for _, normalized in unresolved}
wanted_basenames = {normalized.rsplit('/', 1)[-1] for _, normalized in unresolved}
rel_matches = {}
basename_matches = {}
for folder_type, path_idx, base_dir in roots:
for root, _, files in os.walk(base_dir):
for filename in files:
if not filename.lower().endswith(('.safetensors', '.ckpt', '.pt', '.bin', '.sft')):
continue
rel_path = os.path.relpath(os.path.join(root, filename), base_dir).replace(os.sep, '/').lower()
basename = filename.lower()
if rel_path not in wanted_relpaths and basename not in wanted_basenames:
continue
candidate_info = _model_info_for_path(
folder_type,
path_idx,
base_dir,
os.path.join(root, filename),
)
if rel_path in wanted_relpaths:
rel_matches[rel_path] = candidate_info
if basename in wanted_basenames:
basename_matches[basename] = candidate_info
model_info = dict(exact_results)
for original, normalized in unresolved:
if normalized in rel_matches:
model_info[original] = rel_matches[normalized]
else:
basename = normalized.rsplit('/', 1)[-1]
if basename in basename_matches:
model_info[original] = basename_matches[basename]
return model_info
def _resolve_paths_to_previews_sync(paths, folder_types=None, exact_only=False):
model_info = _resolve_paths_to_model_info_sync(paths, folder_types, exact_only)
return {path: item.get("preview_url", "") for path, item in model_info.items()}
async def api_resolve_paths_to_previews(request):
try:
data = await request.json()
paths = data.get('paths', [])
folder_types = data.get('folder_types')
context_requests = data.get('context_requests', [])
exact_only = data.get('exact_only') is True
except:
return web.json_response({"previews": {}, "models": {}, "context_models": {}})
model_info = await asyncio.to_thread(_resolve_paths_to_model_info_sync, paths, folder_types, exact_only)
context_models = {}
if isinstance(context_requests, list):
for item in context_requests[:16]:
if not isinstance(item, dict):
continue
context_path = item.get('path')
if not isinstance(context_path, str) or not context_path:
continue
resolved = await asyncio.to_thread(
_resolve_paths_to_model_info_sync,
[context_path],
item.get('folder_types'),
item.get('exact_only') is True or exact_only,
)
if context_path in resolved:
context_key = item.get('key')
if not isinstance(context_key, str) or not context_key:
context_key = context_path
context_models[context_key] = resolved[context_path]
previews = {path: item.get("preview_url", "") for path, item in model_info.items()}
return web.json_response({
"previews": previews,
"models": model_info,
"context_models": context_models,
})
def _collect_all_scan_models(page, limit):
source_library_extensions = {'.safetensors', '.ckpt', '.pt', '.pth', '.bin', '.sft', '.gguf'}
all_tuples = []
for model_root in get_active_model_roots():
t = model_root["type"]
path_idx = model_root["path_idx"]
base_dir = model_root["base_dir"]
for root, dirs, files in os.walk(base_dir):
for f in files:
if os.path.splitext(f)[1].lower() in source_library_extensions:
all_tuples.append((t, path_idx, root, base_dir, f))
all_tuples.sort(key=lambda x: (x[0], x[4].lower()))
total = len(all_tuples)
if limit > 0:
start = (page - 1) * limit
end = start + limit
sliced = all_tuples[start:end]
else:
sliced = all_tuples
all_models = []
for t, path_idx, root, base_dir, f in sliced:
file_path = os.path.join(root, f)
rel_subfolder = os.path.relpath(root, base_dir)
if rel_subfolder == '.': rel_subfolder = ''
else: rel_subfolder = rel_subfolder.replace('\\', '/')
try:
size_bytes = os.path.getsize(file_path)
size_mb = round(size_bytes / (1024 * 1024), 2)
except:
size_bytes = 0; size_mb = 0
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(t)
q_idx = str(path_idx)
q_sub = urllib.parse.quote(rel_subfolder.strip('/')) if rel_subfolder and rel_subfolder != '/' else ""
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}"
all_models.append({
"type": t, "path_idx": path_idx, "subfolder": rel_subfolder,
"filename": f, "size_mb": size_mb, "size_bytes": size_bytes,
"preview_url": preview_url, "metadata": meta
})
return {"models": all_models, "total": total, "page": page, "limit": limit}
async def api_get_all_scan_models(request):
page = int(request.query.get('page', 1))
limit = int(request.query.get('limit', 0))
payload = await asyncio.to_thread(_collect_all_scan_models, page, limit)
return web.json_response(payload)
async def api_batch_select(request):
folder_key = request.query.get('folderKey', 'ALL')
action = request.query.get('action', 'all')
def matches_condition(file_path, root, base_name):
if action == 'all':
return True
elif action == 'no_preview':
for ext in PREVIEW_SUFFIXES + MEDIA_EXTENSIONS:
if os.path.exists(os.path.join(root, base_name + ext)):
return False
return True
elif action == 'no_desc':
info_file = file_path + '.info'
civitai_info = os.path.join(root, base_name + '.civitai.info')
if os.path.exists(civitai_info):
try:
with open(civitai_info, 'r', encoding='utf-8') as f:
data = json.load(f)
if data.get('description', '').strip():
return False
except: pass
if os.path.exists(info_file):
try:
with open(info_file, 'r', encoding='utf-8') as f:
data = json.load(f)
if data.get('description', '').strip():
return False
except: pass
return True
return False
results = {}
if folder_key == 'ALL':
target_types = get_active_folder_types()
seen_dirs = set()
for t in target_types:
try: paths = folder_paths.get_folder_paths(t)
except Exception: continue
if not paths: continue
for path_idx, base_dir in enumerate(paths):
if base_dir in seen_dirs: continue
seen_dirs.add(base_dir)
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', '.ckpt', '.pt', '.bin', '.sft')):
file_path = os.path.join(root, f)
base_name = os.path.splitext(f)[0]
if matches_condition(file_path, root, base_name):
rel_subfolder = os.path.relpath(root, base_dir)
if rel_subfolder == '.': rel_subfolder = ''
else: rel_subfolder = rel_subfolder.replace('\\', '/')
fkey = f"{t}|{path_idx}|{rel_subfolder}"
if fkey not in results: results[fkey] = []
results[fkey].append(f)
else:
parts = folder_key.split('|')
if len(parts) >= 3:
t = parts[0]
path_idx = int(parts[1])
subfolder = parts[2]
try: paths = folder_paths.get_folder_paths(t)
except Exception: paths = []
if paths and path_idx < len(paths):
try:
base_dir, target_dir = resolve_folder_subdir(t, path_idx, subfolder)
except ValueError:
return web.json_response({"selected": {}})
if os.path.exists(target_dir):
try: entries = os.listdir(target_dir)
except: entries = []
for f in entries:
if f.endswith(('.safetensors', '.ckpt', '.pt', '.bin', '.sft')):
file_path = os.path.join(target_dir, f)
if os.path.isfile(file_path):
base_name = os.path.splitext(f)[0]
if matches_condition(file_path, target_dir, base_name):
if folder_key not in results: results[folder_key] = []
results[folder_key].append(f)
return web.json_response({"selected": results})
+16
View File
@@ -0,0 +1,16 @@
"""Shared model catalog, media, and recovery constants."""
MEDIA_EXTENSIONS = ('.png', '.jpg', '.jpeg', '.webp', '.gif', '.avif', '.mp4', '.webm', '.mov', '.avi')
MODEL_EXTENSIONS = ('.safetensors', '.ckpt', '.pt', '.bin')
PREVIEW_SUFFIXES = tuple(f'.preview{ext}' for ext in MEDIA_EXTENSIONS)
CIVITAI_BACKUP_SUFFIXES = tuple(f'.civitai_bak{ext}' for ext in MEDIA_EXTENSIONS)
SIDECAR_SUFFIXES = (
'.info', '.civitai.info', '.json', '.txt', '.yaml',
*MEDIA_EXTENSIONS,
*PREVIEW_SUFFIXES,
*CIVITAI_BACKUP_SUFFIXES,
)
RESOLVABLE_MODEL_TYPES = (
'checkpoints', 'loras', 'unet', 'diffusion_models', 'controlnet',
'vae', 'vae_approx', 'clip', 'text_encoders', 'clip_vision',
)
+142
View File
@@ -0,0 +1,142 @@
"""Model preview lookup and bounded custom-cover operations."""
import asyncio
import os
import shutil
import urllib.parse
from aiohttp import web
import folder_paths
from .model_constants import MEDIA_EXTENSIONS, PREVIEW_SUFFIXES
from .utils import require_filename, resolve_folder_subdir, resolve_within
def _cache_token(file_path):
try:
return os.stat(file_path).st_mtime_ns
except OSError:
return 0
async def _handle_custom_cover(target_dir, filename, save_func, source_ext='.png'):
base_name = os.path.splitext(filename)[0]
# Always save custom covers with a .preview.[ext] suffix so standard nodes recognize them as covers.
if source_ext.startswith('.preview.'):
preview_ext = source_ext
else:
preview_ext = f".preview{source_ext}"
dest_path = os.path.join(target_dir, f"{base_name}{preview_ext}")
# Delete any existing .preview.* files to ensure only one custom cover is active
for ext in PREVIEW_SUFFIXES:
p = os.path.join(target_dir, f"{base_name}{ext}")
if os.path.exists(p) and p != dest_path:
try: os.remove(p)
except: pass
await save_func(dest_path)
async def api_set_custom_cover(request):
try:
data = await request.json()
folder_type = data.get('type', 'checkpoints')
subfolder = data.get('subfolder', '/')
filename = data.get('filename', '')
source_image = data.get('source_image', '')
try: path_idx = int(data.get('path_idx', 0))
except: path_idx = 0
try:
filename = require_filename(filename)
_, target_dir = resolve_folder_subdir(folder_type, path_idx, subfolder)
output_dir = folder_paths.get_output_directory()
src_path = resolve_within(output_dir, source_image)
except (ValueError, KeyError):
return web.json_response({"status": "error", "message": "Invalid request parameters"}, status=400)
if not os.path.exists(src_path):
return web.json_response({"status": "error", "message": "Source image not found in output directory"})
source_ext = os.path.splitext(src_path)[1].lower()
if source_ext not in {'.png', '.jpg', '.jpeg', '.webp', '.gif', '.avif', '.mp4', '.webm', '.mov', '.avi'}:
return web.json_response({"status": "error", "message": "Unsupported cover format"}, status=415)
async def save_copy(dest_path):
import shutil
import asyncio
await asyncio.to_thread(shutil.copy2, src_path, dest_path)
await _handle_custom_cover(target_dir, filename, save_copy, source_ext)
return web.json_response({"status": "success"})
except Exception as e:
return web.json_response({"status": "error", "message": str(e)})
async def api_upload_custom_cover(request):
try:
data = await request.post()
folder_type = data.get('type', 'checkpoints')
subfolder = data.get('subfolder', '/')
filename = data.get('filename', '')
try: path_idx = int(data.get('path_idx', 0))
except: path_idx = 0
image_field = data.get('image')
try:
filename = require_filename(filename)
_, target_dir = resolve_folder_subdir(folder_type, path_idx, subfolder)
except (ValueError, KeyError):
return web.json_response({"status": "error", "message": "Invalid request parameters"}, status=400)
if image_field is None:
return web.json_response({"status": "error", "message": "Image is required"}, status=400)
image_data = image_field.file.read()
if len(image_data) > 100 * 1024 * 1024:
return web.json_response({"status": "error", "message": "Cover file is too large"}, status=413)
upload_filename = image_field.filename
source_ext = os.path.splitext(upload_filename)[1].lower()
if source_ext not in {'.png', '.jpg', '.jpeg', '.webp', '.gif', '.avif', '.mp4', '.webm', '.mov', '.avi'}:
return web.json_response({"status": "error", "message": "Unsupported cover format"}, status=415)
async def save_upload(dest_path):
def write_file():
with open(dest_path, 'wb') as f:
f.write(image_data)
import asyncio
await asyncio.to_thread(write_file)
await _handle_custom_cover(target_dir, filename, save_upload, source_ext)
return web.json_response({"status": "success"})
except Exception as e:
return web.json_response({"status": "error", "message": str(e)})
def _preview_url_for_model(folder_type, path_idx, base_dir, file_path):
root = os.path.dirname(file_path)
filename = os.path.basename(file_path)
base_name = os.path.splitext(filename)[0]
preview_file = next(
(
base_name + suffix
for suffix in PREVIEW_SUFFIXES + MEDIA_EXTENSIONS
if os.path.isfile(os.path.join(root, base_name + suffix))
),
None,
)
if not preview_file:
return ""
rel_subfolder = os.path.relpath(root, base_dir)
if rel_subfolder == '.':
rel_subfolder = '/'
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)
version = _cache_token(os.path.join(root, preview_file))
return f"/anomalous/image?type={q_type}&path_idx={q_idx}&subfolder={q_sub}&filename={q_file}&t={version}"
+262
View File
@@ -0,0 +1,262 @@
"""Model metadata, physical rename, deletion, and sidecar lifecycle."""
import json
import os
import shutil
from aiohttp import web
try:
from ..model_policies import is_physical_rename_protected
except ImportError:
from model_policies import is_physical_rename_protected
from .model_constants import (
CIVITAI_BACKUP_SUFFIXES, MEDIA_EXTENSIONS, MODEL_EXTENSIONS,
PREVIEW_SUFFIXES, SIDECAR_SUFFIXES,
)
from .model_catalog import _resolve_paths_to_model_info_sync
from .utils import require_filename, resolve_folder_subdir, resolve_within
def _first_existing_sidecar(base_path, suffixes):
for suffix in suffixes:
candidate = f"{base_path}{suffix}"
if os.path.isfile(candidate):
return candidate, suffix
return None, None
def _reset_model_cover(base_path):
"""Restore a recoverable cover without destroying the only existing image."""
backup_path, backup_suffix = _first_existing_sidecar(base_path, CIVITAI_BACKUP_SUFFIXES)
original_path, _ = _first_existing_sidecar(base_path, MEDIA_EXTENSIONS)
preview_paths = [
f"{base_path}{suffix}"
for suffix in PREVIEW_SUFFIXES
if os.path.isfile(f"{base_path}{suffix}")
]
if backup_path:
media_ext = backup_suffix[len('.civitai_bak'):]
restored_path = f"{base_path}.preview{media_ext}"
temp_path = f"{restored_path}.anomalous_tmp"
try:
# Copy first. If the backup cannot be read, the active custom cover
# remains untouched. os.replace then makes the actual restore atomic.
shutil.copy2(backup_path, temp_path)
os.replace(temp_path, restored_path)
for preview_path in preview_paths:
if preview_path != restored_path and os.path.isfile(preview_path):
os.remove(preview_path)
except Exception as exc:
try:
if os.path.isfile(temp_path):
os.remove(temp_path)
except OSError:
pass
return False, 'restore_failed', str(exc)
return True, 'civitai_backup', None
if original_path:
try:
for preview_path in preview_paths:
os.remove(preview_path)
except Exception as exc:
return False, 'restore_failed', str(exc)
return True, 'original_cover', None
if preview_paths:
# There is no recoverable source. Keep the current cover instead of
# turning a harmless Reset click into irreversible image loss.
return False, 'preserved_current', 'No Civitai backup or original cover exists.'
return True, 'no_cover', None
async def api_delete_model(request):
try:
data = await request.json()
folder_type = data.get('type', 'checkpoints')
subfolder = data.get('subfolder', '/')
filename = data.get('filename', '')
try:
path_idx = int(data.get('path_idx', 0))
except:
path_idx = 0
try:
filename = require_filename(filename)
_, target_dir = resolve_folder_subdir(folder_type, path_idx, subfolder)
model_path = resolve_within(target_dir, filename)
except (ValueError, KeyError):
return web.json_response({"status": "error", "message": "Invalid request parameters"}, status=400)
if not os.path.exists(model_path):
return web.json_response({"status": "error", "message": "Model file not found"})
# 1. 优先尝试删除你点击的主模型文件
try:
os.remove(model_path)
except Exception as e:
error_msg = str(e)
if "being used" in error_msg or "WinError 32" in error_msg or "Permission" in error_msg:
error_msg = "文件被占用 (正在被 ComfyUI 使用)。请先重启 ComfyUI 或在工作流中卸载该模型后再删除!"
return web.json_response({"status": "error", "message": f"主模型删除失败: {error_msg}"})
base_name = os.path.splitext(filename)[0]
# 2. 主模型成功删除后,再清理配套的垃圾文件
# Sidecars are keyed by stem, not by the main model extension. If a
# second real model shares this stem, preserve the shared sidecars for
# the survivor instead of treating that model as cleanup debris.
shared_stem_in_use = any(
os.path.isfile(os.path.join(target_dir, base_name + model_ext))
for model_ext in MODEL_EXTENSIONS
)
deleted_files = [filename]
if not shared_stem_in_use:
for suffix in SIDECAR_SUFFIXES:
file_to_del = os.path.join(target_dir, base_name + suffix)
if os.path.isfile(file_to_del):
try:
os.remove(file_to_del)
deleted_files.append(base_name + suffix)
except Exception as e:
print(f"[Anomalous Browser] Warning: Failed to delete {file_to_del}: {e}")
# 3. 修正:前端期待的成功状态是 "success" 而不是 "ok"
return web.json_response({
"status": "success",
"deleted": deleted_files,
"sidecars_preserved": shared_stem_in_use,
})
except Exception as e:
import traceback
traceback.print_exc()
return web.json_response({"status": "error", "message": str(e)}, status=500)
async def api_update_metadata(request):
try:
data = await request.json()
folder_type = data.get('type', 'checkpoints')
subfolder = data.get('subfolder', '/')
raw_filename = data.get('filename', '')
custom_name = data.get('custom_name', '')
custom_notes = data.get('custom_notes', '')
custom_source_url = data.get('custom_source_url', None)
physical_rename_requested = data.get('physical_rename', False)
try: path_idx = int(data.get('path_idx', 0))
except: path_idx = 0
# Normalize filename and subfolder if directory separators are included
extracted_filename = raw_filename
if isinstance(raw_filename, str) and ('/' in raw_filename or '\\' in raw_filename):
if subfolder in (None, '', '/'):
extracted_sub = os.path.dirname(raw_filename).replace('\\', '/')
if extracted_sub:
subfolder = '/' + extracted_sub.strip('/')
extracted_filename = os.path.basename(raw_filename)
file_path = None
target_dir = None
filename = extracted_filename
try:
filename = require_filename(extracted_filename)
_, target_dir = resolve_folder_subdir(folder_type, path_idx, subfolder)
file_path = resolve_within(target_dir, filename)
except (ValueError, KeyError):
pass
if not file_path or not os.path.exists(file_path):
# Fallback: attempt to locate model across known model directories
search_keys = [k for k in [raw_filename, extracted_filename] if k and isinstance(k, str)]
fallback_resolved = _resolve_paths_to_model_info_sync(search_keys)
resolved_info = fallback_resolved.get(raw_filename) or fallback_resolved.get(extracted_filename)
if resolved_info:
resolved_path = resolved_info.get("file_path")
if resolved_path and os.path.exists(resolved_path):
file_path = resolved_path
target_dir = os.path.dirname(file_path)
folder_type = resolved_info.get("type", folder_type)
path_idx = resolved_info.get("path_idx", path_idx)
filename = resolved_info.get("filename", filename)
if not file_path or 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]
info_file = f"{base_name}.civitai.info"
info_data = {}
if os.path.exists(info_file):
parsed = False
for enc in ['utf-8', 'utf-8-sig', 'mbcs', 'latin-1']:
try:
with open(info_file, 'r', encoding=enc) as f:
info_data = json.load(f)
parsed = True
break
except Exception:
pass
if not parsed:
return web.json_response({"status": "error", "message": "Failed to parse existing .civitai.info file due to encoding or corruption. Rename aborted to prevent data loss."})
if 'custom_name' in data:
info_data["anomalous_custom_name"] = custom_name
if 'custom_notes' in data:
info_data["anomalous_custom_notes"] = custom_notes
if custom_source_url is not None:
info_data["anomalous_source_url"] = str(custom_source_url).strip()
reset_cover = data.get('reset_cover', False)
cover_reset = None
cover_reset_source = None
cover_reset_warning = None
if reset_cover:
cover_reset, cover_reset_source, cover_reset_warning = _reset_model_cover(base_name)
with open(info_file, 'w', encoding='utf-8') as f:
json.dump(info_data, f, indent=4, ensure_ascii=False)
new_filename = filename
if physical_rename and custom_name:
import re
safe_name = re.sub(r'[<>:"/\\|?*]', '_', custom_name).strip(' .')
if not safe_name:
return web.json_response({"status": "error", "message": "The physical filename cannot be empty."}, status=400)
new_file_path = os.path.join(target_dir, f"{safe_name}{model_ext}")
if new_file_path != file_path and not os.path.exists(new_file_path):
os.rename(file_path, new_file_path)
for suffix in SIDECAR_SUFFIXES:
old_sidecar = f"{base_name}{suffix}"
if os.path.isfile(old_sidecar):
os.rename(old_sidecar, os.path.join(target_dir, f"{safe_name}{suffix}"))
new_filename = f"{safe_name}{model_ext}"
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,
"physical_rename_skipped": physical_rename_skipped,
}
if reset_cover:
response_data.update({
"cover_reset": cover_reset,
"cover_reset_source": cover_reset_source,
"cover_reset_warning": cover_reset_warning,
})
return web.json_response(response_data)
except Exception as e:
return web.json_response({"status": "error", "message": str(e)})
+335
View File
@@ -0,0 +1,335 @@
"""Cryptographic and size-based missing-model resolution."""
import asyncio
import json
import os
import struct
import threading
from aiohttp import web
import folder_paths
try:
from ..model_identity import computed_file_identity, normalise_sha256
except ImportError:
from model_identity import computed_file_identity, normalise_sha256
try:
from ..model_policies import requires_hash_for_model_recovery
except ImportError:
from model_policies import requires_hash_for_model_recovery
from .metadata import get_metadata
from .model_constants import MODEL_EXTENSIONS, RESOLVABLE_MODEL_TYPES
from .utils import atomic_write_json
def _parse_resolution_types(expected_types_raw):
if not expected_types_raw:
return RESOLVABLE_MODEL_TYPES
requested = tuple(dict.fromkeys(
value.strip() for value in str(expected_types_raw).split(',') if value.strip()
))
if not requested or any(value not in RESOLVABLE_MODEL_TYPES for value in requested):
raise ValueError("Invalid model type")
return requested
def _collect_resolution_candidates(types):
candidates = []
seen_realpaths = set()
for folder_type in types:
try:
paths = folder_paths.get_folder_paths(folder_type)
except Exception:
continue
for base_dir in paths or []:
if not os.path.exists(base_dir):
continue
for root, _, files in os.walk(base_dir):
for filename in files:
if not filename.lower().endswith(MODEL_EXTENSIONS):
continue
file_path = os.path.join(root, filename)
real_path = os.path.realpath(file_path)
if real_path in seen_realpaths:
continue
try:
file_size = os.path.getsize(file_path)
except OSError:
continue
seen_realpaths.add(real_path)
candidates.append({
"type": folder_type,
"filename": os.path.relpath(file_path, base_dir).replace('\\', '/'),
"path": file_path,
"size": file_size,
})
return candidates
def _candidate_hashes(candidate):
if "hashes" in candidate:
return candidate["hashes"]
values = set()
metadata = candidate.get("metadata")
if metadata is None:
metadata = get_metadata(candidate["path"])
candidate["metadata"] = metadata
meta_hash = normalise_sha256(metadata.get("hash", ""))
if meta_hash:
values.add(str(meta_hash).upper())
candidate["hashes"] = values
return values
def _resolved_payload(candidate, **details):
payload = {
"found": True,
"type": candidate["type"],
"filename": candidate["filename"],
}
payload.update(details)
return payload
def _size_candidate_payload(candidate, require_hash=False):
"""Expose one in-category size match for explicit user confirmation only."""
return {
"found": False,
"confirmation_required": True,
"matched_by_size": True,
"hash_required": bool(require_hash),
"type": candidate["type"],
"filename": candidate["filename"],
"size": candidate["size"],
}
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 os.path.exists(info_path) or os.path.exists(civitai_info_path):
info_path = info_path if os.path.exists(info_path) else civitai_info_path
with open(info_path, encoding='utf-8') as source:
info_data = json.load(source)
else:
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()}}]
}
info_data["anomalous_file_identity"] = computed_file_identity(file_path, file_hash)
atomic_write_json(info_path, info_data)
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
# resolve_paths_to_previews endpoint and does not activate a model match.
has_target_hash = bool(target_hash and target_hash != "UNKNOWN")
size_matches = [
candidate for candidate in candidates
if target_size is not None and candidate["size"] == target_size
]
if has_target_hash and target_size is not None:
combined_matches = [candidate for candidate in size_matches if target_hash in _candidate_hashes(candidate)]
if len(combined_matches) == 1:
return _resolved_payload(combined_matches[0], matched_by_hash=True, matched_by_size=True)
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 or size_matches:
return {"found": False, "identity_conflict": True}
return {"found": False}
if has_target_hash:
hash_matches = [candidate for candidate in candidates if target_hash in _candidate_hashes(candidate)]
if len(hash_matches) == 1:
return _resolved_payload(hash_matches[0], matched_by_hash=True)
if len(hash_matches) > 1:
return {"found": False, "ambiguous": True}
if target_size is not None:
if len(size_matches) == 1:
return _size_candidate_payload(size_matches[0], require_hash=require_hash)
if len(size_matches) > 1:
return {"found": False, "ambiguous": True}
if require_hash:
return {"found": False, "hash_required": True}
return {"found": False}
async def api_resolve_hash(request):
target_hash = request.query.get("hash", "").strip().upper()
size_str = request.query.get("size", "").strip()
filename_query = request.query.get("filename", "").strip()
target_size = int(size_str) if size_str.isdigit() else None
if not target_hash and target_size is None and not filename_query:
return web.json_response({"found": False})
try:
types = _parse_resolution_types(request.query.get("type", "").strip())
except ValueError as exc:
return web.json_response({"found": False, "error": str(exc)}, status=400)
def resolve_one():
candidates = _collect_resolution_candidates(types)
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))
async def api_resolve_hash_batch(request):
try:
data = await request.json()
items = data.get("items", [])
except Exception:
return web.json_response({"error": "Invalid JSON body"}, status=400)
if not isinstance(items, list) or len(items) > 256:
return web.json_response({"error": "items must be a list with at most 256 entries"}, status=400)
parsed_items = []
try:
for index, item in enumerate(items):
if not isinstance(item, dict):
raise ValueError("Invalid batch item")
size_value = item.get("size")
size_string = str(size_value).strip() if size_value is not None else ""
parsed_items.append({
"key": str(item.get("key", index)),
"hash": str(item.get("hash", "")).strip().upper(),
"size": int(size_string) if size_string.isdigit() else None,
"types": _parse_resolution_types(str(item.get("type", "")).strip()),
})
except (TypeError, ValueError) as exc:
return web.json_response({"error": str(exc)}, status=400)
def resolve_batch():
candidate_groups = {}
results = []
for item in parsed_items:
types = item["types"]
if types not in candidate_groups:
candidate_groups[types] = _collect_resolution_candidates(types)
result = _resolve_from_candidates(
candidate_groups[types],
item["hash"],
item["size"],
require_hash=requires_hash_for_model_recovery(types),
)
results.append({"key": item["key"], "result": result})
return results
return web.json_response({"results": await asyncio.to_thread(resolve_batch)})
def collect_model_hash_index():
"""Scanned models keyed by relative path and basename -> {hash, size, url}.
Ambiguous keys (same name, different hash) are dropped. Shared by the
all-hashes endpoint and the output gallery's hash search.
"""
hashes = {}
ambiguous_keys = set()
def add_hash(key, value):
if key in ambiguous_keys:
return
existing = hashes.get(key)
if existing is None or existing == value:
hashes[key] = value
else:
hashes.pop(key, None)
ambiguous_keys.add(key)
types = RESOLVABLE_MODEL_TYPES
seen_dirs = set()
for t in types:
try:
paths = folder_paths.get_folder_paths(t)
if not paths: continue
for base_dir in paths:
if base_dir in seen_dirs: continue
seen_dirs.add(base_dir)
if not os.path.exists(base_dir): continue
for root, dirs, files in os.walk(base_dir):
for file in files:
if file.lower().endswith(MODEL_EXTENSIONS):
file_path = os.path.join(root, file)
try:
size_bytes = os.path.getsize(file_path)
except Exception:
size_bytes = 0
meta = get_metadata(file_path)
hash_val = ""
if meta and meta.get("hash"):
hash_val = meta["hash"]
model_url = ""
if meta:
model_url = meta.get("source_url") or meta.get("civitai_url") or ""
rel_path = os.path.relpath(file_path, base_dir)
if rel_path.startswith('.\\') or rel_path.startswith('./'):
rel_path = rel_path[2:]
rel_path = rel_path.replace('\\', '/')
basename = os.path.basename(file_path)
val = {"hash": hash_val, "size": size_bytes, "url": model_url}
add_hash(rel_path, val)
add_hash(basename, val)
except Exception:
pass
return hashes
async def api_get_all_hashes(request):
"""
Returns a dictionary of all scanned models with their hash and size.
Keyed by both relative path and basename for maximum frontend resilience.
"""
hashes = await asyncio.to_thread(collect_model_hash_index)
return web.json_response(hashes)
+27 -1289
View File
File diff suppressed because it is too large Load Diff
+123 -64
View File
@@ -1,77 +1,136 @@
import asyncio
import hashlib
import json
import logging
import os
import sys
import json
import urllib.parse
import subprocess
import threading
import asyncio
from aiohttp import web
import folder_paths
import struct
from .utils import require_filename, resolve_within
from .utils import atomic_write_json, require_filename, resolve_within
MAX_NOTEBOOK_BYTES = 2 * 1024 * 1024
LEGACY_NOTEBOOKS_DIR = os.path.join(os.path.dirname(__file__), "notebooks")
_notebooks_lock = threading.RLock()
def _read_notebook(path):
if os.path.getsize(path) > MAX_NOTEBOOK_BYTES:
raise ValueError("Notebook is too large")
with open(path, encoding="utf-8") as source:
data = json.load(source)
if not isinstance(data, dict):
raise ValueError("Invalid notebook")
return data
def _migrate_notebooks(directory):
marker = resolve_within(directory, ".legacy_imported.json")
if os.path.exists(marker):
return
skipped = []
if os.path.isdir(LEGACY_NOTEBOOKS_DIR):
for filename in sorted(os.listdir(LEGACY_NOTEBOOKS_DIR)):
if not filename.endswith(".json") or filename.startswith("."):
continue
try:
source = resolve_within(LEGACY_NOTEBOOKS_DIR, require_filename(filename))
data = _read_notebook(source)
except (OSError, ValueError):
skipped.append(filename)
logging.warning("Anomalous: could not migrate notebook %s; original preserved", filename)
continue
target = resolve_within(directory, filename)
if os.path.exists(target):
try:
if _read_notebook(target) == data:
continue
except (OSError, ValueError):
pass
digest = hashlib.sha256(json.dumps(data, sort_keys=True).encode()).hexdigest()[:12]
recovered = f"{os.path.splitext(filename)[0][:100]}_legacy_{digest}.json"
target = resolve_within(directory, recovered)
if os.path.exists(target):
if _read_notebook(target) != data:
raise ValueError("Conflicting migrated notebook")
continue
atomic_write_json(target, data, MAX_NOTEBOOK_BYTES)
atomic_write_json(marker, {"version": 1, "skipped": skipped})
def get_notebooks_dir():
user_dir = folder_paths.get_user_directory()
directory = os.path.join(user_dir, "workflows", "anomalous_notebooks")
os.makedirs(directory, exist_ok=True)
with _notebooks_lock:
_migrate_notebooks(directory)
return directory
def _list_notebooks():
with _notebooks_lock:
directory = get_notebooks_dir()
notebooks = []
for filename in sorted(os.listdir(directory)):
if not filename.endswith(".json") or filename.startswith("."):
continue
try:
data = _read_notebook(resolve_within(directory, filename))
notebooks.append({"filename": filename, "name": data.get("name", filename[:-5]), "data": data})
except (OSError, ValueError):
continue
return notebooks
async def api_get_notebooks(request):
script_dir = os.path.dirname(os.path.abspath(__file__))
nb_dir = os.path.join(script_dir, "notebooks")
if not os.path.exists(nb_dir):
os.makedirs(nb_dir)
notebooks = []
for f in os.listdir(nb_dir):
if f.endswith('.json'):
try:
with open(os.path.join(nb_dir, f), 'r', encoding='utf-8') as file:
data = json.load(file)
notebooks.append({
"filename": f,
"name": data.get("name", f.replace('.json', '')),
"data": data
})
except Exception:
pass
return web.json_response({"notebooks": notebooks})
try:
notebooks = await asyncio.to_thread(_list_notebooks)
return web.json_response({"notebooks": notebooks})
except (OSError, ValueError):
return web.json_response({"status": "error", "message": "Could not load notebooks"}, status=500)
def _save_notebook(filename, data):
with _notebooks_lock:
atomic_write_json(resolve_within(get_notebooks_dir(), filename), data, MAX_NOTEBOOK_BYTES)
async def api_save_notebook(request):
try:
data = await request.json()
filename = data.get("filename", "")
if not filename.endswith('.json'):
filename += '.json'
try:
filename = require_filename(filename)
except ValueError:
return web.json_response({"status": "error", "message": "Invalid filename"}, status=400)
script_dir = os.path.dirname(os.path.abspath(__file__))
nb_dir = os.path.join(script_dir, "notebooks")
if not os.path.exists(nb_dir):
os.makedirs(nb_dir)
file_path = resolve_within(nb_dir, filename)
with open(file_path, 'w', encoding='utf-8') as f:
json.dump(data.get("data", {}), f, indent=4, ensure_ascii=False)
payload = await request.json()
filename = require_filename(payload.get("filename", ""))
if filename.startswith("."):
raise ValueError("Invalid notebook filename")
if not filename.endswith(".json"):
filename += ".json"
data = payload.get("data")
if not isinstance(data, dict):
raise ValueError("Invalid notebook")
await asyncio.to_thread(_save_notebook, filename, data)
return web.json_response({"status": "success"})
except Exception as e:
return web.json_response({"status": "error", "message": str(e)})
except (AttributeError, TypeError, ValueError):
return web.json_response({"status": "error", "message": "Invalid notebook"}, status=400)
except OSError:
return web.json_response({"status": "error", "message": "Could not save notebook"}, status=500)
def _delete_notebook(filename):
with _notebooks_lock:
os.remove(resolve_within(get_notebooks_dir(), filename))
async def api_delete_notebook(request):
try:
data = await request.json()
filename = data.get("filename", "")
try:
filename = require_filename(filename)
except ValueError:
return web.json_response({"status": "error", "message": "Invalid filename"}, status=400)
script_dir = os.path.dirname(os.path.abspath(__file__))
nb_dir = os.path.join(script_dir, "notebooks")
file_path = resolve_within(nb_dir, filename)
if os.path.exists(file_path):
os.remove(file_path)
return web.json_response({"status": "success"})
return web.json_response({"status": "error", "message": "File not found"})
except Exception as e:
return web.json_response({"status": "error", "message": str(e)})
payload = await request.json()
filename = require_filename(payload.get("filename", ""))
if not filename.endswith(".json") or filename.startswith("."):
raise ValueError("Invalid notebook filename")
await asyncio.to_thread(_delete_notebook, filename)
return web.json_response({"status": "success"})
except (AttributeError, TypeError, ValueError):
return web.json_response({"status": "error", "message": "Invalid notebook"}, status=400)
except FileNotFoundError:
return web.json_response({"status": "error", "message": "Notebook not found"}, status=404)
except OSError:
return web.json_response({"status": "error", "message": "Could not delete notebook"}, status=500)
+50 -1
View File
@@ -8,7 +8,10 @@ import re
from aiohttp import web
import folder_paths
from .utils import require_filename, resolve_within
from .recipes import MAX_RECIPE_BYTES, _parameter_signature, _parameter_gallery_images, get_recipes_dir, _read_recipe
from .recipe_constants import MAX_RECIPE_BYTES
from .recipe_images import _parameter_gallery_images
from .recipe_store import get_recipes_dir, _read_recipe
from .workflow_schema import _parameter_signature
def get_parameters_dir():
# Store parameter notebooks in the user directory
@@ -300,6 +303,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:
+89
View File
@@ -0,0 +1,89 @@
"""Filesystem containment and atomic JSON helpers."""
import json
import os
import tempfile
import folder_paths
from .recipe_constants import MODEL_FILE_SUFFIXES
def resolve_within(base_dir, *parts):
"""Resolve a path and require it to stay inside base_dir, including through symlinks."""
base_real = os.path.realpath(base_dir)
candidate = os.path.realpath(os.path.join(base_real, *parts))
try:
if os.path.commonpath([base_real, candidate]) != base_real:
raise ValueError("Path escapes the configured directory")
except ValueError:
raise ValueError("Path escapes the configured directory")
return candidate
def _model_roots():
for folder_type in getattr(folder_paths, "folder_names_and_paths", {}):
try:
paths = folder_paths.get_folder_paths(folder_type)
except Exception:
continue
for path_index, base_dir in enumerate(paths or []):
if os.path.isdir(base_dir):
yield folder_type, path_index, os.path.realpath(base_dir)
def _resolve_exact_model_reference(saved_value):
"""Resolve one saved model value without recursive scanning or hashing."""
if not isinstance(saved_value, str) or not saved_value.strip():
return None
relative_value = saved_value.replace("/", os.sep)
for folder_type, path_index, base_dir in _model_roots():
candidate = os.path.realpath(os.path.join(base_dir, relative_value))
try:
if os.path.commonpath((base_dir, candidate)) != base_dir:
continue
except ValueError:
continue
if not os.path.isfile(candidate) or not candidate.lower().endswith(MODEL_FILE_SUFFIXES):
continue
return {
"path": candidate,
"folder_type": folder_type,
"path_index": path_index,
}
return None
def get_folder_root(folder_type, path_idx=0):
paths = folder_paths.get_folder_paths(folder_type)
if not paths or not isinstance(path_idx, int) or path_idx < 0 or path_idx >= len(paths):
raise ValueError("Invalid folder path")
return os.path.realpath(paths[path_idx])
def resolve_folder_subdir(folder_type, path_idx=0, subfolder='/'):
base_dir = get_folder_root(folder_type, path_idx)
relative = "" if subfolder in (None, "", "/") else str(subfolder).strip("/\\")
return base_dir, resolve_within(base_dir, relative)
def require_filename(filename):
if not isinstance(filename, str) or not filename or os.path.basename(filename) != filename:
raise ValueError("Invalid filename")
return filename
def atomic_write_json(path, value, max_bytes=12 * 1024 * 1024):
encoded = json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
if len(encoded) > max_bytes:
raise ValueError("JSON record is too large")
fd, temporary = tempfile.mkstemp(prefix=".write-", suffix=".tmp", dir=os.path.dirname(path))
try:
with os.fdopen(fd, "wb") as output:
output.write(encoded)
output.flush()
os.fsync(output.fileno())
os.replace(temporary, path)
finally:
if os.path.exists(temporary):
os.remove(temporary)
+54
View File
@@ -0,0 +1,54 @@
"""Shared bounded constants for Workflow Recipe domains."""
import re
MAX_NAME_LENGTH = 120
MAX_TAGS = 20
MAX_TAG_LENGTH = 60
MAX_NOTES_LENGTH = 3000
MAX_MODEL_NOTE_LENGTH = 1000
MAX_THUMBNAIL_LENGTH = 1_500_000
MAX_SOURCE_SUBFOLDER_LENGTH = 500
MAX_RECIPE_BYTES = 12 * 1024 * 1024
MAX_HISTORY_VERSIONS = 20
MAX_PREVIEW_SNAPSHOTS = 12
MAX_PREVIEW_SNAPSHOT_BYTES = 96 * 1024
MAX_PREVIEW_SNAPSHOT_TOTAL_BYTES = 1_250_000
MAX_PREVIEW_SOURCE_BYTES = 20 * 1024 * 1024
MAX_WORKFLOW_NODES = 5_000
MAX_WORKFLOW_LINKS = 30_000
MAX_WORKFLOW_GROUPS = 2_000
MAX_WIDGET_VALUES_PER_NODE = 2_048
MAX_RECIPE_GALLERY_SCAN = 200
MAX_RECIPE_GALLERY_RESULTS = 200
MAX_EMBEDDED_WORKFLOW_BYTES = 3 * 1024 * 1024
MAX_RECIPE_COVER_SOURCE_BYTES = 64 * 1024 * 1024
MAX_RECIPE_COVER_BYTES = 256 * 1024
SAFE_THUMBNAIL_PREFIXES = (
"data:image/jpeg;base64,",
"data:image/png;base64,",
"data:image/webp;base64,",
)
MODEL_FILE_SUFFIXES = (".safetensors", ".ckpt", ".pt", ".bin", ".sft")
STATIC_PREVIEW_EXTENSIONS = (".png", ".jpg", ".jpeg", ".webp", ".gif", ".avif")
STATIC_PREVIEW_SUFFIXES = tuple(f".preview{extension}" for extension in STATIC_PREVIEW_EXTENSIONS)
SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$", re.IGNORECASE)
VOLATILE_WORKFLOW_KEYS = frozenset({
"seed",
"noise_seed",
"random_seed",
"variation_seed",
"batch_size",
"batch_index",
"batch_num",
"last_seed",
})
VERIFIABLE_RECIPE_MODEL_CATEGORIES = frozenset({
"checkpoint",
"lora",
"unet",
"controlnet",
"vae",
"text_encoder",
"clip_vision",
})
+320
View File
@@ -0,0 +1,320 @@
"""Contained Recipe image assets and bounded output-gallery inspection."""
from io import BytesIO
import hashlib
import heapq
import json
import os
import folder_paths
from PIL import Image
from .path_utils import _resolve_exact_model_reference
from .recipe_constants import *
from .utils import require_filename, resolve_within
from .workflow_schema import _node_type, _parameter_signature, _workflow_node_signature
def _recipe_assets_dir(recipes_dir, filename, create=False):
stem = os.path.splitext(require_filename(filename))[0]
assets_dir = resolve_within(recipes_dir, ".assets", stem)
if create:
os.makedirs(assets_dir, exist_ok=True)
return assets_dir
def _preview_source_path(saved_value):
resolved = _resolve_exact_model_reference(saved_value)
if not resolved:
return None
base_path = os.path.splitext(resolved["path"])[0]
for suffix in STATIC_PREVIEW_SUFFIXES + STATIC_PREVIEW_EXTENSIONS:
candidate = f"{base_path}{suffix}"
if os.path.isfile(candidate):
return candidate
return None
def _thumbnail_webp_bytes(source_path):
try:
if os.path.getsize(source_path) > MAX_PREVIEW_SOURCE_BYTES:
return None
with Image.open(source_path) as image:
image.thumbnail((320, 320))
if image.mode not in ("RGB", "RGBA"):
image = image.convert("RGBA" if "A" in image.getbands() else "RGB")
width, height = image.size
for quality in (76, 62, 48):
output = BytesIO()
image.save(output, format="WEBP", quality=quality, method=4)
data = output.getvalue()
if len(data) <= MAX_PREVIEW_SNAPSHOT_BYTES:
return data, width, height
except (OSError, ValueError):
return None
return None
def _recipe_cover_webp_bytes(source_path):
"""Create a portable recipe-cover asset without retaining the full output image."""
try:
if os.path.getsize(source_path) > MAX_RECIPE_COVER_SOURCE_BYTES:
return None
with Image.open(source_path) as image:
image.thumbnail((640, 640))
if image.mode not in ("RGB", "RGBA"):
image = image.convert("RGBA" if "A" in image.getbands() else "RGB")
width, height = image.size
for quality in (84, 72, 60, 48):
output = BytesIO()
image.save(output, format="WEBP", quality=quality, method=4)
data = output.getvalue()
if len(data) <= MAX_RECIPE_COVER_BYTES:
return data, width, height
except (OSError, ValueError):
return None
return None
def _output_source_path(source_image):
output_dir = folder_paths.get_output_directory()
target_dir = resolve_within(output_dir, source_image.get("subfolder", ""))
source_path = resolve_within(target_dir, source_image["filename"])
if not os.path.isfile(source_path):
raise FileNotFoundError
return source_path
def _store_recipe_gallery_cover(recipes_dir, filename, source_image):
source_path = _output_source_path(source_image)
cover = _recipe_cover_webp_bytes(source_path)
if not cover:
raise ValueError("Could not create recipe cover")
data, width, height = cover
asset_id = f"cover-{hashlib.sha256(data).hexdigest()}.webp"
asset_path = resolve_within(_recipe_assets_dir(recipes_dir, filename, create=True), asset_id)
if not os.path.exists(asset_path):
with open(asset_path, "wb") as asset_file:
asset_file.write(data)
return {
"asset_id": asset_id,
"media_type": "image/webp",
"width": width,
"height": height,
}
def _decode_embedded_json(value):
if isinstance(value, bytes):
if len(value) > MAX_EMBEDDED_WORKFLOW_BYTES:
return None
value = value.decode("utf-8")
if not isinstance(value, str) or len(value.encode("utf-8")) > MAX_EMBEDDED_WORKFLOW_BYTES:
return None
parsed = json.loads(value)
return parsed if isinstance(parsed, dict) else None
def _embedded_workflow_payload(image_path):
"""Read bounded workflow/prompt metadata from one PNG without loading pixels."""
try:
with Image.open(image_path) as image:
return {
"workflow": _decode_embedded_json(image.info.get("workflow")),
"prompt": _decode_embedded_json(image.info.get("prompt")),
}
except (OSError, UnicodeDecodeError, ValueError, json.JSONDecodeError):
return None
def _embedded_workflow_node_signature(image_path):
payload = _embedded_workflow_payload(image_path)
if not payload:
return None
workflow = payload.get("workflow") or payload.get("prompt")
if not workflow:
return None
return _workflow_node_signature(workflow)["value"]
def _embedded_parameter_signature(image_path):
payload = _embedded_workflow_payload(image_path)
if not payload:
return None
workflow = payload.get("workflow") or payload.get("prompt")
if not workflow:
return None
return _parameter_signature(workflow)["value"]
def _recent_output_pngs(output_dir, limit=MAX_RECIPE_GALLERY_SCAN):
"""Return a bounded newest-first PNG list without trusting request paths."""
newest = []
try:
for root, _, files in os.walk(output_dir):
for name in files:
if not name.lower().endswith(".png"):
continue
path = os.path.join(root, name)
try:
stat = os.stat(path)
except OSError:
continue
candidate = (stat.st_mtime_ns, path)
if len(newest) < limit:
heapq.heappush(newest, candidate)
elif candidate > newest[0]:
heapq.heapreplace(newest, candidate)
except OSError:
return []
return sorted(newest, reverse=True)
def _recipe_gallery_images(node_signature):
output_dir = folder_paths.get_output_directory()
if not os.path.isdir(output_dir):
return [], 0
matches = []
scanned = 0
for mtime_ns, image_path in _recent_output_pngs(output_dir):
scanned += 1
if _embedded_workflow_node_signature(image_path) != node_signature:
continue
relative_dir = os.path.relpath(os.path.dirname(image_path), output_dir)
subfolder = "" if relative_dir == "." else relative_dir.replace("\\", "/")
matches.append({
"filename": os.path.basename(image_path),
"subfolder": subfolder,
"type": "output",
"mtime": mtime_ns // 1_000_000,
})
if len(matches) >= MAX_RECIPE_GALLERY_RESULTS:
break
return matches, scanned
def _parameter_gallery_images(parameter_signature):
output_dir = folder_paths.get_output_directory()
if not os.path.isdir(output_dir):
return [], 0
matches = []
scanned = 0
for mtime_ns, image_path in _recent_output_pngs(output_dir):
scanned += 1
if _embedded_parameter_signature(image_path) != parameter_signature:
continue
relative_dir = os.path.relpath(os.path.dirname(image_path), output_dir)
subfolder = "" if relative_dir == "." else relative_dir.replace("\\", "/")
matches.append({
"filename": os.path.basename(image_path),
"subfolder": subfolder,
"type": "output",
"mtime": mtime_ns // 1_000_000,
})
if len(matches) >= MAX_RECIPE_GALLERY_RESULTS:
break
return matches, scanned
def _bounded_gallery_value(value, depth=0):
"""Keep image-embedded parameter details useful without echoing huge metadata."""
if depth > 5:
return "…"
if isinstance(value, str):
return value if len(value) <= 2000 else f"{value[:1997]}..."
if isinstance(value, (int, float, bool)) or value is None:
return value
if isinstance(value, list):
return [_bounded_gallery_value(item, depth + 1) for item in value[:64]]
if isinstance(value, dict):
return {
str(key): _bounded_gallery_value(nested, depth + 1)
for key, nested in list(value.items())[:96]
}
return str(value)[:2000]
def _gallery_parameter_records(workflow):
if not isinstance(workflow, dict):
return []
nodes = workflow.get("nodes")
is_ui_workflow = isinstance(nodes, list)
values = nodes if is_ui_workflow else workflow.values()
records = []
occurrence = {}
for node in values:
if not isinstance(node, dict):
continue
node_type = _node_type(node) or str(node.get("class_type") or "").strip()
if not node_type:
continue
occurrence[node_type] = occurrence.get(node_type, 0) + 1
if is_ui_workflow:
parameters = {"widgets": _bounded_gallery_value(node.get("widgets_values", []))}
node_id = node.get("id")
else:
parameters = {"inputs": _bounded_gallery_value(node.get("inputs", {}))}
node_id = None
records.append({
"type": node_type,
"index": occurrence[node_type],
"node_id": node_id,
"parameters": parameters,
})
return records
def _gallery_parameter_diff(recipe_workflow, image_payload):
recipe_records = _gallery_parameter_records(recipe_workflow)
embedded_workflow = image_payload.get("workflow") or image_payload.get("prompt") if image_payload else None
image_records = _gallery_parameter_records(embedded_workflow)
recipe_map = {(item["type"], item["index"]): item for item in recipe_records}
image_map = {(item["type"], item["index"]): item for item in image_records}
changes = []
for key in sorted(set(recipe_map) | set(image_map), key=lambda item: (item[0].casefold(), item[1])):
recipe_item = recipe_map.get(key)
image_item = image_map.get(key)
if recipe_item is None or image_item is None or recipe_item["parameters"] != image_item["parameters"]:
changes.append({
"type": key[0],
"index": key[1],
"recipe": recipe_item["parameters"] if recipe_item else None,
"image": image_item["parameters"] if image_item else None,
})
return {
"recipe_nodes": recipe_records,
"image_nodes": image_records,
"changes": changes,
"comparable": bool(image_records) and all(
isinstance(item.get("workflow"), dict) for item in [image_payload or {}]
),
}
def _attach_preview_snapshots(recipe, recipes_dir, filename, references):
if not recipe.get("presentation", {}).get("save_model_preview_snapshots"):
return
assets_dir = _recipe_assets_dir(recipes_dir, filename, create=True)
total_bytes = 0
for reference in references[:MAX_PREVIEW_SNAPSHOTS]:
source_path = _preview_source_path(reference.get("saved_value"))
if not source_path:
continue
thumbnail = _thumbnail_webp_bytes(source_path)
if not thumbnail:
continue
data, width, height = thumbnail
if total_bytes + len(data) > MAX_PREVIEW_SNAPSHOT_TOTAL_BYTES:
break
asset_id = f"{hashlib.sha256(data).hexdigest()}.webp"
asset_path = resolve_within(assets_dir, asset_id)
if not os.path.exists(asset_path):
with open(asset_path, "wb") as asset_file:
asset_file.write(data)
total_bytes += len(data)
reference["preview"] = {
"snapshot_asset_id": asset_id,
"media_type": "image/webp",
"width": width,
"height": height,
"captured_at": recipe["timestamp"],
}
+40 -11
View File
@@ -14,11 +14,15 @@ import zipfile
from aiohttp import web
from . import recipes as recipe_store
from .recipes import get_recipes_dir
from . import recipe_images, recipe_schema, recipe_store
from .recipe_store import get_recipes_dir
from .utils import require_filename, resolve_within
# Release gates: package transfers stay closed until validation is complete.
# Keep the format implementation for compatibility testing.
RECIPE_PACKAGE_EXPORT_ENABLED = False
RECIPE_PACKAGE_IMPORT_ENABLED = False
PACKAGE_VERSION = 1
MAX_UPLOAD_BYTES = 32 * 1024 * 1024
MAX_ENTRY_COUNT = 256
@@ -155,12 +159,14 @@ def _validate_manifest(manifest, archive, names):
raise ValueError("Package checksum mismatch")
def _sanitize_recipe_for_export(recipe, include_snapshots=True, include_identity=True):
def _sanitize_recipe_for_export(recipe, include_snapshots=True, include_identity=True, include_model_notes=True):
value = json.loads(json.dumps(recipe, ensure_ascii=False))
# A source_image points into the exporting machine's output directory and
# is not portable package data. The bounded thumbnail remains the cover.
value["source_image"] = None
for reference in value.get("params", {}).get("model_references", []):
if not include_model_notes:
reference.pop("user_note", None)
if not include_snapshots:
reference.pop("preview", None)
if not include_identity:
@@ -209,7 +215,8 @@ def _build_export(raw_recipe, recipes_dir, filename, options):
include_snapshots = options.get("include_snapshots") is True
include_history = options.get("include_history") is True
include_identity = options.get("include_identity", True) is True
recipe = _sanitize_recipe_for_export(raw_recipe, include_snapshots, include_identity)
include_model_notes = options.get("include_model_notes", True) is True
recipe = _sanitize_recipe_for_export(raw_recipe, include_snapshots, include_identity, include_model_notes)
history_recipes = []
if include_history:
for history_name in _recipe_history_files(recipes_dir, filename):
@@ -219,7 +226,12 @@ def _build_export(raw_recipe, recipes_dir, filename, options):
if len(data) > MAX_ENTRY_BYTES:
raise ValueError("Historical recipe is too large")
history_recipe = _parse_json(data, "historical recipe")
history_recipes.append((history_name, _sanitize_recipe_for_export(history_recipe, include_snapshots, include_identity)))
history_recipes.append((history_name, _sanitize_recipe_for_export(
history_recipe,
include_snapshots,
include_identity,
include_model_notes,
)))
asset_ids = _referenced_asset_ids(recipe, include_snapshots)
for _, history_recipe in history_recipes:
asset_ids.update(_referenced_asset_ids(history_recipe, include_snapshots))
@@ -230,7 +242,7 @@ def _build_export(raw_recipe, recipes_dir, filename, options):
_add_zip_entry(archive, entries, "recipe.json", recipe_bytes, "application/json")
if asset_ids:
assets_dir = recipe_store._recipe_assets_dir(recipes_dir, filename)
assets_dir = recipe_images._recipe_assets_dir(recipes_dir, filename)
for asset_id in sorted(asset_ids):
asset_path = resolve_within(assets_dir, asset_id)
if not os.path.isfile(asset_path):
@@ -254,6 +266,7 @@ def _build_export(raw_recipe, recipes_dir, filename, options):
"include_snapshots": include_snapshots,
"include_history": include_history,
"include_identity": include_identity,
"include_model_notes": include_model_notes,
},
"entries": entries,
}
@@ -284,14 +297,14 @@ def _inspect_package(raw):
manifest = _parse_json(_read_zip_entry(archive, "manifest.json"), "manifest")
_validate_manifest(manifest, archive, names)
recipe = _parse_json(_read_zip_entry(archive, "recipe.json"), "recipe")
recipe_store._normalise_recipe(recipe)
recipe_schema._normalise_recipe(recipe)
history_names = [name for name in names if name.startswith("history/")]
if len(history_names) > MAX_HISTORY_ENTRIES:
raise ValueError("Too many history entries")
history_recipes = []
for name in history_names:
history = _parse_json(_read_zip_entry(archive, name), "historical recipe")
recipe_store._normalise_recipe(history)
recipe_schema._normalise_recipe(history)
history_recipes.append(history)
_validate_recipe_assets(archive, [recipe, *history_recipes], names)
return {
@@ -381,14 +394,14 @@ def _commit_import(record, payload):
else:
package_recipe["name"] = _unique_name(package_recipe.get("name", ""), existing_names)
normalized = recipe_store._normalise_recipe(package_recipe)
normalized = recipe_schema._normalise_recipe(package_recipe)
normalized["presentation"]["imported"] = True
filename = target_filename or f"recipe_{int(time.time())}_{uuid.uuid4().hex[:8]}.json"
normalized = recipe_store._enrich_recipe(normalized)
normalized = recipe_schema._enrich_recipe(normalized)
staging = tempfile.mkdtemp(prefix=".recipe-import-", dir=recipes_dir)
final_path = resolve_within(recipes_dir, filename)
final_assets = recipe_store._recipe_assets_dir(recipes_dir, filename)
final_assets = recipe_images._recipe_assets_dir(recipes_dir, filename)
final_history = recipe_store._history_dir(recipes_dir, filename)
backup_recipe = None
backup_assets = None
@@ -448,6 +461,12 @@ def _commit_import(record, payload):
async def api_export_recipe_package(request):
if not RECIPE_PACKAGE_EXPORT_ENABLED:
return web.json_response({
"status": "error",
"code": "recipe_export_disabled",
"message": "Recipe package export is temporarily unavailable",
}, status=503)
try:
payload = await request.json()
filename = require_filename(payload.get("filename", ""))
@@ -470,6 +489,11 @@ async def api_export_recipe_package(request):
async def api_import_recipe_package_inspect(request):
if not RECIPE_PACKAGE_IMPORT_ENABLED:
return web.json_response({
"status": "error", "code": "recipe_import_disabled",
"message": "Recipe package import is temporarily unavailable",
}, status=503)
try:
raw = await request.content.read(MAX_UPLOAD_BYTES + 1)
report = await asyncio.to_thread(_inspect_package, raw)
@@ -489,6 +513,11 @@ async def api_import_recipe_package_inspect(request):
async def api_import_recipe_package_commit(request):
if not RECIPE_PACKAGE_IMPORT_ENABLED:
return web.json_response({
"status": "error", "code": "recipe_import_disabled",
"message": "Recipe package import is temporarily unavailable",
}, status=503)
try:
payload = await request.json()
token = payload.get("token")
+371
View File
@@ -0,0 +1,371 @@
"""Workflow Recipe record normalization, model references, and enrichment."""
import hashlib
import json
import os
import re
import time
import folder_paths
from .metadata import get_metadata
from .recipe_constants import *
from .path_utils import _model_roots, _resolve_exact_model_reference
from .recipe_images import _attach_preview_snapshots
from .utils import require_filename, resolve_within
from .workflow_schema import (
_node_title, _node_type, _parameter_signature, _validate_workflow,
_widget_values, _workflow_fingerprint,
)
def _model_reference_specs(node):
"""Known loader adapters only; arbitrary third-party widgets stay parameters."""
node_type = _node_type(node)
lowered = node_type.lower()
specs = []
if re.search(r"checkpointloader(simple)?$", lowered):
specs.append((0, "checkpoint", "checkpoint"))
elif lowered.endswith("unetloader"):
specs.append((0, "unet", "unet"))
elif re.search(r"loraloader", lowered):
specs.append((0, "lora", "lora"))
elif lowered.endswith("vaeloader"):
specs.append((0, "vae", "vae"))
elif lowered.endswith("clipvisionloader"):
specs.append((0, "clip_vision", "clip_vision"))
elif lowered.endswith("controlnetloader"):
specs.append((0, "controlnet", "controlnet"))
elif re.search(r"(?:^|[^a-z])(?:dual|triple)?cliploader$", lowered):
specs.append((0, "text_encoder", "clip"))
if "dualclip" in lowered or "tripleclip" in lowered:
specs.append((1, "text_encoder", "clip"))
if "tripleclip" in lowered:
specs.append((2, "text_encoder", "clip"))
return specs
def _identity_for_reference(saved_value):
resolved = _resolve_exact_model_reference(saved_value)
if not resolved:
return {"status": "unavailable"}, None
identity = {"status": "unverified", "provenance": "local cached metadata"}
origin = None
try:
identity["size"] = os.path.getsize(resolved["path"])
except OSError:
pass
try:
metadata = get_metadata(resolved["path"])
candidate_hash = str(metadata.get("hash") or "").strip()
if SHA256_PATTERN.fullmatch(candidate_hash):
identity["status"] = "verified"
identity["sha256"] = candidate_hash.lower()
identity["provenance"] = metadata.get("hash_source") or "sidecar file SHA-256"
civitai_url = metadata.get("civitai_url")
if civitai_url:
origin = {
"provider": "civitai",
"model_name": metadata.get("name") or "",
"model_url": civitai_url,
"model_id": metadata.get("model_id") or "",
"version_id": metadata.get("version_id") or ""
}
except Exception:
pass
return identity, origin
def _computed_identity_for_reference(saved_value):
"""Compute an exact SHA-256 only after an explicit save-time opt-in."""
resolved = _resolve_exact_model_reference(saved_value)
if not resolved:
return {"status": "unavailable"}
digest = hashlib.sha256()
try:
with open(resolved["path"], "rb") as model_file:
for block in iter(lambda: model_file.read(4 * 1024 * 1024), b""):
digest.update(block)
return {
"status": "verified",
"sha256": digest.hexdigest(),
"size": os.path.getsize(resolved["path"]),
"provenance": "computed during recipe save",
}
except OSError:
return {"status": "unverified"}
def _workflow_identity_for_reference(workflow, node_id, saved_value):
extra = workflow.get("extra") if isinstance(workflow, dict) else None
hashes = extra.get("anomalous_hashes") if isinstance(extra, dict) else None
if not isinstance(hashes, dict) or not isinstance(saved_value, str):
return None
normalized = saved_value.replace("\\", "/")
windows_path = saved_value.replace("/", "\\")
keys = (
f"{node_id}_{saved_value}",
f"{node_id}_{normalized}",
f"{node_id}_{windows_path}",
saved_value,
normalized,
windows_path,
)
record = next((hashes[key] for key in keys if key in hashes), None)
candidate_hash = record if isinstance(record, str) else record.get("hash") if isinstance(record, dict) else None
if not isinstance(candidate_hash, str) or not SHA256_PATTERN.fullmatch(candidate_hash.strip()):
return None
identity = {
"status": "verified",
"sha256": candidate_hash.strip().lower(),
"provenance": "workflow snapshot",
}
if isinstance(record, dict):
try:
identity["size"] = int(record.get("size"))
except (TypeError, ValueError):
pass
return identity
def _build_model_references(recipe, verify_identities=False):
workflow = recipe.get("workflow") if isinstance(recipe, dict) else None
params = recipe.get("params") if isinstance(recipe, dict) else None
base_model = params.get("baseModel") if isinstance(params, dict) else None
references = []
for node in workflow.get("nodes", []) if isinstance(workflow, dict) else []:
if not isinstance(node, dict):
continue
values = _widget_values(node)
for widget_index, category, widget_name in _model_reference_specs(node):
saved_value = values[widget_index] if widget_index < len(values) else None
if not isinstance(saved_value, str) or not saved_value.strip():
continue
identity_result = _identity_for_reference(saved_value)
if isinstance(identity_result, tuple):
identity, origin = (identity_result + (None,))[:2]
else:
# Keep compatibility with older callers/tests that provide the
# pre-origin helper contract and return identity only.
identity, origin = identity_result, None
workflow_identity = _workflow_identity_for_reference(workflow, node.get("id"), saved_value)
if workflow_identity:
identity = workflow_identity
elif (
verify_identities
and category in VERIFIABLE_RECIPE_MODEL_CATEGORIES
and identity.get("status") != "verified"
):
identity = _computed_identity_for_reference(saved_value)
ref_dict = {
"node_id": node.get("id"),
"node_type": _node_type(node) or "Unknown",
"node_title": _node_title(node),
"widget_index": widget_index,
"widget_name": widget_name,
"saved_value": saved_value,
"category": category,
"base_model": base_model,
"identity": identity,
}
if origin:
ref_dict["origin"] = origin
references.append(ref_dict)
return references
def _model_reference_key(reference):
if not isinstance(reference, dict):
return None
return (
reference.get("node_id"),
reference.get("widget_index"),
reference.get("category"),
reference.get("saved_value"),
)
def _preserve_model_reference_fields(previous_references, references, preserve_identity=True):
"""Carry package/history presentation and identity without trusting names."""
previous = {
_model_reference_key(reference): reference
for reference in previous_references or []
if _model_reference_key(reference) is not None
and isinstance(reference, dict)
}
for reference in references:
prior = previous.get(_model_reference_key(reference))
if prior is None:
continue
for field in ("identity", "origin", "preview"):
if field == "preview" or (field in ("identity", "origin") and preserve_identity):
if isinstance(prior.get(field), dict):
reference[field] = json.loads(json.dumps(prior[field], ensure_ascii=False))
user_note = prior.get("user_note")
if isinstance(user_note, str) and user_note.strip():
reference["user_note"] = user_note.strip()
def _enrich_recipe(
recipe,
recipes_dir=None,
filename=None,
recapture_previews=True,
refresh_identities=False,
verify_identities=False,
):
recipe["workflow_fingerprint"] = _workflow_fingerprint(recipe["workflow"])
# Keep the exact parameter-match identity alongside the structural
# fingerprint. The detail panel can use this value for result discovery
# without asking the browser to recreate or upload a legacy notebook.
recipe["parameter_signature"] = _parameter_signature(recipe["workflow"])
params = dict(recipe.get("params") or {})
previous_references = params.get("model_references", [])
references = _build_model_references(recipe, verify_identities=verify_identities)
_preserve_model_reference_fields(
previous_references,
references,
preserve_identity=not (refresh_identities or verify_identities),
)
if recipes_dir and filename and recapture_previews:
_attach_preview_snapshots(recipe, recipes_dir, filename, references)
params["model_references"] = references
recipe["params"] = params
if recipe.get("workflow_scope") not in {"partial", "complete"}:
recipe["workflow_scope"] = "complete"
recipe["schema_version"] = 7
encoded = json.dumps(recipe, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
if len(encoded) > MAX_RECIPE_BYTES:
raise ValueError("Recipe is too large")
return recipe
def _normalise_source_image(value):
if value is None:
return None
if not isinstance(value, dict) or value.get("type") != "output":
raise ValueError("Invalid source image")
filename = require_filename(value.get("filename", ""))
subfolder = value.get("subfolder", "")
if not isinstance(subfolder, str) or len(subfolder) > MAX_SOURCE_SUBFOLDER_LENGTH:
raise ValueError("Invalid source image")
output_dir = folder_paths.get_output_directory()
resolve_within(output_dir, subfolder)
return {
"filename": filename,
"subfolder": subfolder,
"type": "output",
}
def _normalise_recipe(payload):
if not isinstance(payload, dict):
raise ValueError("Invalid recipe")
name = payload.get("name", "")
if not isinstance(name, str) or not (name := name.strip()) or len(name) > MAX_NAME_LENGTH:
raise ValueError("Invalid recipe name")
raw_tags = payload.get("tags", [])
if not isinstance(raw_tags, list):
raise ValueError("Invalid recipe tags")
tags = []
for tag in raw_tags:
if not isinstance(tag, str):
raise ValueError("Invalid recipe tag")
tag = tag.strip()
if not tag or len(tag) > MAX_TAG_LENGTH:
continue
if tag not in tags:
tags.append(tag)
if len(tags) >= MAX_TAGS:
break
notes = payload.get("notes", "")
if not isinstance(notes, str) or len(notes) > MAX_NOTES_LENGTH:
raise ValueError("Invalid recipe notes")
params = payload.get("params", {})
workflow = payload.get("workflow")
if not isinstance(params, dict) or not isinstance(workflow, dict):
raise ValueError("Invalid recipe workflow")
model_references = params.get("model_references")
if model_references is not None:
if not isinstance(model_references, list):
raise ValueError("Invalid recipe model references")
for reference in model_references:
if not isinstance(reference, dict):
raise ValueError("Invalid recipe model reference")
user_note = reference.get("user_note")
if user_note is None:
continue
if not isinstance(user_note, str) or len(user_note) > MAX_MODEL_NOTE_LENGTH:
raise ValueError("Invalid recipe model note")
if user_note.strip():
reference["user_note"] = user_note.strip()
else:
reference.pop("user_note", None)
_validate_workflow(workflow)
workflow_scope = payload.get("workflow_scope", "complete")
if workflow_scope not in {"partial", "complete"}:
raise ValueError("Invalid recipe workflow scope")
thumbnail = payload.get("thumbnail")
if thumbnail is not None:
if (
not isinstance(thumbnail, str)
or len(thumbnail) > MAX_THUMBNAIL_LENGTH
or not thumbnail.lower().startswith(SAFE_THUMBNAIL_PREFIXES)
):
thumbnail = None
presentation = payload.get("presentation", {})
if presentation is None:
presentation = {}
if not isinstance(presentation, dict):
raise ValueError("Invalid recipe presentation")
save_model_preview_snapshots = presentation.get("save_model_preview_snapshots", True)
if not isinstance(save_model_preview_snapshots, bool):
raise ValueError("Invalid recipe presentation")
cover_asset_id = presentation.get("cover_asset_id")
if cover_asset_id is not None:
cover_asset_id = require_filename(cover_asset_id)
if not cover_asset_id.startswith("cover-") or not cover_asset_id.endswith(".webp"):
raise ValueError("Invalid recipe presentation")
recipe = {
"schema_version": 1,
"name": name,
"tags": tags,
"notes": notes.strip(),
"params": params,
"workflow": workflow,
"workflow_scope": workflow_scope,
"thumbnail": thumbnail,
"source_image": _normalise_source_image(payload.get("source_image")),
"presentation": {
"save_model_preview_snapshots": save_model_preview_snapshots,
**({"cover_asset_id": cover_asset_id} if cover_asset_id else {}),
},
"timestamp": int(time.time() * 1000),
}
encoded = json.dumps(recipe, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
if len(encoded) > MAX_RECIPE_BYTES:
raise ValueError("Recipe is too large")
return recipe
def _updated_recipe(payload, existing):
recipe = _normalise_recipe(payload)
existing_presentation = existing.get("presentation") if isinstance(existing.get("presentation"), dict) else {}
existing_cover = existing_presentation.get("cover_asset_id")
source_unchanged = recipe.get("source_image") == existing.get("source_image")
if existing_cover and source_unchanged and not recipe["presentation"].get("cover_asset_id"):
recipe["presentation"]["cover_asset_id"] = existing_cover
created_timestamp = existing.get("created_timestamp", existing.get("timestamp"))
if isinstance(created_timestamp, int):
recipe["created_timestamp"] = created_timestamp
recipe["updated_timestamp"] = recipe["timestamp"]
return recipe
+141
View File
@@ -0,0 +1,141 @@
"""User-owned Workflow Recipe records and bounded history persistence."""
import json
import os
import shutil
import tempfile
import time
import uuid
import folder_paths
from .recipe_constants import MAX_HISTORY_VERSIONS
from .recipe_images import _recipe_assets_dir
from .utils import require_filename, resolve_within
def get_recipes_dir():
"""Return the user-owned recipe directory, creating it if needed."""
user_dir = (
folder_paths.get_user_directory()
if hasattr(folder_paths, "get_user_directory")
else os.path.join(folder_paths.base_path, "user", "default")
)
recipes_dir = os.path.join(user_dir, "workflows", "anomalous_recipes")
os.makedirs(recipes_dir, exist_ok=True)
return os.path.realpath(recipes_dir)
def _read_recipe(path):
with open(path, "r", encoding="utf-8") as recipe_file:
return json.load(recipe_file)
def _list_recipes(recipes_dir):
recipes = []
try:
with os.scandir(recipes_dir) as entries:
for entry in entries:
if not entry.is_file() or not entry.name.endswith(".json"):
continue
try:
filename = require_filename(entry.name)
data = _read_recipe(resolve_within(recipes_dir, filename))
if not isinstance(data, dict):
continue
# The graph can be much larger than all card data combined.
summary = {key: value for key, value in data.items() if key != "workflow"}
recipes.append({"filename": filename, "data": summary})
except (OSError, ValueError, json.JSONDecodeError):
continue
except OSError:
return []
recipes.sort(key=lambda item: item["data"].get("timestamp", 0), reverse=True)
return recipes
def _write_recipe(recipes_dir, filename, recipe):
path = resolve_within(recipes_dir, filename)
fd, temp_path = tempfile.mkstemp(prefix=".recipe-", suffix=".tmp", dir=recipes_dir)
try:
with os.fdopen(fd, "w", encoding="utf-8") as recipe_file:
json.dump(recipe, recipe_file, ensure_ascii=False, separators=(",", ":"))
recipe_file.flush()
os.fsync(recipe_file.fileno())
os.replace(temp_path, path)
finally:
if os.path.exists(temp_path):
try:
os.remove(temp_path)
except OSError:
pass
def _history_dir(recipes_dir, filename, create=False):
"""Return the contained, user-data-only history directory for one recipe."""
stem = os.path.splitext(require_filename(filename))[0]
history_dir = resolve_within(recipes_dir, ".history", stem)
if create:
os.makedirs(history_dir, exist_ok=True)
return history_dir
def _archive_recipe(recipes_dir, filename, recipe):
"""Atomically retain a bounded pre-update snapshot before replacing a recipe."""
history_dir = _history_dir(recipes_dir, filename, create=True)
version_name = f"version_{int(time.time() * 1000)}_{uuid.uuid4().hex[:8]}.json"
_write_recipe(history_dir, version_name, recipe)
entries = []
with os.scandir(history_dir) as scan:
for entry in scan:
if entry.is_file() and entry.name.endswith(".json"):
entries.append(entry)
entries.sort(key=lambda entry: entry.stat().st_mtime_ns, reverse=True)
for entry in entries[MAX_HISTORY_VERSIONS:]:
try:
os.remove(resolve_within(history_dir, entry.name))
except OSError:
pass
def _list_recipe_history(recipes_dir, filename):
history_dir = _history_dir(recipes_dir, filename)
versions = []
try:
with os.scandir(history_dir) as entries:
for entry in entries:
if not entry.is_file() or not entry.name.endswith(".json"):
continue
try:
version = require_filename(entry.name)
data = _read_recipe(resolve_within(history_dir, version))
if not isinstance(data, dict):
continue
versions.append({
"version": version,
"timestamp": data.get("timestamp", 0),
"name": data.get("name", ""),
"workflow_fingerprint": data.get("workflow_fingerprint"),
"model_reference_count": len(
(data.get("params") or {}).get("model_references", [])
if isinstance(data.get("params"), dict)
else []
),
})
except (OSError, ValueError, json.JSONDecodeError):
continue
except OSError:
return []
versions.sort(key=lambda item: item["timestamp"], reverse=True)
return versions
def _delete_recipe_with_history(recipes_dir, filename):
"""Delete an explicitly selected recipe and its contained local history."""
os.remove(resolve_within(recipes_dir, filename))
history_dir = _history_dir(recipes_dir, filename)
if os.path.isdir(history_dir):
shutil.rmtree(history_dir)
assets_dir = _recipe_assets_dir(recipes_dir, filename)
if os.path.isdir(assets_dir):
shutil.rmtree(assets_dir)
+81 -989
View File
File diff suppressed because it is too large Load Diff
+317 -124
View File
@@ -1,14 +1,216 @@
from .metadata import get_metadata
import os
import sys
import json
import urllib.parse
import subprocess
import threading
import asyncio
from aiohttp import web
import folder_paths
import struct
from .utils import get_active_folder_types, get_active_scan_paths, resolve_folder_subdir
import subprocess
import threading
import asyncio
import ctypes
from ctypes import wintypes
import time
import uuid
from aiohttp import web
import folder_paths
import struct
from .folder_types import get_active_folder_types, get_active_scan_paths
from .path_utils import resolve_folder_subdir
try:
from ..model_policies import is_physical_rename_protected
except ImportError:
from model_policies import is_physical_rename_protected
SCAN_SESSION_ID = uuid.uuid4().hex
SCAN_MARKER_LOCK = threading.Lock()
ACTIVE_SCAN_MARKERS = set()
SCAN_RUNTIME_STATE = {}
def _read_json_file(path):
try:
with open(path, 'r', encoding='utf-8') as f:
value = json.load(f)
return value if isinstance(value, dict) else None
except (OSError, ValueError, TypeError):
return None
def _remove_file(path):
try:
os.remove(path)
except FileNotFoundError:
return True
except OSError:
return False
return True
def _pid_is_running(pid):
try:
pid = int(pid)
except (TypeError, ValueError):
return False
if pid <= 0:
return False
if pid == os.getpid():
return True
if os.name == 'nt':
kernel32 = ctypes.WinDLL('kernel32', use_last_error=True)
open_process = kernel32.OpenProcess
open_process.argtypes = (wintypes.DWORD, wintypes.BOOL, wintypes.DWORD)
open_process.restype = wintypes.HANDLE
get_exit_code = kernel32.GetExitCodeProcess
get_exit_code.argtypes = (wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD))
get_exit_code.restype = wintypes.BOOL
close_handle = kernel32.CloseHandle
close_handle.argtypes = (wintypes.HANDLE,)
close_handle.restype = wintypes.BOOL
process = open_process(0x1000, False, pid)
if not process:
return False
try:
exit_code = wintypes.DWORD()
return bool(get_exit_code(process, ctypes.byref(exit_code))) and exit_code.value == 259
finally:
close_handle(process)
try:
os.kill(pid, 0)
return True
except PermissionError:
return True
except OSError:
return False
def _write_marker(marker_file, marker):
temp_file = f"{marker_file}.{os.getpid()}.{threading.get_ident()}.tmp"
with open(temp_file, 'w', encoding='utf-8') as f:
json.dump(marker, f)
os.replace(temp_file, marker_file)
def _marker_owner_is_running(marker):
if not marker:
return False
if marker.get("session_id") == SCAN_SESSION_ID:
return marker.get("marker_file") in ACTIVE_SCAN_MARKERS
worker_pid = marker.get("worker_pid")
if worker_pid:
return _pid_is_running(worker_pid)
return _pid_is_running(marker.get("owner_pid"))
def _cleanup_scan_artifacts(marker_file, artifact_files=()):
_remove_file(marker_file)
for path in artifact_files:
_remove_file(path)
def _claim_scan_marker(marker_file, kind, artifact_files=()):
recovered = False
with SCAN_MARKER_LOCK:
if marker_file in ACTIVE_SCAN_MARKERS:
return False, False
if os.path.exists(marker_file):
marker = _read_json_file(marker_file)
if _marker_owner_is_running(marker):
return False, False
_cleanup_scan_artifacts(marker_file, artifact_files)
recovered = True
marker = {
"version": 2,
"job_id": uuid.uuid4().hex,
"kind": kind,
"session_id": SCAN_SESSION_ID,
"owner_pid": os.getpid(),
"worker_pid": 0,
"started_at": time.time(),
"marker_file": marker_file,
}
try:
with open(marker_file, 'x', encoding='utf-8') as f:
json.dump(marker, f)
except FileExistsError:
return False, recovered
ACTIVE_SCAN_MARKERS.add(marker_file)
SCAN_RUNTIME_STATE[marker_file] = {
"phase": "preparing",
"error": "",
"recovered": recovered,
}
return True, recovered
def _update_scan_marker(marker_file, **values):
with SCAN_MARKER_LOCK:
if marker_file not in ACTIVE_SCAN_MARKERS:
return
marker = _read_json_file(marker_file) or {}
marker.update(values)
marker["marker_file"] = marker_file
_write_marker(marker_file, marker)
def _update_scan_state(marker_file, **values):
with SCAN_MARKER_LOCK:
if marker_file in ACTIVE_SCAN_MARKERS:
SCAN_RUNTIME_STATE.setdefault(marker_file, {}).update(values)
def _release_scan_marker(marker_file, artifact_files=()):
with SCAN_MARKER_LOCK:
ACTIVE_SCAN_MARKERS.discard(marker_file)
SCAN_RUNTIME_STATE.pop(marker_file, None)
_cleanup_scan_artifacts(marker_file, artifact_files)
def _scan_marker_status(marker_file, artifact_files=()):
with SCAN_MARKER_LOCK:
if marker_file in ACTIVE_SCAN_MARKERS:
return True, False, _read_json_file(marker_file) or {}
if not os.path.exists(marker_file):
return False, False, {}
marker = _read_json_file(marker_file)
if _marker_owner_is_running(marker):
return True, False, marker or {}
_cleanup_scan_artifacts(marker_file, artifact_files)
return False, True, marker or {}
def _scan_status_payload(marker_file, progress_file, artifact_files=()):
scanning, interrupted, marker = _scan_marker_status(marker_file, artifact_files)
data = {
"scanning": scanning,
"interrupted": interrupted,
"job_id": marker.get("job_id", ""),
"phase": "idle",
"total": 0,
"current": 0,
"filename": "",
}
if scanning:
with SCAN_MARKER_LOCK:
data.update(SCAN_RUNTIME_STATE.get(marker_file, {}))
progress = _read_json_file(progress_file)
if progress:
data.update(progress)
return data
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')
@@ -28,12 +230,13 @@ async def api_scan_status(request):
except ValueError:
return web.json_response({"scanning": False})
marker_file = os.path.join(target_dir, '.scan_in_progress')
result_file = os.path.join(target_dir, '.scan_result.json')
scanning = os.path.exists(marker_file)
data = {"scanning": scanning}
if not scanning and os.path.exists(result_file):
marker_file = os.path.join(target_dir, '.scan_in_progress')
progress_file = os.path.join(target_dir, '.scan_progress.json')
targets_file = os.path.join(target_dir, '.scan_targets.json')
result_file = os.path.join(target_dir, '.scan_result.json')
data = _scan_status_payload(marker_file, progress_file, (progress_file, targets_file))
if not data["scanning"] and os.path.exists(result_file):
try:
with open(result_file, 'r', encoding='utf-8') as f:
data["result"] = __import__('json').load(f)
@@ -77,7 +280,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", [])
@@ -85,22 +292,26 @@ async def api_scan_folder(request):
target_files_str = request.query.get('target_files', '')
target_files_list = [f.strip() for f in target_files_str.split(',')] if target_files_str else []
claimed = False
try:
marker_file = os.path.join(target_dir, '.scan_in_progress')
try:
with open(marker_file, 'x') as f:
f.write('1')
except FileExistsError:
progress_file = os.path.join(target_dir, '.scan_progress.json')
targets_file = os.path.join(target_dir, '.scan_targets.json')
claimed, recovered = _claim_scan_marker(
marker_file,
"folder",
(progress_file, targets_file),
)
if not claimed:
return web.json_response({"status": "error", "message": "Scan already in progress"}, status=409)
if target_files_list:
targets_file = os.path.join(target_dir, '.scan_targets.json')
with open(targets_file, 'w', encoding='utf-8') as f:
__import__('json').dump(target_files_list, f)
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:
@@ -112,15 +323,15 @@ async def api_scan_folder(request):
if force_overwrite:
cmd.append("--force-overwrite")
result = subprocess.run(
cmd,
cwd=plugin_dir
)
if result.returncode != 0:
cmd.extend(["--progress-file", progress_file])
process = subprocess.Popen(cmd, cwd=plugin_dir)
_update_scan_marker(marker_file, worker_pid=process.pid)
return_code = process.wait()
if return_code != 0:
result_file = os.path.join(target_dir, '.scan_result.json')
with open(result_file, 'w', encoding='utf-8') as f:
json.dump({"success": 0, "fail": 1, "error": f"Scanner exited with code {result.returncode}"}, f)
finally:
json.dump({"success": 0, "fail": 1, "error": f"Scanner exited with code {return_code}"}, f)
finally:
if hasattr(folder_paths, "filename_list_cache"):
try: folder_paths.filename_list_cache.clear()
except: pass
@@ -128,23 +339,25 @@ async def api_scan_folder(request):
try: folder_paths.cache_helper.clear()
except: pass
if os.path.exists(marker_file):
try: os.remove(marker_file)
except: pass
_release_scan_marker(marker_file, (progress_file, targets_file))
threading.Thread(target=run_bg, daemon=True).start()
return web.json_response({"status": "ok", "message": "Scan started in background. Check console for details."})
return web.json_response({
"status": "ok",
"message": "Scan started in background. Check console for details.",
"recovered": recovered,
})
except Exception as e:
if 'marker_file' in locals() and os.path.exists(marker_file):
try: os.remove(marker_file)
except OSError: pass
if claimed:
_release_scan_marker(marker_file, (progress_file, targets_file))
return web.json_response({"status": "error", "message": str(e)})
async def api_scan_all(request):
plugin_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
scraper_path = os.path.join(plugin_dir, "scraper.py")
scraper_path = os.path.join(plugin_dir, "scraper.py")
marker_file = os.path.join(plugin_dir, '.global_scan_in_progress')
progress_file = os.path.join(plugin_dir, '.global_scan_progress.json')
try:
data = await request.json()
@@ -155,47 +368,73 @@ 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)
claimed = False
try:
try:
with open(marker_file, 'x') as f:
f.write('1')
except FileExistsError:
claimed, recovered = _claim_scan_marker(marker_file, "global", (progress_file,))
if not claimed:
return web.json_response({"status": "error", "message": "Global scan already in progress"}, status=409)
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]
def run_global_bg():
try:
paths_to_scan = get_active_scan_paths()
paths_to_scan = [path for path in paths_to_scan if os.path.exists(path)]
_update_scan_state(
marker_file,
phase="preparing",
folder_total=len(paths_to_scan),
folder_current=0,
folder="",
)
for folder_index, base_dir in enumerate(paths_to_scan, 1):
if not os.path.exists(base_dir): continue
try:
print(f"[Anomalous Browser] Global scan processing: {base_dir}")
_remove_file(progress_file)
_update_scan_state(
marker_file,
phase="enumerating",
folder_total=len(paths_to_scan),
folder_current=folder_index,
folder=os.path.basename(os.path.normpath(base_dir)) or base_dir,
error="",
)
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:
cmd.append("--skip-media")
if not use_local_metadata:
cmd.append("--skip-local-metadata")
result = subprocess.run(
cmd,
cwd=plugin_dir
)
if result.returncode != 0:
print(f"[Anomalous Browser] Scanner exited with code {result.returncode}: {base_dir}")
except Exception as e:
print(f"[Anomalous Browser] Global scan error on {base_dir}: {e}")
if not use_local_metadata:
cmd.append("--skip-local-metadata")
cmd.extend(["--progress-file", progress_file])
process = subprocess.Popen(cmd, cwd=plugin_dir)
_update_scan_marker(marker_file, worker_pid=process.pid)
return_code = process.wait()
_update_scan_marker(marker_file, worker_pid=0)
if return_code != 0:
message = f"Scanner exited with code {return_code}: {base_dir}"
_update_scan_state(marker_file, error=message)
print(f"[Anomalous Browser] {message}")
except Exception as e:
_update_scan_state(marker_file, error=str(e))
print(f"[Anomalous Browser] Global scan error on {base_dir}: {e}")
finally:
if hasattr(folder_paths, "filename_list_cache"):
try: folder_paths.filename_list_cache.clear()
@@ -204,22 +443,20 @@ async def api_scan_all(request):
try: folder_paths.cache_helper.clear()
except: pass
if os.path.exists(marker_file):
try: os.remove(marker_file)
except: pass
_release_scan_marker(marker_file, (progress_file,))
threading.Thread(target=run_global_bg, daemon=True).start()
return web.json_response({"status": "ok", "message": "Global scan started"})
return web.json_response({"status": "ok", "message": "Global scan started", "recovered": recovered})
except Exception as e:
if os.path.exists(marker_file):
try: os.remove(marker_file)
except OSError: pass
if claimed:
_release_scan_marker(marker_file, (progress_file,))
return web.json_response({"status": "error", "message": str(e)})
async def api_global_scan_status(request):
plugin_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
marker_file = os.path.join(plugin_dir, '.global_scan_in_progress')
return web.json_response({"scanning": os.path.exists(marker_file)})
async def api_global_scan_status(request):
plugin_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
marker_file = os.path.join(plugin_dir, '.global_scan_in_progress')
progress_file = os.path.join(plugin_dir, '.global_scan_progress.json')
return web.json_response(_scan_status_payload(marker_file, progress_file, (progress_file,)))
async def api_clean_civitai_info(request):
try:
paths_to_clean = get_active_scan_paths()
@@ -262,7 +499,7 @@ async def api_scan_missing_models(request):
sys.path.insert(0, plugin_dir)
try:
from scraper import extract_safetensors_hash, calculate_sha256, fetch_civitai_info, infer_base_model_from_header
from scraper import calculate_sha256, fetch_civitai_info, infer_base_model_from_header, computed_file_identity
except ImportError:
return web.json_response({"status": "error", "message": "Failed to load scraper module"})
@@ -293,29 +530,8 @@ async def api_scan_missing_models(request):
if not file.endswith('.safetensors'):
continue
file_path = os.path.join(root, file)
base_path = os.path.splitext(file_path)[0]
info_path = base_path + ".info"
civitai_info_path = base_path + ".civitai.info"
# Check if a valid info file exists
has_valid_info = False
actual_info = info_path if os.path.exists(info_path) else (civitai_info_path if os.path.exists(civitai_info_path) else None)
if force_overwrite:
actual_info = None
if actual_info:
try:
with open(actual_info, 'r', encoding='utf-8') as f:
data = json.load(f)
for fi in data.get("files", []):
if isinstance(fi, dict) and "hashes" in fi and "SHA256" in fi["hashes"]:
if fi["hashes"]["SHA256"]:
has_valid_info = True
break
except Exception:
pass
has_valid_info = not force_overwrite and bool(get_metadata(file_path).get("hash"))
if not has_valid_info:
files_to_scan.append(file_path)
@@ -327,31 +543,15 @@ async def api_scan_missing_models(request):
GLOBAL_SCAN_STATE["filename"] = filename
try:
civitai_data = None
file_hash = None
# Fallback 1: Try header hash on Civitai
header_hash = extract_safetensors_hash(file_path)
if header_hash:
civitai_data = fetch_civitai_info(header_hash)
if civitai_data:
file_hash = header_hash
# Fallback 2: If header hash fails, compute full SHA256
if not civitai_data:
full_hash = calculate_sha256(file_path)
civitai_data = fetch_civitai_info(full_hash)
file_hash = full_hash
file_hash = calculate_sha256(file_path)
civitai_data = fetch_civitai_info(file_hash)
# Fallback 3: Local Offline Inference
if not civitai_data:
inferred_base = infer_base_model_from_header(file_path)
if inferred_base == 'Unknown':
inferred_base = ""
if not file_hash:
file_hash = header_hash or calculate_sha256(file_path)
civitai_data = {
"id": -1,
"modelId": -1,
@@ -365,15 +565,8 @@ async def api_scan_missing_models(request):
"files": [{"hashes": {"SHA256": file_hash}}]
}
# Ensure SHA256 is explicitly injected into the info data
if civitai_data:
if not civitai_data.get("files"):
civitai_data["files"] = [{"hashes": {"SHA256": file_hash}}]
elif isinstance(civitai_data["files"][0], dict):
if "hashes" not in civitai_data["files"][0]:
civitai_data["files"][0]["hashes"] = {}
civitai_data["files"][0]["hashes"]["SHA256"] = file_hash
civitai_data["anomalous_file_identity"] = computed_file_identity(file_path, file_hash)
# Save info file
info_path = os.path.splitext(file_path)[0] + ".info"
with open(info_path, 'w', encoding='utf-8') as f:
+120
View File
@@ -0,0 +1,120 @@
"""Bounded translation-provider HTTP route."""
import asyncio
import json
import os
import urllib.parse
import urllib.request
from aiohttp import web
def _translate_with_deepl(text, tl, deepl_key):
deepl_map = { "zh-CN": "ZH", "en": "EN", "ja": "JA", "ko": "KO", "fr": "FR", "de": "DE", "es": "ES", "ru": "RU" }
d_tl = deepl_map.get(tl, "EN")
url = "https://api-free.deepl.com/v2/translate" if ":fx" in deepl_key else "https://api.deepl.com/v2/translate"
payload = urllib.parse.urlencode({
"auth_key": deepl_key,
"text": text,
"target_lang": d_tl
}).encode('utf-8')
req = urllib.request.Request(url, data=payload)
with urllib.request.urlopen(req, timeout=5) as resp:
result = json.loads(resp.read().decode('utf-8'))
return result["translations"][0]["text"]
def _translate_with_google(text, tl):
clients = ["dict-chrome-ex", "gtx"]
last_err = None
target_lang = "zh-CN" if tl in ("zh", "zh-CN") else tl
for client in clients:
try:
url = f"https://translate.googleapis.com/translate_a/single?client={client}&sl=auto&tl={target_lang}&dt=t&q={urllib.parse.quote(text)}"
req = urllib.request.Request(url, headers={
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
})
with urllib.request.urlopen(req, timeout=5) as resp:
result = json.loads(resp.read().decode('utf-8'))
if result and isinstance(result, list) and len(result) > 0 and isinstance(result[0], list):
translated_text = "".join([part[0] for part in result[0] if part and len(part) > 0 and part[0]])
if translated_text:
return translated_text
except Exception as e:
last_err = e
continue
if last_err:
raise last_err
raise RuntimeError("Google translate returned empty result")
def _translate_with_mymemory(text, tl):
has_cn = any('\u4e00' <= char <= '\u9fa5' for char in text)
sl = 'zh-CN' if has_cn else 'en'
pair_tl = 'en' if has_cn else ('zh-CN' if tl in ('zh', 'zh-CN') else tl)
url = f"https://api.mymemory.translated.net/get?q={urllib.parse.quote(text)}&langpair={sl}|{pair_tl}"
req = urllib.request.Request(url, headers={
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
})
with urllib.request.urlopen(req, timeout=5) as resp:
data = json.loads(resp.read().decode('utf-8'))
res = data.get("responseData", {}).get("translatedText")
if res and res != text:
return res
for m in data.get("matches", []):
t_match = m.get("translation")
if t_match and t_match != text:
return t_match
if res:
return res
raise RuntimeError("MyMemory returned no translation")
async def api_translate(request):
try:
data = await request.json()
text = str(data.get("text", "")).strip()
tl = str(data.get("target_lang", "zh-CN")).strip()
if not text:
return web.json_response({"translated": "", "status": "success"})
# 1. Try DeepL if configured
script_dir = os.path.dirname(os.path.abspath(__file__))
config_path = os.path.join(script_dir, "config.json")
deepl_key = ""
if os.path.exists(config_path):
try:
with open(config_path, 'r', encoding='utf-8') as f:
cfg = json.load(f)
deepl_key = cfg.get("DEEPL_API_KEY", "").strip()
except Exception:
pass
if deepl_key:
try:
translated = await asyncio.to_thread(_translate_with_deepl, text, tl, deepl_key)
if translated:
return web.json_response({"translated": translated, "status": "success", "engine": "deepl"})
except Exception as e:
print(f"[Anomalous] DeepL translate failed: {e}")
# 2. Try Google Translate (client=dict-chrome-ex)
try:
translated = await asyncio.to_thread(_translate_with_google, text, tl)
if translated:
return web.json_response({"translated": translated, "status": "success", "engine": "google"})
except Exception as e:
print(f"[Anomalous] Google translate failed: {e}")
# 3. Fallback to MyMemory
try:
translated = await asyncio.to_thread(_translate_with_mymemory, text, tl)
if translated:
return web.json_response({"translated": translated, "status": "success", "engine": "mymemory"})
except Exception as e:
print(f"[Anomalous] MyMemory translate failed: {e}")
raise e
except Exception as e:
print(f"[Anomalous] All translation providers failed: {e}")
return web.json_response({"translated": text, "error": str(e), "status": "error"})
+21 -600
View File
@@ -1,610 +1,31 @@
import os
import sys
import json
import urllib.parse
import urllib.request
import subprocess
import threading
import asyncio
from aiohttp import web
import folder_paths
import struct
import hashlib
import tempfile
import time
"""Compatibility exports for focused backend utility modules."""
import os
from . import gallery_routes as _gallery
from .folder_types import *
from .media_routes import *
from .path_utils import *
from .translation_routes import *
CARD_THUMBNAIL_EDGE = 512
CARD_THUMBNAIL_CACHE_LIMIT = 256 * 1024 * 1024
CARD_THUMBNAIL_STATIC_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.webp', '.avif'}
_thumbnail_cleanup_lock = threading.Lock()
_thumbnail_generation_slots = threading.BoundedSemaphore(2)
_thumbnail_last_cleanup = 0.0
_thumbnail_created_since_cleanup = 0
_gallery_snapshot = _gallery._gallery_snapshot
_gallery_collect_impl = _gallery._collect_gallery_images
_gallery_images_impl = _gallery._gallery_images
def resolve_within(base_dir, *parts):
"""Resolve a path and require it to stay inside base_dir, including through symlinks."""
base_real = os.path.realpath(base_dir)
candidate = os.path.realpath(os.path.join(base_real, *parts))
try:
if os.path.commonpath([base_real, candidate]) != base_real:
raise ValueError("Path escapes the configured directory")
except ValueError:
raise ValueError("Path escapes the configured directory")
return candidate
def _collect_gallery_images(output_dir):
return _gallery_collect_impl(output_dir)
def get_folder_root(folder_type, path_idx=0):
paths = folder_paths.get_folder_paths(folder_type)
if not paths or not isinstance(path_idx, int) or path_idx < 0 or path_idx >= len(paths):
raise ValueError("Invalid folder path")
return os.path.realpath(paths[path_idx])
def _gallery_images(output_dir, refresh=False):
_gallery._collect_gallery_images = _collect_gallery_images
return _gallery_images_impl(output_dir, refresh)
def resolve_folder_subdir(folder_type, path_idx=0, subfolder='/'):
base_dir = get_folder_root(folder_type, path_idx)
relative = "" if subfolder in (None, "", "/") else str(subfolder).strip("/\\")
return base_dir, resolve_within(base_dir, relative)
def _invalidate_gallery_snapshot():
return _gallery._invalidate_gallery_snapshot()
def require_filename(filename):
if not isinstance(filename, str) or not filename or os.path.basename(filename) != filename:
raise ValueError("Invalid filename")
return filename
def _thumbnail_cache_directory():
try:
base_dir = folder_paths.get_temp_directory()
except Exception:
base_dir = None
if not isinstance(base_dir, (str, os.PathLike)):
base_dir = tempfile.gettempdir()
cache_dir = os.path.join(base_dir, "anomalous_model_browser", "card_thumbnails")
os.makedirs(cache_dir, exist_ok=True)
return cache_dir
def _prune_thumbnail_cache(cache_dir, created=False):
"""Occasionally cap the derived thumbnail cache without touching source covers."""
global _thumbnail_last_cleanup, _thumbnail_created_since_cleanup
now = time.monotonic()
with _thumbnail_cleanup_lock:
if created:
_thumbnail_created_since_cleanup += 1
now = time.monotonic()
if (
now - _thumbnail_last_cleanup < 3600
and _thumbnail_created_since_cleanup < 64
):
return
_thumbnail_last_cleanup = now
_thumbnail_created_since_cleanup = 0
entries = []
total_size = 0
try:
with os.scandir(cache_dir) as iterator:
for entry in iterator:
if not entry.is_file() or not entry.name.endswith('.webp'):
continue
try:
stat = entry.stat()
except OSError:
continue
total_size += stat.st_size
entries.append((stat.st_mtime_ns, stat.st_size, entry.path))
except OSError:
return
if total_size <= CARD_THUMBNAIL_CACHE_LIMIT:
return
target_size = int(CARD_THUMBNAIL_CACHE_LIMIT * 0.8)
for _, size, path in sorted(entries):
try:
os.remove(path)
total_size -= size
except OSError:
pass
if total_size <= target_size:
break
def _build_card_thumbnail_impl(source_path):
"""Return a cached 512px WebP card image, or the original on any safe fallback."""
try:
from PIL import Image, ImageOps
source_stat = os.stat(source_path)
cache_key = "\0".join((
os.path.realpath(source_path),
str(source_stat.st_size),
str(source_stat.st_mtime_ns),
str(getattr(source_stat, 'st_ctime_ns', 0)),
str(CARD_THUMBNAIL_EDGE),
))
digest = hashlib.sha256(cache_key.encode('utf-8', errors='surrogatepass')).hexdigest()
cache_dir = _thumbnail_cache_directory()
cached_path = os.path.join(cache_dir, f"{digest}.webp")
if os.path.isfile(cached_path):
try:
os.utime(cached_path, None)
except OSError:
pass
_prune_thumbnail_cache(cache_dir)
return cached_path
with Image.open(source_path) as image:
if getattr(image, 'is_animated', False):
return source_path
image = ImageOps.exif_transpose(image)
if max(image.size) <= CARD_THUMBNAIL_EDGE:
return source_path
resampling = getattr(Image, 'Resampling', Image).LANCZOS
image.thumbnail((CARD_THUMBNAIL_EDGE, CARD_THUMBNAIL_EDGE), resampling)
if image.mode not in ('RGB', 'RGBA'):
image = image.convert('RGBA' if 'transparency' in image.info else 'RGB')
temp_path = (
f"{cached_path}.{os.getpid()}.{threading.get_ident()}.tmp"
)
try:
image.save(temp_path, format='WEBP', quality=84, method=4)
os.replace(temp_path, cached_path)
finally:
if os.path.exists(temp_path):
try:
os.remove(temp_path)
except OSError:
pass
_prune_thumbnail_cache(cache_dir, created=True)
return cached_path
except Exception:
return source_path
def _build_card_thumbnail(source_path):
# Bound simultaneous decodes so opening a large folder cannot monopolize CPU/RAM.
with _thumbnail_generation_slots:
return _build_card_thumbnail_impl(source_path)
async def api_serve_image(request):
"""Dedicated image serving endpoint for model preview images."""
folder_type = request.query.get('type', 'checkpoints')
try:
path_idx = int(request.query.get('path_idx', 0))
except:
path_idx = 0
subfolder = request.query.get('subfolder', '')
filename = request.query.get('filename', '')
try:
filename = require_filename(filename)
_, target_dir = resolve_folder_subdir(folder_type, path_idx, subfolder)
file_path = resolve_within(target_dir, filename)
except (ValueError, KeyError):
return web.Response(status=400, text='Invalid request')
if not os.path.exists(file_path) or not os.path.isfile(file_path):
return web.Response(status=404, text='Image not found')
ext = os.path.splitext(filename)[1].lower()
content_types = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.webp': 'image/webp',
'.gif': 'image/gif',
'.avif': 'image/avif',
'.mp4': 'video/mp4',
'.webm': 'video/webm',
'.mov': 'video/quicktime',
'.avi': 'video/x-msvideo'
}
content_type = content_types.get(ext)
if content_type is None:
return web.Response(status=415, text='Unsupported media type')
served_path = file_path
if (
request.query.get('variant') == 'card'
and ext in CARD_THUMBNAIL_STATIC_EXTENSIONS
):
served_path = await asyncio.to_thread(_build_card_thumbnail, file_path)
if served_path != file_path:
content_type = 'image/webp'
headers = {'Content-Type': content_type}
if request.query.get('t'):
headers['Cache-Control'] = 'public, max-age=31536000, immutable'
return web.FileResponse(served_path, headers=headers)
async def api_clear_cache(request):
try:
from .metadata import clear_metadata_cache
if hasattr(folder_paths, "filename_list_cache"):
folder_paths.filename_list_cache.clear()
if hasattr(folder_paths, "cache_helper") and hasattr(folder_paths.cache_helper, "clear"):
folder_paths.cache_helper.clear()
clear_metadata_cache()
return web.json_response({"status": "success"})
except Exception as e:
return web.json_response({"status": "error", "message": str(e)})
async def api_translate(request):
try:
data = await request.json()
text = data.get("text", "")
tl = data.get("target_lang", "zh-CN")
if not text:
return web.json_response({"translated": ""})
# Try DeepL if API key exists
script_dir = os.path.dirname(os.path.abspath(__file__))
config_path = os.path.join(script_dir, "config.json")
deepl_key = ""
if os.path.exists(config_path):
try:
with open(config_path, 'r', encoding='utf-8') as f:
cfg = json.load(f)
deepl_key = cfg.get("DEEPL_API_KEY", "").strip()
except:
pass
if deepl_key:
deepl_map = { "zh-CN": "ZH", "en": "EN", "ja": "JA", "ko": "KO", "fr": "FR", "de": "DE", "es": "ES", "ru": "RU" }
d_tl = deepl_map.get(tl, "EN")
url = "https://api-free.deepl.com/v2/translate" if ":fx" in deepl_key else "https://api.deepl.com/v2/translate"
payload = urllib.parse.urlencode({
"auth_key": deepl_key,
"text": text,
"target_lang": d_tl
}).encode('utf-8')
req = urllib.request.Request(url, data=payload)
with urllib.request.urlopen(req, timeout=5) as resp:
result = json.loads(resp.read().decode('utf-8'))
return web.json_response({"translated": result["translations"][0]["text"]})
else:
# Fallback to Google Translate (free, no key)
url = f"https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl={tl}&dt=t&q={urllib.parse.quote(text)}"
req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
with urllib.request.urlopen(req, timeout=5) as resp:
result = json.loads(resp.read().decode('utf-8'))
# Google Translate returns a list of fragments
translated_text = "".join([part[0] for part in result[0]])
return web.json_response({"translated": translated_text})
except Exception as e:
print(f"Translate Error: {e}")
return web.json_response({"translated": text, "error": str(e)})
async def api_get_gallery_images(request):
try:
output_dir = folder_paths.get_output_directory()
if not os.path.exists(output_dir):
return web.json_response({"images": [], "total": 0, "page": 1, "pages": 0})
page = max(1, int(request.query.get('page', 1)))
limit = min(200, max(1, int(request.query.get('limit', 50))))
valid_exts = {'.png', '.jpg', '.jpeg', '.webp', '.gif'}
def collect_images():
images = []
for root, dirs, files in os.walk(output_dir):
for f in files:
ext = os.path.splitext(f)[1].lower()
if ext in valid_exts:
rel_path = os.path.relpath(root, output_dir)
subfolder = "" if rel_path == "." else rel_path.replace('\\', '/')
full_path = os.path.join(root, f)
try:
mtime = os.path.getmtime(full_path)
except OSError:
mtime = 0
images.append({
"filename": f,
"subfolder": subfolder,
"type": "output",
"mtime": mtime
})
images.sort(key=lambda x: x['mtime'], reverse=True)
return images
images = await asyncio.to_thread(collect_images)
total = len(images)
start_idx = (page - 1) * limit
end_idx = start_idx + limit
paginated_images = images[start_idx:end_idx]
return web.json_response({
"images": paginated_images,
"total": total,
"page": page,
"pages": (total + limit - 1) // limit if limit > 0 else 0
})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
async def api_delete_gallery_image(request):
try:
data = await request.json()
filename = data.get("filename")
subfolder = data.get("subfolder", "")
output_dir = folder_paths.get_output_directory()
try:
filename = require_filename(filename)
target_dir = resolve_within(output_dir, subfolder)
file_path = resolve_within(target_dir, filename)
except ValueError:
return web.json_response({"status": "error", "message": "Invalid parameters"}, status=400)
if os.path.exists(file_path):
os.remove(file_path)
return web.json_response({"status": "success"})
else:
return web.json_response({"status": "error", "message": "File not found"})
except Exception as e:
return web.json_response({"status": "error", "message": str(e)}, status=500)
def read_png_text_fast(path):
try:
with open(path, 'rb') as f:
signature = f.read(8)
if signature != b'\x89PNG\r\n\x1a\n':
return None
while True:
length_bytes = f.read(4)
if not length_bytes: break
length = struct.unpack('>I', length_bytes)[0]
chunk_type = f.read(4)
if chunk_type == b'tEXt':
data = f.read(length)
keyword, text = data.split(b'\0', 1)
if keyword == b'prompt':
return text.decode('utf-8', errors='ignore')
else:
f.seek(length, 1)
f.seek(4, 1)
except Exception:
pass
return None
async def api_get_model_images(request):
model_name = request.rel_url.query.get('model_name', '')
if not model_name:
return web.json_response({'images': []})
base_target = os.path.basename(model_name).lower()
# We will search the output directory
output_dir = folder_paths.get_output_directory()
if not os.path.exists(output_dir):
return web.json_response({'images': []})
def find_images():
matched_images = []
for root, _, files in os.walk(output_dir):
for file in files:
if not file.lower().endswith('.png'):
continue
full_path = os.path.join(root, file)
prompt_text = read_png_text_fast(full_path)
if not prompt_text:
continue
try:
prompt_data = json.loads(prompt_text)
matched = any(
os.path.basename(v).lower() == base_target
for node in prompt_data.values()
if isinstance(node, dict) and 'class_type' in node
for v in node.get('inputs', {}).values()
if isinstance(v, str)
)
if not matched:
continue
rel_path = os.path.relpath(root, output_dir).replace('\\', '/')
if rel_path == '.':
rel_path = ''
url = f'/view?filename={urllib.parse.quote(file)}&type=output'
if rel_path:
url += f'&subfolder={urllib.parse.quote(rel_path)}'
matched_images.append({'url': url, 'mtime': os.path.getmtime(full_path)})
except (OSError, ValueError, TypeError, json.JSONDecodeError):
pass
matched_images.sort(key=lambda x: x['mtime'], reverse=True)
return matched_images
matched_images = await asyncio.to_thread(find_images)
return web.json_response({'images': matched_images})
def get_folder_view_mode():
script_dir = os.path.dirname(os.path.abspath(__file__))
config_path = os.path.join(script_dir, "config.json")
try:
if os.path.exists(config_path):
with open(config_path, 'r', encoding='utf-8') as f:
cfg = json.load(f)
return cfg.get("folder_view_mode", "abstract")
except:
pass
return "abstract"
def get_all_physical_basenames():
basenames = set()
for t in folder_paths.folder_names_and_paths.keys():
try:
paths = folder_paths.get_folder_paths(t)
if not paths: continue
for p in paths:
bn = os.path.basename(os.path.normpath(p))
if bn: basenames.add(bn)
except:
pass
return list(basenames)
def get_active_physical_basenames():
script_dir = os.path.dirname(os.path.abspath(__file__))
config_path = os.path.join(script_dir, "config.json")
all_bns = get_all_physical_basenames()
active = []
configured = set()
try:
if os.path.exists(config_path):
with open(config_path, 'r', encoding='utf-8') as f:
cfg = json.load(f)
pfc = cfg.get("physical_folders_config")
if pfc and isinstance(pfc, list):
for item in pfc:
bn = item.get("type")
if bn in all_bns:
configured.add(bn)
if item.get("visible", True):
active.append(bn)
for bn in all_bns:
if bn not in configured:
active.append(bn)
return active
except:
pass
return all_bns
def get_active_scan_paths():
mode = get_folder_view_mode()
paths = set()
if mode == "physical":
active_bns = set(get_active_physical_basenames())
for t in folder_paths.folder_names_and_paths.keys():
try:
ps = folder_paths.get_folder_paths(t)
if not ps: continue
for p in ps:
if not os.path.exists(p): continue
bn = os.path.basename(os.path.normpath(p))
if bn in active_bns:
paths.add(os.path.realpath(p))
except:
pass
else:
active_types = get_active_folder_types()
for t in active_types:
try:
ps = folder_paths.get_folder_paths(t)
if not ps: continue
for p in ps:
if not os.path.exists(p): continue
paths.add(os.path.realpath(p))
except:
pass
return list(paths)
def get_active_folder_types():
script_dir = os.path.dirname(os.path.abspath(__file__))
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']
try:
if os.path.exists(config_path):
with open(config_path, 'r', encoding='utf-8') as f:
cfg = json.load(f)
ftc = cfg.get("folder_types_config")
if ftc and isinstance(ftc, list):
active = []
configured_types = set()
for item in ftc:
t = item.get("type")
if t in all_types:
configured_types.add(t)
if item.get("visible", True):
active.append(t)
# For any newly registered types not in config, do not show them by default
# unless they are in default_types
for t in all_types:
if t not in configured_types and t in default_types:
active.append(t)
return active
except Exception:
pass
# Fallback if no config exists
active = [t for t in default_types if t in all_types]
return active
async def api_get_all_folder_types(request):
mode = get_folder_view_mode()
script_dir = os.path.dirname(os.path.abspath(__file__))
config_path = os.path.join(script_dir, "config.json")
result = []
if mode == "physical":
all_bns = get_all_physical_basenames()
configured = set()
try:
if os.path.exists(config_path):
with open(config_path, 'r', encoding='utf-8') as f:
cfg = json.load(f)
pfc = cfg.get("physical_folders_config")
if pfc and isinstance(pfc, list):
for item in pfc:
bn = item.get("type")
if bn in all_bns:
configured.add(bn)
result.append({
"type": bn,
"visible": item.get("visible", True)
})
except:
pass
default_physical_types = ['checkpoints', 'loras', 'unet', 'diffusion_models', 'controlnet', 'vae']
for bn in all_bns:
if bn not in configured:
result.append({
"type": bn,
"visible": bn in default_physical_types
})
else:
# Abstract mode
all_types = list(folder_paths.folder_names_and_paths.keys())
default_types = ['checkpoints', 'loras', 'diffusion_models', 'controlnet', 'vae']
configured_types = set()
try:
if os.path.exists(config_path):
with open(config_path, 'r', encoding='utf-8') as f:
cfg = json.load(f)
ftc = cfg.get("folder_types_config")
if ftc and isinstance(ftc, list):
for item in ftc:
t = item.get("type")
if t in all_types:
configured_types.add(t)
result.append({
"type": t,
"visible": item.get("visible", True)
})
except:
pass
for t in all_types:
if t not in configured_types:
result.append({
"type": t,
"visible": t in default_types
})
return web.json_response({"folder_types": result, "folder_view_mode": mode})
api_get_gallery_images = _gallery.api_get_gallery_images
api_delete_gallery_image = _gallery.api_delete_gallery_image
+364
View File
@@ -0,0 +1,364 @@
"""Plugin version display, published-release listing and version switching.
The plugin is normally installed as a git clone (ComfyUI Manager), so the
installed version is read from git and switching checks out a published
release tag. Nothing runs automatically: every network call starts from a
button in the UI. Switching refuses when tracked files have local changes, and
only tags that are published releases (or at least exist on the remote) can be
checked out.
"""
import asyncio
import json
import os
import re
import shutil
import subprocess
import threading
import time
import urllib.request
from aiohttp import web
PLUGIN_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
STATE_FILE = os.path.join(PLUGIN_DIR, ".anomalous_version.local.json")
# A version whose tree contains this file has the version panel; older ones do not.
SWITCHER_MARKER = "api/version_manager.py"
TAG_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._\-]{0,99}$")
GITHUB_REMOTE = re.compile(r"github\.com[:/]+([^/]+)/([^/]+?)(?:\.git)?/?$", re.IGNORECASE)
RELEASES_CACHE_SECONDS = 300
NETWORK_TIMEOUT = 10
FETCH_TIMEOUT = 180
_lock = threading.Lock()
_releases_cache = {"expires": 0, "data": None}
class VersionError(Exception):
"""A user-facing failure with a stable code for the UI."""
def __init__(self, code, message="", **details):
super().__init__(message or code)
self.code = code
self.details = details
# ---------- git helpers ----------
def _git_executable():
found = shutil.which("git")
if not found:
raise VersionError("git_missing", "git executable not found")
return found
def _git(*args, timeout=30, check=True):
kwargs = {}
if os.name == "nt":
kwargs["creationflags"] = getattr(subprocess, "CREATE_NO_WINDOW", 0)
env = dict(os.environ, GIT_TERMINAL_PROMPT="0")
try:
result = subprocess.run(
[_git_executable(), "-C", PLUGIN_DIR, *args],
capture_output=True, text=True, encoding="utf-8", errors="replace",
timeout=timeout, env=env, **kwargs,
)
except subprocess.TimeoutExpired:
raise VersionError("git_timeout", f"git {' '.join(args[:2])} timed out")
if check and result.returncode != 0:
raise VersionError("git_failed", (result.stderr or result.stdout).strip()[-500:])
return result
def _git_out(*args, **kwargs):
result = _git(*args, check=False, **kwargs)
return result.stdout.strip() if result.returncode == 0 else ""
def version_key(tag):
"""Sortable key for tags such as v1.4.2, v1.57-beta, v1.58.0-rc1 (stable after pre-release)."""
match = re.match(r"^v?(\d+(?:\.\d+)*)(.*)$", str(tag or ""))
if not match:
return ((), 0, str(tag))
numbers = tuple(int(part) for part in match.group(1).split("."))
suffix = match.group(2).lstrip("-_.")
return (numbers, 1 if not suffix else 0, suffix)
def current_state():
"""Local only (no network): what is installed and whether it can be switched."""
if not os.path.isdir(os.path.join(PLUGIN_DIR, ".git")) and _git_out("rev-parse", "--is-inside-work-tree") != "true":
return {"is_git": False}
commit = _git_out("rev-parse", "HEAD")
branch = _git_out("symbolic-ref", "--short", "-q", "HEAD") or None
exact_tag = _git_out("describe", "--tags", "--exact-match", "HEAD") or None
described = _git_out("describe", "--tags", "--always")
base_tag = _git_out("describe", "--tags", "--abbrev=0") or None
# Porcelain lines are "XY path"; keep the leading status column (no strip before slicing).
status = _git("status", "--porcelain", "--untracked-files=no", check=False).stdout
changed = [line[3:] for line in status.splitlines() if line.strip()]
return {
"is_git": True,
"commit": commit[:12],
"branch": branch,
"tag": exact_tag,
"base_tag": base_tag,
"label": exact_tag or described or commit[:12],
"dirty": bool(changed),
"changed_files": changed[:20],
"remote": _git_out("remote", "get-url", "origin") or None,
"previous": _read_state().get("previous"),
}
# ---------- releases ----------
def _github_repo(remote_url):
match = GITHUB_REMOTE.search(remote_url or "")
return (match.group(1), match.group(2)) if match else None
def _fetch_github_releases(owner, repo):
url = f"https://api.github.com/repos/{owner}/{repo}/releases?per_page=50"
request = urllib.request.Request(url, headers={
"Accept": "application/vnd.github+json",
"User-Agent": "Anomalous-Model-Browser",
})
with urllib.request.urlopen(request, timeout=NETWORK_TIMEOUT) as response:
payload = json.loads(response.read().decode("utf-8"))
releases = []
for item in payload if isinstance(payload, list) else []:
if item.get("draft") or not item.get("tag_name"):
continue
releases.append({
"tag": item["tag_name"],
"name": item.get("name") or item["tag_name"],
"published_at": item.get("published_at"),
"prerelease": bool(item.get("prerelease")),
"notes": (item.get("body") or "")[:8000],
"url": item.get("html_url"),
})
return releases
def _remote_tags():
output = _git_out("ls-remote", "--tags", "--refs", "origin", timeout=NETWORK_TIMEOUT * 3)
tags = [line.split("refs/tags/", 1)[1] for line in output.splitlines() if "refs/tags/" in line]
return [{"tag": tag, "name": tag, "published_at": None, "prerelease": False, "notes": "", "url": None} for tag in tags]
def list_releases(force=False):
"""Published releases, newest first. GitHub API first, `git ls-remote` (uses git's proxy) as fallback."""
state = current_state()
if not state.get("is_git"):
raise VersionError("not_git", "The plugin is not a git checkout")
now = time.monotonic()
if not force and _releases_cache["data"] and now < _releases_cache["expires"]:
releases, source = _releases_cache["data"]
else:
releases, source = None, None
repo = _github_repo(state.get("remote"))
if repo:
try:
releases, source = _fetch_github_releases(*repo), "github"
except Exception:
releases = None
if releases is None:
releases, source = _remote_tags(), "git"
if not releases:
raise VersionError("offline", "Could not reach GitHub or the git remote")
releases.sort(key=lambda item: (item["published_at"] or "", version_key(item["tag"])), reverse=True)
if source == "git":
releases.sort(key=lambda item: version_key(item["tag"]), reverse=True)
_releases_cache.update(expires=now + RELEASES_CACHE_SECONDS, data=(releases, source))
current_key = version_key(state.get("tag") or state.get("base_tag"))
stable = [item for item in releases if not item["prerelease"]]
latest = stable[0]["tag"] if stable else None
result = []
for item in releases:
key = version_key(item["tag"])
entry = dict(item)
entry["current"] = item["tag"] == state.get("tag")
entry["latest"] = item["tag"] == latest
entry["direction"] = "same" if entry["current"] else ("newer" if key > current_key else "older")
result.append(entry)
return {"state": state, "releases": result, "latest": latest, "source": source}
# ---------- switching ----------
def _read_state():
try:
with open(STATE_FILE, "r", encoding="utf-8") as handle:
data = json.load(handle)
return data if isinstance(data, dict) else {}
except (OSError, ValueError):
return {}
def _write_state(data):
temporary = STATE_FILE + ".tmp"
with open(temporary, "w", encoding="utf-8") as handle:
json.dump(data, handle, ensure_ascii=False, indent=2)
os.replace(temporary, STATE_FILE)
def _require_switchable():
state = current_state()
if not state.get("is_git"):
raise VersionError("not_git", "The plugin is not a git checkout")
if state["dirty"]:
raise VersionError("dirty", "Tracked files have local changes", files=state["changed_files"])
return state
def _known_tag(tag):
if not TAG_NAME.match(str(tag or "")):
raise VersionError("bad_tag", "Invalid version name")
tags = {item["tag"] for item in list_releases()["releases"]}
if tag not in tags:
raise VersionError("unknown_tag", f"{tag} is not a published version")
return tag
def _fetch_tag(tag):
_git("fetch", "--no-tags", "--force", "origin", f"+refs/tags/{tag}:refs/tags/{tag}", timeout=FETCH_TIMEOUT)
def preview_switch(tag):
"""Fetch the target and report what switching would mean, without changing anything."""
state = _require_switchable()
_known_tag(tag)
_fetch_tag(tag)
has_switcher = _git("cat-file", "-e", f"refs/tags/{tag}:{SWITCHER_MARKER}", check=False).returncode == 0
target_key = version_key(tag)
current_key = version_key(state.get("tag") or state.get("base_tag"))
return {
"tag": tag,
"direction": "same" if tag == state.get("tag") else ("newer" if target_key > current_key else "older"),
"has_switcher": has_switcher,
"from": state["label"],
"from_branch": state["branch"],
}
def switch_to(tag):
with _lock:
state = _require_switchable()
_known_tag(tag)
_fetch_tag(tag)
previous = {"commit": _git_out("rev-parse", "HEAD"), "branch": state["branch"], "label": state["label"]}
_git("-c", "advice.detachedHead=false", "checkout", "--quiet", "--detach", f"refs/tags/{tag}")
_write_state({"previous": previous, "switched_at": time.time()})
return {"state": current_state(), "restart_required": True}
def _default_branch():
ref = _git_out("symbolic-ref", "--short", "refs/remotes/origin/HEAD")
if ref.startswith("origin/"):
return ref.split("/", 1)[1]
for name in ("main", "master"):
if _git("ls-remote", "--exit-code", "--heads", "origin", name, check=False, timeout=NETWORK_TIMEOUT * 3).returncode == 0:
return name
raise VersionError("no_default_branch", "Could not determine the default branch")
def return_to_latest():
"""Back to the default branch at its newest commit; the same path ComfyUI Manager's update takes."""
with _lock:
state = _require_switchable()
branch = _default_branch()
_git("fetch", "--no-tags", "origin", f"+refs/heads/{branch}:refs/remotes/origin/{branch}", timeout=FETCH_TIMEOUT)
previous = {"commit": _git_out("rev-parse", "HEAD"), "branch": state["branch"], "label": state["label"]}
if _git("rev-parse", "--verify", "--quiet", f"refs/heads/{branch}", check=False).returncode == 0:
_git("checkout", "--quiet", branch)
else:
_git("checkout", "--quiet", "-b", branch, f"origin/{branch}")
merge = _git("merge", "--ff-only", "--quiet", f"origin/{branch}", check=False)
if merge.returncode != 0:
raise VersionError("diverged", f"Local {branch} has commits that are not on the remote")
_write_state({"previous": previous, "switched_at": time.time()})
return {"state": current_state(), "restart_required": True}
def undo_last_switch():
with _lock:
previous = _read_state().get("previous")
if not previous or not previous.get("commit"):
raise VersionError("no_previous", "There is no switch to undo")
state = _require_switchable()
here = {"commit": _git_out("rev-parse", "HEAD"), "branch": state["branch"], "label": state["label"]}
if previous.get("branch"):
# Going back to a branch means its current tip, even if it moved since (e.g. a Manager update).
_git("checkout", "--quiet", previous["branch"])
else:
_git("-c", "advice.detachedHead=false", "checkout", "--quiet", "--detach", previous["commit"])
_write_state({"previous": here, "switched_at": time.time()})
return {"state": current_state(), "restart_required": True}
# ---------- routes ----------
def _error_response(error):
status = 409 if error.code in {"dirty", "diverged"} else 400
if error.code in {"offline", "git_timeout", "git_failed"}:
status = 502
return web.json_response({"success": False, "code": error.code, "error": str(error), **error.details}, status=status)
async def _run(handler, *args):
try:
return web.json_response({"success": True, **(await asyncio.to_thread(handler, *args))})
except VersionError as error:
return _error_response(error)
async def _json_body(request):
try:
data = await request.json()
except Exception:
return {}
return data if isinstance(data, dict) else {}
async def api_get_version(request):
"""GET /anomalous/version - Installed version (local, no network)."""
try:
return web.json_response({"success": True, "state": await asyncio.to_thread(current_state)})
except VersionError as error:
return _error_response(error)
async def api_get_releases(request):
"""GET /anomalous/version/releases?refresh=1 - Published releases (network, only when asked)."""
return await _run(list_releases, request.query.get("refresh") == "1")
async def api_preview_switch(request):
"""POST /anomalous/version/preview {tag}"""
return await _run(preview_switch, str((await _json_body(request)).get("tag", "")))
async def api_switch_version(request):
"""POST /anomalous/version/switch {tag}"""
return await _run(switch_to, str((await _json_body(request)).get("tag", "")))
async def api_return_to_latest(request):
"""POST /anomalous/version/latest"""
return await _run(return_to_latest)
async def api_undo_switch(request):
"""POST /anomalous/version/undo"""
return await _run(undo_last_switch)
def register_routes(app):
app.router.add_get('/anomalous/version', api_get_version)
app.router.add_get('/anomalous/version/releases', api_get_releases)
app.router.add_post('/anomalous/version/preview', api_preview_switch)
app.router.add_post('/anomalous/version/switch', api_switch_version)
app.router.add_post('/anomalous/version/latest', api_return_to_latest)
app.router.add_post('/anomalous/version/undo', api_undo_switch)
+219
View File
@@ -0,0 +1,219 @@
"""Pure Workflow Recipe graph validation and fingerprinting."""
import hashlib
import json
import re
from .recipe_constants import *
def _normalise_workflow_key(value):
return re.sub(r"[^a-z0-9]+", "_", str(value).strip().lower()).strip("_")
def _volatile_widget_indexes(node):
"""Known serialized LiteGraph widget positions for native sampler seeds."""
node_type = _node_type(node).lower()
if node_type == "ksampler":
return (0,)
if node_type == "ksampleradvanced":
return (1,)
return ()
def _clean_volatile_params(value):
"""Produce a stable graph view while retaining all generation-defining data."""
if isinstance(value, list):
return [_clean_volatile_params(item) for item in value]
if not isinstance(value, dict):
return value
cleaned = {}
for key, nested_value in value.items():
if _normalise_workflow_key(key) in VOLATILE_WORKFLOW_KEYS:
continue
cleaned[str(key)] = _clean_volatile_params(nested_value)
widgets = cleaned.get("widgets_values")
if isinstance(widgets, list):
for index in _volatile_widget_indexes(value):
if 0 <= index < len(widgets):
widgets[index] = "__anomalous_volatile_seed__"
return cleaned
def _workflow_fingerprint(workflow):
"""Hash the workflow structure after removing known run-volatile values."""
canonical = json.dumps(
_clean_volatile_params(workflow),
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
return {
"algorithm": "sha256-structural-v1",
"value": hashlib.sha256(canonical).hexdigest(),
}
def _parameter_signature(workflow):
"""Hash node types and parameter values for UI workflows or API prompts."""
if not isinstance(workflow, dict):
return {"algorithm": "sha256-params-v1", "value": ""}
nodes = workflow.get("nodes")
is_ui_workflow = isinstance(nodes, list)
if not is_ui_workflow:
nodes = list(workflow.values())
cleaned_nodes = []
for node in nodes:
if not isinstance(node, dict):
continue
if is_ui_workflow:
parameters = {"widgets": _clean_volatile_params(node.get("widgets_values") or [])}
widgets = parameters["widgets"]
if isinstance(widgets, list):
for index in _volatile_widget_indexes(node):
if 0 <= index < len(widgets):
widgets[index] = "__anomalous_volatile_seed__"
else:
parameters = {"inputs": _clean_volatile_params(node.get("inputs") or {})}
cleaned_nodes.append({
"type": _node_type(node),
**parameters,
})
cleaned_nodes.sort(key=lambda x: (x["type"], json.dumps(x, sort_keys=True, ensure_ascii=False)))
canonical = json.dumps(
cleaned_nodes,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
return {
"algorithm": "sha256-params-v1",
"value": hashlib.sha256(canonical).hexdigest(),
}
def _workflow_node_types(workflow):
"""Extract only node class names from either UI workflow or API prompt data."""
if not isinstance(workflow, dict):
return []
nodes = workflow.get("nodes")
if isinstance(nodes, list):
values = nodes
else:
values = workflow.values()
result = []
for node in values:
if not isinstance(node, dict):
continue
node_type = _node_type(node) or str(node.get("class_type") or "").strip()
if node_type:
result.append(node_type)
return sorted(result, key=lambda value: value.casefold())
def _workflow_node_signature(workflow):
"""Stable node-composition signature used for tolerant result discovery."""
canonical = json.dumps(
_workflow_node_types(workflow),
ensure_ascii=False,
separators=(",", ":"),
).encode("utf-8")
return {
"algorithm": "sha256-node-types-v1",
"value": hashlib.sha256(canonical).hexdigest(),
}
def _node_type(node):
return str(node.get("type") or node.get("class_type") or "").strip()
def _node_title(node):
meta = node.get("_meta") if isinstance(node.get("_meta"), dict) else {}
return str(meta.get("title") or node.get("title") or _node_type(node) or "Unknown node").strip()
def _widget_values(node):
values = node.get("widgets_values")
return values if isinstance(values, list) else []
def _workflow_node_key(value):
if isinstance(value, bool) or not isinstance(value, (int, str)):
raise ValueError("Invalid workflow node id")
value = str(value).strip()
if not value:
raise ValueError("Invalid workflow node id")
return value
def _workflow_link_records(workflow):
links = workflow.get("links", [])
if links is None:
return []
if isinstance(links, list):
return links
if isinstance(links, dict):
return list(links.values())
raise ValueError("Invalid workflow links")
def _validate_workflow(workflow):
"""Reject malformed graph topology before it reaches user recipe storage."""
if not isinstance(workflow, dict):
raise ValueError("Invalid recipe workflow")
nodes = workflow.get("nodes")
if not isinstance(nodes, list) or len(nodes) > MAX_WORKFLOW_NODES:
raise ValueError("Invalid workflow nodes")
node_ids = set()
for node in nodes:
if not isinstance(node, dict):
raise ValueError("Invalid workflow node")
node_key = _workflow_node_key(node.get("id"))
if node_key in node_ids:
raise ValueError("Duplicate workflow node id")
node_ids.add(node_key)
values = node.get("widgets_values")
if values is not None and (not isinstance(values, list) or len(values) > MAX_WIDGET_VALUES_PER_NODE):
raise ValueError("Invalid workflow widget values")
groups = workflow.get("groups", [])
if groups is not None and (not isinstance(groups, list) or len(groups) > MAX_WORKFLOW_GROUPS):
raise ValueError("Invalid workflow groups")
links = _workflow_link_records(workflow)
if len(links) > MAX_WORKFLOW_LINKS:
raise ValueError("Too many workflow links")
for link in links:
if isinstance(link, list) and len(link) >= 5:
origin_id, target_id = link[1], link[3]
elif isinstance(link, dict):
origin_id, target_id = link.get("origin_id"), link.get("target_id")
else:
raise ValueError("Invalid workflow link")
if _workflow_node_key(origin_id) not in node_ids or _workflow_node_key(target_id) not in node_ids:
raise ValueError("Dangling workflow link")
def _recipe_receipt(recipe, filename):
"""Return a small, user-visible confirmation for the accepted graph."""
workflow = recipe.get("workflow") if isinstance(recipe, dict) else {}
params = recipe.get("params") if isinstance(recipe, dict) else {}
return {
"filename": filename,
"node_count": len(workflow.get("nodes", [])) if isinstance(workflow, dict) else 0,
"link_count": len(_workflow_link_records(workflow)) if isinstance(workflow, dict) else 0,
"group_count": len(workflow.get("groups", [])) if isinstance(workflow.get("groups", []), list) else 0,
"parameter_node_count": len(params.get("nodes", [])) if isinstance(params, dict) and isinstance(params.get("nodes", []), list) else 0,
"pinned_count": len(params.get("pinned", [])) if isinstance(params, dict) and isinstance(params.get("pinned", []), list) else 0,
"workflow_fingerprint": recipe.get("workflow_fingerprint") if isinstance(recipe, dict) else None,
}
+24
View File
@@ -0,0 +1,24 @@
# Architecture Topic Guide
Start with the repository-root [`ARCHITECTURE.md`](../../ARCHITECTURE.md), then
open only the topic needed for the current task.
- [`backend.md`](backend.md): Python routes, filesystem safety, persistence,
scan state, model sidecars, and backend performance.
- [`frontend.md`](frontend.md): extension bootstrap, UI ownership, localization,
media lifecycle, panel state, and graph mutations.
- [`recipes.md`](recipes.md): Workflow Recipes, packages, galleries, Parameter
Notebooks, prompt roles, and recipe-to-model transitions.
- [`material-library.md`](material-library.md): generated-image snapshots,
material assets, node-block reuse, and Model Doctor handoff.
- [`recipe-model-lifecycle.md`](recipe-model-lifecycle.md): authoritative model
fields, Hash synchronization, local matching, partial append, and note privacy.
- [`model-resolution.md`](model-resolution.md): model provenance, Model Doctor,
hash injection, deep scanning, and recovery policy.
- [`../decisions/README.md`](../decisions/README.md): concise records of product
and architectural decisions that still explain current behavior.
These documents describe current contracts rather than implementation history.
For exact changes, use Git history. For user-visible releases, use
`CHANGELOG.md`. For recurring implementation traps, use
`.agents/logs/ai_lessons.md`.
+195
View File
@@ -0,0 +1,195 @@
# Backend Architecture
Read this document for changes to `api/`, `scraper.py`, model files and sidecars,
recipe persistence, or filesystem-facing routes.
## Runtime and route boundary
`__init__.py` exposes the frontend directory and imports the modular route
package. `api/__init__.py` registers `aiohttp` routes, all prefixed with
`/anomalous/`. Changes to Python modules require a full ComfyUI restart; a
frontend reload alone does not replace registered handlers or module state.
Route registration uses explicit module references. `tests/test_route_manifest.py`
locks the HTTP method/path pairs so internal ownership changes cannot silently
drop, duplicate, or rename an endpoint.
The backend owns filesystem authority. A browser-supplied path, filename,
category, output reference, recipe asset, or archive member is untrusted until
validated. Use the shared helpers in `api/path_utils.py` (also re-exported by the
small `api/utils.py` compatibility facade):
- `resolve_folder_subdir()` for configured model-folder boundaries;
- `resolve_within()` for containment below an owned root;
- `require_filename()` for single-file identifiers.
Never rely only on `..` rejection. Absolute Windows paths, UNC paths, alternate
separators, device paths, and symlinks can bypass naïve string checks. Media
routes also enforce an explicit extension allowlist.
Configured model scope belongs to `api.folder_types`. Category mode uses
`get_active_folder_types()`; folder-manager-facing library enumeration uses
`get_active_model_roots()` so physical mode can retain the exact registered
`type`, `path_idx`, and real root for every enabled directory. Real roots are
deduplicated without merging same-named files from different roots. Do not
hardcode a walk across `checkpoints`, `loras`, or another category. A directory
disabled in `config.json` must cause no walk or metadata I/O.
`/anomalous/all_scan_models` follows that current folder-manager mode and keeps
its paginated response plus `limit=0` full-list behavior. Its local source-link
inventory recognizes GGUF and PTH case-insensitively in addition to the existing
list formats. This route-level inventory does not expand global model-format,
scanner, metadata-parser, recovery, or cloud-identification support.
## Storage ownership
Runtime UI settings and newly stored API keys live in `api/config.json`.
`scraper.py` reads it first and may fall back to the legacy root `config.json`.
Secrets are never persisted to browser `localStorage`.
Workflow Recipes live below the active ComfyUI user directory in
`workflows/anomalous_recipes`; Parameter Notebooks live in
`workflows/anomalous_parameters`. They are user data, not repository assets.
Writes validate their bounded schema and use atomic replacement.
Backend modules follow route/domain/storage ownership. Model discovery,
identity recovery, metadata mutation, and cover media live in
`model_catalog.py`, `model_resolution.py`, `model_metadata.py`, and
`model_media.py`. Recipe graph rules, recipe shaping, image processing, and
persistence/history live in `workflow_schema.py`, `recipe_schema.py`,
`recipe_images.py`, and `recipe_store.py`; `recipes.py` is the HTTP facade.
Material shaping, private assets, and the single persistence lock/summary cache
live in `material_schema.py`, `material_assets.py`, and `material_store.py`;
`materials.py` owns request/response mapping and compatibility entry points.
The former mixed utility routes are separated: `media_routes.py` owns card
thumbnails and model/output media lookups, `gallery_routes.py` owns the bounded
output snapshot and deletion (searched through `image_search.py`, which reads
PNG text chunks only and caches per-image records by mtime), `translation_routes.py`
owns provider fallback, and `folder_types.py` owns configured visibility and scan scope.
`version_manager.py` runs git against the plugin checkout only (no shell, no
prompts, `CREATE_NO_WINDOW` on Windows) and accepts only tags that appear in the
published release list. It refuses switches while tracked files are modified,
never rewrites a local branch that has diverged from the remote, and reports
stable error codes (`dirty`, `diverged`, `offline`, `unknown_tag`, ...) that the UI
localizes. Switching changes code only; a ComfyUI restart applies it.
Offline inference sidecars use non-positive Civitai IDs as sentinels. Metadata
normalization must not expose those values as release-page URLs or resolved
model/version identities; only positive IDs may form a Civitai source link.
Prompt Notes use `workflows/anomalous_notebooks`. First access copies legacy
`api/notebooks` records without deleting originals or overwriting current notes.
Conflicts receive a deterministic recovered filename. A completion marker makes
the copy retryable after write failure and prevents deleted notes reappearing.
Invalid legacy records remain untouched and are listed in the migration marker.
Notebook I/O runs in a worker under its persistence lock; the UI queues snapshots
and reports failed saves instead of showing success. `api.path_utils.atomic_write_json`
owns bounded, flushed temporary writes followed by atomic file replacement.
The recipe card endpoint returns lightweight metadata. Full graphs and history
are fetched only for detail, edit, compare, restore, export, or another operation
that needs them. Successful save, update, and restore responses include a compact
integrity receipt containing graph counts and the persisted workflow fingerprint.
Package import is inspect-then-commit. `recipe_packages.py` accepts only bounded
ZIP packages with a manifest, recipe JSON, declared contained WebP assets, and
optional bounded history. It rejects traversal, symlinks, undeclared members,
archive bombs, and checksum failures; stages all content before the final rename;
and restores the previous recipe set if replacement commit fails. Packages never
install code or dependencies.
## Event-loop and scan-state rules
Potentially large disk work does not run on the `aiohttp` event loop. Recursive
walks, bulk metadata parsing, full-file hashing, and similar operations use
`asyncio.to_thread()` or the established worker process/thread path. Reading a
small bounded safetensors header is acceptable; reading a whole multi-gigabyte
model to discover metadata is not.
Background work claims state before launching. Folder scans use
`.scan_in_progress`; global quick scans use `.global_scan_in_progress`; deep
missing-model scans use `GLOBAL_SCAN_STATE` and the corresponding status route.
Marker files are versioned JSON records containing backend session, owner PID,
worker PID, and job ID. Status checks validate ownership and process liveness;
file existence alone is not proof that a scan is active.
A marker owned by a dead process, or a legacy plain-text marker, represents an
interrupted job. Its marker, progress file, and pending selection may be removed
so a restart does not permanently lock scanning. A still-running orphan worker
remains locked until that recorded process exits.
`scraper.py --progress-file` atomically publishes enumeration state, selected
file count, current index, and filename. Status responses merge worker progress
with parent-owned folder progress. The frontend can reconstruct this state by
polling after its UI has been reopened.
## Metadata and cache behavior
The output gallery keeps one ordered directory snapshot for at most ten seconds
and 50,000 images. Page requests reuse it; explicit refresh and image deletion
invalidate it immediately. Larger inventories are returned normally but not cached.
Metadata and embedded safetensors-header hashes may be cached only in a bounded
cache. The key includes the model's real path and the physical `size`, `mtime_ns`,
and `ctime_ns` signatures of the model and relevant sidecars. Return independent
copies; never expose cached mutable dictionaries or lists.
Folder listing inventories a directory once with `os.scandir()` and preserves
preview priority: `.preview.*` before bare same-stem media. Avoid an `exists()`
sequence for every candidate when the directory inventory already has the
answer.
Preview URLs use the selected preview file's stable nanosecond modification time
as the cache version. Do not append `Date.now()` or random request tokens during
ordinary listing. A changed file must change its URL; an unchanged file should
remain browser-cacheable.
Preview lookup tries a contained exact relative path first and recursively walks
the library only for unresolved basename fallbacks. This locates presentation
for a model value already supplied by ComfyUI; it is not Model Doctor discovery.
Balanced grid thumbnails are derived, longest-edge 512 px WebP files in
ComfyUI's temporary area. Their cache is keyed by source real path and physical
signature and capped at 256 MiB with oldest-accessed eviction. The original mode
serves source covers, and detail views always use originals. Derived media never
modifies or sits beside a user's cover; unsupported, animated, or failed inputs
fall back to the original.
## Model sidecar and cover lifecycle
Sidecar handling is non-negotiable because it can destroy user metadata or
media.
- `.civitai_bak.*` is a persistent restore source created from a real Civitai
download. Setting a custom cover changes only `.preview.*`.
- Cover reset first restores `.civitai_bak.*` to `.preview.*`. Otherwise it may
remove `.preview.*` only when a bare original cover can take over. If the
preview is the only image, preserve it and return a visible warning. Reset
never silently downloads from the network.
- Physical rename moves every recognized sidecar to the new model stem. Model
deletion may clean sidecars only after the main model is successfully deleted.
- Foundation components—`vae`, `vae_approx`, `clip`, `text_encoders`, and
`clip_vision`—are never physical-rename targets. UI and all backend entry
points enforce the same denial.
- Main model extensions (`.safetensors`, `.ckpt`, `.pt`, `.bin`) are never
sidecar suffixes. Cleanup must not delete a same-stem model with another
extension. If such a sibling remains, ambiguous stem-keyed sidecars remain.
- Rename/delete/reset uses centralized immutable suffix tuples and a constant
number of exact-path checks. Do not walk, glob, index, hash, or call the
network for these operations.
Keep product wording precise: deletion cleans sidecars; rename migrates them.
## Backend implementation rules
- Prefer standard-library and existing ComfyUI facilities; missing optional
dependencies must fail as a localized capability, not break plugin import.
- Use `os.path`, `pathlib`, `os.sep`, or normalization helpers for paths. Avoid
hand-written backslash replacement.
- Update an existing `.civitai.info` dictionary rather than overwriting it with
a one-field object.
- Large input collections use a JSON `POST` body. Do not place them in a query
string or pass them as command-line arguments on Windows.
- The private local `tests/` directory is ignored and is not imported by runtime
code or shipped in the installable plugin.
+305
View File
@@ -0,0 +1,305 @@
# Frontend Architecture
Read this document for changes under `web/`, browser entry modes, panel state,
localization, media, or explicit canvas mutations.
## Bootstrap and module ownership
The verified AMB0/AMB1 workflow share-code Import / Export Center is available
from Toolbox through `window.AMB_WorkflowShare.showUnifiedModal()`. Both directions
are enabled by explicit product decision. It is independent of the paused Recipe
package import/export and closed Material Library file import. Image/workflow hash
injection, host saving, and ordinary image downloads remain unchanged.
ComfyUI loads JavaScript in the extension `WEB_DIRECTORY` as ES modules.
`web/main.js` registers `Anomalous.ModelBrowser` and coordinates host hooks.
`browser.js` defines the browser class and binds extracted feature methods;
`browser_entry.js` owns the single browser instance and all entry presentation;
`interface_settings.js` owns locale and theme preferences. A syntax error or duplicate
top-level declaration in any imported module can prevent registration and make
the entire entry disappear. For affected modules, validate syntax and module linking
via Node's experimental VM modules (`vm.SourceTextModule`) and verify
the real ComfyUI runtime creates the configured entry. Floating trigger styling in
the ordered stylesheet bundle rooted at `web/styles.css` uses dynamic `1em` SVG
scaling and flex centering to guarantee
consistent visual presentation across all configured trigger sizes.
Major UI panels live in `web/modules/ui_*.js`. Shared browser state remains on
the `AnomalousBrowser` instance. Pure parsing, normalization, comparison, and
transaction helpers remain in focused modules rather than acquiring DOM state.
`ui_sidebar.js` assembles the browser shell and folder navigation.
`ui_settings_hub.js` owns settings and model-card preferences, while
`ui_toolbox.js` owns the catalog, fixed shortcut actions, and tool dispatch.
The Material Library shortcut remains a native ComfyUI command/keybinding; a
deferred window-key fallback invokes the same command path only when the host
did not bring the library forward, so handled shortcuts are not executed twice.
`ui_browser_navigation.js` owns shared panel hiding, recoverable detail cleanup,
and workspace return. `ui_scan_wizard.js` owns scan configuration and scan-launch polling,
`ui_folder_manager.js` owns folder visibility/order and presentation-mode
changes, and `ui_help.js` owns the help dialog. Public entry functions remain
browser-instance methods so existing actions share current browser state.
Update-guide content and UI lifecycle are separate modules. The header and Help
open it only on explicit user action; browser close disposes it. Read
[update-guide.md](update-guide.md) before changing guide IDs, steps, persistence,
or sidebar icon/label transitions. Do not embed guide logic in the main/sidebar
bootstrap or reuse its content version as a feature flag.
The main surfaces are:
- Sidebar shell: navigation and persistent scan controls; focused child modules
own the scan wizard, folder manager, and help dialog.
- Grid and model detail: model browsing plus coordinated detail display in
`ui_detail.js`, metadata editing in `ui_model_editor.js`, and advanced selection
in `ui_model_selector.js`.
- Gallery: generated outputs and the full-screen Image Detail Studio Workbench.
`ui_gallery_detail.js` owns its singleton lifecycle and cache, `ui_image_stage.js`
owns header/zoom/filmstrip interaction, and `ui_image_inspector.js` owns metadata tabs.
- Workspace: Prompt Notes, Workflow Recipes, and the Material Library. Prompt Note
catalog/persistence, editing, and canvas creation are separated across
`ui_notebooks.js`, `ui_notebook_editor.js`, and `notebook_canvas.js`. Material
discovery/pagination, cards, detail, and node application are separated across
`ui_materials.js`, `ui_material_cards.js`, `ui_material_detail.js`, and
`ui_material_application.js`.
- Node Assistant/Model Doctor: selected-node actions, diagnostics, parameter
presets, and missing-model recovery. `ui_doctor.js` coordinates diagnosis and
global scans, `ui_node_assistant.js` owns assistant history, `ui_node_model_picker.js`
owns the native-widget model replacer, and `ui_node_presets.js` owns parameter
preset previews and application.
`ui_dom.js` owns generic `text` and `jsonResponse` helpers.
`material_inspector.js` shares image metadata helpers and node-parameter rendering
between the material and image-inspector modules. The workbench does not import
the library UI, keeping this dependency chain acyclic. Library discovery uses
server-side filters and pages with cancellable requests. Detail entry fetches
metadata and scoped node blocks; the complete workflow loads only on the explicit
open action. See `material-library.md` for the API and persistence contract.
## Entry modes and host integration
One shared `openBrowser()` command backs the floating trigger, ComfyUI action-bar
button, and `Extensions -> Anomalous Model Browser` command. Entry presentations
are mutually exclusive and reuse the same browser instance. The Extensions
command remains available as a recovery path in every mode.
Native commands own the default `Ctrl + Shift + M` browser binding and the
`Ctrl + Shift + L` Material Library binding. Shortcut customization delegates
to ComfyUI's command/keybinding panel and recorder; the plugin does not install
a parallel global keyboard listener or maintain a second shortcut preference.
Language changes replace the registered Interface setting descriptors with
freshly translated copies. ComfyUI's reactive settings tree therefore updates
the open category heading, labels, tooltips, custom controls, and combo options
immediately without requiring a page refresh.
Hidden reusable dialogs must not advertise an active modal state. In particular,
`aria-modal` is `true` only while the dialog is visible, so ComfyUI's global
modal guard does not suppress unrelated host shortcuts.
## Panel and lifecycle state
Main content surfaces are exclusive. Opening one must hide or clean the surface
it replaces, while preserving only the state required for an explicit return
path. Search from the sidebar exits model detail and shows the grid. Workspace
records the previously visible main-browser panel and restores it on close,
falling back to the grid when the prior state is unavailable.
Recipe-to-model navigation is a special recoverable transition described in
`recipes.md`. It keeps the outer browser open and must not call the browser-wide
`close()` lifecycle.
Grid folder navigation removes old content rather than accumulating hidden
grids. Before removing DOM that contains video or audio, pause the media and
release sources where applicable. Closing the browser aborts active listing,
disconnects observers, pauses media, and releases grid video/audio sources.
Lightweight cards may remain warm for 90 seconds, after which card DOM and model
payloads are released. A new listing cancels the previous request and render
generation.
The full ordered model result remains available, but cards are created in
bounded animation-frame chunks. Images use lazy loading and async decoding.
Grid video sources are attached only near the viewport and then follow the
configured `always` or `hover` behavior with muted, loop, and plays-inline
attributes.
When identifying video media from a served URL, inspect the full URL with a
query-aware extension test such as `/\.(mp4|webm)(?:$|\?|&|#)/i`. Do not use
`new URL(url).pathname`, because the backend may put the actual filename in a
query parameter.
## Model source component links
The source hub uses `inferModelFolderTypes` for workflow component labels and
category-scoped, exact-path local metadata reads. Context requests are batched
to respect the endpoint's 16-item limit. Missing source links and unavailable
native model choices are independent states; dynamic choices are not treated
as evidence of absence. Native extensionless component choices and PTH/GGUF
references remain visible even when the backend cannot locate an individual file.
`model_source_data.js` owns library-result shaping and the source hub's pure
main/component grouping and filtering. CLIP, text encoder, CLIP Vision, VAE, and
preview-VAE entries live in a session-local, default-collapsed disclosure; the
source-status pills and search affect both main models and components. Collapsed
component rows are not constructed. Main-model completion statistics exclude
optional component links, while the disclosure's missing-local count describes
only the currently filtered component matches.
Legacy Civitai placeholder links with non-positive model IDs are normalized to
an unfilled source before grouping, so offline inference records remain visible
under the source-needed filter.
Workflow save and Note generation always receive the complete workflow model
set; summary copy receives only the visible rows. Such links may be saved to the
workflow, but component sidecar writes require a resolved local target. Opening,
filtering, and expanding the hub do not initiate cloud scans, hash calculation,
or sidecar writes, and do not change graph/model identity recovery rules.
## Localization and DOM safety
`web/modules/locales.js` is the canonical catalog for user-visible runtime
strings. Resolve strings at render time through the shared translator. Chinese
and English dictionaries keep identical key sets, have no duplicate keys, and
cover all statically referenced translation keys.
Dynamic filenames, paths, model names, node types, counts, and user-authored
content remain parameters rather than dictionary entries. Insert untrusted text
with `textContent` or escaping. Only fields intentionally supporting trusted
formatting may pass through `setSafeRichHtml()` in `safe_dom.js`; never assign
metadata directly to `innerHTML`.
Persistent localized nodes that survive a language change carry an i18n key and
are refreshed in place or rebuilt from current state. Missing translation data
must not prevent a panel or action from rendering.
Scope plugin DOM IDs and CSS classes with `anomalous-`. Before introducing a
dynamic ID, search `styles.css` for collisions. Follow the established z-index
tiers and ensure a child modal sits above the parent that would otherwise consume
its clicks.
## Graph and widget mutation rules
Explicit graph mutations are transactional. Validate the entire intended change
before mutation, wrap it in `graph.beforeChange()` / `graph.afterChange()`, and
restore prior nodes, links, and widget values if any operation or callback fails.
A successful change marks graph and canvas dirty and emits the host change event
expected by dependent surfaces.
Partial Recipe Append clones serialized nodes, remaps IDs and links, supports
groups, and rolls back everything it created on failure. It never calls
`loadGraphData` on the live graph and rejects unsupported subgraph definitions
until they have a complete remapping path. Complete recipes use
`loadGraphData` intentionally so ComfyUI owns new-workflow canvas creation.
`graph_splice.js` handles deliberate MODEL/CLIP insertion. It analyzes declared
port types rather than slot indexes or display names. Ambiguous downstream
fan-out is rejected until a user can choose a branch. Picker candidates come
from the target widget's native combo values; metadata may filter or decorate
that set but may not introduce foreign category values.
Parameter application resolves a serialized match back to a live ComfyUI node
before accessing widgets or callbacks. It validates all target slots first,
skips volatile values, and calls the host's widget hooks with the current
four-argument contract. Serialized workflow records are never treated as live
widget objects.
## Optional capabilities
`web/hash_resolver.js` may hook a compatible graph serializer to carry model
provenance. If the host API is unavailable, it disables only that integration
with a useful warning. It must not prevent `main.js` from registering or remove
the visible browser entry.
Network-backed enrichment is explicit and recoverable. An unavailable Civitai
or translation service may produce a local error state; it must not block local
browsing, editing, or already stored data.
## Prompt Studio ownership
The studio supports copying prompt text and saving combinations to the local
Material Library. Standalone prompt-plan JSON export has been removed and the
Material Library file-import entry is closed.
Source cards automatically refresh on studio open, browser focus/visibility, and
every 30 seconds while the page is visible (scheduled after the previous request).
Saving a studio combination also refreshes immediately. `sourceKind: 'material'`
cards are reconciled by filename and role from a complete paginated snapshot;
renames/content edits replace them and deleted sources disappear. Failed or
cancelled reads retain existing cards. Equal text from different sources retains
each source identity. Built-in and unsaved local cards remain independent.
Library cards show their origin and have no delete action in the studio. Mixer
blocks are editable copies; refreshing sources must not modify existing drafts.
The deck owns its refresh timer, listeners and AbortController and releases all
of them when its view closes. New-card role is supplied by the workbench callback.
The studio has one standalone drawer; the former embedded side/full composer
and material import drawer are removed. Existing browser integration continues
to call `openPromptStudio(owner)`, and external prompt dispatch uses
`appendPromptToStudio(owner, text, isPositive, title)`.
| Module | Owned state and responsibilities |
| --- | --- |
| `ui_prompt_composer.js` | Active drawer, docking width/side, trigger visibility and plan-loading request |
| `ui_prompt_workbench.js` | Owner-backed draft, active role, block editing, ordering, write/copy/save |
| `ui_prompt_source_deck.js` | Source cards, filters, new-card form, preview popover and current sync request |
| `ui_prompt_inspector.js` | Full-text inspection window, role tab and its translation lifetime |
| `prompt_studio_data.js` | Starter cards, display categories, draft/block initialization and synthesis |
| `prompt_composition.js` | Pure plan conversion, sorting and composition; no DOM ownership |
| `prompt_material_source.js` | All-page prompt discovery, detail loading and reconciliation by source identity |
| `ui_lifecycle.js` | View-scoped listeners, AbortSignal, cleanup callbacks and resizing |
Child views receive their container and narrow callbacks. The workbench keeps
`owner.promptPlanDraft` for reopening and exposes only `updateAll` / `addBlock`
through `owner.sidePromptComposerControl`. The source deck keeps its state local
and returns extraction/refresh/sync actions instead of assigning new owner fields.
Draft `plan.parts` is the authoritative editable representation. Full-text
inspector edits merge the enabled blocks in the edited role into one block,
preserving disabled blocks and the opposite role. The inspector explains this
behavior before editing. Copy, save and reopen derive text from the same parts.
Every close route (button, Escape, backdrop, replacement and parent close) must
dispose the same view scope. Parent close also closes its inspector. An Escape
handled by an inspector must not close its parent in the same event dispatch.
Closing during resize releases move/up listeners and restores body cursor and
selection styles. UI reads are cancelled on close; a save already sent to the
server may still complete, but must not reopen or repaint a disposed view.
Initial and automatic source synchronization share a paginated loader. List
summaries are identifiers, not prompt bodies: each unique filename is resolved
through `material_prompt_data.js`. Reconciliation uses source filename and role,
so equal text from different materials retains each origin. A newer sync cancels the old
one and only the current result updates the deck.
The translator captures graph, node, widget and widget value before an async
write. It revalidates that destination and the input after translation; close,
selection changes and intervening widget edits invalidate the pending write.
Explicit English translation applies to all input languages, including kana
and Korean. Transport errors and rejected bridge responses remain local errors;
provider-specific validation belongs to the backend translation route.
## Visual styling and theme architecture
`styles.css` is the single external entry and ordered import manifest for current
visual values. Its `web/styles/00-*.css` through `10-*.css` children preserve the
original cascade order; every child import carries the same cache version so an
entry-cache hit cannot leave stale child rules. `tests/css_bundle_order.mjs`
guards the unique ordered list and byte-for-byte reconstructed bundle. Shared `--amb-*`
tokens express surfaces, text, borders and control shapes; theme overrides must
be scoped to `.theme-abyssal-scarlet` rather than changing unrelated surfaces.
Studio drawer rules keep the source deck at the screen edge and the assembly
track next to the canvas. Common geometry is shared between dock directions;
direction-specific rules set column order and separators. The removed embedded
composer's `#anomalous-container.anomalous-docked` overrides must not return.
Before adding an override or `!important`, locate and edit the owning rule.
Older component and theme overrides elsewhere in the ordered bundle still need a
separate, visually verified consolidation.
## Verification
Use `node --experimental-vm-modules tests/prompt_ui_lifecycle.mjs` for actual
module open/close, nested Escape, resize cleanup, block insertion and delayed
node writes. `tests/prompt_material_source.mjs` covers pagination, deduplication
and cancellation; `tests/translation_service_contracts.mjs` covers the HTTP
bridge. The UI fixture simulates DOM and ComfyUI APIs and does not replace
checking the real host, layout, focus, drag gestures and theme rendering.
+333
View File
@@ -0,0 +1,333 @@
# Material Library
The Material Library is a user-curated reuse layer. It does not replace
Workflow Recipes, Prompt Notes, Node Assistant, or Model Doctor. The initial
material kind is an image workflow snapshot captured from a generated PNG that
contains a complete ComfyUI UI workflow.
## Persistence and API ownership
`api/material_schema.py` owns record shaping, prompt-role rules and bounded
normalization; `api/material_assets.py` owns source inspection and private image
copies; `api/material_store.py` is the sole owner of persistence locking, the
summary cache, query/update/delete, and recipe-source resolution. `api/materials.py`
owns HTTP request/response mapping and narrow compatibility entry points. Records live under
`user/<profile>/workflows/anomalous_materials`; source PNG copies and bounded
WebP previews live in a private `.assets/<material-stem>/` directory.
The backend accepts output-image descriptors only after applying the shared
filename and containment checks. It reads bounded embedded metadata, requires a
valid UI workflow, copies at most 64 MiB of source image data, validates the
graph with the Recipe workflow validator, and writes JSON atomically. List
responses omit the full workflow. The explicit image-inspection route returns
the validated workflow plus summary node blocks only after the user opens one
image detail. This lets the workbench display exact `widgets_values`, bounded
node `properties`, mode, and volatile widget indexes without allocating a
second full-PNG buffer in the browser. Direct PNG parsing remains a temporary
compatibility fallback for an older running backend. Exact metadata is kept in
a small 16-entry in-memory LRU, and duplicate workflow/widget fields are
discarded before caching. Local-model preview resolution is deferred until the
Models tab is opened and then cached with that image's metadata. Asset reads
require both a valid material record and a contained private asset path.
Saving stages the image, preview, and JSON together below a private temporary
directory. Assets are promoted before the record becomes visible. A failed final
record commit removes only the newly promoted assets; staging is cleaned on exit.
Existing records and their images are never overwritten by this path.
Material discovery caches up to 4,096 summaries, keyed by the contained record's
real path, size, mtime, and ctime. It never caches full workflows. A directory
inventory detects additions/deletions and re-parses only changed records. Node-type
lookup filters summaries before opening matching workflows; asset authorization
uses the same validated summary cache. Callers receive independent summary copies.
The library requests 48 summaries per page. `materials` accepts `q` (name, tags,
or node type), `tag`, `kind`, `category`, exact `node_type`, `page`, and `limit` (at most 100), and returns
`total`, `page`, `pages`, and the library's available `tags`. Older callers without
page/limit retain their complete summary response. Name/tag edits use
`update_material`, preserve the source workflow, and atomically replace the record.
Tags are trimmed, deduplicated without case sensitivity, and limited to 20 tags
of 60 characters each; older records without tags remain valid.
`category` groups sources before pagination: `workflow` contains full snapshots;
`prompts` contains note/text/plan kinds and selections consisting only of prompt
nodes; `params` contains other node/recipe selections. `all` is the default.
Exact `kind` remains compatible. `material_prompt_data.js` reads authoritative
detail fields for the studio: notes use `note.promptEn`, plans compose saved
parts, and workflow selections use top-level `prompt_groups`. The import drawer
pages summaries and fetches prompt text only on inspection; search covers names,
tags and node types. Side/full editors have separate view references and share
the active draft; closing or resetting their importer cancels pending requests.
Save, edit, and delete serialize their writes within the server process. Before
publishing a new record, save compares the source PNG SHA-256, material kind, and
selected node IDs against existing summaries. A duplicate returns HTTP 409 with
`status: duplicate` and the existing name/filename, without leaving new assets.
An explicit retry with `allow_duplicate: true` creates a separate copy. This is
exact-source duplicate detection, not visual similarity or model identity.
The route family is:
- `POST /anomalous/inspect_image_material`
- `POST /anomalous/save_image_material`
- `POST /anomalous/save_parameter_material`
- `POST /anomalous/save_prompt_note_material`
- `POST /anomalous/save_prompt_plan`
- `GET /anomalous/materials`
- `GET /anomalous/material_full`
- `GET /anomalous/material_asset`
- `GET /anomalous/materials/by_node_type`
- `POST /anomalous/update_material`
- `POST /anomalous/delete_material`
## Frontend ownership
`ui_materials.js` owns Workspace discovery, filters, pagination and CRUD
coordination; `ui_material_cards.js` owns summary cards; `ui_material_detail.js`
owns names/tags and full-workflow detail/handoff; `ui_material_application.js`
owns selected-node tracking and explicit application. `ui_gallery_detail.js`
owns the single image-workbench lifecycle/cache, while `ui_image_stage.js` owns
media/zoom/filmstrip interaction and `ui_image_inspector.js` owns image/node
inspection and saving. These views use `material_inspector.js` for shared metadata helpers and exact node
parameter rendering; the workbench does not import the library UI.
`ui_gallery.js` and
`ui_recipe_detail.js` only supply non-invasive gallery entry points. Main
Gallery image clicks open the focused pan/zoom viewer, while the dedicated
parameter action opens the Image Detail Studio. Drag, delete, and cover-selection
behavior remains independent of those two entry points.
`ui_node_presets.js` presents Node Assistant presets; it shares node application
with the library through `ui_material_application.js` and `node_material_actions.js`.
`ui_recipe_detail.js` can publish the active Recipe parameters or the selected
Parameter Notebook as `recipe_parameter_selection`. The primary panel saves all
reusable widget-bearing nodes; the raw-node inspector supports direct single-node
save and explicit multi-selection. Prompt cards expose the same direct save.
The backend reloads the named Recipe/Parameter Notebook from its contained user
directory instead of trusting a browser-supplied workflow.
Material cards remain compact, summary-only discovery items. “View Details”
switches the library itself to a master-detail inspector: a contained reference
image stays on the left, while scope, model references, and reusable node blocks
are grouped on the right; exact widget values remain nested under each node.
Only the opened material fetches `material_full?include_workflow=0`: metadata and
scoped node blocks, with no complete source workflow. Only prompt cards initially
expand; other node cards build their parameter DOM on first expansion and reuse
it on later toggles. Expand/collapse-all follows the actual card state. Selectable
cards in the image workbench remain closed initially.
Returning to the list aborts
an unfinished request and releases the detail payload/DOM so browsing never
accumulates full workflows in browser memory.
Opening a complete snapshot explicitly requests `include_workflow=1`, which
returns the original workflow and an empty `node_blocks` array to avoid duplicate
widget payloads. The original seed and hash evidence remain intact. The server
reads, shapes, and serializes these responses in a worker thread. Selected-node
records filter before copying widget values; they never return a full workflow
and reject explicit workflow requests with HTTP 403. For legacy callers, omitting
the flag retains both workflow and node blocks for complete, openable snapshots.
Only `0`, `1`, or an omitted flag are accepted.
Search is debounced and each list request cancels its predecessor. Changing a
filter resets the page; returning from detail preserves the current filters.
Card activation supports Enter/Space as well as mouse clicks. A name/tag edit
refreshes summary cards and filter choices. Shared confirmation dialogs sit above
the image workbench, whose keyboard shortcuts yield while a dialog is open.
A full material workflow is exact and retains its seed. Node-sized reuse is a
preset operation and therefore uses the existing transactional parameter
application path, which skips known volatile seed widgets. The lookup endpoint
filters blocks by exact `node.type`; when more than one source node matches, the
user chooses the block explicitly.
Every node card in the image workbench can be saved directly as an
`image_node_selection`; checking several cards exposes one colocated save action
above the node list. The initially hidden footer remains dedicated to the full image and
workflow snapshot rather than mixing both concepts in a scope selector. The
original workflow remains in a selected-node record as source provenance, but
list/count/lookup APIs expose only the selected blocks. Such a material
deliberately lacks `open_workflow`; the library and Node Assistant apply its
parameters to one node without opening the hidden source workflow.
The image workbench also exposes saving on its primary surfaces. The top action
reveals the full-snapshot name/tag form. One generation-settings action captures
the sampler and size nodes together; each prompt node can be saved without visiting
the all-nodes tab. A metric action is intentionally node-sized: it does not claim
to persist one isolated widget value. The former plain-text “share text” action
is not part of this workbench.
Prompt roles are inferred from workflow topology first, with node-title hints as
a fallback. Image details display the inferred role beside every prompt node and
allow an explicit positive, negative, shared, unknown, or ignored override.
Overrides travel into the saved material but never write back to a source Recipe.
Material details expose the same selector; `update_material` atomically persists
or clears these overrides, and choosing Automatic restores the topology result.
## Model identity handoff
Material storage preserves the workflow's existing `extra.anomalous_hashes`.
When one node block is applied, records scoped to the source node ID are copied
to the target node ID. This is evidence transport, not identity resolution.
Model Doctor remains the authority that decides whether a missing model may be
repaired. A material name, preview, saved path, or size is never promoted to
cryptographic identity.
## Compatibility and future material kinds
Records declare `schema_version`, `kind`, and `capabilities`. New sources such
as Recipe selections and Prompt Note blocks should add explicit kinds or
source metadata without weakening the image snapshot contract. Existing direct
Recipe and Prompt Note use paths remain available; the library is optional
curation rather than a mandatory intermediary.
The currently saved kinds are `image_workflow_snapshot`, `image_node_selection`,
and `recipe_parameter_selection`. Recipe parameter materials intentionally have
no image asset and only advertise `apply_node_parameters`; they cannot replace
the canvas with the source Recipe. A prompt badge on a CLIPTextEncode selection
still describes a workflow prompt node, not a Prompt Note import. Prompt Notes use the explicit kinds described below. The `reference_image` capability currently
means a preserved image that can be viewed; copying it into ComfyUI input or
configuring LoadImage is also future work.
## Prompt Note capture and return
`POST save_prompt_note_material` accepts `notebook_filename` as a provenance
label, `name`, optional `tags`, `scope` (`note` or `prompt`), and an immutable
client snapshot in `note`. It does not use that filename to read a file. The
snapshot includes the latest text before the editor's autosave timer fires.
The server bounds it to the notebook's 2 MiB limit and retains only the supported
prompt, translation, language, base-model, main-model and LoRA fields. Model
objects remain saved selections, never identity evidence. Prompt-only capture
excludes all model fields. `promptZh` is a translation, not a negative prompt.
- `prompt_note_bundle`: entire note, translations and companion model selections.
- `prompt_text`: prompt text and translations without companion models.
Both kinds advertise `copy_prompt` and `restore_prompt_note`, contain no image
assets or synthetic workflow, and never appear in node-type lookup. Explicit
full-workflow requests return 403. Atomic creation and duplicate confirmation
reuse the image-free material writer; deduplication compares content signature
and kind. Editing a note later cannot alter its captured material.
The library copies prompt text or loads the snapshot as a newly named, uniquely
identified Prompt Note. It flushes any pending note before switching, writes the
new record successfully before navigation, and preserves both original note and
material. Canvas use remains the existing explicit Prompt Note action.
`material_feedback.js` supplies a shared save receipt with View Material.
`web/main.js` binds `openSavedMaterial` from `ui_materials.js`; it owns navigation
and workspace return state, keeping workbench imports acyclic. Image details,
recipes and Prompt Notes use that same handoff. Node-sized receipts lead to a
detail that explains reuse through the library or Node Assistant.
Primary surfaces use progressive disclosure: image save form, prompt role
selectors, technical node metadata, companion models, recipe export/source
editing, and recipe multi-selection are revealed on request. Parameter-page
metrics appear once with exact-value copy controls. Display wording is Parameter
Sets (参数方案); existing notebook routes and storage identifiers stay stable.
## Entry points and shared application
The lower-left control opens a standalone Material Library container, with no
workspace tabs and no initial Prompt Note fetch. The upper-right control opens
Workflow Recipes; that workspace retains its Recipes and Prompt Notes tabs.
Both containers share the browser's panel area but have separate headers and
lifecycle state; switching restores the appropriate container. Material Library
file import and its transfer-center entry are closed. It has no independent
image-material bundle exporter. The verified workflow share-code Import / Export
Center lives in Toolbox. Recipe package import/export remains paused, while prompt-plan
JSON export has been removed. Local capture/save actions remain available.
Opening the library with exactly one live selected node enables apply mode.
Summary requests filter by exact `node_type`; activating a card fetches only that
material's scoped detail. No selection means normal detail browsing. An explicit
toggle returns to browsing all materials; prompt-only kinds also use browse mode.
Multiple source blocks of the same type require choosing one block. The full
workflow quick-open button is omitted from apply-mode cards.
`node_material_actions.js` is the shared, UI-independent mutation owner. It checks
live graph/node identity, indexes, value types and native combo choices before
editing. It skips seed widgets, preserves node identity/links/position, calls the
live widget callback and four-argument node hook, and marks the graph dirty.
Values, serialized widget values and target-scoped hash evidence change in one
before/after transaction; hook failures restore their snapshots. Model paths must
already be available in the native combo. This path does not invoke global model
repair after application. Hash transport remains evidence, never verification.
`material_full?include_workflow=0` includes `workflow_hashes` restricted to returned
blocks, without exposing the hidden workflow. Both library and assistant show the
same application receipt. Undo restores values and scoped hashes only while the
same live node, values, serialized values and target hashes still match the applied
state. Later edits are protected. Unsupported third-party widget side effects
still require real-host compatibility testing.
Selection hooks chain the host's callbacks and batch updates in a microtask;
there is no polling. List browsing retains 48-item pagination and cancellation.
Language changes rebuild visible library/composer text while keeping the current
draft. Node labels reuse ComfyUI's registered localized titles with raw type fallback.
## Prompt combinations
`prompt_plan` is an image-free material with `compose_prompt` capability and no
synthetic workflow. `POST save_prompt_plan` accepts a name, tags, and `plan`:
`parts` is an ordered list of up to 100 records with `name` (up to 120 characters),
`category` (`general` or `specific`), `enabled`, `positive`, and `negative`.
Top-level `positive` and `negative` hold prompt text. The plan is bounded to
2 MiB; unknown fields are discarded. Atomic writes and explicit duplicate-copy
confirmation reuse the material persistence lock and canonical content signature.
The current panel has simple positive/negative text fields and a beginning/end
drop-position selector. Fragment categories, ordering and enable controls are
deferred. Opening an older plan joins its enabled fragments and final content
into those two fields; a new save has empty `parts`, leaving the original record
intact. Text insertion preserves weights, commas, duplicates and original nonblank
string whitespace. A Prompt Note's `promptZh` translation is never inferred as
negative text. Captured notes and classified workflow prompt groups can populate
the fields; unknown workflow roles are not silently assigned a role.
Saving creates an independent new snapshot. Studio source cards automatically
follow library changes; assembled draft blocks remain independent copies without
model binding. A page-session draft survives library navigation and is replaced
by an existing saved plan only after confirmation; it must be saved before page
reload. Stale detail requests cannot replace the current draft. Standalone JSON
export has been removed, and the file-import entry is closed. The shared save
endpoint still supports local combination saves. The panel also exposes role and target-widget selection,
beginning/end buttons for a selected node, copy controls and guarded undo.
## Canvas drag and drop
`material_drag.js` binds native drag handles to all material cards and to each
prompt field's drag button (`bindPolymorphicMaterialCardDrag`). Only an active,
same-page drag is trusted; transfer data is a marker, not an external mutation
command. During drag, the browser window becomes transparent and stops intercepting
pointer events. End, drop, Escape and loss of focus restore it and remove temporary
event listeners. There is no persistent drag polling or detail request during hover.
Hit testing evaluates live node target acceptance before canvas blank surface drop.
Because nodes sit on the canvas element, testing canvas surface first would hijack
node drops; evaluating `accepts(node, data)` first guarantees node targeting takes
precedence. Live node lookup uses ComfyUI canvas coordinate conversion (including pan/zoom),
canvas bounds, and graph node hit-testing. DOM-widget surfaces are accepted. The
graph/canvas identities are captured at drag start and checked again at drop. The actual
drop node is the target, regardless of the previously selected node. Receipts name
that target and provide the shared guarded undo.
When dropped onto a compatible node:
- Parameter-bearing materials fetch scoped node blocks only after drop and replace
compatible widget values through the transactional parameter path, preserving seed,
node position, and links.
- Prompt materials inject prompt text into target nodes via semantic prompt widget
sniffing (`customtext`, `multiline`, `text_g`, `text_l`, `prompt`, `positive`,
`negative`, and localized labels), relaxing strict node-type matching for third-party
text nodes. Text is cleanly inserted without synthetic prefixes ("负向:" / "正向:").
When dropped onto blank canvas:
- Workflow materials (`image_workflow_snapshot`) trigger full workflow loading
via `openMaterialWorkflow`.
- Prompt materials (prompt notes, prompt plans, prompt node selections) auto-instantiate
native `CLIPTextEncode` nodes at canvas drop coordinates, populating the pure prompt
text, setting bilingual titles (`CLIP Text Encode (Negative/Positive)` /
`CLIP 文本编码器 (负向/正向)`), and applying standard LiteGraph dark theme colors
(`#532323` dark red for negative, `#235327` dark green for positive).
Empty/unsupported targets and canceled drags do not mutate the graph. Neither drop path
queues generation.
+143
View File
@@ -0,0 +1,143 @@
# Model Identity and Scanning
Read this document for Model Doctor, workflow provenance, missing-model recovery,
hash caches, Civitai metadata resolution, or deep scanning.
## Identity boundary
Model Doctor recovers the same physical model referenced by provenance embedded
in a workflow or image. Local renames and path differences are the problem it
solves, so names cannot also be its proof.
Allowed automatic evidence is:
1. plugin-carried cryptographic hash;
2. exact physical byte size only as a disambiguator for that hash;
3. the model category required by the target widget.
Without a hash, one unique in-category size match is only a candidate. Model
Doctor may show it during an explicit manual check, but it cannot redirect the
node until the user confirms that candidate. Confirmation applies to the current
node only; it does not create a persistent binding. If both hash and size are
present and point to conflicting physical files (both match distinct local files),
resolution reports an identity conflict and is rejected. When a requested model hash
simply does not exist in any local file, it returns `{"found": False}` cleanly without
falsely flagging a conflict.
Paths, filenames, source filenames, display/custom names, previews, workflow
fingerprints, and fuzzy/visual similarity are never candidate evidence. They may
be used only after identity is established to return a local dropdown value,
locate presentation media, and verify the value against ComfyUI's native choices.
Foundation components—`vae`, `vae_approx`, `clip`, `text_encoders`, and
`clip_vision`—are hash-only automatic-recovery categories. Byte size alone
cannot automatically repair them. When size provenance is available it may be
shown as the same explicit manual candidate, but a supplied hash mismatch never
falls back to a filename or size-only guess.
This is a Model Doctor confidence boundary, not a scanner-support boundary. The
scan wizard may traverse any active registered model folder and can still
calculate a local hash when Civitai has no matching record. Sparse or ambiguous
remote metadata is a reason to require cryptographic identity, not to exclude
the category from scanning.
An existing native combo value remains loadable even if a foundation component's
current local hash differs from stored provenance. Model Doctor shows a
non-blocking identity-change warning instead of declaring the node missing or
replacing it. If the value is absent, redirection requires one exact in-category
hash match. Missing or ambiguous evidence remains unresolved for manual action.
Slash normalization of a value that already has an exact native-option
equivalent is representation normalization, not discovery. The resolver must
never append a foreign or cross-category value to `widget.options.values` merely
to claim success.
## Frontend provenance cache and injection
`window.anomalous_hash_cache` maps model widget values to exact SHA-256 and size
records. It is fetched by the optional resolver from `/anomalous/all_hashes`.
Relative-path and basename aliases exist only when unambiguous; if two files
share an alias but differ in identity, omit that alias. Lookups prefer the full
widget value before basename fallback.
After a scan writes new metadata, `window.anomalous_reload_hashes()` refreshes
the frontend cache.
When provenance injection is enabled, the compatible active graph constructor's
`serialize` path writes
`extraObj.anomalous_hashes[node_id_filename] = {hash, size}` for known model
widgets. The cache covers ordinary models and foundation categories. A
foundation component without a recorded hash receives no size-only provenance.
A missing compatible graph API disables only injection and recovery integration,
not the main browser.
Exact-path checks are limited to preflight confirmation that an already resolved
local value exists. Backend discovery constrains candidates to the inferred
category and intersects saved hash/size evidence. One unique in-category
size-only match is returned as a confirmation-required candidate; it is never
an automatic result. Multiple equal-sized candidates remain unresolved. A real
hash/size conflict is rejected.
## Metadata association
A Civitai `.info` record can describe multiple physical files, such as a model,
text encoder, and VAE. `get_metadata()` selects the matching `files[]` entry by
exact physical byte size. An unmatched entry may be used only when it is the sole
hash candidate. Never take the first SHA-256 or select among entries by filename.
Metadata refresh is enrichment, not identity mutation. Offline generated
metadata includes the discovered hash, but consumers still associate the record
with the current physical file through the established size/name rules; array
position is not identity.
## Deep scanning
Deep Hash Scan runs outside the aiohttp event loop and identifies a model through
the established fallback sequence:
1. use existing valid file SHA-256 metadata when the scan does not request refresh;
2. otherwise calculate the complete file SHA-256, never a ModelSpec header digest;
3. if the remote service has no record, infer a bounded local base-model family
from tensor/header fingerprints and write offline metadata.
`model_identity.py` owns digest validation and the `anomalous_file_identity`
sidecar record: algorithm, file scope, digest, physical size/mtime, and computed
source. ModelSpec hashes may describe tensor content and are never full-file
identity ([specification](https://github.com/Stability-AI/ModelSpec)). Old locally
inferred sidecars without this record keep their display data but need an explicit
scan or successful on-demand SHA-256 check before supplying identity again.
Successful on-demand verification preserves existing notes and remote metadata.
Imported workflow provenance stays intact; it must match valid local file evidence.
Remote metadata requests are part of an explicit user-initiated scan. Local
browsing and offline inference remain usable when the service is unavailable.
Physical rename conflicts are non-destructive. If the generated target filename
already exists, hash both complete files. Deletion is allowed only when their
full SHA-256 values match. Distinct files with the same generated display name
are both preserved.
## Resolution execution
The frontend finds unresolved workflow nodes and sends provenance hash, byte
size, and inferred model category to the batch endpoint, falling back to the
single-item endpoint if batching fails. Requests are grouped by the exact model
category tuple so the backend scans each group once, but every result retains
the same hash, size, category, conflict, and ambiguity rules.
If a size-selected candidate lacks cached hash metadata, the backend may hash it
on demand. Only an exact hash match establishes identity; successful discovery
may write offline metadata for later reuse.
After a hash match—or explicit confirmation of a size-only candidate—the
frontend refreshes ComfyUI's native combo definitions and accepts the returned
path only if it is present in the target widget's choices. Then it updates the
dropdown and clears the missing-model presentation. Background checks may
surface size candidates but never prompt for or apply them.
Provenance-rich workflows skip the redundant full filename-to-hash cache refresh.
Legacy workflows without injected provenance may refresh it for compatibility.
This is an I/O optimization only and does not change identity evidence.
Civitai lookup by hash may resolve a model/version page and route mature content
to the configured domain. Hash-detail UI may compare and copy workflow and local
hash/size values. Neither presentation feature changes recovery rules.
+140
View File
@@ -0,0 +1,140 @@
# Workflow Recipe Model Lifecycle
Read this document before changing Workflow Recipe model cards, local matching,
partial-canvas append, package export, or Model Doctor integration. UI code may
change freely, but the data ownership and transition rules below are contracts.
## Three representations with different jobs
A saved recipe keeps model information in three related representations:
1. `workflow.nodes[].widgets_values[]` is authoritative for the value ComfyUI
will load into a model widget.
2. `params.model_references[]` is the structured recipe record used by the UI,
package system, history, and matching actions.
3. `workflow.extra.anomalous_hashes` is Model Doctor provenance keyed to the
serialized node ID and widget value.
Changing only one representation creates a broken recipe. A path change must
update the workflow widget, its model reference, and its node-scoped Hash key in
the same recipe update. A partial append must remap the Hash key alongside the
node ID. Presentation fields must never become identity evidence.
## Schema v7 model reference
`params.model_references[]` contains these persisted fields:
- `node_id`, `node_type`, `node_title`: serialized graph location and display
context;
- `widget_index`, `widget_name`: authoritative widget slot;
- `saved_value`: exact dropdown/path value stored in the workflow;
- `category`, `base_model`: bounded matching and presentation context;
- `identity`: `status`, optional SHA-256, optional byte size, and provenance;
- `origin`: editable official-source metadata, independent of identity;
- `preview`: optional recipe-owned bounded snapshot descriptor;
- `user_note`: optional recipe-scoped personal note, at most 1000 characters.
`user_note` is not global model metadata, is not used for matching, and does not
change Model Doctor confidence. Enrichment preserves it by the complete model
reference key. Package export explicitly asks whether to include it; excluding
it removes the field only from the exported current/history records.
Runtime-only fields such as `localModel`, `localMatch`,
`currentAvailability`, and `currentPreviewUrl` must not be persisted as schema.
## Hash record shape and keys
The canonical record is compatible with:
```json
{
"hash": "lowercase SHA-256 or an empty string",
"size": 123456
}
```
Node-scoped keys use `<node_id>_<saved_widget_value>`. Slash-normalized aliases
may coexist for Windows compatibility. Readers check node-scoped keys first;
global path aliases are legacy fallback only and must not be relied on when
mutating a recipe.
Graph serialization prefers current verified local-cache data. When a widget
value is still missing—or no current cache record exists—it must preserve the
node-scoped provenance already carried by the graph. Clearing it would make a
successfully appended partial recipe lose its Model Doctor identity on the next
save.
An empty Hash with a known size represents unverified provenance. It may produce
a manual same-size candidate, but never an automatic repair. A Hash copied from
the original recipe must not be attached to a size-only candidate.
## Lifecycle transitions
### Save and update
The frontend captures the graph once. The backend validates it, derives model
references, preserves compatible identity/origin/preview/user-note fields, and
writes schema v7 atomically. Optional save-time verification may calculate a
full SHA-256. Normal enrichment does not silently replace imported identity.
### Availability and local matching
Availability and previews describe the current computer only. Matching sends
saved Hash/size/category evidence to Model Doctor:
- exact Hash match: may be offered for explicit recipe application;
- unique size-only match: confirmation-required candidate;
- Hash/size conflict: rejected;
- ambiguity: unresolved.
Finding a candidate is presentation-only. The recipe is not changed until the
user activates its Apply/Confirm action.
### Apply local match
One full-recipe update performs all of these changes together:
1. replace the target `widgets_values[widget_index]` with the local dropdown
value;
2. replace `model_references[].saved_value` and its identity;
3. update `params.baseModel` when it held the same old value;
4. remove old node-scoped Hash aliases and write aliases for the new value;
5. archive the previous recipe through the normal update endpoint.
For a Hash match, the stored local identity retains the proven SHA-256. For a
manually confirmed size candidate, use a valid Hash from the local candidate's
metadata when available; otherwise store size-only unverified provenance. The
author's editable `origin` and the recipe-scoped `user_note` remain intact.
### Apply to canvas
Complete recipes pass their cloned workflow to ComfyUI, so the contained
`extra.anomalous_hashes` opens with the new workflow canvas.
Partial recipes are a merge transaction. New collision-free node IDs are
allocated, links and groups are restored, and node-scoped Hash records are
copied under the new IDs without deleting current-canvas records. If any part
fails, inserted nodes/groups and the previous Hash map are restored together.
### Export and import
Export choices independently control preview snapshots, history, model
identity, and personal model notes. Removing identity or notes sanitizes only
the package clone. Import validates the contained recipe and assets, then uses
the same normalization and enrichment boundaries as local recipes.
## Implementation map for UI refactors
- `api/recipes.py`: schema validation, enrichment, field preservation, history;
- `api/recipe_packages.py`: export privacy and package boundaries;
- `api/models.py`: Model Doctor evidence and candidate policy;
- `web/modules/recipe_identity.js`: model-reference adapters for presentation;
- `web/modules/recipe_provenance.js`: pure Hash lookup/remap/replace helpers;
- `web/modules/recipe_actions.js`: canvas transactions and append rollback;
- `web/modules/ui_recipe_detail.js`: user actions that invoke the contracts;
- `web/modules/ui_recipes.js`: save/import/export orchestration;
- `web/modules/recipe_diff.js`: history-visible semantic changes.
When restructuring UI, call these data functions rather than recreating Hash
keys, matching confidence, or append behavior inside components. Filenames,
notes, previews, and official names must never be promoted to identity proof.
+231
View File
@@ -0,0 +1,231 @@
# Workflow Recipes and Parameter Notebooks
Read this document for recipe schemas, cards/detail behavior, package handling,
result galleries, Parameter Notebooks, prompt roles, and the recipe-powered Node
Assistant.
## Package import/export availability
Recipe package import and export are temporarily closed pending validation.
Topbar/card/detail buttons are disabled with a localized explanation. The registered
`POST /anomalous/export_recipe_package` route returns HTTP 503 with code
`recipe_export_disabled` before reading request JSON or recipe files.
`RECIPE_PACKAGE_EXPORT_ENABLED` in `api/recipe_packages.py` is a release gate,
not a user setting. `RECIPE_PACKAGE_IMPORT_ENABLED` likewise gates both
`POST /anomalous/import_recipe_package_inspect` and
`POST /anomalous/import_recipe_package_commit`: each returns HTTP 503 with code
`recipe_import_disabled` before reading uploaded bytes, request JSON, inspection
tokens or writing files. Package format helpers remain for compatibility tests;
local saves are unaffected.
Reopening requires an explicit release decision, package round-trip and failure
validation, then restoring the frontend export action and enabling the backend
gates together. Do not reopen package transfers as a side effect of UX work. Workflow
share-code import/export is a separate, verified feature available from Toolbox.
## Product and data model
Workspace contains Prompt Notes, Workflow Recipes, and the Material Library.
Recipe Parameter Notebooks are presented as Parameter Sets (参数方案). Internal
notebook route and property names may remain stable for compatibility even when
the user-facing presentation changes.
A Workflow Recipe stores the authoritative serialized graph plus bounded
metadata for browsing and comparison. Summary adapters expose common model,
LoRA, prompt, and sampling semantics; generic bounded widget summaries cover
other nodes, including third-party nodes. Summaries are presentation only. Edit,
apply, structural validation, and detailed values resolve from the contained
serialized workflow by node ID and widget index.
The card API stays lightweight. Full workflow and history payloads are loaded on
demand. Every update archives the previous full recipe locally, bounded to 20
versions. The structural fingerprint (`sha256-structural-v1`) is an integrity
and version-comparison value, not model identity evidence.
Backend ownership is split by responsibility: `workflow_schema.py` owns graph
validation, fingerprints, signatures and integrity receipts; `recipe_schema.py`
owns recipe/model-reference normalization; `recipe_images.py` owns source images,
covers and output-gallery inspection; `recipe_store.py` owns directory, record and
history I/O. `recipes.py` maps HTTP requests and errors and retains narrow
compatibility exports only. `recipe_packages.py`, `parameters.py`, and materials
import those owners directly rather than reaching through the route facade.
The current persisted recipe schema is v7. Earlier schema steps introduced the
structural fingerprint, explicit model-reference identity records, and optional
recipe-owned preview descriptors; v5 separates model identity from editable
official-origin fields. V6 records whether the saved graph is a partial or
complete recipe. V7 adds bounded recipe-scoped model notes and makes Hash
synchronization across local matching and partial append an explicit invariant.
Normal save/update paths preserve compatible imported
records rather than rebuilding identity from the current machine. An explicit
save-time verification choice may replace missing identity with a freshly
computed SHA-256 for supported model categories.
Recipe model references separate saved identity from current-machine
availability and official origin metadata. Origin refresh is an explicit
enrichment mode. A refresh-only request clones the stored recipe before
enrichment so it cannot be rejected by complete-recipe validation or discard the
workflow. Normal updates preserve imported identity/origin records when the
current machine cannot resolve them.
## Save, edit, and presentation
Save captures the live graph exactly once before opening the dialog; that fixed
snapshot is the graph sent to the backend. The dialog records recipe identity,
notes, tags, and cover/source image. It does not ask users to select presentation
pins or a per-recipe snapshot policy. Existing `params.pinned` values survive an
edit, while new recipes use an empty list.
Before the dialog opens, an advisory check reads only the captured workflow hash
records and cached local metadata. Recognized model references without verified
identity appear as an optional action. The checkbox is off by default; enabling
it computes the exact full-file SHA-256 in a worker thread during persistence.
Foundation components such as VAE and text encoders are included because Model
Doctor requires their hash for automatic recovery and treats size-only evidence
as a manual candidate at most.
Inspection failure never blocks the workflow snapshot from being saved.
The detail view contains Overview, Parameters, Gallery, and Versions as
applicable. Compact cards may ellipsize bounded values while preserving their
full copy value. Detail rows provide visible expand/collapse and copy controls
for long values and prompts; they do not silently truncate authoritative data.
`ui_recipe_detail.js` coordinates the detail session, active tab, and model composition.
`ui_recipe_overview.js` owns the overview; `ui_recipe_parameters.js` owns prompt roles,
parameter editing, raw nodes, and preset saving; and `ui_recipe_model_matching.js`
owns preview resolution plus explicit local matching. Inline persistence is centralized
in `ui_recipe_metadata.js`, with pure ordering/value helpers in
`ui_recipe_parameter_utils.js`. The catalog shell lives in `ui_recipe_catalog.js`,
cards and card actions in `ui_recipe_cards.js`, save/edit dialogs in
`ui_recipe_dialogs.js`, and shared cover helpers in `ui_recipe_media.js`.
`ui_recipe_versions.js` owns version comparison and restore, while
`ui_recipe_gallery.js` owns result rendering and opens `ui_gallery_detail.js`
directly for image inspection. Shared detail DOM/copy primitives live in
`ui_recipe_detail_dom.js`; the subviews return refresh/finish decisions through
callbacks instead of redrawing one another.
Model names in compact recipe presentation use a basename or official model
name, never a full filesystem path. Saved paths and hashes belong behind advanced
information. A preview or exact path can locate current-machine presentation
only after the reference is already understood; it cannot establish identity.
Import matching is a separate explicit recovery action. Unresolved references
are sent to the hash/size/category resolver. A discovered candidate remains
presentation-only until the user chooses Apply match; that action updates the
authoritative workflow widget, model reference, and node-scoped Hash index
through the full-recipe update path and archives the previous recipe. The
author's saved filename or path is never match evidence. Model-reference
`user_note` is recipe-scoped presentation metadata and never match evidence.
Recipe-owned model preview snapshots are bounded, content-addressed WebP files
below `.assets/<recipe-stem>/`. They are at most 320 px or 96 KiB each, limited
to 12 images and 1.25 MiB per save/update. Static local preview files are
accepted; original videos are never packaged, though a bound local video may
contribute a bounded first-frame thumbnail. Deleting a recipe removes only its
contained assets after the recipe file is deleted. History may share an asset
ID.
## Canvas actions
Recipe cards and details expose a scope-aware canvas action. A live graph with
an active output node and no unconnected required inputs is saved as a complete
recipe and opens as a new workflow canvas. A graph without an output node or
with required connection boundaries is saved as a partial recipe and appends to
the current canvas. Append clones saved nodes, assigns collision-free IDs,
remaps links, places/selects the inserted content, treats groups as first-class
items, remaps node-scoped model Hash records, and rolls back nodes/groups/Hash
records together on failure. Legacy recipes without
scope metadata are complete recipes because earlier releases only documented
and captured complete workflows.
Structural editing is separate from composition. It may load a recipe into a new
canvas after explicit confirmation and saves back through the full-recipe update
path. It is not presented as an ambiguous “Open to canvas” recipe action.
All list/detail actions await the shared transaction before reporting success or
restoring controls. A failure must leave the button usable and the host graph in
its prior state.
## Result galleries
Recipe result discovery uses `sha256-node-types-v1`: the sorted node class
composition and count. It intentionally ignores seeds, prompts, model values,
and other parameters, so ordinary generation variations can match. This is
separate from the structural integrity fingerprint.
Opening detail scans at most the newest 200 PNG files below the ComfyUI output
directory and reads only bounded embedded `workflow` or API `prompt` metadata.
There is no persistent output index or background polling. A chosen result may
become the recipe-owned compressed cover; its original output path remains a
local convenience reference rather than package content or identity evidence.
The main output Gallery performs its page-one scan when opened and exposes a
manual Refresh action. It does not poll in the background and preserves scroll
position across manual refresh.
## Parameter Notebooks
Parameter Notebooks are immutable generation-value snapshots owned by a recipe.
Saving or updating a recipe creates a new snapshot. The Parameters tab uses a
two-pane history browser: the left pane selects a notebook and the right pane
prominently identifies the active name and timestamp. Selection requests carry a
token so an older gallery response cannot overwrite a newer selection. Parameter
result matching uses the separate `sha256-params-v1` signature.
“New parameter note” clones the selected recipe/snapshot into an editable draft
and saves a new immutable snapshot. Rename uses the dedicated parameter endpoint
and atomic persistence. Delete accepts only the validated notebook filename,
invalidates in-flight requests, reloads the list, and selects the newest remaining
note when necessary. The recipe's current baseline is not a deletable stored row.
Runtime-volatile values such as sampler seeds remain visible by field name but
their values are excluded from summaries, copying, editing, matching, and apply.
The serialized workflow retains the slots for compatibility.
“Read current and create” and “Apply to current workflow” share a skeleton
preflight. Every saved node must match a local node by type and shape, preferring
stable ID/title evidence; extra local nodes are allowed. Read-current refuses to
create a draft if the skeleton is incomplete. Apply validates all widget slots
before mutation, resolves serialized records to live nodes, invokes ComfyUI
callbacks, and rolls values back if a callback fails.
The Node Assistant queries bounded notebook presets by selected node type. Its
cache is invalidated by save/delete and bypassed by an explicit refresh. Applying
a preset is a single-node transaction; it does not apply the full workflow.
## Prompt roles
Automatic prompt roles are conservative. `recipe_parser.js` traces backward
from allowlisted official sampler/Guider conditioning inputs through allowlisted
conditioning pass-through nodes to native `CLIPTextEncode` nodes. Unknown
third-party nodes are opaque; titles, type-name guesses, prompt content, and a
default-positive rule are not semantic evidence.
Saved role metadata joins to assistant records by string-safe node ID. Legacy
recipes may recover a role only from an exact, unambiguous full prompt value in
the saved positive/negative arrays. The same value in both arrays remains
unknown.
Manual overrides live in `params.promptRoleOverrides`, keyed by string-safe node
ID and guarded by saved node type. Values are `positive`, `negative`, `both`,
`ignored`, or `unknown`. An override wins over automatic/legacy metadata; removing
it restores the saved automatic result. Updates retain it only while ID and type
still match.
## Recipe-to-model navigation
Opening a resolved local model from Recipe Overview keeps the outer browser open
and temporarily replaces all main content surfaces with model detail. A compact
return token retains the recipe payload, active tab, scroll position, and prior
Workspace return state.
Model-detail Back, or reopening Workspace and selecting Workflow Recipes,
consumes that token and reconstructs the exact recipe detail. Navigating directly
to another main panel or closing Workspace abandons the transition and clears
stale media/DOM/return state. Opening Workspace during the transition must not
overwrite the earlier outer-panel return state.
Workflow Recipes and recipe-powered Assistant parameter presets remain beta
surfaces until an explicit stability decision changes that status. Localized
notices identify their data directories and distinguish single-node preset apply
from full-skeleton Parameter Notebook apply.
+56
View File
@@ -0,0 +1,56 @@
# 更新引导维护契约
更新引导只解释明显变化,不承担功能开关、数据迁移或执行用户操作。
修改前阅读根目录 [AGENTS.md](../../AGENTS.md)。
## 内容与实现分工
| 文件 | 责任 |
| --- | --- |
| `web/modules/update_guide_data.js` | 当前引导的稳定 ID、步骤顺序、图标和文案键;也提供配置校验 |
| `web/modules/locales.js` | 每个步骤及按钮的中英文文案,均按纯文本展示 |
| `web/modules/ui_update_guide.js` | 显示、翻页、关闭、焦点、版本记录;一般内容更新不要修改 |
| `web/modules/sidebar_actions.js` | 六个底部按钮的短标签、完整名称和用途提示,不改变点击动作 |
| `web/styles.css` | 引导布局和底部图标/文字切换的现有规则 |
| `web/main.js` / `ui_sidebar.js` | 父窗口关闭清理,以及上方按钮和帮助页的手动入口 |
## 后续 AI 更新步骤
1. 先确认功能已实现及真实入口。不要在引导里承诺未上线、暂停或验证失败的功能。
2. 在 `CURRENT_UPDATE_GUIDE.steps` 编写 1–5 个步骤,每项包含唯一 `id`、短 `icon`、`titleKey`、`bodyKey`。
3. 在 `locales.js` 补齐这些键的中文和英文。标题说明变化,正文说明入口和用户该怎么用。禁止 HTML、可执行回调、请求及文件写入。
4. **只有引导内容属于新一轮功能变化时才换 `id`**,例如 `2026-10-library-navigation`。修改错字、翻译、样式时保持 ID。不使用时间戳、随机数、Git 提交号或普通版本号自动生成 ID;换 ID 也不能自动弹窗。
5. 配置校验会拒绝重复步骤、空步骤及缺失双语文案。配置错误只跳过引导,不应阻止插件打开;必须修正错误后再交付。
6. 运行下方相关测试,再在浏览器验证打开插件不弹窗、上方入口、跳过后手动重开、帮助页重看、Esc 和长文案。
7. 修改现有样式规则,不在文件末尾追加覆盖包。没有明确产品决策,不增加强制阅读、自动跳转、网络请求或额外追踪。
## 生命周期与记录
- 面板右上角常驻感叹号按钮(`#anomalous-update-notice-btn`),提供完整的用途提示和 accessible name。只有用户点击该按钮或帮助页入口才打开引导;初始化、打开插件、版本变化都不能触发弹窗。
- 使用原生 `dialog` 的顶层显示和焦点约束,关闭时尽可能恢复原焦点。
- 完成、跳过、×、Esc、点击空白关闭会记录已读,上方入口仍然保留;关闭父浏览器仅清理,不将未读引导标记为已读。入口不需要全局事件或延时隐藏。
- 持久化键为 `anomalous_update_guide_seen:<id>`,值为 `1`。只保存本地已读状态,不发送到服务端。
- 存储不可用时,当前页面会话仍记住关闭状态;刷新后仍必须手动点击才能显示。
- 同一时刻只有一个引导。重复调用不叠加弹窗;所有关闭路径通过 `closeUpdateGuide` 释放资源。
- 上方按钮和帮助页的“查看更新引导”均使用 `force: true`,允许重看已确认版本。
- 引导不自动点击对应功能,也不修改画布、模型、素材、导入导出限制。
## 底部按钮约束
原按钮保留点击处理器、尺寸和图标。短标签是独立且 `aria-hidden` 的 `span`,
不能占用已用于详细提示的 `::before`/`::after`。扫描状态替换 SVG 后重新配置标签。
语言切换须更新六个标签、完整 accessible name 和用途提示。
悬停与键盘焦点使用相同文字切换;无悬停设备直接显示短文字;减少动态效果偏好禁用位移动画。
**防闪烁与稳定性铁律**:底栏按钮必须显式声明 `cursor: pointer !important; overflow: hidden;`,内部 SVG 与 Label 必须设置 `pointer-events: none !important;`。图标切换文本时**严禁使用 `display: none`**(必须使用 `opacity: 0` 保持物理文档流支撑),以避免浏览器 Hit-testing 丢失目标导致高频触发 `mouseleave` -> `mouseenter` 乒乓震荡闪烁。
## 验证
在插件根目录运行:
```powershell
node --experimental-vm-modules tests/update_guide.mjs
```
`update_guide.mjs` 加载真实模块,检查双语配置、版本记忆、重看、翻页、关闭清理、
父窗口关闭、存储失败及扫描图标替换后的标签。它不能代替实际浏览器的布局与焦点验证。
只有同时修改工坊生命周期或导入导出行为时,才追加对应测试;纯引导样式不运行无关测试。
+200
View File
@@ -0,0 +1,200 @@
# Anomalous Model Browser 全功能实测审计与问题复原报告
**Audit & Functional Verification Report**
- **测试对象**:`Anomalous Model Browser` (异构模型浏览器 ComfyUI 插件)
- **运行环境**:ComfyUI Portable (Windows x64) / 本地端口 `http://127.0.0.1:8188`
- **当前 Git 分支**:`feature/recipe-model-binding`
- **浏览器会话录屏存档**:`anomalous_deep_test_1789816916045.webp`
---
## 一、 测试操作过程完整复原 (Step-by-Step Reproduction)
本次实测通过浏览器子智能体(Browser Subagent)与本地真实运行的 ComfyUI 实例进行端到端(E2E)交互操作,测试全流程操作复原如下:
### 1. 插件唤起与主界面交互
1. **浏览器进入 ComfyUI 画布**:
- 页面初始加载默认工作流节点(包括 `Load Checkpoint`、`CLIP Text Encode`、`KSampler`、`Anima VAE` 等)。
2. **唤起插件主界面**:
- 点击 WebUI 界面悬浮触发球及左侧快捷立方体图标,`#anomalous-modal` 顺畅展开,居中层叠在画布上。
- 验证右上角 `×` 关闭按钮与侧边栏 `☰` 折叠按钮,动画过渡自然,能顺畅收起并安全回到主画布。
### 2. 模型医生(Model Doctor)全局体检与修复测试
1. **打开体检中心**:
- 点击侧边栏听诊器图标(🩺),切换至 Model Doctor 面板。
2. **体检扫描表现**:
- 面板显示当前画布的节点模型状态:“健康 2 个 / 缺失 1 个”。
- 正确检测到当前画布缺失模型:`Anima\qwen_image_vae.safetensors`。
3. **交互操作测试**:
- 点击右上角“刷新缓存”按钮,控制台正确触发 `app.refreshComboInNodes()` 和 `/anomalous/all_hashes` 刷新。
- 查看缺失模型右侧操作按钮:包含了“深度哈希扫描”、“手动替换”、“查看哈希”以及“Civitai 搜索”链接。
### 3. 素材库(Material Library)与分类检索测试
1. **打开素材库**:
- 点击工具箱或侧边栏素材库图标,成功打开 Material Library 视图。
2. **分类与搜索切换**:
- 点击顶部筛选胶囊:“全部”、“工作流”、“提示词”等分类,卡片即时响应过滤。
- 在搜索框输入关键词 `Kirazuri`,检索到对应的完整工作流素材卡片 `Kirazuri (Anima)_v4.0`。
3. **卡片拖拽交互测试**:
- 测试按住卡片尝试拖拽至画布空白处或节点上,排查拖拽反馈(发现重大逻辑断点,详见下文第二节)。
### 4. 模型扫描向导(Scan Wizard)与设置中心测试
1. **打开扫描向导**:
- 点击侧边栏雷达扫描图标,弹出 Scan Wizard 原生 Dialog 弹窗。
- 查看各项扫描策略配置:包括完整哈希、Civitai 元数据提取等。
- 测试取消弹窗:按 `Escape` 键或点击外部背景,发现原生 Dialog 没有绑定外部点击关闭逻辑,必须向下滚动找到底部的“取消”按钮才能退出遮罩层。
2. **全局设置中心(Settings Hub)**:
- 打开齿轮图标设置面板,测试在“紧凑模式 (Compact)”与“标准模式 (Standard)”之间来回切换,网格与卡片间距自适应良好。
---
## 二、 发现的核心缺陷与不合理之处深度剖析 (Critical Issues & Bugs)
通过“浏览器实际操作 + 源码 AST/逻辑级走查”,发现以下 **4 个影响核心体验或违反技术方案** 的问题:
---
### 🚨 缺陷 1(核心技术方案违背):本地缺失模型被误判为“⛔ 身份冲突”,导致修复链路完全中断
#### 1. 问题定位
- **文件**:`api/model_resolution.py`(第 178-181 行)
- **函数**:`_resolve_from_candidates`
#### 2. 代码问题根源
在比对带有来源信息(Provenance,同时包含 `hash` 与 `size`)的模型时:
```python
hash_matches = [candidate for candidate in candidates if target_hash in _candidate_hashes(candidate)]
if hash_matches:
return {"found": False, "identity_conflict": True}
return {"found": False, "identity_conflict": True} # <--- 无论 hash_matches 是否为空,均返回 identity_conflict: True!
```
- 当用户导入一个网上下载的带有哈希的工作流,而本地**压根没有这个模型**时,`candidates` 或 `size_matches` 是空的。
- 代码执行到末尾,**无条件返回了 `{"found": False, "identity_conflict": True}`**!
#### 3. 产生的不合理现象
1. **误导用户**:在前端 `ui_doctor.js` 和 `ui_recipe_model_matching.js` 中,该模型会被标上鲜红的 `⛔ 身份冲突 (Identity Conflict)`,给用户一种“本地文件损坏或文件哈希冲突被系统拒绝”的假象。
2. **阻断修复方案**:根据 `docs/architecture/model-resolution.md` 规范,只有当本地存在尺寸相同但哈希不同(或哈希相同但尺寸不同)的文件时,才算作真正的 `identity_conflict`。如果本地根本没有该模型,应该返回普通的 `{"found": False}`,以便触发正常的“未找到模型”提示、Civitai 自动检索建议或下载引导。
#### 4. 修复建议
修改 `api/model_resolution.py`,只有在发现真正的冲突(如尺寸符合但哈希不匹配、或哈希符合但尺寸矛盾)时返回 `identity_conflict: True`;无任何匹配时应返回普通未找到:
```python
hash_matches = [candidate for candidate in candidates if target_hash in _candidate_hashes(candidate)]
if hash_matches:
return {"found": False, "identity_conflict": True}
return {"found": False}
```
---
### 🚨 缺陷 2(功能承诺未兑现 & 事件拦截冲突):素材库卡片“拖入画布载入工作流”无效且拖拽逻辑存在优先级漏洞
#### 1. 问题定位
- **文件 A**:`web/modules/ui_material_cards.js`(第 90-96 行)
- **文件 B**:`web/modules/material_drag.js`(第 55-84 行)
#### 2. 代码问题根源
1. **提示与实现脱节(虚假功能)**:
- 卡片上的 Tooltip 明确写着:`"按住可拖拽至画布节点注入参数,或拖至空白处载入工作流"`(`t('materialCardDragHint')`)。
- 但是在 `ui_material_cards.js` 中绑定拖拽时:
```javascript
if (material.node_types?.length) {
bindMaterialDrag(card, owner, {
payload: () => ({ ...material, node_types: [...material.node_types], dragHint: t('materialDragParameters') || '拖拽素材参数至目标节点' }),
accepts: (node, source) => source.node_types.includes(node.type),
drop: (node, source, graph) => applyLibraryMaterial(owner, source, node, graph),
// 致命缺失:根本没有传入 dropOnCanvas 回调!
});
}
```
**根本没有传入 `dropOnCanvas` 回调**!导致用户拖到空白画布释放时什么都没发生。
- 此外,如果一个素材是**整图工作流素材**(`material.node_types` 为空),因 `if (material.node_types?.length)` 为假,导致**整张卡片完全没有被绑定拖拽能力**(`draggable = false`),用户根本拖不动!
2. **`material_drag.js` 中的命中优先级缺陷(Precedence Inversion)**:
在 `material_drag.js` 的拖拽结束处理中:
```javascript
if (dropOnCanvas && overCanvas) {
try { await dropOnCanvas(event, data, graph); }
return;
}
if (!node || !accepts?.(node, data)) return;
try { await drop(node, data, graph); }
```
- 只要定义了 `dropOnCanvas`,一旦鼠标落在画布区域(`overCanvas == true`),代码会**无条件先执行 `dropOnCanvas`**(载入整图工作流覆盖画布)!
- 即便鼠标此时正精准悬停在某个兼容的节点上,也不会触发节点的参数注入!
#### 3. 修复建议
1. 在 `material_drag.js` 中调整执行优先级:**先判断是否命中了兼容节点(Target Node),若命中了则优先执行节点注入;仅当未命中任何节点且落在画布空白区域时,才触发 `dropOnCanvas` 载入工作流**。
2. 在 `ui_material_cards.js` 中,只要素材包含工作流(`material.has_workflow` 或 `material.data?.workflow`),就为其绑定 `dropOnCanvas: async (e, data, graph) => openMaterialWorkflow(owner, material.filename)`,并在 `material.node_types` 为空时也允许拖拽。
---
### ⚠️ 缺陷 3(交互体验问题):扫描向导(Scan Wizard)缺少遮罩点击关闭 & 纵向溢出
#### 1. 问题表现
- 扫描向导使用原生的 HTML `<dialog>` 挂载并使用 `showModal()`。
- 当弹窗内容较多(包含多项模型目录开关与说明)时,若屏幕纵向高度较小,底部“开始扫描”与“取消”按钮会被推到视口外部。
- 用户尝试点击周围半透明深色遮罩(Backdrop)时,无法关闭弹窗;由于未在 `dialog` 上绑定 `click` 检测 `event.target === dialog` 的关闭逻辑,用户必须手动向下滚动寻找底部取消按钮。
#### 2. 优化建议
- 在 `ui_scan_wizard.js` 中,为 Dialog 添加点击遮罩关闭逻辑:
```javascript
dialog.addEventListener('click', (e) => {
const rect = dialog.getBoundingClientRect();
const isInDialog = (rect.top <= e.clientY && e.clientY <= rect.top + rect.height
&& rect.left <= e.clientX && e.clientX <= rect.left + rect.width);
if (!isInDialog) dialog.close();
});
```
- 限制 `max-height: 85vh` 并对内部表单容器开启 `overflow-y: auto`,确保底部的操作按钮始终固定可见(Sticky Bottom)。
---
### ⚠️ 缺陷 4(同步与竞态隐患):模型单次精准扫描后前端下拉列表刷新时机
#### 1. 问题表现
- 在针对单个模型触发精准扫描(`triggerDirectModelScan`)时,后端会计算 SHA-256 并生成 `.info` 文件。
- 前端在通知扫描成功后,虽然调用了 `window.anomalous_reload_hashes()`,但在 ComfyUI 原生节点的下拉菜单选项(`widget.options.values`)重新拉取存在微弱的异步延迟,此时若立即在 Model Doctor 中点击“自动修复”,偶尔会触发 `optionsCacheStale`,需等待二次刷新才能消除红框。
#### 2. 优化建议
- 在单模型扫描写入完成后,增加 `await fetch('/anomalous/clear_cache', { method: 'POST' });` 并等待 `await app.refreshComboInNodes()` 完成后再触发 Doctor 的状态比对。
---
## 三、 总结与排查结论
1. **整体功能架构**:
插件整体架构非常先进且完整,Model Doctor、Material Library、Recipe 工作流、Scan Wizard 的多面板协调运作良好,UI 设计美观、动效细腻。
2. **核心问题收敛**:
- **核心 Bug 1**(`api/model_resolution.py` 误报 `identity_conflict`)是必须修复的高危逻辑漏洞,它直接导致工作流模型恢复方案失效;
- **核心 Bug 2**(素材库拖拽无响应及画布/节点优先级颠倒)直接影响了用户对于“素材一拖即用”的核心预期。
---
## 四、 修复复查与验收记录 (Verification Sign-off)
- **验收时间**:2026-09-19 20:30
- **修复 Commit**:`04b2eeff8f` (*fix(materials, doctor, scan): polymorphic material drag, third-party prompt nodes, model resolution, and scan wizard improvements*)
- **复查录屏文件**:`anomalous_fix_verify_1789820782702.webp`
### 1. 自动化回归测试结果
- **Python 后端单元测试**:运行 `unittest discover -s tests -p "test_*.py"`,**99 个测试全数通过 (OK)**。
- 特别验证 `test_dynamic_hash_mismatch_is_rejected` 与空候选测试,确认无哈希匹配时不再误报 `identity_conflict`。
- **前端契约与模块测试**:运行全部 32 个前端 `.mjs` 测试(涵盖 `studio_contracts.mjs`、`sidebar_feature_modules.mjs`、`material_feature_modules.mjs` 等),**32 项全部通过 (PASSED)**。
### 2. 浏览器端到端实测验证
1. **模型修复与身份冲突 (Model Doctor)**:
- 模型比对逻辑恢复正常,本地缺失模型返回 `found: False`,恢复正常下载与引导逻辑;
- 唤起模型医生点击 `🔄 刷新缓存` 顺畅运行,控制台 0 报错。
2. **素材库多态拖拽 (Polymorphic Material Drag)**:
- 全图工作流素材:卡片 Tooltip 提示更新为“按住拖至空白画布载入完整工作流”,拖拽至空白画布成功加载完整图谱;
- 提示词素材:支持拖拽至任意文本/提示词节点注入文本,或拖至空白画布自动创建对应 `CLIPTextEncode` 节点;
- 节点参数素材:拖拽至目标节点优先注入参数,拖至空白画布新建对应节点;修复了过去由于优先级颠倒导致的画布与节点拦截冲突。
3. **扫描向导 (Scan Wizard)**:
- 弹窗底部操作按钮已设置固定粘性定位(Sticky Bottom),在任何屏幕高度下均不会被挤出视口;
- 点击外部暗色遮罩 Backdrop 立即平滑关闭向导;
- 按下键盘 `Escape` 键立即响应并关闭向导。
4. **控制台与稳定性**:
- 全流程操作无任何未捕获的 JavaScript 异常或 HTTP 异常状态码。
**验收结论**:本次修复完整、精准,所有指出的严重缺陷及交互体验问题均已彻底解决并闭环。
+17
View File
@@ -0,0 +1,17 @@
# 审计与测试报告索引 (Audits & Verification Reports)
本目录专门用于归档与整理 Anomalous Model Browser 插件在各开发阶段的端到端浏览器实测、质量审计、缺陷复原以及验收记录。
---
## 报告目录归档
| 日期 | 报告文件 | 测试类型 | 涉及模块 | 验收状态 |
| :--- | :--- | :--- | :--- | :--- |
| **2026-09-19** | [AUDIT_AND_BUG_REPORT.md](file:///e:/ComfyUI_windows_portable/ComfyUI/custom_nodes/Anomalous_Model_Browser/docs/audits/AUDIT_AND_BUG_REPORT.md) | E2E 浏览器全功能实测 & 缺陷深度排查 | Model Doctor / 素材库多态拖拽 / 扫描向导 / 模型恢复 | ✅ 已全数修复并验收闭环 |
---
## 维护规范
1. 新增功能测试、跨版本兼容性体检或重大 Bug 排查报告时,请统一存放在本目录 (`docs/audits/`)。
2. 保持插件根目录整洁,避免在根目录直接堆放临时或专项测试文档。
+117
View File
@@ -0,0 +1,117 @@
# Current Architecture Decisions
This index records concise reasons for enduring product and architecture
boundaries. It is not a changelog. When a decision becomes obsolete, update or
replace the record and rely on Git history for the former implementation.
## AD-001 — One command, exclusive browser entries
The floating trigger, action-bar button, and Extensions command share one
`openBrowser()` command and one browser instance. Presentations are mutually
exclusive, while the Extensions command always remains as a recovery route.
Native ComfyUI command/keybinding infrastructure owns shortcut recording and
modal guards.
## AD-002 — Recipes are templates; notebooks are immutable instances
A Workflow Recipe is the authoritative workflow template. Parameter Notebooks
are immutable generation-value snapshots attached to a recipe and browsed inside
its Parameters tab. The Node Assistant may reuse those snapshots for a selected
same-type node, but this does not replace full-skeleton recipe application.
## AD-003 — Recipe canvas actions follow graph scope
Recipe cards and detail views append partial recipes because their open required
connections make them composition fragments. Complete, independently runnable
recipes load through ComfyUI's workflow path and therefore open as a new canvas
in the current frontend. Structural editing remains a separate, explicitly
confirmed workflow.
## AD-004 — Recipe save hides implementation policy
Save collects identity, notes, tags, and cover/source image. Presentation pins
and snapshot policy are implementation choices, not per-save questions. Legacy
pin data is preserved without exposing a control; new recipes default to none.
## AD-005 — Model identity excludes names and previews
Model Doctor automatically proves identity through provenance hash and required
category; byte size may disambiguate that hash. A unique size-only match is a
manual candidate, never proof or an automatic redirect. Hash/size conflicts are
rejected. Local paths, filenames, display names, previews, and similarity are
presentation or post-resolution routing data. This boundary prevents silent
substitution after local rename or across categories.
## AD-006 — Foundation components are hash-only recovery categories
VAE, VAE Approx, CLIP/Text Encoder, and CLIP Vision files can commonly share
sizes or ambiguous names. Automated redirection therefore requires one exact
in-category hash match. A size-only candidate may still be shown for explicit,
one-time confirmation on the current node. Existing native combo values remain
usable with a visible identity-change warning when provenance differs. This does
not restrict scan coverage: scanning may calculate hashes for any active
registered folder, including categories whose Civitai metadata coverage is weak.
## AD-007 — Recipe origins and local availability are separate
Official origin metadata can be refreshed or edited independently of saved
identity. Exact-path availability checks and previews describe the current
machine; they do not rewrite imported provenance or activate a local match.
## AD-008 — Prompt roles prefer unknown over a guess
Automatic prompt role tracing follows only allowlisted native prompt,
conditioning, and consumer nodes. Unknown third-party nodes are opaque. Legacy
full-value recovery must be exact and unambiguous, and users may store explicit
recipe-scoped overrides.
## AD-009 — Recipe-to-model navigation is recoverable and exclusive
Model detail temporarily replaces all main content surfaces while retaining a
small recipe return token. Back reconstructs recipe detail; unrelated navigation
abandons the token and cleans stale state. The transition never closes the outer
browser or exposes model detail beside Node Assistant.
## AD-010 — Parameter selection must identify both panes
The active Parameter Notebook is visible in the list and in a right-pane banner
with name and timestamp. Requests are selection-tokened so stale gallery results
cannot overwrite the new notebook. Sidebar input/button rows must remain usable
in the narrow docked layout.
## AD-011 — Output discovery remains bounded and user-refreshed
Recipe matching inspects at most the newest bounded set of PNG metadata and has
no persistent index. The main Gallery scans on open and refreshes manually. This
avoids background polling and hidden indexing cost while retaining predictable
freshness.
## AD-012 — Licensing and branding are separate
The standard MIT `LICENSE` applies to project-owned code and documentation.
`TRADEMARKS.md` separately governs the Anomalous Model Browser name and official
branding. Project-specific restrictions are not appended to the MIT license.
## AD-013 — Beta guidance is scoped to experimental workflow features
Workflow Recipes and recipe-powered Assistant presets carry localized backup and
data-safety guidance until an explicit stability decision removes it. Stable
Node Assistant model replacement and LoRA insertion are outside that warning.
Notices distinguish single-node preset application from full-skeleton Parameter
Notebook application.
## AD-014 — Architecture documentation is state, not history
Architecture documents change only when an owner, data flow, interface contract,
persistence format, security boundary, or critical invariant changes. Ordinary
fixes do not append “unchanged” entries. Git is the implementation history;
`CHANGELOG.md` is user-facing release history; `.agents/logs/ai_lessons.md` holds
only durable recurring traps.
## AD-015 — Recipe model mutations update all identity representations
The authoritative workflow widget, structured model reference, and node-scoped
Model Doctor Hash index form one mutation boundary. Local matching updates all
three through the archived full-recipe path. Partial append remaps Hash records
with node IDs and rolls them back with the inserted graph. Personal model notes
are recipe-scoped presentation data and may be excluded from package export.
+248
View File
@@ -0,0 +1,248 @@
# 代码结构拆分计划(交给 GPT-5.6-sol)
日期:2026-09-17。代码基线:`29b14d2c7`。**状态:阶段 0–7 已完成;自动化验收通过,真实 ComfyUI 宿主视觉/交互验收仍需在运行中的宿主完成。**
用户当前优先级是整理代码结构,暂停其他 UI 美化与功能扩展。本文保留原实施任务单,并在末尾记录实际完成结果;当前架构以专题文档和代码为准。
## 1. 要达到的结果
将大文件按实际职责拆开,做到修改一个页面或一项行为时,不必理解整个浏览器。保留现有界面、操作、接口和用户数据。每完成一个可验证的小阶段就本地提交,继续下一阶段,不必逐项询问用户。
不是把大文件切成 `part1/part2`,也不是迁移到新框架。不得为拆分引入打包系统、事件总线、通用服务容器或新依赖。
阅读顺序:[AGENTS.md](../../AGENTS.md) → [架构入口](../../ARCHITECTURE.md) → 当前阶段对应的专题。不要每轮通读所有文档。本文的文件名是拟定归属,先搜索是否已有等价模块;存在时优先复用。
## 2. 当前规模与真正的拆分点
以下是含空行的物理行数,仅用于定位;执行时按函数和调用方搜索,不按固定行号切割。
| 文件 | 行数 | 主要问题 |
| --- | ---: | --- |
| `web/styles.css` | 14,620 | 多个页面、主题和响应式规则混在一起,层叠顺序容易被破坏 |
| `web/modules/ui_recipe_detail.js` | 3,348 | 总览、模型匹配、参数编辑、版本、图库集中在一个模块 |
| `web/modules/ui_sidebar.js` | 2,963 | `createDOM()` 约 2,250 行,包含扫描向导、设置、工具箱、导航和窗口交互 |
| `web/modules/ui_doctor.js` | 2,186 | 模型诊断、节点助手、替换图库、参数方案交叉放置 |
| `web/modules/ui_detail.js` | 1,938 | 模型详情、编辑弹窗、高级选择器合并 |
| `api/recipes.py` | 1,514 | 工作流规范化、身份信息、图片、文件存储、历史和 HTTP 路由混合 |
| `web/modules/ui_gallery_detail.js` | 1,453 | 图片工作台生命周期、媒体交互、参数检查与保存操作集中 |
| `web/main.js` / `api/models.py` | 各 1,413 | 前者注册、设置、实例、浮动入口耦合;后者目录、媒体、修改、匹配路由耦合 |
| `web/modules/ui_recipes.js` | 1,297 | 列表筛选、卡片、保存/编辑对话框与保存流程混合 |
| `api/materials.py` | 1,294 | 素材构建、提示词规则、资源存储、查询、路由混合 |
| `web/modules/ui_materials.js` | 1,186 | 目录、详情、节点选择监听、应用操作混合 |
| `web/modules/ui_notebooks.js` | 1,042 | 工作台返回、笔记目录、编辑器和节点创建混合 |
`locales.js` 为 2,373 行,主体是字典,不优先拆。已有提示词工坊、来源中控等模块虽然仍较大,已有明确边界,放到后续复核,避免第一轮同时动所有系统。
**已确认的耦合:**
- `main.js` 通过 `AnomalousBrowser.prototype` 接入各 UI 模块。首轮保留方法名、参数和 `this` 绑定,只调整导入归属;不要同时更换整个实例模式。
- `ui_sidebar.js` 的扫描状态、语言刷新、按钮和导航大量依赖 `createDOM()` 闭包。移动函数前必须列出它读取、修改的外部变量。
- `api/materials.py` 从 `recipes.py` 导入了 12 个名称;`parameters.py` 也导入其中的参数签名、读取与图库辅助函数。后端不能把每份文件单独移动后再补循环导入。
- `ui_gallery_detail.js` 有模块级 `wb` 和元数据缓存。它们不能在多个新文件各复制一份。
- `api/__init__.py` 目前用星号导入路由。最后接线时应改成明确的模块引用,防止移动后隐式导出丢失或名称覆盖。
## 3. 拆分时保持的边界
依赖方向采用现有结构:**宿主接线 → 页面协调与视图 → 领域逻辑/存储与已有公共工具**。纯逻辑不能反向导入页面,子视图不能导入 `main.js` 获取实例。
- 每项状态只保留一份。已有跨页返回状态先留在当前 owner;局部 DOM、请求和清理归所属视图。迁移状态和移动代码分开提交,避免难以区分故障来源。
- 为实际功能传入必要容器、数据和回调。若拆分需要几十个闭包参数,说明边界选错,应把相关状态和行为一起归入该功能模块。
- 模块接入时直接修改所有内部调用方。只有确有外部调用或兼容需求的入口才保留薄转接,不建立永久的重复实现。
- 同一阶段保持路由 URL、请求/响应结构、文件路径、字段、存储锁、撤销行为、节点连接与参数写入语义不变。不迁移用户数据。
- 复用 `ui_lifecycle.js`、`ui_dom.js`、`recipe_actions.js`、`recipe_parser.js`、`recipe_identity.js`、`node_material_actions.js` 等现有能力,不再复制一套。
- 普通逻辑模块通常以 200–600 行为参考。确有多个职责才继续拆,不能靠压缩代码、删注释或空壳包装达标。语言表和样式表不套这个阈值。
- 文件改放子目录时重新计算相对导入,特别是 ComfyUI 的 `scripts/app.js` 和图片 URL。导入无副作用的模块不应注册第二个扩展、启动扫描或立即创建页面。
## 4. 分阶段执行
每个阶段允许拆成几个提交;一个提交完成一个可验证的职责迁移。不要一次把以下所有文件建好再尝试运行。
### 阶段 0:建立本轮基线
1. 查看状态和最新差异,记录起始提交;保留他人的改动。
2. 为即将移动的职责记录:入口/调用方、读写状态、全局监听、请求、DOM/CSS 依赖、关闭路径。只写本阶段,不建立全仓超大清单。
3. 先运行本阶段已有的相关测试,记录已有失败。需要补测试时优先覆盖真实行为,而不是源码中是否存在某个字符串。
4. 记录相关页面的当前视觉和交互。后续比较必须使用同一主题、窗口尺寸和数据;不拿不同时期截图作基线。
### 阶段 1:先从侧栏移走独立功能
目标文件:`ui_sidebar.js`。第一刀选扫描向导,不先重写导航。
| 拟建模块 | 迁移范围与接口 |
| --- | --- |
| `ui_scan_wizard.js` | `createWizardModal()` 及其专属构建/扫描控制;接收全局或指定模型范围与完成回调,返回打开、关闭与语言刷新所需接口 |
| `ui_folder_manager.js` | `openFolderManager()` 与该对话框专属请求、DOM、关闭逻辑;原来的目录浏览 `renderSidebar/loadFolders` 暂时保留 |
| `ui_help.js` | `showHelp()` 及内容构建,仅在打开时创建所需内容 |
扫描进度继续使用 `scan_progress.js`,不能新增另一份“是否正在扫描”。若该状态也被全局按钮使用,明确由扫描功能拥有,按钮只读取/订阅实际状态。关闭向导是否取消扫描必须保持当前语义,不能把 UI 关闭误当成后台任务取消。
验收:扫描向导打开/关闭、单模型与全局入口、失败反馈、文件夹管理取消与保存刷新、帮助关闭。涉及扫描或写文件的验证优先使用隔离数据与桩服务,不自动扫描用户整个模型库。
### 阶段 2:收拢外壳和入口
在阶段 1 稳定后,从 `createDOM()` 提取:
- `ui_settings_hub.js`:设置中心与模型显示设置。接口覆盖 open/close/refreshLanguage,保留原来的设置来源、键名和生效时机。
- `ui_toolbox.js`:工具箱内容、开关和底部按钮装配;复用 `tool_registry.js`、`shortcut_layout.js`、`sidebar_actions.js`、`ui_shortcut_organizer.js`。按**当前代码**保持固定栏与工具过滤规则,不顺便实施旧计划中的另一种交互。
- `ui_browser_navigation.js`:主面板切换与返回协调。先收拢现有 `hideAllPanels/closeWorkspace` 相关调用,不引入新的路由系统;笔记模块最终不再独占其他工作台的公共关闭逻辑。
- 窗口拖动、缩放、停靠先保留在外壳;仍形成独立大段时再提取 `browser_window.js`,复用生命周期工具,不改变尺寸偏好。
`createDOM()` 最终只创建骨架、装配功能、连接明确回调。初始化完所有被引用的控件后再绑定/刷新语言,避免迁移后出现 TDZ 或读取尚未初始化的按钮。
随后整理 `main.js`:浏览器类与方法接线放到 `browser.js`,浮动入口放到 `browser_entry.js`,设置描述与语言/主题应用放到 `interface_settings.js`。扩展注册、实例创建顺序和宿主钩子仍由 `main.js` 统一协调。设置模块通过参数/回调工作,不能反向导入 main;保留被其他模块调用的现有全局入口,先核实调用者再删除。
验收:首次打开、关闭重开、各主页面切换、配方→模型详情→配方、语言切换、设置保存、工具箱提示、停靠、窗口拖动与调整尺寸。确认只有一个浏览器实例和一份全局监听。
### 阶段 3:拆配方详情,再拆目录
`ui_recipe_detail.js` 留下 `showRecipeDetail()`、活动页签、一次详情会话的状态与生命周期协调。按下面顺序逐块移动:
1. `ui_recipe_versions.js`:`renderVersions/renderDiffPanel`。继续用 `recipe_diff.js`;恢复版本后的刷新由明确回调交回详情协调器。
2. `ui_recipe_gallery.js`:`renderRecipeGallery/showGalleryComparison` 及专属资源处理;图片详情打开通过回调或直接依赖现有图片工作台入口,不能绕素材目录模块中转。
3. `recipe_model_matching.js` + `ui_recipe_models.js`:前者负责匹配操作与结果,后者负责组成卡片、来源编辑和本地匹配操作界面;复用身份规则,不能从名称/预览图推断同一模型。
4. `recipe_parameter_data.js` + `ui_recipe_parameters.js`:先移出 `parameterNodeOrder/topologicalSortNodes/parseEditorValue/paramsWithPromptRole` 等无 DOM 逻辑,再移参数方案 UI。较大的原始节点检查与编辑器可分别放 `ui_recipe_node_inspector.js`、`ui_recipe_parameter_editor.js`。
5. `ui_recipe_overview.js`:总览与元数据编辑,参数正文读取继续遵循真实工作流,不改用列表摘要。
不要把所有小函数扔进 `recipe_detail_utils.js`。格式化工具仅在确有多个调用者时共享;请求成功后的刷新由协调器处理,不让子页互相直接调用重绘。
接着将 `ui_recipes.js` 的 `showRecipeSaveDialog/showRecipeEditDialog` 放到 `ui_recipe_dialogs.js`,卡片渲染放 `ui_recipe_cards.js`;列表查询、筛选、分页与页面状态留在目录模块。`handleSaveRecipe` 继续协调实际捕获和保存,不能在新对话框中复制该流程。
验收:配方筛选与详情、长提示词、原始参数、参数方案保存再读回、模型详情往返、历史比较/恢复、图库打开/关闭。配方包导入导出保持当前关闭状态,移除的入口不恢复。
### 阶段 4:拆其余混合页面
逐行执行下表,每行独立提交与验证,禁止把全部页面同时改成半成品。
| 当前文件 | 拟定归属 | 重点验证 |
| --- | --- | --- |
| `ui_doctor.js` | 保留诊断面板和全局扫描;节点助手到 `ui_node_assistant.js`;`_openGalleryReplacer` 到 `ui_node_model_picker.js`;参数方案卡片与预览到 `ui_node_presets.js` | 哈希依据不变;替换正确节点;取消无写入;助手历史可用 |
| `ui_detail.js` | 保留 `showDetail`;`showEditModal` 到 `ui_model_editor.js`;`_openAdvancedModelSelector` 到 `ui_model_selector.js` | 详情往返、编辑保存、选择器取消、原节点与路径不串 |
| `ui_gallery_detail.js` | 保留工作台打开/关闭与唯一 `wb`;舞台缩放/胶片栏到 `ui_image_stage.js`;参数、提示词、模型与节点检查到 `ui_image_inspector.js` | 快速换图无旧结果覆盖;关闭释放媒体与请求;选中块保存准确 |
| `ui_materials.js` | 保留目录与分页;详情到 `ui_material_detail.js`;卡片到 `ui_material_cards.js`;应用和选择监听尽量归入已有 `ui_material_application.js` | 素材拖放仍整体替换;节点身份复核;详情读正文;翻页与筛选不丢 |
| `ui_notebooks.js` | 保留笔记目录与保存协调;编辑器到 `ui_notebook_editor.js`;画布创建到 `notebook_canvas.js`;公共返回已归阶段 2 | 保存队列和重开不丢内容;正负提示词隔离;节点创建与连接不变 |
图片元数据缓存可以留在协调模块,缓存上限和失效规则保持不变。需要跨模块使用时暴露窄访问接口,不复制缓存,不把 `wb` 作为可随意修改的全局对象导出。
### 阶段 5:样式独立拆分,不同时重新设计
保留 `web/styles.css` 作为外部入口。先核对 `main.js` 的 CSS 加载逻辑,再把样式拆到 `web/styles/` 下的基础控件、外壳、模型、图库、配方、素材、提示词、工具与主题等文件。
**首先保证原始层叠顺序。** 不能按关键词把分散的同名选择器直接合并搬走。先记录规则顺序与主题/媒体条件;需要时按连续职责块分两步迁移,稳定后再清理重复声明。混合多个领域的选择器先归公共部分,未经确认不拆成几个不同位置的规则。
低风险方案是入口顶部有序 `@import`。若使用多 link,也必须只有一份确定顺序的清单,并保留防重复加载。二选一,不能双重加载。不要引入 CSS cascade layers,因为它会改变已有优先级。
迁移时检查相对 `url(...)` 资源路径、`@keyframes`、主题限定、媒体查询和插件作用域;子样式的缓存刷新也要验证,不能只刷新入口版本。检查网络中是否缺文件、串行请求造成明显闪烁。没有测试依据,不顺便做按页面动态加载。
验收:同条件截图/关键计算样式对比,普通主题与深海血族、普通窗口与停靠、浮层层级、文件夹隐藏/恢复、提示定位、拖放与键盘焦点。此阶段不换配色、间距、动画或 DOM 结构。
### 阶段 6:先拆后端共享领域,再移动路由
先读 [后端专题](../architecture/backend.md)、[配方专题](../architecture/recipes.md)、[素材专题](../architecture/material-library.md)。
1. 从 `recipes.py` 提取 `workflow_schema.py`:工作流校验、指纹、参数签名、易变字段规则等纯计算。确属配方专有的规范化放 `recipe_schema.py`;不要把文件路径和 HTTP request 带入纯逻辑。
2. 提取 `recipe_store.py`:目录、读取、写入与历史操作;提取 `recipe_images.py`:来源图片验证、读取与封面处理。路径授权仍调用现有安全工具,I/O 仍放到原来的工作线程边界。
3. 同步改 `materials.py`、`parameters.py` 和 `recipe_packages.py` 的调用。后者目前是 `from . import recipes as recipe_store`,这个别名指向旧路由模块,不是拟建的存储模块。逐项检查其 `recipe_store.xxx` 访问,不能只搜索导入行或直接把别名换成新文件。
4. 路由模块依赖领域与存储模块,领域模块不得反向导入路由。锁和缓存只保留在实际拥有者中,同一类写入不能因拆文件变成两把独立锁。
5. `api/recipes.py` 最终保留 HTTP 参数解析、错误/状态码映射、调用和响应。不要为了“薄路由”把整个旧文件原封不动搬进另一个 1,500 行的 service。
随后拆 `api/models.py`:目录查询/列表、身份解析、元数据修改、媒体与封面分别归入 `model_catalog.py`、`model_resolution.py`、`model_metadata.py`、`model_media.py`。先按现有端点边界移动,公共路径与类别规则继续复用 `utils.py` 和根目录的 `model_policies.py/model_identity.py`。保留跨目录回退和受保护类别规则。
最后拆 `api/materials.py`:`material_schema.py` 负责构建与提示词规范化,`material_store.py` 负责记录及资源生命周期,路由模块负责请求响应和有界查询。若存储模块仍大,再将图片资产操作独立成 `material_assets.py`,不预先造空模块。
`api/__init__.py` 改成明确的模块引用和注册,例如 `from . import models`、`models.api_get_models`;比较拆分前后的 **HTTP method + path** 清单,避免少注册、多注册或重复注册。用户设置和导入导出 gate 保持不变。
验收全部使用临时目录与隔离配置:保存/读取往返、非法路径拒绝、失败保留旧文件、历史恢复、素材分页过滤、修改封面/元数据、禁用导入导出的 503。不能通过删除或覆盖用户真实数据测试。
### 阶段 7:收尾与剩余热点复核
- 再统计大文件,按职责复核 `ui_prompt_workbench.js`、`ui_prompt_source_deck.js`、`ui_model_sources.js`、`material_inspector.js` 和 `api/utils.py`。有独立职责才继续拆,并记录边界;不能仅因超过阈值机械拆分。
- 复核导入环、无人调用的旧导出、遗留重复实现和 `EXTRACTED` 占位。不要用任意 optional chaining 或空 catch 掩盖断掉的接线。
- 更新实际发生变化的架构专题与入口所有者列表。此时删除过时说明,不能把本文的拟定方案提前复制成已实现架构。
- 本计划逐阶段补充提交号、验证和剩余事项;发生中断时,下一轮从这里继续,不重新扫描整个仓库。
## 5. 验证与效率要求
每阶段先做 JS 语法/模块链接或 Python 编译/导入检查,再跑相关行为测试。涉及界面和宿主行为时还需真实 ComfyUI 验证,语法通过不能代替点击路径。移动模块后更新测试入口,尽量加载实际模块,不把待测行为本身 mock 成成功。
当前存在的定向检查(从插件根目录运行)。下表每个 JS 文件分别用 `node --experimental-vm-modules <文件路径>` 执行;此处参数表示替换成表内实际路径,不是一次执行整表:
| 影响范围 | 现有检查 |
| --- | --- |
| 外壳/快捷栏 | `tests/shortcut_layout.mjs`、`tests/shortcut_organizer.mjs`、`tests/update_guide.mjs` |
| 素材与节点应用 | `tests/material_workspace_flow.mjs`、`tests/studio_contracts.mjs` |
| 笔记 | `tests/notebook_render_test.mjs`、`tests/notebook_save_roundtrip.mjs` |
| 提示词工坊/读取 | `tests/prompt_ui_lifecycle.mjs`、`tests/prompt_material_source.mjs`、`tests/prompt_library_sync.mjs` |
| 模型来源 | `tests/model_sources_hub.mjs` |
| 导出 gate | `tests/export_availability.mjs`;Python 用便携环境执行 `python -B tests/test_export_availability.py` |
表中测试存在不等于本轮已经通过。配方详情、模型编辑、扫描向导、后端存储等未被这些测试充分覆盖的路径,需要有针对性地补行为验证,不能拿无关测试通过数充当覆盖。
新增测试先核对 `.gitignore`,必须明确哪些随仓库交付。每次只跑受影响的集合;阶段完成后不重复跑同一组,除非又改了代码或发现新问题。
完成标准:原调用方都接入新归属;原文件没有第二份实现;关键行为验证通过;入口和返回仍可用;无新增持续轮询、全量数据加载或监听泄漏。行数下降本身不是验收标准。
## 6. 交给 GPT-5.6-sol 的启动指令
> 请按 docs/plans/code-structure-refactor.md 整理 Anomalous_Model_Browser 的代码结构。先读 AGENTS.md 和相关架构专题,确认最新差异,从阶段 0、阶段 1 开始,然后按依赖顺序继续。保留现有外观、功能、数据和接口,不顺手美化,不恢复已关闭或移除的功能。
>
> 每次完成一个职责迁移,检查调用方、状态归属、闭包依赖和关闭路径,运行相关验证后本地提交,再继续。常规实现选择自行处理;遇到已有故障先区分基线与回归,不能隐藏或伪造成功。不要把所有文件一次拆完才验证。
>
> 每阶段在计划中记录实际模块归属、提交号、验证结果与未完成项。需要中断时留下可接续状态;不推送远端。最终说明职责如何变清楚、哪些行为已验证,以及还有哪些真实限制。
## 执行记录
- 阶段 0(完成):以 `c39701773` 为本轮起点,开工时工作区干净。基线通过
`shortcut_layout.mjs`、`shortcut_organizer.mjs`、`update_guide.mjs`;未建立真实
ComfyUI 点击截图基线,因此后续不能把 DOM 模拟检查称为宿主视觉验收。
- 阶段 1(完成,`2a53e3edf`):扫描向导归 `ui_scan_wizard.js`,文件夹管理归
`ui_folder_manager.js`,帮助弹窗归 `ui_help.js`。单模型卡片与全局扫描入口共用
`openScanWizard()`;新增行为检查覆盖帮助关闭、文件夹取消/保存刷新、单模型扫描请求
与向导关闭不取消后台扫描。
- 阶段 2(代码拆分完成,`1e42033ac`、`67d5be6277`、`a984ee4a31`):设置中心与模型卡显示设置归
`ui_settings_hub.js`,工具目录、固定快捷栏和工具分派归 `ui_toolbox.js`,公共面板
隐藏/配方详情清理归 `ui_browser_navigation.js`。`ui_sidebar.js` 已从约 2,963 行降至
约 780 行,`createDOM()` 现在主要保留外壳、顶部导航、窗口交互和子模块装配。
浏览器类和方法装配归 `browser.js`,浮动/顶栏/菜单入口及唯一实例归
`browser_entry.js`,语言和主题设置归 `interface_settings.js`;`main.js` 现只协调
扩展注册与宿主钩子。笔记、配方和素材共用的工作区返回协调也已迁入
`ui_browser_navigation.js`。新增模块边界测试覆盖注册描述、入口设置、语言、主题与
工作区恢复;真实 ComfyUI 拖动、停靠与窗口缩放仍待宿主验收。
- 阶段 3(代码拆分完成,`d8691d881`、`413b876dfe`、`56774a2d99`、`881570e887`、`e5f3301971`):配方版本历史/差异/恢复归
`ui_recipe_versions.js`,结果图库与图片工作台跳转归 `ui_recipe_gallery.js`,跨子页
共用的 DOM、长值展示和复制控件归 `ui_recipe_detail_dom.js`。删除了无人调用的旧图库
对比实现;图库不再借道素材目录模块打开图片工作台。目录筛选归
`ui_recipe_catalog.js`,卡片与拖拽归 `ui_recipe_cards.js`,保存/编辑对话框归
`ui_recipe_dialogs.js`,封面媒体归 `ui_recipe_media.js`;模型预览、匹配与显式替换归
`ui_recipe_model_matching.js`,总览归 `ui_recipe_overview.js`,提示词角色、参数编辑、原始节点与
参数预设归 `ui_recipe_parameters.js`,内联持久化归 `ui_recipe_metadata.js`。协调器
`ui_recipe_detail.js` 从约 2,983 行降至约 760 行,`ui_recipes.js` 从约 1,297 行降至约
690 行。新增检查覆盖版本恢复、图库交接、目录卡片隔离、模型替换持久化、参数拓扑/解析、
总览和提示词提取;真实 ComfyUI 视觉与交互仍待宿主验收。
- 阶段 4(完成,`e6217bb557`、`12875f9053`、`3e3e26d2b2`、`2951d610c5`、`7afa7518bc`):
模型诊断保留诊断、全局扫描和哈希协调,助手、原生控件选择器、参数方案分别归
`ui_node_assistant.js`、`ui_node_model_picker.js`、`ui_node_presets.js`。模型详情保留展示协调,
编辑器与高级选择器归 `ui_model_editor.js`、`ui_model_selector.js`。图片工作台仍唯一持有 `wb`
和元数据缓存,舞台/缩放/胶片条归 `ui_image_stage.js`,检查器标签归 `ui_image_inspector.js`。
素材目录、卡片、详情与节点应用/监听分别归 `ui_materials.js`、`ui_material_cards.js`、
`ui_material_detail.js`、`ui_material_application.js`。笔记目录与保存留在 `ui_notebooks.js`,
编辑器归 `ui_notebook_editor.js`,画布节点创建归 `notebook_canvas.js`。新增边界与行为检查覆盖
以上接线、监听器清理、控件路径、素材应用和笔记保存队列;真实 ComfyUI 视觉仍待宿主验收。
- 阶段 5(完成,`e8b50ecaf5`):`web/styles.css` 保留为唯一外部入口,并按原始字节顺序有序导入
`web/styles/00-foundation-models.css` 至 `10-model-sources.css`。所有子样式带同一缓存版本;
`css_bundle_order.mjs` 校验导入唯一/有序,且子文件拼接 SHA-256 与拆分前完全一致
(`d014a9d1826e2c48a46ac72389fe4e5bdd0b2ba85618f653d13852cd93812bc1`)。
未引入 cascade layer、动态加载或视觉改版;真实主题、停靠和浮层对比仍需宿主验收。
- 阶段 6(完成,`6ad975c1cd`、`9cebdbd994`、`a0c6849878`):配方的工作流规则、配方规范化、
图片和存储分别归 `workflow_schema.py`、`recipe_schema.py`、`recipe_images.py`、`recipe_store.py`;
模型目录、身份解析、元数据写入和媒体归 `model_catalog.py`、`model_resolution.py`、
`model_metadata.py`、`model_media.py`;素材规范化、资产和单一锁/缓存存储归
`material_schema.py`、`material_assets.py`、`material_store.py`。路由入口改为显式模块注册,
`test_route_manifest.py` 固定 68 个 method/path 对。兼容门面仅保留历史内部测试/调用点,不复制状态。
- 阶段 7(完成,`a63a615514`):复核后保留 `ui_prompt_workbench.js` 与
`ui_prompt_source_deck.js` 的单工厂闭包边界,避免拆出大量可变状态参数;`ui_model_sources.js`
仍是模型来源这一项功能的查询/编辑/持久化协调器;`material_inspector.js` 仍聚焦共享元数据与
节点参数检查。发现 `api/utils.py` 确有五类独立职责,已拆为 `path_utils.py`、
`media_routes.py`、`translation_routes.py`、`gallery_routes.py`、`folder_types.py`,原文件仅作兼容导出。
Python/JavaScript 相对导入图均无环,未发现 `EXTRACTED`/待提取占位或旧模型/配方/素材星号路由导入。
最终通过全部 29 个 `.mjs` 检查、89 个 Python `unittest`、全量 `api/*.py` 编译和路由清单检查。
本轮未连接运行中的 ComfyUI,因此不声称已完成真实拖放、停靠、主题、媒体释放和点击路径视觉验收。
+133
View File
@@ -0,0 +1,133 @@
# 工具箱与快捷栏自定义:交给 Gemini 的实施计划
状态:待实施的产品与工程计划,不代表当前版本已经具备以下功能。
基线:`77ae7ce88`;开工时以实际 Git 差异和现有代码为准,不回退后续工作。
## 目标与范围
把左下角做成用户可整理的快捷栏:工具箱的工具能拖到栏中,栏中的可移动工具能拖回工具箱,也能调整顺序。
同时完成短文字、详细提示的出现时间与位置调整。保留当前紧凑布局、工具箱外框尺寸和现有工具行为。
先阅读 `AGENTS.md`、`docs/architecture/frontend.md`、`docs/architecture/update-guide.md`,仅加载本次相关内容。
本任务有拖动状态与持久化,按下面阶段实施;不借机重写整个侧栏、换 UI 框架或引入拖拽依赖。
## 一、交互设计定稿要求
实施前用简短文字或草图说明:正常、拖出、拖回、排序、容量已满、取消六种状态。
按本计划做出完整方案后直接实施;常规视觉细节由 Gemini 决定,不增加多轮审批。若与现有代码冲突,优先保持业务语义。
### 1. 工具箱是完整目录,底栏是快捷入口
- 工具箱始终保留所有可用工具。拖到快捷栏代表“固定”,工具箱卡片仍在,并标注“已固定”。
- 从快捷栏拖回工具箱代表“从快捷栏移除”,不删除工具、不关闭正在执行的任务、不更改数据。
- 第一版固定两个锚点:最左的工具箱、最右的设置。它们不能被拖走,防止用户丢失整理和恢复入口。
- 中间最多 4 个可自定义入口,默认依旧是扫描、模型医生、节点助手、素材库,总共最多 6 个按钮。
- 工具箱中现有的导入导出中心、提示词工坊、翻译助手、模型来源也能固定到中间位置。
- 工具箱卡片顺序固定,快捷栏顺序由用户决定。第一版不做工具箱内部自由排序、分组、分页或自由浮动窗口。
- 拖出只接受快捷栏区域;丢到画布、图库、模型卡片或窗口外一律取消,不能生成节点或触发文件操作。
- 容量已满时不自动挤走其他工具,提示“快捷栏已满,请先将一个工具移回工具箱”;菜单固定与拖动规则一致。
- 重复固定不产生副本;已固定工具拖到快捷栏只调整已有位置。
- 提供“恢复默认快捷栏”,只重置本布局,不调用重置插件全部设置的功能。
### 2. 工具箱内的可发现性
- 在工具箱空白背景区域设置低干扰、可读的提示:“拖到下方,固定常用工具;拖回这里,收起快捷入口”。
- 使用独立的提示/放置区域,不把文字覆盖在卡片上;空间不足时改为底部短提示,不能撑大工具箱。
- 保持工具箱外框尺寸,工具增多只让内部列表滚动;底部提示/放置区保持可见,不遮挡卡片和关闭按钮。
- 工具卡片悬停或键盘聚焦时显露小拖动把手和“更多”操作入口;不要用常驻的大段教程挤占工具区域。
- 拖动快捷入口进入工具箱时,放置区域高亮并显示“松开,从快捷栏移除”;工具箱按钮本身也可作为拖回目标。
- 工具箱关闭时,拖到工具箱按钮上约 350 毫秒后自动展开;计时离开即取消。只在确实拖动快捷入口时启用。
- 没有完成放置时,不提前改动排列或保存设置。预览插入位置使用占位符/指示线。
### 3. 前一轮文字、时间与布局调整(本任务必做)
| 元素 | 第一版参数与行为 |
| --- | --- |
| 按钮内短文字 | 鼠标停留约 100 毫秒后,以约 120 毫秒的向上淡入替换图标;保持按钮尺寸与点击区域不变 |
| 完整说明框 | 从进入按钮开始累计约 600 毫秒后出现;不是短文字结束后再多等 600 毫秒 |
| 离开按钮 | 取消未触发的等待,详细提示立即隐藏;图标在约 100 毫秒内恢复,不保留离场延迟 |
| 说明框位置 | 默认在按钮上方,间隔约 8 像素;左右限制在实际侧栏边界内,并兼顾视口 |
| 排版 | 名称稍突出;正文常规字重、颜色稍淡、自然换行;不把整段解释都加粗 |
| 键盘 | focus-visible 立即显示短文字和清晰焦点,详细说明遵循约 600 毫秒等待;失焦立即清理 |
| 无悬停设备 | 短文字直接可见;提供点击“更多”的整理方式,不依赖悬停或长按才能使用 |
| 减少动态效果 | 保留识别文字,但取消位移动画,遵守已有 prefers-reduced-motion 规则 |
这些时间是初始设计值,真实浏览器验证时允许小幅调整,最终只在一处定义,不能散落在多个模块。
从一个按钮快速移到另一个时,重新计时,不闪现上一个按钮的说明。
拖动开始立即隐藏所有按钮提示,拖动期间不显示短文字过渡和说明框;结束后等待新的悬停/聚焦再触发。
提示框不能拦截点击、遮挡拖放目标;不要再使用超宽且正文加粗的提示框。
当前 tooltip 使用伪元素:若要分开标题/正文或准确限制边界,替换这一组按钮的提示渲染为一个共享提示节点,并移除旧提示属性/规则,避免出现两套提示。
不要改动插件所有其他 tooltip。新增可固定工具也必须有短标签、完整名称、说明与中英文翻译。
### 4. 拖动和普通点击必须分清
- 鼠标按下不立即拖动,移动超过约 6 CSS 像素才进入拖动态;工具箱优先从把手起拖,卡片正文点击继续打开工具。
- 快捷栏支持按住按钮拖动。超过阈值后的 pointerup/click 不能打开工具或触发扫描,即使最后取消也不能误点击。
- 执行原有动作只允许来自明确的普通点击或键盘激活;拖动预览不能调用工具 action。
- 拖动时保留来源占位、显示小型拖动预览和有效放置提示,不扩大按钮、推动整个侧栏或影响画布缩放。
- 拖动过程暂缓工具箱的“点击外部关闭”。与现有 document mousedown 关闭监听统一协调,不能边拖边把目标关闭。
- Esc、pointercancel、失焦、关闭插件、语言切换或重建相关视图均取消拖动并恢复原布局;清理指针捕获、全局监听、计时器和预览。
- 自动展开的工具箱取消后恢复此前开关状态;原本打开的工具箱保持打开。放置后可保留打开,方便继续整理。
- 在插件缩放和停靠模式下统一使用视口坐标与 getBoundingClientRect,不能混用缩放前尺寸计算落点。
### 5. 不会拖动也能完成整理
工具箱的“更多”操作提供“固定到快捷栏/从快捷栏移除”;快捷入口提供“左移/右移/移回工具箱”。
不要仅提供右键菜单,也不要依赖鼠标。使用真实 button、可读名称、可见焦点;操作后把焦点留在合理的位置。
菜单和拖动走同一个布局修改函数,包含相同的容量、去重、固定入口约束;成功放置可用 aria-live 简短播报。
## 二、工程结构
### 现有责任与风险
- `web/modules/ui_sidebar.js`:目前拥有工具箱卡片、侧栏按钮、扫描状态、打开/关闭和语言刷新;它已经很大,新完整拖动实现不要继续塞入 createDOM。
- `web/modules/sidebar_actions.js`:目前只认识六个硬编码 DOM ID;自定义后必须让新增快捷工具也能获取标签和用途。
- `web/modules/ui_lifecycle.js`:复用视图资源清理机制;不要新建永久全局拖动监听。
- `web/styles.css`:找到现有 sidebar-actions、toolbox、tooltip 规则并修改,移除本次取代的规则;禁止追加覆盖包。
- `web/modules/ui_update_guide.js`:保持上方手动入口,不因自定义布局或新引导版本恢复自动弹窗。
### 建议职责拆分(可按仓库命名习惯调整,不为拆分增加空壳)
1. 工具定义:一个稳定 ID 对应一份名称键、短标签键、说明键、图标和可固定属性;目录和快捷栏共用。动作回调在侧栏装配时显式传入,纯数据模块不读取画布。
2. 布局状态:单独的小模块负责默认值、去重/过滤、增删排序、容量与存储;只保存可移动 ID 的顺序,固定两端由视图添加。
3. 整理视图:独立模块负责拖动、放置预览、菜单与取消清理。只在有效放置/菜单确认时调用布局修改接口。
4. 现有 sidebar_actions 模块负责短标签和说明提示,可改为接收工具定义;禁止同时保留两套工具元信息和两套点击动作。
不要直接搬移或克隆工具 DOM 来实现固定:这会带走旧关闭逻辑、造成重复 ID 或丢失状态。
每个工具在两处调用同一个动作。保留现有隐藏其他面板、初始化、扫描中状态和错误反馈;不要通过隐藏按钮的 `.click()` 间接调用。
特别核对 `setScanButtonState`、`anomalous-scan-btn` 及语言刷新的所有调用方:扫描运行中移动入口,不能丢失进度、重复启动、产生失效引用。
DOM ID 保证唯一;两个可见入口用稳定 toolId 关联。当前未固定的工具也不能因为入口不在底栏而失去业务状态。
### 持久化与兼容
- 使用一个带 schemaVersion 的独立 localStorage 键,例如 `anomalous_shortcut_layout_v1`,内容仅为 `{ schemaVersion: 1, pinned: [...] }`。
- 首次使用采用原来的四个中间入口;显式的空数组是合法选择,不得又补回默认值。
- 读取时拒绝错误结构/未知版本;去掉未知工具 ID、重复项和固定锚点,限制容量;删除工具或升级后仍能打开界面。
- 不保存 DOM、翻译文本、回调代码或文件路径;不修改后端配置、工作流或素材数据。
- 写入失败保留当前会话布局,准确提示刷新后可能丢失;禁止伪报“已保存”,也不能让插件打不开。
- 第一版只在当前浏览器配置中持久化,不实现账户同步、多设备同步或布局导入导出。
## 三、不能改变的产品边界
- 导入导出快捷入口只打开现有已验证的 AMB0/AMB1 中心,仍是同一个中心;不能恢复配方、素材库、提示词工坊的独立文件导入导出。
- 保留素材提示词自动同步、来源标注和不可在工坊删除来源卡片的规则。
- 不修改图片/工作流哈希、模型识别、模型扫描内容、节点写入语义或用户文件。
- 不新增全局自动教程;功能上线后再更新引导内容配置和双语文案,仍由用户点击感叹号查看。
## 四、实施顺序与验收
1. **设计与状态**:说明六种交互状态,提取工具定义和布局纯逻辑,验证默认、空数组、去重、容量、损坏数据及存储失败。此阶段保留旧界面可运行。
2. **无拖动整理**:先让目录与快捷栏共用动作,通过菜单完成固定、移除、排序、恢复默认和刷新恢复;核对扫描状态、语言刷新、唯一 DOM ID。验证通过即本地提交。
3. **拖动与提示**:接入同一状态接口,实现阈值、放置、取消和文字/说明时间布局;验证后再提交,不另造第二套状态。
4. **交付**:仅更新改变的架构契约与用户可见更新说明。汇报实际验证、未测环境及本地提交,不推送 GitHub。
自动测试加载真实布局模块与交互模块,只模拟 DOM/存储边界。拖动至少覆盖成功放置、移出目标取消、Esc/父视图关闭清理、拖后不触发点击。
现有相关测试是 `node --experimental-vm-modules tests/update_guide.mjs`;扫描动作和已验证导入导出入口发生接线变化时追加相应回归,不默认跑全部后端测试。
新增测试确保被 Git 跟踪。语法/模块链接检查不能代替下面的浏览器操作:
- 工具箱 → 快捷栏、快捷栏 → 工具箱、栏内排序、重复固定、满栏拒绝、取消拖动;默认工具都能打开且只执行一次。
- 刷新、恢复默认、所有可移动项移除、扫描中移动入口、中英文切换;工具箱与设置始终可达。
- 快速掠过不闪说明,停留出现分层文案,边缘按钮提示不越出侧栏;拖动无提示遮挡。
- 普通窗口、窄侧栏、插件缩放、侧边停靠;工具箱尺寸稳定、内部可滚动、拖放坐标准确。
- 键盘菜单能完成同样操作;点击感叹号才显示更新引导。至少验证常用主题,未测环境如实说明。
按 AGENTS.md 及时保存阶段快照;若额度将尽或任务中断,保存有明确“验证待完成”说明的恢复快照,而不是无提交地把全部工作留到最后。
+88
View File
@@ -0,0 +1,88 @@
# 前端设计与 Gemini 协作方案
更新:2026-09-16。取代 9 月 9–11 日的前端视觉、布局建议,后续直接更新本文,不再按日期追加同类建议文件。
这是设计任务说明;工程规范以根目录 [AGENTS.md](../../AGENTS.md) 为准。具体任务若已有更新的专门方案,以专门方案为准。
## 1. 当前起点
本次核对基于 `3f71ddfcb` 的代码与计划,没有重新进行浏览器视觉验收。
- 配方已经有独立容器和工具栏,笔记有自己的标题,不再把“拆开创作工作台”列为待办。
- 提示词工坊已经独立。继续使用现有工坊,不恢复旧嵌入式组合面板,也不拿早期的简单组合设想限制现在已有的能力。
- 文件夹与配方覆盖层的定位问题已有修复,不重复下发修复任务。
- 工具箱已有入口;快捷栏自定义是**待实施计划**,见 [工具箱与快捷栏方案](customizable-tool-shortcuts.md)。不要把计划当作已经实现的功能。
- 当前配方包导入按钮处于禁用状态。设计可以解释不可用原因,不能为了演示效果直接启用、伪造成功或隐藏这个限制。
以上用于避免重复劳动,不代表所有关联功能已经验收通过。旧审查报告中的问题需要复现后判断,不能整份搬成当前待办,也不能直接宣布全部解决。
## 2. 怎样发挥 Gemini 的优势
让 Gemini 负责视觉层次、排版、密度、图标、过渡和响应式细节,并完成对应前端实现。不给每个像素下指令,也不要求每个小调整都等待批准。
任务边界和操作语义先明确:用户从哪里进入、想完成什么、写入什么、如何取消、完成后去哪里。Codex 可以负责梳理这些约束与检查数据、节点和生命周期;Gemini 做实现时也必须覆盖它们,不能把操作闭环全部留给下一位补。
**每轮交付一条完整操作流程。** “把工具箱做漂亮”范围模糊;“让用户从工具箱固定一个工具,再移除或取消,且任何时候都能恢复默认”才是可完成的任务。
## 3. 统一的设计方向
目标是清楚、紧凑、有质感的 ComfyUI 创作工具。图片和用户内容是视觉重点,装饰只辅助识别。
| 区域 | 设计职责 |
| --- | --- |
| 顶部导航 | 模型、图库、工作流配方;进入和返回的位置稳定 |
| 模型文件夹 | 只负责模型目录,不承担全局导航 |
| 左下工具区 | 默认保留素材库;工具箱是完整目录,快捷栏是常用入口。自定义行为遵循专门方案 |
| 列表工具栏 | 搜索、筛选、结果数、主操作;窄窗口可换行,控件不能互相挤没 |
| 内容卡片 | 图片、名称、类型和一项主操作。技术细节按需要展开,次操作可集中到“更多” |
| 详情与编辑 | 返回路径明确;区分查看、编辑、应用,明确应用到哪个节点或画布 |
不要为了统一外观强行让所有卡片显示同样字段。模型用于选择资产,配方用于复用工作流,素材用于复用内容,信息重点各不相同。
外观由 Gemini 在现有主题内自行发挥:统一按钮层级、间距、圆角与文字密度;选中、警告、禁用和键盘焦点要清楚。普通按钮不加持续发光或跳动。两套主题共享布局与操作规则,不能各自长出一套交互。
不把新的全站导航重构与本轮工具箱改动捆绑实施。发现其他页面需要调整时,写一句后续项即可。
## 4. 防止只考虑一个局部
动手前用几行列出本次操作链,并填下面这张表。只检查与本次变更有关的部分,不做每轮全仓审查。
| 必须想清楚的事 | 交付时需要回答 |
| --- | --- |
| 入口与出口 | 从哪个页面进入?关闭、返回、再次打开后分别是什么状态? |
| 正常与边界状态 | 加载、空内容、筛选无结果、失败、禁用、取消是否可理解?没有此状态的说明不适用 |
| 关联影响 | 改了共享按钮或浮层,哪些相邻页面、主题、提示框会受影响? |
| 操作对象 | 当前改的是快捷入口、素材数据、节点参数还是画布?文案是否说清楚? |
| 尺寸与输入 | 普通窗口、侧边停靠、长中文/英文、鼠标与键盘是否能完成同一任务? |
| 离开后的行为 | 悬浮预览、拖动、视频和请求会不会在关闭后继续活动或写错对象? |
例如“更多”菜单变漂亮,仍必须考虑靠右时是否出界、被父容器裁切、滚动时是否漂移,以及 Esc 关闭的是菜单还是整个浏览器。选择一个代表页面打磨公共样式后,再核对受影响页面,不要求一次重做它们。
## 5. 性能怎么兼顾
- 默认采用静态布局与局部短过渡。大面积模糊、连续光效、鼠标移动驱动全页动画必须有实际价值,否则不用。
- 大列表继续按现有分页、缩略图和懒加载策略工作;不能为视觉预览一次性加载全部原图、详情和视频。
- 工具提示随内容和交互更新,不为一小段文字增加持续轮询。隐藏页面的事件、预览和请求按照已有生命周期释放。
- 验证本次增加的成本:是否多了持续任务、重复请求或重复监听,滚动和拖动是否出现明显卡顿。需要声称性能提升时提供同一条件下的测量;没有测量就不报百分比,也不猜显存变化。
## 6. 推荐工作顺序
**第一轮:完成工具箱与快捷栏专门方案。** 这是明确的当前任务单;视觉与交互一起完成,包含正常放置、拖回、取消、重复固定、容量已满和恢复默认,不顺手改其他页面导航。
**第二轮:统一受影响的公共控件。** 以工具箱的按钮、提示、选中和禁用状态为起点,检查它们在模型、配方、素材库中的实际使用。修改原规则,清掉本次替代的旧声明。
**第三轮:逐页优化信息密度。** 先选一个真实使用频繁的页面,完整处理列表、详情、返回和失败反馈,再决定下一页。无需为了执行本文件同时美化全插件。
每轮完成后更新状态,删除已完成待办。验证发现的新问题直接修复;超出本轮范围的只记录具体触发条件,避免积累泛泛的“以后再优化”。
## 7. 可直接发给 Gemini 的任务模板
> 先阅读 GEMINI.md、AGENTS.md,以及 docs/plans/frontend-design-brief.md。本轮只完成【具体操作流程】,具体规则以【对应方案】为准。
>
> 先简述入口、正常操作、取消/失败、返回路径,以及会受影响的相邻界面,然后直接实施。颜色、排版和细节由你在现有主题内决定,不必逐项询问。
>
> 交付完整交互,不只做正常状态的截图。保留真实数据和操作语义,覆盖相关的窄窗口、主题、键盘与关闭路径。发现接口或业务阻塞时明确说明,不能用演示数据、假成功或静默隐藏功能替代。
>
> 完成后用简短清单说明:改变了什么、实际验证了哪些路径、哪些未验证、剩余的具体问题和本地提交。验证范围与改动相称,不默认跑全部测试;未完成项不得写成已完成。
这样用户主要判断“是否顺手、是否好看”,无需每次替实现者补齐所有边界情况。
+34
View File
@@ -0,0 +1,34 @@
# 后续构想:让 AI 从图库中帮助创作
记录日期:2026-09-18。
**状态:仅保存想法,现阶段不开发。本文只描述想实现什么,不规定代码、接口或技术方案。**
## 核心想法
图库不仅用于看图,也积累了生成图片时使用的提示词、模型、参数和工作流。未来允许用户连接自己选择的 AI 服务,从这些创作记录中总结经验,推荐值得尝试的工作流组合。
希望它能回答:“根据我喜欢的结果、已经拥有的模型和现在想画的内容,下一次可以怎样搭配?”
## 希望提供的体验
- **分析选中的作品**:选择一张或一组图片,查看它们使用了哪些模型、提示词和参数,有哪些共同点与差异。
- **推荐工作流组合**:结合创作目标与现有模型,推荐可尝试的节点、模型、提示词和参数组合,并解释推荐理由、参考了哪些作品、需要补充哪些条件。
- **总结个人偏好**:结合收藏、喜欢/不喜欢及主动评价,帮助用户发现自己偏好的风格和搭配,而不是把生成次数最多的组合当成最好。
- **比较与改进**:对比满意与不满意的结果,提出值得尝试的调整方向;通过后续试图和反馈,逐步判断建议是否适合用户。
- **整理可复用经验**:把用户认可的总结整理成提示词笔记、素材或工作流配方,方便再次使用。
- **探索更多玩法**:以后可以考虑用自然语言寻找历史作品、归纳某个模型的常用搭配、为新题材寻找可参考的旧工作流。逐项评估,不要求一次做完。
## 对结果的期望
推荐应当具体、可解释,区分“图库记录中的事实”和“AI 提出的尝试建议”。缺少必要模型或节点时提前说明,用户确认后再决定是否保存或应用。
仅凭参数不能保证找出“最佳工作流”,也不能把相关性当作某个参数造成画面改善的证明。有图片分析能力时可结合实际画面;没有时应明确仅依据参数与用户评价。
## 保留的边界
- 图库中并非每张图片都带有完整参数或工作流;缺失信息要明确标注,不让 AI 编造来源和配置。
- 用户主动选择分析范围,并能在发送前了解将交给外部 AI 的图片、提示词和工作流内容;不默认上传整个图库。
- 分析与应用分开:不自动改写现有工作流、不覆盖记录、不自动下载模型或启动生成。
- 用户能控制分析规模与费用;现有看图和素材功能保持独立,不连接 AI 也能正常使用。
这是一项长期探索方向,当前优先完成现有功能收尾。真正启动时再讨论最小可用范围。
+29
View File
@@ -0,0 +1,29 @@
# 素材库:保存画布选中节点
记录日期:2026-09-18。
**状态:后续需求,仅记录,本轮不开发。**
## 想做什么
在 ComfyUI 画布中选中一个或多个节点,通过一个方便的入口直接保存到素材库,方便以后复用,不必先生成图片或建立工作流配方。
- 单选:将这个节点及其当前参数保存为一份素材。
- 多选:将全部选中节点作为一个整体,保存为一份素材,不拆成多个独立条目。
- 保存选中节点之间的内部连线;与未选中节点相连的部分不自动扩展收录,不能留下指向不存在节点的无效连线。
- 保存不改变当前画布、节点参数或现有连接。
## 建议的最小交互
- 优先考虑节点或画布右键菜单中的“保存到素材库”,单选和多选共用入口;具体入口接入方式开发时核对 ComfyUI 能力。
- 轻量确认框显示“已选 N 个节点”,填写名称即可保存,标签可选;不强制提供封面。
- 未选中节点时不允许保存;成功提示只在实际保存完成后出现,并提供查看新素材的入口。失败保留输入,便于重试。
- 点击保存时固定节点选择与参数快照,后续切换选择不改变本次保存对象。
## 开发时注意
- 先核对现有节点素材、参数素材的保存与应用能力,复用素材库的存储、列表、详情和校验流程,不另建一套收藏系统。
- 当前图片/配方来源的保存接口不能直接假定支持画布快照;需要改协议时,明确新增来源的校验与兼容规则,不伪造图片来源。
- 单节点素材应能继续参与已有节点参数应用;多节点素材保留整体结构。首次开发需明确复用入口,保存成功后必须能在库中查看并通过相应流程使用。
- 先做好单选、多选、内部连线、外部连接边界、无选中节点和保存失败的验证;不借此扩展复杂节点组编辑器或自动收集依赖。
正式开发前遵循 [AGENTS.md](../../AGENTS.md),阅读 [素材库架构](../architecture/material-library.md),按最新实现细化方案。本文件不表示功能已完成。
+157
View File
@@ -0,0 +1,157 @@
# 模型来源中控:链接管理范围与后续方案
更新:2026-09-18。实施基线:`1b2013fe69`;接手时先核对最新代码。
**状态:2026-09-18 已完成“交给 5.6Sol”范围的代码与自动化验收;真实 ComfyUI 后端需重启后再做最终页面复核。** 本文是唯一交接计划,后续原地更新。
## 交给 5.6Sol:本轮只做目录范围对齐与基础组件折叠
### 目标与已确认原因
来源中控仍只管理链接。补齐本地模型列表,让用户通过现有文件夹管理决定纳入哪些目录,同时将基础组件默认收起,降低整理负担。
已确认的代码问题:
- 中控 `fetchAllLibraryModels()` 调用 `/anomalous/all_scan_models`,后端 `_collect_all_scan_models()` 只读分类模式的 `get_active_folder_types()`,没有遵循当前物理目录模式。
- 本次本地配置中,物理目录模式的 `clip` 已开启,但分类配置的 `text_encoders` 关闭。这能解释本地库遗漏 CLIP;不能因此认定所有缺项都只有这一种原因。
- 此接口只枚举 `.safetensors/.ckpt/.pt/.bin/.sft`,遗漏 `.gguf/.pth`,且扩展名比较区分大小写。
- 上轮提交只补了工作流收集、基础组件标签与对应类别的元数据读取,没有修复本地目录范围。
### 一、目录范围与文件格式
1. **本地库遵循文件夹管理当前模式。** 分类模式按分类勾选;物理目录模式按物理目录勾选。隐藏目录不遍历、不读取元数据;不自动开启 CLIP 等目录,不改用户配置。
2. 复用 `api/folder_types.py` 的模式、启用目录规则。若需提取目录枚举,放在该职责内,返回真实的 `type/path_idx/base_dir`;不能只拿物理目录名当模型类别。物理模式下同一类别可对应多个根目录,必须逐根判断,不能把启用其中一个解释为全部启用。
3. 对同一实际根目录进行规范化去重,保留可被后续接口解析的类别与路径索引。不要按 basename 合并模型;不同目录里的同名文件是独立条目。
4. 补齐此列表的 GGUF/PTH,并统一大小写判断。仅表示“可列出并记录链接”,不代表这些格式已支持元数据解析、身份恢复或云端识别。不要直接扩大全局 `MODEL_EXTENSIONS` 后让所有读写路径被动接受新格式。
5. `/all_scan_models` 同时被 `ui_model_selector.js` 使用。先检查两处调用;保持返回结构、分页与 `limit=0` 语义,验证选择器没有因此丢项或出现重复。确需区分调用用途时再加最小兼容参数,不先造专用数据库或另一套扫描系统。
6. **当前工作流仍列出实际引用的模型**,不被本地库隐藏目录设置抹掉。文件夹设置控制库盘点范围,不能让分享工作流时遗漏依赖。
### 二、基础组件默认折叠
- 工作流和本地库都保留两个显示组:主要模型直接显示;基础组件放进默认收起的 **“基础组件(高级) · N”**。使用可键盘操作的折叠控件,不再加一层弹窗。
- 本轮基础组件固定为现有策略已覆盖的 `clip/text_encoders/clip_vision/vae/vae_approx`。Checkpoint、扩散模型、LoRA、ControlNet 留在主要列表。其他已启用类别维持原有展示,避免顺手建设全站“核心/非核心”分类系统。
- 展开状态由当前中控会话持有,筛选、编辑、保存后的刷新应保留;重新打开默认收起。折叠时不要生成所有组件行的 DOM。
- 无来源的基础组件显示 **“未填写(可选)”**,不警告、不计入主要模型“来源待补充”数量;已有来源照常查看、修改和保存。
- “全部/已填写来源/来源待补充”筛选作用于主要模型;组件组独立于这三个来源状态筛选,展开后显示全部组件。搜索同时作用于两组,折叠栏数量随搜索更新;文案明确“基础组件来源可选,不计入待补”。
- 工作流里确定缺少本地文件的组件,折叠栏仍显示 **“缺少本地文件 × 项”**;该警告按当前范围全部组件计算,不被搜索或来源筛选隐藏。动态下拉选项未知不等于文件缺失,保留上轮判断保护。
- 底部统计明确标为“主要模型来源:已填写 X / Y”,使用当前搜索与筛选后的主要列表;组件数量单列在折叠栏,不把未填可选来源算成未完成任务。
- 无主要模型但存在组件时,保留组件入口;不能显示“未检测到任何模型”。目录未纳入范围时提供简短提示“本地库范围跟随设置 → 文件夹管理”,不增加重复配置界面。
- 所有新增文案同步中英文。使用现有样式变量和中性标签,不加发光、动效或强提醒。
### 三、保存、性能与职责边界
- 折叠只影响显示,不能删除已有来源或改变底层模型集合。保存到工作流、生成 Note 保留当前整份工作流语义;复制摘要应明确当前显示范围,不能因折叠悄悄改成导出所有内容。
- 本地保存继续使用准确的 `type/path_idx/subfolder/filename`。未定位实际文件的工作流组件只允许记录工作流链接,不得猜测路径写入 `.civitai.info`。
- 保留人工链接、原文件名、封面和已有 info。打开、展开、筛选均不触发云端扫描、哈希计算或附属文件写入。
- 列表枚举继续放在已有工作线程路径;折叠、搜索只处理已加载数据,不反复全盘读取。加载成功但为空、加载失败、尚未加载要能区分;打开时若指定 library 范围也须正确加载。
- `ui_model_sources.js` 已超过规模阈值:优先把目录结果整形、分组/筛选等有明确职责的逻辑提取到来源中控专用模块,弹窗保留生命周期与协调。只在本轮需要处提取,不做全仓拆分,也不把业务塞入通用 utils。
### 修改落点
| 位置 | 本轮责任 |
| --- | --- |
| `api/folder_types.py`、`api/model_catalog.py` | 当前模式对应的目录范围、路径去重、列表格式支持 |
| `web/modules/ui_model_sources.js` 及必要的来源专用模块 | 组件折叠、分组筛选、统计、空态与加载状态 |
| `web/modules/model_policies.js` | 复用现有类型判断,避免改动重命名或身份恢复策略 |
| `web/modules/locales.js`、`web/styles/10-model-sources.css` | 中英文文案、局部折叠布局 |
| `tests/model_sources_hub.mjs`、`tests/model_source_components.mjs` | 更新为新交互,同时保留准确定位与保存保护测试 |
| 对应 Python 测试 | 使用临时目录验证目录模式、过滤与去重,不能拿真实模型做写入夹具 |
### 验收清单
1. 物理模式开启 `clip`、分类配置关闭 `text_encoders` 时,库仍显示所选物理目录里的 CLIP;反向关闭物理目录时不枚举它。分类模式按自身配置工作。
2. 多根目录只启用其中一个、路径别名指向同一实际根、不同根有同名文件,均得到正确范围与去重结果;隐藏根目录零遍历、零元数据读取。
3. 无来源模型仍出现;GGUF/PTH 及大写扩展名可列出。现有格式、分页、选择器调用继续可用。
4. 基础组件初始折叠,展开后可查看/编辑;切换筛选或保存不丢展开状态与输入内容。主要待补计数不含组件;搜索、仅有组件、全空、加载失败均有明确结果。
5. 折叠时仍能看到组件缺文件告警;“无来源”和“文件缺失”独立。搜索无匹配不能抹掉当前工作流的缺文件告警。
6. 折叠与筛选不清空已保存链接。已定位文件准确保存;未知目标不写本地;关闭窗口后迟到请求不重新渲染。
7. 跑上述两组 JS 测试和对应 Python 目录枚举测试。实际 ComfyUI 验证两个范围、展开/收起、筛选搜索、窄窗口和原有选择器;写入/缺失场景用隔离夹具,不清空真实链接来制造测试条件。
8. Python 改动需重启 ComfyUI 才生效;只有前端刷新不能证明新接口已加载。不要强停有任务的实例;如暂不能验证新后端,明确报告缺口。
9. 完成后审查差异、更新本文状态与最窄的架构专题、本地提交,不推送。架构中的目录范围说明也要同步区分分类与物理模式。
### 本轮实施记录(2026-09-18)
- 已增加文件夹管理范围的统一根目录枚举,来源库按当前分类/物理模式取根,保留 `type/path_idx`、真实路径去重,并只在来源库列表补充大小写无关的 GGUF/PTH。
- 已提取来源中控专用的数据整形与分组模块;工作流和本地库的基础组件默认折叠,主要模型筛选、组件搜索计数、缺文件提示和当前可见范围复制各自保持独立语义。
- 自动化验证:来源中控两组 JS 专项测试、95 项 Python 测试、CSS/本地化契约通过;临时目录覆盖物理/分类范围、隐藏根零遍历与零元数据、路径别名、同名文件和 GGUF/PTH 大小写。
- 已在运行中的 ComfyUI 页面验证默认折叠、键盘展开、来源筛选不隐藏组件、搜索计数、主要模型统计与本地库范围提示;并在独立 Python 进程按真实物理配置确认 7 个启用根,其中 `text_encoders/path_idx=1` 正确对应已开启的 `clip` 根。运行实例未强停;Python 路由仍是重启前模块,目录范围新逻辑须在安全重启后复核。当前浏览器控制器不支持临时窄视口覆盖,窄窗口实机检查仍待补。
### 本轮不做
多来源切换、HF 抓图/info、平台多选、一键补全、扫描结果共享、全站格式支持和目录设置重做均不做。下文“链接可信度”中的历史识别/保存问题另列后续,不因本次计划一并扩张;若阻断上述验收,才做最小必要修复并说明。
## 已完成:工作流基础组件链接展示(2026-09-18)
- 复用模型类别判断,显示文本编码器、VAE、预览 VAE、视觉编码器标签;纳入无扩展名的组件选项及 PTH/GGUF 文件引用。
- 来源待补充使用普通提示,与本地缺失警告独立;保留手动填写和保存到工作流的能力。
- 基础组件只按对应类别、明确相对路径读取本地元数据,不搜索其他类别或进行云端扫描。不能定位实际文件时不提供本地附属文件写入;无扩展名选项、后端尚不支持定位的文件格式仍可保存工作流链接。
- 本地库目录范围、封面、文件名保持现状。大于 16 项的组件分批读取,覆盖后端请求上限。
- 验证:来源中控与组件专项测试通过;真实 ComfyUI 页面确认 CLIP/VAE 标签和已有链接正常。真实库当前没有待补来源条目,缺失、手动填写和保存分支使用隔离夹具验证,未改写用户链接或画布。
## 当前方向:先把链接管好
中控现阶段定位为模型链接管理页,负责查看、填写、修改、打开与保存来源链接。保留已有工作流/本地库范围与各自保存含义;不因为缩小范围顺手删除现有功能。
下次实施优先解决与链接可信度直接相关的问题:
- 无来源的模型必须能够被看到并补链接。“没有来源链接”不能成为排除模型的条件。
- 区分“未填写来源”和“本地缺少模型文件”,不把两者都显示成缺失模型。
- 自动识别失败返回的搜索页只能作为“去搜索”入口,不能保存为已识别来源、显示识别成功或计入已补全数量。当前 `autoDetectModelSource()` 的搜索回退及调用方提示需要一起检查。
- 保存成功以实际写入结果为准;明确“保存到本地”和“写入当前工作流”的区别。无法确定本地文件身份时,不凭同名文件猜测写入位置。
- 保留手工录入的合法网页链接,不把用户填写的地址称为“已验证官方来源”。打开链接不隐式抓图、覆盖介绍或启动扫描。
用户报告“缺失来源的模型没有显示”,当前静态代码没有直接按空链接排除列表项。本次已确认目录模式不一致与格式遗漏,见上面的实施范围;其他来源识别与保存问题仍需分别复现,不将它们统称为已修复。
## 暂缓一:模型详情的多来源入口
双链接可行,但应理解为“同一模型有多个来源”,不是切换到另一份模型数据。以下是后续建议,尚未实现。
- 只有一个来源时保持现有入口;两个来源时,详情的来源区域并列显示 `Civitai`、`Hugging Face` 两个文字按钮。无需为了两个链接引入整个页面的切换标签。
- 如果确实需要默认入口,可提供“首选来源”单选;切换只影响默认打开哪个链接,不改变封面、介绍、模型文件、哈希或扫描设置。
- 第三个及更多来源再考虑菜单,不预先建设无限多平台管理器。无链接的平台不显示空按钮。
- 当前 `civitai_url` 是独立字段,`source_url` 是通用自定义来源,不能假定后者必然是 HF。初版可优先复用现有字段;确需额外首选来源字段时,单独定义兼容规则。
- 多个链接并存不证明文件一致。确认匹配仍依赖现有模型身份规则;不能根据名称近似自动合并模型。
不建议首版让“切换来源”同时切换整套封面和 info:这会引入两套详情状态、缓存和失败回退,超出当前链接管理范围。
## 暂缓二:扫描来源的单选/多选
放在现有扫描向导或相关设置里,不在中控再做另一套扫描控制台。
明确区分三件事:
| 选项 | 含义 |
| --- | --- |
| 模型范围 | 扫描哪些本地模型,沿用现有选择能力 |
| 来源平台 | 去哪些已支持的平台获取信息,可单选或多选 |
| 首选链接 | 默认打开哪个已保存来源,与扫描平台互不联动 |
推荐平台选择采用复选框:只勾一个就是单来源,勾多个就是多来源,无需再增加“单选模式/多选模式”开关。只展示真正实现并可用的提供方,不能提前让 HF 扫描成为可点击的空功能。
多平台补全规则另行实施时必须一起确定:优先复用本地信息、只补缺项、保留人工内容、一个平台失败不丢其他结果、明确取消语义。已有链接不等于已有封面;已有 info 文件也不等于每个字段完整。
## 暂缓三:中控与扫描共享结果
两者共用现有模型附属信息读写能力,不建立中控专用数据库,也不让两个界面分别维护一份权威副本。
- 向导保存结果后,中控在打开时或收到完成通知后重新读取;不用持续轮询所有模型。
- 中控保存的手工链接,向导可读取并保护。链接只说明用户记录的来源,不代表扫描器已经支持解析该平台。
- 如增加“补全缺失来源”,仅复用已有扫描任务,限定当前范围并显示真实成功/未找到/失败结果,不自动扫描整个模型库。
## 暂缓四:HF 信息补全
- 工作流基础组件展示已完成,见上文;不据此扩大本地库目录范围或重做全站模型类型系统。
- 基础组件保留原文件名,不强制提供封面;没有封面时使用类型图标。
- 填写 HF 链接时保留现有封面、介绍和 Civitai 信息,不将自定义链接写进 Civitai 身份字段。
- 将来如实现 HF 信息读取,先只读明确仓库的模型卡与有明确声明的缩略图;缺失时保留本地信息。不做自动抓任意 README 图片、全网找封面或无限平台适配。
- 图片和介绍的来源与下载链接可以不同,但应可辨别,且不能把仓库级说明误当成已验证的具体文件信息。
参考:[HF 模型卡官方说明](https://huggingface.co/docs/hub/model-cards)。这只是后续技术参考,不表示本项目已支持自动抓取。
## 后续实施顺序
1. 先完成本文“交给 5.6Sol”范围:目录模式对齐、列表格式补齐、基础组件折叠与统计。
2. 再单独核查历史识别结果与保存反馈,完成链接管理的基本用途。
3. 确有需要时再做模型详情双来源入口;此时仍不联动封面或扫描。
4. 最后评估扫描结果共享、平台多选及 HF 元数据读取,逐项实施,不一次打包上线。
实施时遵循 [AGENTS.md](../../AGENTS.md),核对最新代码和相关测试;本文不替代架构契约,也不授权自动启用所有暂缓功能。
+39
View File
@@ -0,0 +1,39 @@
"""File SHA-256 evidence shared by the standalone scanner and API."""
import os
import re
def normalise_sha256(value):
if not isinstance(value, str) or not re.fullmatch(r"[0-9a-fA-F]{64}", value.strip()):
return ""
return value.strip().lower()
def computed_file_identity(path, digest):
digest = normalise_sha256(digest)
if not digest:
raise ValueError("Invalid file SHA-256")
stat = os.stat(path)
return {
"algorithm": "sha256", "scope": "file", "value": digest,
"size": stat.st_size, "mtime_ns": stat.st_mtime_ns, "source": "computed",
}
def sidecar_file_hash(data, path, selected_file):
"""Keep cached file evidence separate from unverified embedded model hashes."""
identity = data.get("anomalous_file_identity")
if identity is not None:
if not isinstance(identity, dict) or identity.get("algorithm") != "sha256" or identity.get("scope") != "file":
return "", ""
stat = os.stat(path)
if identity.get("size") != stat.st_size or identity.get("mtime_ns") != stat.st_mtime_ns:
return "", ""
return normalise_sha256(identity.get("value")), "computed file SHA-256"
# Older offline records could label a BLAKE3/tensor digest as SHA-256.
# Keep their presentation data, but require a scan or on-demand verification.
if data.get("id") == -1 or data.get("modelId") == -1:
return "", ""
hashes = selected_file.get("hashes", {}) if isinstance(selected_file, dict) else {}
return normalise_sha256(hashes.get("SHA256")), "sidecar file SHA-256"
+53
View File
@@ -0,0 +1,53 @@
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",
})
# This is a recovery-confidence policy, not a scanner-support list. The scan
# wizard may hash these folders, but Model Doctor must not redirect one of these
# components from size, filename, metadata labels, or Civitai availability.
HASH_ONLY_RECOVERY_TYPES = frozenset({
"vae",
"vae_approx",
"clip",
"text_encoders",
"clip_vision",
})
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)
+76 -66
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,8 @@ import urllib.parse
import argparse
import shutil
from typing import Dict, Optional
from model_policies import is_physical_rename_protected
from model_identity import computed_file_identity
# Fixed tuples avoid rebuilding long extension lists for every scanned model.
@@ -33,6 +38,37 @@ SIDECAR_SUFFIXES = (
*PREVIEW_SUFFIXES,
*CIVITAI_BACKUP_SUFFIXES,
)
def write_scan_progress(progress_file, phase, total=0, current=0, filename=""):
if not progress_file:
return
temp_file = f"{progress_file}.{os.getpid()}.tmp"
try:
with open(temp_file, 'w', encoding='utf-8') as f:
json.dump({
"phase": phase,
"total": total,
"current": current,
"filename": filename,
}, f, ensure_ascii=False)
os.replace(temp_file, progress_file)
except OSError:
try:
os.remove(temp_file)
except OSError:
pass
def count_scan_files(target_folder, target_files_basenames):
total = 0
selected = set(target_files_basenames)
for _, _, files in os.walk(target_folder):
total += sum(
1 for filename in files
if filename.endswith(".safetensors") and (not selected or filename in selected)
)
return total
# ==============================================================================
# CIVITAI API 配置读取
@@ -69,35 +105,6 @@ def calculate_sha256(file_path: str) -> str:
import struct
def extract_safetensors_hash(file_path: str) -> Optional[str]:
"""尝试从 safetensors 头文件中以 O(1) 速度提取内置的 Hash,跳过全量计算"""
try:
with open(file_path, "rb") as f:
header_size_bytes = f.read(8)
if len(header_size_bytes) < 8:
return None
header_size = struct.unpack('<Q', header_size_bytes)[0]
if header_size > 100 * 1024 * 1024: # 异常大小保护 (头文件大于100MB)
return None
header_json_bytes = f.read(header_size)
header_str = header_json_bytes.decode('utf-8')
header_json = json.loads(header_str)
metadata = header_json.get('__metadata__', {})
if not metadata:
return None
# 优先级1: 标准 modelspec
if 'modelspec.hash.sha256' in metadata:
return metadata['modelspec.hash.sha256']
if 'modelspec.hash.blake3' in metadata:
return metadata['modelspec.hash.blake3']
except Exception as e:
pass
return None
def infer_base_model_from_header(file_path: str) -> str:
"""从 safetensors 头文件的张量键名推断底层 Base Model (用于脱机/HuggingFace 兼容)"""
try:
@@ -219,13 +226,19 @@ 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 传入的模型目录类型,用于执行安全策略")
parser.add_argument("--progress-file", default="", help=argparse.SUPPRESS)
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")
@@ -286,19 +299,31 @@ def main():
if args.target_files:
target_files_basenames.extend([os.path.basename(t.strip()) for t in args.target_files.split(',')])
print(f"[*] 开始扫描文件夹: {target_folder}")
print(f"[*] 开始扫描文件夹: {target_folder}")
if args.dry_run:
print("==================================================")
print("[警告]: 当前处于 Dry-Run (空跑) 模式,不会修改系统中的任何文件!")
print("==================================================")
for root, _, files in os.walk(target_folder):
for filename in files:
write_scan_progress(args.progress_file, "enumerating")
total_files = count_scan_files(target_folder, target_files_basenames)
write_scan_progress(args.progress_file, "scanning", total_files)
current_file = 0
last_progress_write = 0.0
for root, _, files in os.walk(target_folder):
for filename in files:
if not filename.endswith(".safetensors"):
continue
if target_files_basenames and filename not in target_files_basenames:
continue
if target_files_basenames and filename not in target_files_basenames:
continue
current_file += 1
now = time.monotonic()
if current_file == 1 or current_file == total_files or now - last_progress_write >= 0.2:
write_scan_progress(args.progress_file, "scanning", total_files, current_file, filename)
last_progress_write = now
file_path = os.path.join(root, filename)
old_base = os.path.splitext(file_path)[0]
@@ -327,6 +352,7 @@ def main():
print(f"\n---> 处理文件: {filename} (位于 {root})")
civitai_data = None
file_hash = None
if info_exists and needs_rename:
info_path = old_base + ".info"
if not os.path.exists(info_path):
@@ -339,29 +365,10 @@ def main():
pass
if not civitai_data:
file_hash = None
file_hash = calculate_sha256(file_path)
if not args.offline_only:
# Fallback 1: Try header hash on Civitai
header_hash = extract_safetensors_hash(file_path)
if header_hash:
print(f"[*] 成功从头文件提取 Hash: {header_hash},尝试请求 Civitai...")
civitai_data = fetch_civitai_info(header_hash)
if civitai_data:
file_hash = header_hash
# Fallback 2: If header hash fails (or doesn't exist), compute full SHA256
if not civitai_data:
print(f"[*] 头文件 Hash 未命中或不存在,计算全量物理 SHA256...")
full_hash = calculate_sha256(file_path)
civitai_data = fetch_civitai_info(full_hash)
file_hash = full_hash
else:
print(f"[*] Offline-only: 跳过 Civitai 获取,将强制使用脱机张量推断")
file_hash = extract_safetensors_hash(file_path)
if not file_hash:
file_hash = calculate_sha256(file_path)
civitai_data = fetch_civitai_info(file_hash)
# Fallback 3: Local Offline Inference (if Civitai still fails or offline_only)
if not civitai_data:
if args.skip_local_metadata:
@@ -419,6 +426,8 @@ def main():
# 兼容性大刀阔斧改革:直接保存全宇宙最原汁原味的格式
# ==========================================
info_data = civitai_data
if file_hash:
info_data["anomalous_file_identity"] = computed_file_identity(file_path, file_hash)
if args.virtual_rename:
info_data["anomalous_custom_name"] = f"{model_name}_{version_name}"
@@ -522,8 +531,9 @@ def main():
success_count += 1
# Save scan results
if not args.dry_run:
# Save scan results
write_scan_progress(args.progress_file, "complete", total_files, total_files)
if not args.dry_run:
result_path = os.path.join(target_folder, ".scan_result.json")
try:
with open(result_path, 'w', encoding='utf-8') as f:
Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 267 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

+179 -83
View File
@@ -1,5 +1,11 @@
import { app } from "../../scripts/app.js";
import { translate } from './modules/locales.js';
import { anomalousConfirm } from './modules/ui_dialog.js';
import { findWorkflowHashRecord } from './modules/recipe_provenance.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 +23,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 +45,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 +68,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;
}
@@ -96,12 +110,22 @@ window.anomalous_resolve_all_missing_nodes = async function (is_manual = false,
let fixed_count = 0;
// These are per-check results shown by Model Doctor. Clear stale evidence
// before resolving the current graph again.
for (const node of app.graph._nodes) {
for (const widget of (node.widgets || [])) {
delete widget.anomalous_resolution_status;
delete widget.anomalous_size_candidate;
}
}
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 +140,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 +183,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)) {
@@ -204,7 +228,39 @@ window.anomalous_resolve_all_missing_nodes = async function (is_manual = false,
resData = await res.json();
}
if (resData.found) {
if (resData.identity_conflict) {
w.anomalous_resolution_status = 'identity_conflict';
continue;
}
if (resData.confirmation_required) {
w.anomalous_resolution_status = 'size_candidate';
w.anomalous_size_candidate = {
filename: resData.filename,
type: resData.type,
size: resData.size,
};
// Background checks may discover a candidate, but only an
// explicit manual check is allowed to ask for and apply it.
if (!is_manual || silent) continue;
const confirmed = await anomalousConfirm(
translate('doctorSizeCandidatePrompt', {
original: val,
candidate: resData.filename,
size: resData.size ?? s ?? '',
}),
translate('doctorSizeCandidateTitle'),
{
okLabel: translate('doctorUseSizeCandidate'),
cancelLabel: translate('doctorKeepMissing'),
},
);
if (!confirmed) continue;
resData = { ...resData, found: true, manually_confirmed: true };
}
if (resData.found) {
const normVal = val.replace(/\\/g, '/');
const normRes = resData.filename.replace(/\\/g, '/');
@@ -240,7 +296,8 @@ window.anomalous_resolve_all_missing_nodes = async function (is_manual = false,
finalValue = refreshedMatch;
}
console.log(`[Anomalous Hash Resolver] Auto-fixed missing model: ${val} -> ${finalValue}`);
const repairKind = resData.manually_confirmed ? 'Manually confirmed' : 'Auto-fixed';
console.log(`[Anomalous Hash Resolver] ${repairKind} missing model: ${val} -> ${finalValue}`);
w.value = finalValue;
const wIdx = node.widgets.indexOf(w);
if (wIdx !== -1 && node.widgets_values) {
@@ -249,8 +306,10 @@ window.anomalous_resolve_all_missing_nodes = async function (is_manual = false,
delete node.color;
delete node.bgcolor;
node.has_errors = false;
node.anomalous_auto_resolved = true;
node.anomalous_original_missing_val = val;
node.anomalous_auto_resolved = !resData.manually_confirmed;
node.anomalous_original_missing_val = val;
delete w.anomalous_resolution_status;
delete w.anomalous_size_candidate;
if (w.callback) {
w.callback(w.value, app.canvas, node, app.canvas.graph_mouse, null);
}
@@ -335,65 +394,102 @@ 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)) : {};
const existingProvenance = {
extra: {
anomalous_hashes: extraObj.anomalous_hashes
? JSON.parse(JSON.stringify(extraObj.anomalous_hashes))
: {},
},
};
extraObj.anomalous_hashes = {};
const liveSources = (this.extra && this.extra.anomalous_model_sources) ||
(app.graph?.extra && app.graph.extra.anomalous_model_sources) ||
extraObj.anomalous_model_sources || null;
if (liveSources && typeof liveSources === 'object') {
extraObj.anomalous_model_sources = JSON.parse(JSON.stringify(liveSources));
}
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) {
let hashObj = typeof cache_data === 'string' ? { hash: cache_data, size: "" } : cache_data;
if (matchingWidget && requiresHashForModelRecovery(liveNode, matchingWidget) && !hashObj.hash) {
const preserved = findWorkflowHashRecord(existingProvenance, node.id, val);
if (preserved?.hash) hashObj = preserved;
else {
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 {
const preserved = findWorkflowHashRecord(existingProvenance, node.id, val);
if (preserved) {
extraObj.anomalous_hashes[`${node.id}_${val}`] = preserved;
if (normVal !== val) extraObj.anomalous_hashes[`${node.id}_${normVal}`] = preserved;
} else if (!unscanned_models.includes(basename)) {
unscanned_models.push(basename);
}
}
} else {
// Missing dropdown values cannot be rediscovered from the
// local cache. Preserve recipe/workflow provenance so Model
// Doctor can still recover the exact appended reference.
const preserved = findWorkflowHashRecord(existingProvenance, node.id, val);
if (preserved) {
const normVal = val.replace(/\\/g, '/');
extraObj.anomalous_hashes[`${node.id}_${val}`] = preserved;
if (normVal !== val) extraObj.anomalous_hashes[`${node.id}_${normVal}`] = preserved;
} 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 +497,5 @@ app.registerExtension({
return origLoadGraphData.apply(this, arguments);
};
}
}
});
}
});
+25 -582
View File
@@ -1,589 +1,32 @@
import { showDetail, showEditModal, _openAdvancedModelSelector, setWidgetValuePath } from './modules/ui_detail.js';
import { loadModels, applyModelToCanvas, stopMediaInContainer } from './modules/ui_grid.js';
import { createDOM, renderSidebar, loadFolders, showHelp, hideAllPanels, openFolderManager } from './modules/ui_sidebar.js';
import { loadGalleryImages, refreshGalleryImages, showGeneratedGallery, showGallerySelectMode, showGalleryViewer } from './modules/ui_gallery.js';
import { showNotebooks, closeWorkspace, refreshNotebooks, saveCurrentNotebook, deleteCurrentNotebook, renderNotebookEditor, fillNotebookGalleries, sendNotebookToCanvas } from './modules/ui_notebooks.js';
import { showRecipes, refreshRecipes, renderRecipeList, handleSaveRecipe } from './modules/ui_recipes.js';
import { initDoctorPanel, diagnoseNode, renderGlobalDashboard, initAssistantPanel, renderAssistantModelCard, _loadAssistantHistory, _openGalleryReplacer, openLoraInsertionPicker, runGlobalDoctorScan } from './modules/ui_doctor.js';
try {
if (typeof localStorage !== 'undefined') {
localStorage.removeItem('anomalous_btn_x');
localStorage.removeItem('anomalous_btn_y');
}
} catch (_) {}
import { app } from "../../scripts/app.js";
import { normalizeLocale, resolveLocale, translate } from './modules/locales.js';
// ============================================================================
// TABLE OF CONTENTS (TOC)
// 1. App Registration & Entry (Search for "app.registerExtension")
// 2. State & Class Constructor (Search for "class AnomalousBrowser")
// 3. UI - Sidebar (Search for "createDOM")
// 4. UI - Main Grid (Search for "renderGrid")
// 5. UI - Detail Panel (Search for "showDetail")
// 6. UI - Gallery Viewer (Search for "createGalleryViewer")
// 7. UI - Doctor Panel (Search for "createDoctorPanel")
// 8. Notebooks & Workflow Recipes (Search for "Notebook" or "Recipes")
// ============================================================================
let defaultLang = 'zh';
try {
let comfyDetected = false;
const aglLang = localStorage.getItem('Comfy.Settings.AIGODLIKE-COMFYUI-TRANSLATION.Language');
if (aglLang) {
defaultLang = aglLang.toLowerCase().includes('en') ? 'en' : 'zh';
comfyDetected = true;
} else {
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && (key.toLowerCase().includes('lang') || key.toLowerCase().includes('locale'))) {
const val = localStorage.getItem(key);
if (typeof val === 'string') {
const lowerVal = val.toLowerCase();
if (lowerVal.includes('zh') || lowerVal.includes('chinese')) {
defaultLang = 'zh';
comfyDetected = true;
break;
} else if (lowerVal.includes('en') || lowerVal.includes('english')) {
defaultLang = 'en';
comfyDetected = true;
break;
}
}
}
}
}
if (!comfyDetected && navigator.language && !navigator.language.toLowerCase().startsWith('zh')) {
defaultLang = 'en';
}
} catch (e) {
if (navigator.language && !navigator.language.toLowerCase().startsWith('zh')) {
defaultLang = 'en';
}
}
let currentLang = resolveLocale(localStorage.getItem('anomalous_lang') || defaultLang);
window.anomalous_browser_lang = currentLang;
const t = (key, params) => translate(key, params, window.anomalous_browser_lang || currentLang);
class AnomalousBrowser {
constructor() {
this.modal = null;
this.sidebar = null;
this.grid = null;
this.detailPanel = null;
this.currentType = 'loras';
this.currentPathIdx = 0;
this.currentSubfolder = '/';
this.foldersData = null;
this.expandedFolders = new Set(['/', 'checkpoints', 'loras', 'unet', 'diffusion_models']);
this.energySaving = localStorage.getItem('anomalous_energy_saving') === 'true';
this.cardThumbnailMode = localStorage.getItem('anomalous_card_thumbnail_mode') === 'original'
? 'original'
: 'balanced';
this.createDOM();
}
// [EXTRACTED] createDOM
// [EXTRACTED] showHelp
// [EXTRACTED] loadFolders
// [EXTRACTED] renderSidebar
// [EXTRACTED] loadModels
// [EXTRACTED] applyModelToCanvas
// [EXTRACTED] stopMediaInContainer
// [EXTRACTED] showDetail
show() {
if (this._idleReleaseTimer) {
clearTimeout(this._idleReleaseTimer);
this._idleReleaseTimer = null;
}
this.setTriggerVisible(false);
this.modal.classList.add('visible');
if (!this.foldersData) {
this.loadFolders();
} else {
this.loadModels();
}
}
close() {
this.modal.classList.remove('visible');
this.setTriggerVisible(true);
if (this._modelLoadController) this._modelLoadController.abort();
if (this._modelMediaObserver) this._modelMediaObserver.disconnect();
this.modal.querySelectorAll('video, audio').forEach(media => media.pause());
this.stopMediaInContainer(this.grid);
if (this._idleReleaseTimer) clearTimeout(this._idleReleaseTimer);
this._idleReleaseTimer = setTimeout(() => {
if (this.modal.classList.contains('visible')) return;
this.stopMediaInContainer(this.grid);
this.grid.replaceChildren();
this.models = [];
}, 90000);
}
import { createBrowserEntry } from "./modules/browser_entry.js?v=20260927-hotfix-1";
import { createInterfaceSettings, getCurrentLanguage, setAbyssalScarletTheme, t } from "./modules/interface_settings.js";
setTriggerVisible(visible) {
const trigger = this.triggerButton || document.getElementById('anomalous-trigger-btn');
trigger?.classList.toggle('anomalous-trigger-hidden', !visible);
}
// [EXTRACTED] showNotebooks
async translateText(text) {
if (!text || !text.trim()) return "";
try {
const res = await fetch('/anomalous/translate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: text, target_lang: window.anomalous_browser_lang === 'zh' ? 'zh-CN' : 'en' })
});
const data = await res.json();
return data.translated || text;
} catch (e) { return text; }
}
// [EXTRACTED] loadGalleryImages
// [EXTRACTED] showEditModal
// [EXTRACTED] showGeneratedGallery
// [EXTRACTED] showGallerySelectMode
// [EXTRACTED] showGalleryViewer
// [EXTRACTED] refreshNotebooks
// [EXTRACTED] saveCurrentNotebook
// [EXTRACTED] deleteCurrentNotebook
// [EXTRACTED] renderNotebookEditor
// [EXTRACTED] fillNotebookGalleries
// [EXTRACTED] sendNotebookToCanvas
// [EXTRACTED] setWidgetValuePath
// [EXTRACTED] hideAllPanels
// [Doctor & Assistant Panel methods extracted to ui_doctor.js]
showPreflightImportModal() {
let overlay = document.getElementById('anomalous-import-overlay');
if (!overlay) {
overlay = document.createElement('div');
overlay.id = 'anomalous-import-overlay';
overlay.style.position = 'fixed';
overlay.style.top = '0';
overlay.style.left = '0';
overlay.style.width = '100vw';
overlay.style.height = '100vh';
overlay.style.backgroundColor = 'rgba(0,0,0,0.85)';
overlay.style.zIndex = '9999999';
overlay.style.display = 'flex';
overlay.style.justifyContent = 'center';
overlay.style.alignItems = 'center';
const modal = document.createElement('div');
modal.style.width = '600px';
modal.style.maxWidth = '90vw';
modal.style.background = '#222';
modal.style.borderRadius = '12px';
modal.style.padding = '20px';
modal.style.border = '1px solid rgba(255,255,255,0.1)';
modal.style.boxShadow = '0 10px 30px rgba(0,0,0,0.5)';
modal.style.display = 'flex';
modal.style.flexDirection = 'column';
modal.style.gap = '15px';
const header = document.createElement('h2');
header.style.margin = '0';
header.style.color = '#fff';
header.textContent = t('mainPreflightTitle');
modal.appendChild(header);
const desc = document.createElement('div');
desc.style.color = '#aaa';
desc.style.fontSize = '14px';
desc.textContent = t('mainPreflightDesc');
modal.appendChild(desc);
const textarea = document.createElement('textarea');
textarea.id = 'anomalous-import-textarea';
textarea.style.width = '100%';
textarea.style.height = '150px';
textarea.style.background = '#111';
textarea.style.color = '#fff';
textarea.style.border = '1px solid #444';
textarea.style.borderRadius = '6px';
textarea.style.padding = '10px';
textarea.style.fontFamily = 'monospace';
textarea.style.resize = 'vertical';
textarea.style.boxSizing = 'border-box';
textarea.placeholder = '{"last_node_id": ...}';
modal.appendChild(textarea);
const resultArea = document.createElement('div');
resultArea.id = 'anomalous-import-result';
resultArea.style.maxHeight = '300px';
resultArea.style.overflowY = 'auto';
resultArea.style.display = 'none';
resultArea.style.flexDirection = 'column';
resultArea.style.gap = '10px';
resultArea.style.background = 'rgba(0,0,0,0.2)';
resultArea.style.padding = '15px';
resultArea.style.borderRadius = '8px';
modal.appendChild(resultArea);
const btnRow = document.createElement('div');
btnRow.style.display = 'flex';
btnRow.style.justifyContent = 'flex-end';
btnRow.style.gap = '10px';
const closeBtn = document.createElement('button');
closeBtn.textContent = t('mainCancel');
closeBtn.style.padding = '8px 16px';
closeBtn.style.background = 'transparent';
closeBtn.style.color = '#ccc';
closeBtn.style.border = '1px solid #555';
closeBtn.style.borderRadius = '6px';
closeBtn.style.cursor = 'pointer';
closeBtn.onclick = () => { overlay.style.display = 'none'; };
const analyzeBtn = document.createElement('button');
analyzeBtn.id = 'anomalous-import-analyze-btn';
analyzeBtn.textContent = t('mainAnalyzePreflight');
analyzeBtn.style.padding = '8px 16px';
analyzeBtn.style.background = '#1a73e8';
analyzeBtn.style.color = '#fff';
analyzeBtn.style.border = 'none';
analyzeBtn.style.borderRadius = '6px';
analyzeBtn.style.cursor = 'pointer';
const loadBtn = document.createElement('button');
loadBtn.id = 'anomalous-import-load-btn';
loadBtn.textContent = t('mainForceLoad');
loadBtn.style.padding = '8px 16px';
loadBtn.style.background = '#28a745';
loadBtn.style.color = '#fff';
loadBtn.style.border = 'none';
loadBtn.style.borderRadius = '6px';
loadBtn.style.cursor = 'pointer';
loadBtn.style.display = 'none';
let parsedGraphData = null;
analyzeBtn.onclick = async () => {
try {
const jsonStr = textarea.value.trim();
if (!jsonStr) return;
parsedGraphData = JSON.parse(jsonStr);
try {
const res = await fetch('/anomalous/all_hashes');
const hData = await res.json();
const hObj = hData.hashes ? hData.hashes : hData;
Object.assign(window.anomalous_hash_cache, hObj);
} catch (e) { }
const models = [];
if (parsedGraphData.nodes) {
for (const node of parsedGraphData.nodes) {
if (node.widgets_values) {
for (const v of node.widgets_values) {
if (typeof v === 'string' && (v.endsWith('.safetensors') || v.endsWith('.ckpt') || v.endsWith('.pt') || v.endsWith('.sft') || v.endsWith('.bin'))) {
models.push({ nodeType: node.type, value: v });
}
}
}
}
}
textarea.style.display = 'none';
resultArea.style.display = 'flex';
analyzeBtn.style.display = 'none';
loadBtn.style.display = 'block';
if (models.length === 0) {
const noDependencies = document.createElement('div');
noDependencies.style.color = '#28a745';
noDependencies.textContent = t('mainNoDependencies');
resultArea.replaceChildren(noDependencies);
return;
}
export { setAbyssalScarletTheme };
const detectedHeader = document.createElement('div');
detectedHeader.style.color = '#fff';
detectedHeader.style.fontWeight = 'bold';
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;
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';
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.textContent = t('mainReady');
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);
}
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';
}
if (resultArea) {
resultArea.style.display = 'none';
resultArea.innerHTML = '';
}
if (analyzeBtn) analyzeBtn.style.display = 'block';
if (loadBtn) loadBtn.style.display = 'none';
overlay.style.display = 'flex';
}
// [EXTRACTED] _openAdvancedModelSelector
}
// --- Extracted Module Bindings ---
AnomalousBrowser.prototype.initDoctorPanel = initDoctorPanel;
AnomalousBrowser.prototype.diagnoseNode = diagnoseNode;
AnomalousBrowser.prototype.renderGlobalDashboard = renderGlobalDashboard;
AnomalousBrowser.prototype.initAssistantPanel = initAssistantPanel;
AnomalousBrowser.prototype.renderAssistantModelCard = renderAssistantModelCard;
AnomalousBrowser.prototype._loadAssistantHistory = _loadAssistantHistory;
AnomalousBrowser.prototype._openGalleryReplacer = _openGalleryReplacer;
AnomalousBrowser.prototype.openLoraInsertionPicker = openLoraInsertionPicker;
AnomalousBrowser.prototype.runGlobalDoctorScan = runGlobalDoctorScan;
const browserEntry = createBrowserEntry({ translate: t, getCurrentLanguage });
AnomalousBrowser.prototype.showNotebooks = showNotebooks;
AnomalousBrowser.prototype.closeWorkspace = closeWorkspace;
AnomalousBrowser.prototype.refreshNotebooks = refreshNotebooks;
AnomalousBrowser.prototype.saveCurrentNotebook = saveCurrentNotebook;
AnomalousBrowser.prototype.deleteCurrentNotebook = deleteCurrentNotebook;
AnomalousBrowser.prototype.renderNotebookEditor = renderNotebookEditor;
AnomalousBrowser.prototype.fillNotebookGalleries = fillNotebookGalleries;
AnomalousBrowser.prototype.sendNotebookToCanvas = sendNotebookToCanvas;
app.registerExtension({
name: "Anomalous.ModelBrowser",
settings: [...browserEntry.settings, ...createInterfaceSettings()],
actionBarButtons: browserEntry.actionBarButtons,
commands: browserEntry.commands,
keybindings: browserEntry.keybindings,
menuCommands: browserEntry.menuCommands,
setup: browserEntry.setup
});
AnomalousBrowser.prototype.showRecipes = showRecipes;
AnomalousBrowser.prototype.refreshRecipes = refreshRecipes;
AnomalousBrowser.prototype.renderRecipeList = renderRecipeList;
AnomalousBrowser.prototype.handleSaveRecipe = handleSaveRecipe;
AnomalousBrowser.prototype.loadGalleryImages = loadGalleryImages;
AnomalousBrowser.prototype.refreshGalleryImages = refreshGalleryImages;
AnomalousBrowser.prototype.showGeneratedGallery = showGeneratedGallery;
AnomalousBrowser.prototype.showGallerySelectMode = showGallerySelectMode;
AnomalousBrowser.prototype.showGalleryViewer = showGalleryViewer;
AnomalousBrowser.prototype.createDOM = createDOM;
AnomalousBrowser.prototype.openFolderManager = openFolderManager;
AnomalousBrowser.prototype.renderSidebar = renderSidebar;
AnomalousBrowser.prototype.loadFolders = loadFolders;
AnomalousBrowser.prototype.showHelp = showHelp;
AnomalousBrowser.prototype.hideAllPanels = hideAllPanels;
AnomalousBrowser.prototype.loadModels = loadModels;
AnomalousBrowser.prototype.applyModelToCanvas = applyModelToCanvas;
AnomalousBrowser.prototype.stopMediaInContainer = stopMediaInContainer;
AnomalousBrowser.prototype.showDetail = showDetail;
AnomalousBrowser.prototype.showEditModal = showEditModal;
AnomalousBrowser.prototype._openAdvancedModelSelector = _openAdvancedModelSelector;
AnomalousBrowser.prototype.setWidgetValuePath = setWidgetValuePath;
app.registerExtension({
name: 'Anomalous.ModelBrowser',
async setup() {
const cssUrl = '/extensions/Anomalous_Model_Browser/styles.css?v=' + Date.now();
if (!document.querySelector(`link[href^="/extensions/Anomalous_Model_Browser/styles.css"]`)) {
const link = document.createElement("link");
link.rel = "stylesheet";
link.type = "text/css";
link.href = cssUrl;
document.head.appendChild(link);
}
if (!localStorage.getItem('anomalous_lang')) {
try {
if (app && app.ui && app.ui.settings) {
const locale = app.ui.settings.getSettingValue('Comfy.Locale') || app.ui.settings.getSettingValue('Comfy.Locale.Language');
if (locale) {
currentLang = normalizeLocale(locale) || defaultLang;
window.anomalous_browser_lang = currentLang;
}
}
} catch (e) { }
}
let browser = null;
const btn = document.createElement('button');
btn.id = 'anomalous-trigger-btn';
btn.textContent = '📦';
btn.setAttribute('aria-label', 'Anomalous Model Browser');
const ensureBrowser = () => {
if (browser) return browser;
try {
browser = new AnomalousBrowser();
browser.triggerButton = btn;
window.anomalousBrowserInstance = browser;
btn.classList.remove('anomalous-trigger-error');
return browser;
} catch (error) {
console.error('[Anomalous Model Browser] UI initialization failed:', error);
btn.classList.add('anomalous-trigger-error');
btn.title = currentLang === 'zh' ? t('mainRetryInit') : t('mainRetryInitEn');
btn.setAttribute('aria-label', btn.title);
return null;
}
};
btn.title = t('mainOpenTitle');
let isDragging = false;
let startX, startY, initialX, initialY;
btn.addEventListener('mousedown', (e) => {
isDragging = true;
startX = e.clientX; startY = e.clientY;
const rect = btn.getBoundingClientRect();
initialX = rect.left; initialY = rect.top;
btn.style.transition = 'none';
});
window.addEventListener('mousemove', (e) => {
if (!isDragging) return;
e.preventDefault();
let newX = initialX + (e.clientX - startX);
let newY = initialY + (e.clientY - startY);
if (newX < 0) newX = 0;
if (newY < 0) newY = 0;
if (newX > window.innerWidth - 60) newX = window.innerWidth - 60;
if (newY > window.innerHeight - 60) newY = window.innerHeight - 60;
btn.style.left = newX + 'px';
btn.style.top = newY + 'px';
btn.style.right = 'auto'; btn.style.bottom = 'auto';
});
window.addEventListener('mouseup', (e) => {
if (isDragging) {
isDragging = false;
btn.style.transition = 'transform 0.15s, box-shadow 0.15s';
localStorage.setItem('anomalous_btn_x', btn.style.left);
localStorage.setItem('anomalous_btn_y', btn.style.top);
if (Math.abs(e.clientX - startX) < 5 && Math.abs(e.clientY - startY) < 5) {
ensureBrowser()?.show();
}
}
});
const savedX = localStorage.getItem('anomalous_btn_x');
const savedY = localStorage.getItem('anomalous_btn_y');
const updateBtnBounds = () => {
let numX = parseInt(btn.style.left || savedX);
let numY = parseInt(btn.style.top || savedY);
if (isNaN(numX)) numX = window.innerWidth - 90;
if (isNaN(numY)) numY = window.innerHeight - 90;
if (numX < 0) numX = 0;
if (numY < 0) numY = 0;
if (numX > window.innerWidth - 60) numX = window.innerWidth - 60;
if (numY > window.innerHeight - 60) numY = window.innerHeight - 60;
btn.style.left = numX + 'px';
btn.style.top = numY + 'px';
};
if (savedX && savedY && savedX !== 'NaN' && savedY !== 'NaN') {
btn.style.right = 'auto';
btn.style.bottom = 'auto';
btn.style.left = savedX;
btn.style.top = savedY;
}
// Always trigger an update slightly after load to ensure it's in bounds
setTimeout(updateBtnBounds, 200);
window.addEventListener('resize', () => {
if (btn.style.left) updateBtnBounds();
});
document.body.appendChild(btn);
// Keep the entry point visible even when a panel regression prevents
// the full browser UI from being constructed.
ensureBrowser();
// Pre-create a lightweight, translucent, and aesthetic drag ghost image for huge Hires Fix images
window.anomalousDragGhostImg = new Image();
window.anomalousDragGhostImg.src = "data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='80' height='80'><rect width='76' height='76' x='2' y='2' fill='%231a1a1a' fill-opacity='0.6' rx='16' stroke='%2300ffcc' stroke-width='2'/><text x='40' y='50' font-family='sans-serif' font-size='32' font-weight='bold' fill='%2300ffcc' text-anchor='middle'>W</text></svg>";
}
});
// --- INJECTED WORKFLOW SHARE MODULE ---
// Workflow Share and Preview Modal for Anomalous_Model_Browser
const AMB_WorkflowShare = {
const AMB_WorkflowShare = {
// ----------------------------------------------------------------------
// 1. Data Compression & Base64 Utils
// ----------------------------------------------------------------------
@@ -758,7 +201,7 @@ const AMB_WorkflowShare = {
// ----------------------------------------------------------------------
// 4. Encode / Decode
// ----------------------------------------------------------------------
async encodeShareCode(workflowJson, isSkeleton) {
async encodeShareCode(workflowJson, isSkeleton) {
let targetJson = workflowJson;
if (isSkeleton) {
targetJson = this.skeletonize(workflowJson);
@@ -812,7 +255,7 @@ const AMB_WorkflowShare = {
}, 3000);
},
showExportModal() {
showExportModal() {
const overlay = document.createElement('div');
overlay.id = 'amb-export-modal';
overlay.style.cssText = `
@@ -1001,8 +444,8 @@ const AMB_WorkflowShare = {
const exportBtn = document.createElement('button');
exportBtn.textContent = t('mainExportWorkflow');
exportBtn.style.cssText = `padding: 12px; background: #4a90e2; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: 14px;`;
exportBtn.onclick = () => { overlay.remove(); this.showExportModal(); };
exportBtn.style.cssText = `padding: 12px; background: #4a90e2; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: 14px;`;
exportBtn.onclick = () => { overlay.remove(); this.showExportModal(); };
const importBtn = document.createElement('button');
importBtn.textContent = t('mainImportWorkflow');
+136
View File
@@ -0,0 +1,136 @@
import { showDetail } from './ui_detail.js';
import { showEditModal } from './ui_model_editor.js';
import { _openAdvancedModelSelector, setWidgetValuePath } from './ui_model_selector.js';
import { loadModels, applyModelToCanvas, stopMediaInContainer } from './ui_grid.js';
import { createDOM, renderSidebar, loadFolders } from './ui_sidebar.js';
import { closeWorkspace, hideAllPanels } from './ui_browser_navigation.js';
import { openScanWizard, triggerDirectModelScan } from './ui_scan_wizard.js';
import { openFolderManager } from './ui_folder_manager.js';
import { showHelp } from './ui_help.js';
import { loadGalleryImages, refreshGalleryImages, showGeneratedGallery, showGallerySelectMode, showGalleryViewer } from './ui_gallery.js';
import { showNotebooks, refreshNotebooks, saveCurrentNotebook, deleteCurrentNotebook } from './ui_notebooks.js';
import { renderNotebookEditor, fillNotebookGalleries } from './ui_notebook_editor.js';
import { sendNotebookToCanvas } from './notebook_canvas.js';
import { showRecipes, refreshRecipes } from './ui_recipe_catalog.js';
import { renderRecipeList, handleSaveRecipe } from './ui_recipes.js';
import { showMaterials, refreshMaterials, openSavedMaterial, openMaterialLibrary } from './ui_materials.js';
import { openPromptStudio } from './ui_prompt_composer.js';
import { openPromptTranslator } from './ui_prompt_translator.js';
import { closeUpdateGuide } from './ui_update_guide.js';
import { showImageWorkbench } from './ui_gallery_detail.js';
import { initDoctorPanel, diagnoseNode, renderGlobalDashboard, openLoraInsertionPicker, runGlobalDoctorScan } from './ui_doctor.js';
import { initAssistantPanel, renderAssistantModelCard, _loadAssistantHistory } from './ui_node_assistant.js';
import { _openGalleryReplacer } from './ui_node_model_picker.js';
export class AnomalousBrowser {
constructor() {
this.modal = null;
this.sidebar = null;
this.grid = null;
this.detailPanel = null;
this.currentType = 'loras';
this.currentPathIdx = 0;
this.currentSubfolder = '/';
this.foldersData = null;
this.expandedFolders = new Set(['/', 'checkpoints', 'loras', 'unet', 'diffusion_models']);
this.energySaving = localStorage.getItem('anomalous_energy_saving') === 'true';
this.cardThumbnailMode = localStorage.getItem('anomalous_card_thumbnail_mode') === 'original'
? 'original'
: 'balanced';
this.entryMode = 'floating';
this.createDOM();
}
show() {
if (this._idleReleaseTimer) {
clearTimeout(this._idleReleaseTimer);
this._idleReleaseTimer = null;
}
this.setTriggerVisible(false);
this.modal.classList.add('visible');
if (!this.foldersData) {
this.loadFolders();
} else {
this.loadModels();
}
}
close() {
closeUpdateGuide(this);
this.modal.classList.remove('visible');
this.setTriggerVisible(true);
const canvas = document.getElementById('graph-canvas');
if (canvas instanceof HTMLElement) canvas.focus({ preventScroll: true });
if (this._modelLoadController) this._modelLoadController.abort();
if (this._modelMediaObserver) this._modelMediaObserver.disconnect();
this.modal.querySelectorAll('video, audio').forEach(media => media.pause());
this.stopMediaInContainer(this.grid);
if (this._idleReleaseTimer) clearTimeout(this._idleReleaseTimer);
this._idleReleaseTimer = setTimeout(() => {
if (this.modal.classList.contains('visible')) return;
this.stopMediaInContainer(this.grid);
this.grid.replaceChildren();
this.models = [];
}, 90000);
}
setTriggerVisible(visible) {
const trigger = this.triggerButton || document.getElementById('anomalous-trigger-btn');
trigger?.classList.toggle('anomalous-trigger-hidden', !visible || this.entryMode !== 'floating');
}
}
AnomalousBrowser.prototype.initDoctorPanel = initDoctorPanel;
AnomalousBrowser.prototype.diagnoseNode = diagnoseNode;
AnomalousBrowser.prototype.renderGlobalDashboard = renderGlobalDashboard;
AnomalousBrowser.prototype.initAssistantPanel = initAssistantPanel;
AnomalousBrowser.prototype.renderAssistantModelCard = renderAssistantModelCard;
AnomalousBrowser.prototype._loadAssistantHistory = _loadAssistantHistory;
AnomalousBrowser.prototype._openGalleryReplacer = _openGalleryReplacer;
AnomalousBrowser.prototype.openLoraInsertionPicker = openLoraInsertionPicker;
AnomalousBrowser.prototype.runGlobalDoctorScan = runGlobalDoctorScan;
AnomalousBrowser.prototype.showNotebooks = showNotebooks;
AnomalousBrowser.prototype.closeWorkspace = closeWorkspace;
AnomalousBrowser.prototype.refreshNotebooks = refreshNotebooks;
AnomalousBrowser.prototype.saveCurrentNotebook = saveCurrentNotebook;
AnomalousBrowser.prototype.deleteCurrentNotebook = deleteCurrentNotebook;
AnomalousBrowser.prototype.renderNotebookEditor = renderNotebookEditor;
AnomalousBrowser.prototype.fillNotebookGalleries = fillNotebookGalleries;
AnomalousBrowser.prototype.sendNotebookToCanvas = sendNotebookToCanvas;
AnomalousBrowser.prototype.showRecipes = showRecipes;
AnomalousBrowser.prototype.refreshRecipes = refreshRecipes;
AnomalousBrowser.prototype.renderRecipeList = renderRecipeList;
AnomalousBrowser.prototype.handleSaveRecipe = handleSaveRecipe;
AnomalousBrowser.prototype.showMaterials = showMaterials;
AnomalousBrowser.prototype.openSavedMaterial = openSavedMaterial;
AnomalousBrowser.prototype.openMaterialLibrary = openMaterialLibrary;
AnomalousBrowser.prototype.openPromptStudio = openPromptStudio;
AnomalousBrowser.prototype.openPromptTranslator = function() { openPromptTranslator(this); };
AnomalousBrowser.prototype.refreshMaterials = refreshMaterials;
AnomalousBrowser.prototype.loadGalleryImages = loadGalleryImages;
AnomalousBrowser.prototype.refreshGalleryImages = refreshGalleryImages;
AnomalousBrowser.prototype.showGeneratedGallery = showGeneratedGallery;
AnomalousBrowser.prototype.showGallerySelectMode = showGallerySelectMode;
AnomalousBrowser.prototype.showGalleryViewer = showGalleryViewer;
AnomalousBrowser.prototype.showImageWorkbench = showImageWorkbench;
AnomalousBrowser.prototype.createDOM = createDOM;
AnomalousBrowser.prototype.openScanWizard = openScanWizard;
AnomalousBrowser.prototype.scanSingleModel = triggerDirectModelScan;
AnomalousBrowser.prototype.openFolderManager = openFolderManager;
AnomalousBrowser.prototype.renderSidebar = renderSidebar;
AnomalousBrowser.prototype.loadFolders = loadFolders;
AnomalousBrowser.prototype.showHelp = showHelp;
AnomalousBrowser.prototype.hideAllPanels = hideAllPanels;
AnomalousBrowser.prototype.loadModels = loadModels;
AnomalousBrowser.prototype.applyModelToCanvas = applyModelToCanvas;
AnomalousBrowser.prototype.stopMediaInContainer = stopMediaInContainer;
AnomalousBrowser.prototype.showDetail = showDetail;
AnomalousBrowser.prototype.showEditModal = showEditModal;
AnomalousBrowser.prototype._openAdvancedModelSelector = _openAdvancedModelSelector;
AnomalousBrowser.prototype.setWidgetValuePath = setWidgetValuePath;
+482
View File
@@ -0,0 +1,482 @@
try {
if (typeof localStorage !== 'undefined') {
localStorage.removeItem('anomalous_btn_x');
localStorage.removeItem('anomalous_btn_y');
}
} catch (_) {}
import { app } from '../../../scripts/app.js';
import { AnomalousBrowser } from './browser.js';
import {
clampFloatingTriggerPosition,
clearSavedTriggerPosition,
DEFAULT_SAFE_LEFT,
DEFAULT_SAFE_TOP,
isValidSavedTriggerPosition,
loadSavedTriggerPosition,
normalizeEntryMode,
normalizeFloatingTriggerSize,
normalizeFloatingTriggerStyle,
saveTriggerPosition
} from './entry_controls.js?v=20260921-flicker-free-1';
import {
createShortcutSettingControl,
DEFAULT_BROWSER_SHORTCUT,
DEFAULT_MATERIALS_SHORTCUT,
installDeferredShortcutFallback,
} from './shortcut_controls.js';
export const ENTRY_MODE_SETTING_ID = 'Anomalous.ModelBrowser.EntryMode';
export const SHORTCUT_SETTING_ID = 'Anomalous.ModelBrowser.Shortcut';
export const MATERIALS_SHORTCUT_SETTING_ID = 'Anomalous.ModelBrowser.MaterialsShortcut';
export const FLOATING_TRIGGER_SIZE_SETTING_ID = 'Anomalous.ModelBrowser.FloatingTriggerSize';
export const FLOATING_TRIGGER_STYLE_SETTING_ID = 'Anomalous.ModelBrowser.FloatingTriggerStyle';
export const OPEN_BROWSER_COMMAND_ID = 'Anomalous.ModelBrowser.Open';
export const OPEN_MATERIALS_COMMAND_ID = 'Anomalous.ModelBrowser.OpenMaterials';
export const RESET_TRIGGER_POSITION_COMMAND_ID = 'Anomalous.ModelBrowser.ResetFloatingTriggerPosition';
export function createBrowserEntry({ translate, getCurrentLanguage }) {
let entryMode = 'floating';
let floatingTriggerSize = 'medium';
let floatingTriggerStyle = 'icon';
let browserInstance = null;
let triggerButton = null;
let triggerBoundsUpdater = null;
let disposeMaterialsShortcutFallback = null;
const t = translate;
function getSettingTranslationPatches() {
const category = t('mainInterfaceCategory');
return {
[FLOATING_TRIGGER_STYLE_SETTING_ID]: {
name: t('mainFloatingTriggerStyleSetting'),
category: ['Anomalous Model Browser', category, 'floating-trigger-style'],
tooltip: t('mainFloatingTriggerStyleTooltip'),
options: [
{ value: 'icon', text: t('mainFloatingTriggerStyleIcon') },
{ value: 'pill', text: t('mainFloatingTriggerStylePill') }
]
},
[FLOATING_TRIGGER_SIZE_SETTING_ID]: {
name: t('mainFloatingTriggerSizeSetting'),
category: ['Anomalous Model Browser', category, 'floating-trigger-size'],
tooltip: t('mainFloatingTriggerSizeTooltip'),
options: [
{ value: 'small', text: t('mainFloatingTriggerSizeSmall') },
{ value: 'medium', text: t('mainFloatingTriggerSizeMedium') },
{ value: 'large', text: t('mainFloatingTriggerSizeLarge') }
]
},
[SHORTCUT_SETTING_ID]: {
name: t('mainShortcutSetting'),
category: ['Anomalous Model Browser', category, 'shortcut'],
tooltip: t('mainShortcutTooltip')
},
[MATERIALS_SHORTCUT_SETTING_ID]: {
name: t('mainMaterialsShortcutSetting'),
category: ['Anomalous Model Browser', category, 'materials-shortcut'],
tooltip: t('mainMaterialsShortcutTooltip')
},
[ENTRY_MODE_SETTING_ID]: {
name: t('mainEntryModeSetting'),
category: ['Anomalous Model Browser', category, 'entry-mode'],
tooltip: t('mainEntryModeTooltip'),
options: [
{ value: 'floating', text: t('mainEntryModeFloating') },
{ value: 'topbar', text: t('mainEntryModeTopbar') },
{ value: 'menu', text: t('mainEntryModeMenu') }
]
}
};
}
function refreshRegisteredSettings() {
const settingsApi = app.extensionManager?.setting;
const registry = settingsApi?.settings?.value || settingsApi?.settings;
if (!registry || typeof registry !== 'object') return;
for (const [id, patch] of Object.entries(getSettingTranslationPatches())) {
if (registry[id]) registry[id] = { ...registry[id], ...patch };
}
}
function syncVisibility() {
document.documentElement.classList.toggle('anomalous-topbar-entry-enabled', entryMode === 'topbar');
document.documentElement.classList.toggle('anomalous-floating-entry-enabled', entryMode === 'floating');
const browserIsOpen = browserInstance?.modal?.classList.contains('visible') === true;
triggerButton?.classList.toggle('anomalous-trigger-hidden', entryMode !== 'floating' || browserIsOpen);
}
function applyPresentation() {
if (!triggerButton) return;
triggerButton.dataset.size = floatingTriggerSize;
triggerButton.dataset.style = floatingTriggerStyle;
const icon = document.createElement('span');
icon.className = 'anomalous-trigger-icon';
icon.setAttribute('aria-hidden', 'true');
icon.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:1em;height:1em;display:block;"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>';
const label = document.createElement('span');
label.className = 'anomalous-trigger-label';
label.textContent = t('mainFloatingTriggerLabel');
triggerButton.replaceChildren(icon, label);
triggerButton.title = t('mainOpenTitle');
triggerButton.setAttribute('aria-label', t('mainOpenTitle'));
if (loadSavedTriggerPosition()) {
requestAnimationFrame(() => triggerBoundsUpdater?.());
}
}
function resetPosition() {
clearSavedTriggerPosition();
if (!triggerButton) return;
triggerButton.style.left = '';
triggerButton.style.top = '';
triggerButton.style.right = '';
triggerButton.style.bottom = '';
}
function ensureBrowser() {
if (browserInstance) return browserInstance;
try {
browserInstance = new AnomalousBrowser();
browserInstance.entryMode = entryMode;
browserInstance.triggerButton = triggerButton;
window.anomalousBrowserInstance = browserInstance;
triggerButton?.classList.remove('anomalous-trigger-error');
if (triggerButton) {
triggerButton.title = t('mainOpenTitle');
triggerButton.setAttribute('aria-label', t('mainOpenTitle'));
}
syncVisibility();
return browserInstance;
} catch (error) {
console.error('[Anomalous Model Browser] UI initialization failed:', error);
triggerButton?.classList.add('anomalous-trigger-error');
if (triggerButton) {
triggerButton.title = getCurrentLanguage() === 'zh' ? t('mainRetryInit') : t('mainRetryInitEn');
triggerButton.setAttribute('aria-label', triggerButton.title);
}
return null;
}
}
function open() {
ensureBrowser()?.show();
}
async function openMaterials() {
const browser = ensureBrowser();
if (!browser) return;
await browser.openMaterialLibrary();
}
function getMaterialsShortcutCombo() {
const commands = app.extensionManager?.command?.commands;
const command = Array.isArray(commands)
? commands.find(item => item.id === OPEN_MATERIALS_COMMAND_ID)
: null;
return command ? command.keybinding?.combo || null : DEFAULT_MATERIALS_SHORTCUT;
}
function materialLibraryIsOpen() {
return browserInstance?.modal?.classList.contains('visible') === true
&& browserInstance?.nbPanel?.style.display === 'flex'
&& browserInstance?.materialContainer?.style.display === 'flex';
}
function installMaterialsShortcutFallback() {
disposeMaterialsShortcutFallback?.();
disposeMaterialsShortcutFallback = installDeferredShortcutFallback({
target: window,
getCombo: getMaterialsShortcutCombo,
isHandled: materialLibraryIsOpen,
onFallback: openMaterials,
onError: error => console.error('[Anomalous Model Browser] Material shortcut failed:', error),
});
}
const translationPatches = getSettingTranslationPatches();
const settings = [
{
id: FLOATING_TRIGGER_STYLE_SETTING_ID,
...translationPatches[FLOATING_TRIGGER_STYLE_SETTING_ID],
type: 'combo',
defaultValue: 'icon',
onChange(value) {
floatingTriggerStyle = normalizeFloatingTriggerStyle(value);
applyPresentation();
}
},
{
id: FLOATING_TRIGGER_SIZE_SETTING_ID,
...translationPatches[FLOATING_TRIGGER_SIZE_SETTING_ID],
type: 'combo',
defaultValue: 'medium',
onChange(value) {
floatingTriggerSize = normalizeFloatingTriggerSize(value);
applyPresentation();
}
},
{
id: SHORTCUT_SETTING_ID,
...translationPatches[SHORTCUT_SETTING_ID],
type: () => createShortcutSettingControl({ app, commandId: OPEN_BROWSER_COMMAND_ID, translate: t }),
defaultValue: '',
telemetry: { trackChanges: false }
},
{
id: MATERIALS_SHORTCUT_SETTING_ID,
...translationPatches[MATERIALS_SHORTCUT_SETTING_ID],
type: () => createShortcutSettingControl({
app,
commandId: OPEN_MATERIALS_COMMAND_ID,
translate: t,
settingLabelKey: 'mainMaterialsShortcutSetting',
}),
defaultValue: '',
telemetry: { trackChanges: false }
},
{
id: ENTRY_MODE_SETTING_ID,
...translationPatches[ENTRY_MODE_SETTING_ID],
type: 'combo',
defaultValue: 'floating',
onChange(value) {
entryMode = normalizeEntryMode(value);
if (browserInstance) browserInstance.entryMode = entryMode;
syncVisibility();
}
}
];
window.addEventListener('anomalous-language-change', () => {
applyPresentation();
refreshRegisteredSettings();
});
async function setup() {
const cssUrl = '/extensions/Anomalous_Model_Browser/styles.css?v=20260927-hotfix-1';
if (!document.querySelector('link[href^="/extensions/Anomalous_Model_Browser/styles.css"]')) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.type = 'text/css';
link.href = cssUrl;
document.head.appendChild(link);
}
try {
const registeredSettings = app.extensionManager?.setting;
entryMode = normalizeEntryMode(registeredSettings?.get(ENTRY_MODE_SETTING_ID));
floatingTriggerSize = normalizeFloatingTriggerSize(registeredSettings?.get(FLOATING_TRIGGER_SIZE_SETTING_ID));
floatingTriggerStyle = normalizeFloatingTriggerStyle(registeredSettings?.get(FLOATING_TRIGGER_STYLE_SETTING_ID));
} catch (error) {
console.warn('[Anomalous Model Browser] Unable to read entry preferences:', error);
}
const btn = document.createElement('button');
btn.id = 'anomalous-trigger-btn';
btn.setAttribute('aria-label', 'Anomalous Model Browser');
// Prevent FOUC: Start hidden until presentation & exact coordinates are bound
btn.classList.add('anomalous-trigger-initializing');
triggerButton = btn;
if (browserInstance) browserInstance.triggerButton = btn;
btn.title = t('mainOpenTitle');
// Pre-configure content & presentation BEFORE mounting
applyPresentation();
// Pre-calculate and assign exact target coordinates BEFORE appending to DOM
const savedInitialPos = loadSavedTriggerPosition();
if (savedInitialPos) {
const estSize = floatingTriggerSize === 'small' ? 44 : (floatingTriggerSize === 'large' ? 76 : 60);
const initialClamped = clampFloatingTriggerPosition({
x: savedInitialPos.x,
y: savedInitialPos.y,
width: estSize,
height: estSize,
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight,
minX: 70
});
btn.style.position = 'fixed';
btn.style.left = initialClamped.x + 'px';
btn.style.top = initialClamped.y + 'px';
btn.style.right = 'auto';
btn.style.bottom = 'auto';
}
let isDragging = false;
let hasMoved = false;
let startPointerX = 0;
let startPointerY = 0;
let startBtnLeft = 0;
let startBtnTop = 0;
let currentDragX = null;
let currentDragY = null;
let activePointerId = null;
const onPointerMove = (event) => {
if (!isDragging) return;
const dx = event.clientX - startPointerX;
const dy = event.clientY - startPointerY;
if (!hasMoved && Math.hypot(dx, dy) >= 4) {
hasMoved = true;
}
if (!hasMoved) return;
event.preventDefault();
const btnW = btn.offsetWidth || 60;
const btnH = btn.offsetHeight || 60;
const clamped = clampFloatingTriggerPosition({
x: startBtnLeft + dx,
y: startBtnTop + dy,
width: btnW,
height: btnH,
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight,
minX: 70
});
currentDragX = clamped.x;
currentDragY = clamped.y;
btn.style.left = clamped.x + 'px';
btn.style.top = clamped.y + 'px';
btn.style.right = 'auto';
btn.style.bottom = 'auto';
};
const finishDrag = (event) => {
if (!isDragging) return;
isDragging = false;
btn.removeEventListener('pointermove', onPointerMove);
btn.removeEventListener('pointerup', finishDrag);
btn.removeEventListener('pointercancel', finishDrag);
btn.removeEventListener('lostpointercapture', finishDrag);
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerup', finishDrag);
window.removeEventListener('pointercancel', finishDrag);
if (activePointerId != null) {
try {
if (btn.hasPointerCapture(activePointerId)) {
btn.releasePointerCapture(activePointerId);
}
} catch (_) {}
activePointerId = null;
}
btn.style.transition = '';
if (hasMoved && currentDragX != null && currentDragY != null) {
saveTriggerPosition({ x: currentDragX, y: currentDragY });
} else if (!hasMoved) {
open();
}
};
btn.addEventListener('pointerdown', (event) => {
if (event.button !== 0) return;
isDragging = true;
hasMoved = false;
startPointerX = event.clientX;
startPointerY = event.clientY;
activePointerId = event.pointerId;
const rect = btn.getBoundingClientRect();
startBtnLeft = rect.left;
startBtnTop = rect.top;
currentDragX = rect.left;
currentDragY = rect.top;
btn.style.transition = 'none';
try {
btn.setPointerCapture(event.pointerId);
} catch (_) {}
btn.addEventListener('pointermove', onPointerMove, { passive: false });
btn.addEventListener('pointerup', finishDrag);
btn.addEventListener('pointercancel', finishDrag);
btn.addEventListener('lostpointercapture', finishDrag);
window.addEventListener('pointermove', onPointerMove, { passive: false });
window.addEventListener('pointerup', finishDrag);
window.addEventListener('pointercancel', finishDrag);
});
const updateBtnBounds = () => {
const saved = loadSavedTriggerPosition();
if (saved) {
const estFallback = floatingTriggerSize === 'small' ? 44 : (floatingTriggerSize === 'large' ? 76 : 60);
const btnW = btn.offsetWidth || estFallback;
const btnH = btn.offsetHeight || btnW;
const clamped = clampFloatingTriggerPosition({
x: saved.x,
y: saved.y,
width: btnW,
height: btnH,
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight,
minX: 70
});
btn.style.left = clamped.x + 'px';
btn.style.top = clamped.y + 'px';
btn.style.right = 'auto';
btn.style.bottom = 'auto';
} else {
btn.style.left = '';
btn.style.top = '';
btn.style.right = '';
btn.style.bottom = '';
}
};
triggerBoundsUpdater = updateBtnBounds;
window.addEventListener('resize', () => {
updateBtnBounds();
syncVisibility();
});
// Mount pre-positioned and styled element to DOM
document.body.appendChild(btn);
syncVisibility();
ensureBrowser();
installMaterialsShortcutFallback();
// Reveal smoothly on first animation frame without any positional jump or flicker
requestAnimationFrame(() => {
updateBtnBounds();
btn.classList.remove('anomalous-trigger-initializing');
});
window.anomalousDragGhostImg = new Image();
window.anomalousDragGhostImg.src = "data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='80' height='80'><rect width='76' height='76' x='2' y='2' fill='%23140812' fill-opacity='0.85' rx='16' stroke='%23f59e0b' stroke-width='2'/><text x='40' y='50' font-family='sans-serif' font-size='32' font-weight='bold' fill='%23f59e0b' text-anchor='middle'>W</text></svg>";
}
return {
settings,
actionBarButtons: [{
icon: 'pi pi-box',
label: t('mainTopbarTriggerLabel'),
tooltip: t('mainOpenTitle'),
class: 'anomalous-topbar-entry',
onClick: open
}],
commands: [
{ id: OPEN_BROWSER_COMMAND_ID, label: t('mainOpenTitle'), function: open },
{ id: OPEN_MATERIALS_COMMAND_ID, label: t('mainOpenMaterialsTitle'), function: openMaterials },
{ id: RESET_TRIGGER_POSITION_COMMAND_ID, label: t('mainResetFloatingTriggerPosition'), function: resetPosition }
],
keybindings: [
{ combo: DEFAULT_BROWSER_SHORTCUT, commandId: OPEN_BROWSER_COMMAND_ID },
{ combo: DEFAULT_MATERIALS_SHORTCUT, commandId: OPEN_MATERIALS_COMMAND_ID },
],
menuCommands: [{
path: ['Extensions', 'Anomalous Model Browser'],
commands: [OPEN_BROWSER_COMMAND_ID, OPEN_MATERIALS_COMMAND_ID, RESET_TRIGGER_POSITION_COMMAND_ID]
}],
setup,
open,
openMaterials,
ensureBrowser,
resetPosition,
syncVisibility
};
}
+125
View File
@@ -0,0 +1,125 @@
// Unconditionally purge legacy contaminated storage keys on module load
try {
if (typeof localStorage !== 'undefined') {
localStorage.removeItem('anomalous_btn_x');
localStorage.removeItem('anomalous_btn_y');
}
} catch (_) {}
export const FLOATING_TRIGGER_SIZES = new Set(['small', 'medium', 'large']);
export const FLOATING_TRIGGER_STYLES = new Set(['icon', 'pill']);
export const ENTRY_MODES = new Set(['floating', 'topbar', 'menu']);
export function normalizeFloatingTriggerSize(value) {
return FLOATING_TRIGGER_SIZES.has(value) ? value : 'medium';
}
export function normalizeFloatingTriggerStyle(value) {
return FLOATING_TRIGGER_STYLES.has(value) ? value : 'icon';
}
export function normalizeEntryMode(value) {
return ENTRY_MODES.has(value) ? value : 'floating';
}
export const DEFAULT_SAFE_TOP = 80;
export const DEFAULT_SAFE_LEFT = 80; // Safe position outside ComfyUI left sidebar dock
export const DEFAULT_SAFE_MARGIN_RIGHT = 24;
export const DEFAULT_MIN_SAFE_X = 70; // Avoid ComfyUI left sidebar dock
export const DEFAULT_TRIGGER_POSITION = Object.freeze({ top: DEFAULT_SAFE_TOP, left: DEFAULT_SAFE_LEFT });
export const TRIGGER_POSITION_STORAGE_KEY = 'anomalous_trigger_pos_v3';
export const LEGACY_TRIGGER_X_KEY = 'anomalous_btn_x';
export const LEGACY_TRIGGER_Y_KEY = 'anomalous_btn_y';
export function isValidSavedTriggerPosition(x, y) {
if (x == null || y == null) return false;
const px = Number.parseFloat(x);
const py = Number.parseFloat(y);
// Must be a finite number, py >= 0, and px >= DEFAULT_MIN_SAFE_X (70px) to prevent docking inside sidebar
return Number.isFinite(px) && Number.isFinite(py) && px >= DEFAULT_MIN_SAFE_X && py >= 0;
}
export function loadSavedTriggerPosition(storage = (typeof localStorage !== 'undefined' ? localStorage : null)) {
if (!storage) return null;
try {
const raw = storage.getItem(TRIGGER_POSITION_STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw);
if (isValidSavedTriggerPosition(parsed?.x, parsed?.y)) {
return { x: Math.round(Number(parsed.x)), y: Math.round(Number(parsed.y)) };
} else {
storage.removeItem(TRIGGER_POSITION_STORAGE_KEY);
}
}
} catch (_) {}
return null;
}
export function saveTriggerPosition(pos, storage = (typeof localStorage !== 'undefined' ? localStorage : null)) {
if (!storage || !pos) return;
const x = Number.parseFloat(pos.x);
const y = Number.parseFloat(pos.y);
if (!isValidSavedTriggerPosition(x, y)) return;
try {
storage.setItem(TRIGGER_POSITION_STORAGE_KEY, JSON.stringify({
x: Math.round(x),
y: Math.round(y)
}));
storage.removeItem(LEGACY_TRIGGER_X_KEY);
storage.removeItem(LEGACY_TRIGGER_Y_KEY);
storage.removeItem('anomalous_trigger_pos_v2');
} catch (_) {}
}
export function clearSavedTriggerPosition(storage = (typeof localStorage !== 'undefined' ? localStorage : null)) {
if (!storage) return;
try {
storage.removeItem(TRIGGER_POSITION_STORAGE_KEY);
storage.removeItem(LEGACY_TRIGGER_X_KEY);
storage.removeItem(LEGACY_TRIGGER_Y_KEY);
storage.removeItem('anomalous_trigger_pos_v2');
} catch (_) {}
}
export function sanitizeSavedTriggerPosition(x, y) {
if (!isValidSavedTriggerPosition(x, y)) return null;
return { x: Number.parseFloat(x), y: Number.parseFloat(y) };
}
export function normalizeSavedTriggerPosition(x, y) {
return sanitizeSavedTriggerPosition(x, y);
}
export function clampFloatingTriggerPosition({
x,
y,
width,
height,
viewportWidth,
viewportHeight,
minX = DEFAULT_MIN_SAFE_X,
margin = 0
}) {
const safeWidth = Math.max(1, Number(width) || 60);
const safeHeight = Math.max(1, Number(height) || 60);
const rawVw = Number(viewportWidth) || (typeof window !== 'undefined' ? window.innerWidth : 1024) || 1024;
const rawVh = Number(viewportHeight) || (typeof window !== 'undefined' ? window.innerHeight : 768) || 768;
const safeVw = rawVw >= 300 ? rawVw : 1024;
const safeVh = rawVh >= 200 ? rawVh : 768;
const maxX = Math.max(minX, safeVw - safeWidth);
const maxY = Math.max(0, safeVh - safeHeight);
const parsedX = Number.parseFloat(x);
const parsedY = Number.parseFloat(y);
const fallbackX = Math.max(minX, Math.min(maxX, DEFAULT_SAFE_LEFT));
const fallbackY = Math.min(maxY, DEFAULT_SAFE_TOP);
const targetX = Number.isFinite(parsedX) ? parsedX : fallbackX;
const targetY = Number.isFinite(parsedY) ? parsedY : fallbackY;
return {
x: Math.min(maxX, Math.max(minX, Math.round(targetX))),
y: Math.min(maxY, Math.max(0, Math.round(targetY)))
};
}
+174
View File
@@ -0,0 +1,174 @@
import { app } from '../../../scripts/app.js';
import { normalizeLocale, resolveLocale, translate } from './locales.js';
export const LANGUAGE_SETTING_ID = 'Anomalous.ModelBrowser.Language';
export const ABYSSAL_SCARLET_SETTING_ID = 'Anomalous.ModelBrowser.AbyssalScarletTheme';
let defaultLang = 'zh';
try {
let comfyDetected = false;
const aglLang = localStorage.getItem('Comfy.Settings.AIGODLIKE-COMFYUI-TRANSLATION.Language');
if (aglLang) {
defaultLang = aglLang.toLowerCase().includes('en') ? 'en' : 'zh';
comfyDetected = true;
} else {
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (!key || (!key.toLowerCase().includes('lang') && !key.toLowerCase().includes('locale'))) continue;
const value = localStorage.getItem(key);
if (typeof value !== 'string') continue;
const normalizedValue = value.toLowerCase();
if (normalizedValue.includes('zh') || normalizedValue.includes('chinese')) {
defaultLang = 'zh';
comfyDetected = true;
break;
}
if (normalizedValue.includes('en') || normalizedValue.includes('english')) {
defaultLang = 'en';
comfyDetected = true;
break;
}
}
}
if (!comfyDetected && navigator.language && !navigator.language.toLowerCase().startsWith('zh')) {
defaultLang = 'en';
}
} catch (_) {
if (navigator.language && !navigator.language.toLowerCase().startsWith('zh')) {
defaultLang = 'en';
}
}
let currentLang = resolveLocale(localStorage.getItem('anomalous_lang') || defaultLang);
window.anomalous_browser_lang = currentLang;
export const t = (key, params) => translate(key, params, window.anomalous_browser_lang || currentLang);
export const getCurrentLanguage = () => currentLang;
function normalizeLanguagePreference(value) {
return value === 'zh' || value === 'en' ? value : 'auto';
}
function resolveComfyLanguage() {
try {
const settings = app.extensionManager?.setting;
const locale = settings?.get('Comfy.Locale')
|| app.ui?.settings?.getSettingValue?.('Comfy.Locale')
|| app.ui?.settings?.getSettingValue?.('Comfy.Locale.Language');
return normalizeLocale(locale) || defaultLang;
} catch (_) {
return defaultLang;
}
}
function getSettingTranslationPatches() {
const category = t('mainInterfaceCategory');
return {
[LANGUAGE_SETTING_ID]: {
name: t('mainLanguageSetting'),
category: ['Anomalous Model Browser', category, 'language'],
tooltip: t('mainLanguageTooltip'),
options: [
{ value: 'auto', text: t('mainLanguageAuto') },
{ value: 'zh', text: t('mainLanguageChinese') },
{ value: 'en', text: t('mainLanguageEnglish') }
]
},
[ABYSSAL_SCARLET_SETTING_ID]: {
name: t('mainAbyssalScarletThemeSetting'),
category: ['Anomalous Model Browser', category, 'theme'],
tooltip: t('mainAbyssalScarletThemeTooltip')
}
};
}
function refreshRegisteredSettings() {
const settingsApi = app.extensionManager?.setting;
const registry = settingsApi?.settings?.value || settingsApi?.settings;
if (!registry || typeof registry !== 'object') return;
for (const [id, patch] of Object.entries(getSettingTranslationPatches())) {
if (registry[id]) registry[id] = { ...registry[id], ...patch };
}
}
function applyLanguagePreference(value) {
const preference = normalizeLanguagePreference(value);
if (preference === 'auto') localStorage.removeItem('anomalous_lang');
else localStorage.setItem('anomalous_lang', preference);
const nextLanguage = preference === 'auto' ? resolveComfyLanguage() : preference;
const changed = nextLanguage !== window.anomalous_browser_lang;
currentLang = nextLanguage;
window.anomalous_browser_lang = nextLanguage;
refreshRegisteredSettings();
if (changed) {
window.dispatchEvent(new CustomEvent('anomalous-language-change', {
detail: { language: nextLanguage, preference }
}));
}
}
if (!localStorage.getItem('anomalous_lang')) {
currentLang = resolveComfyLanguage();
window.anomalous_browser_lang = currentLang;
}
function showThemeNoticeToast(isEnabled) {
document.getElementById('anomalous-theme-toast')?.remove();
const toast = document.createElement('div');
toast.id = 'anomalous-theme-toast';
toast.className = 'anomalous-theme-toast' + (isEnabled ? ' is-abyssal' : '');
toast.textContent = isEnabled ? t('themeDomainActivated') : t('themeDomainDeactivated');
document.body.appendChild(toast);
requestAnimationFrame(() => toast.classList.add('is-show'));
setTimeout(() => {
toast.classList.remove('is-show');
setTimeout(() => toast.remove(), 400);
}, 2400);
}
export function setAbyssalScarletTheme(enabled, notify = false) {
const isEnabled = Boolean(enabled);
localStorage.setItem('anomalous_theme_abyssal_scarlet', isEnabled ? 'true' : 'false');
document.documentElement.classList.toggle('theme-abyssal-scarlet', isEnabled);
document.getElementById('anomalous-modal')?.classList.toggle('theme-abyssal-scarlet', isEnabled);
document.getElementById('anomalous-container')?.classList.toggle('theme-abyssal-scarlet', isEnabled);
try {
const settings = app.extensionManager?.setting;
if (settings && typeof settings.set === 'function' && settings.get(ABYSSAL_SCARLET_SETTING_ID) !== isEnabled) {
settings.set(ABYSSAL_SCARLET_SETTING_ID, isEnabled);
}
} catch (_) {}
if (notify) showThemeNoticeToast(isEnabled);
window.dispatchEvent(new CustomEvent('anomalous-theme-change', {
detail: { theme: isEnabled ? 'abyssal-scarlet' : 'default', enabled: isEnabled }
}));
}
window.setAbyssalScarletTheme = setAbyssalScarletTheme;
setAbyssalScarletTheme(localStorage.getItem('anomalous_theme_abyssal_scarlet') === 'true', false);
export function createInterfaceSettings() {
const translations = getSettingTranslationPatches();
return [
{
id: LANGUAGE_SETTING_ID,
...translations[LANGUAGE_SETTING_ID],
type: 'combo',
defaultValue: () => normalizeLanguagePreference(localStorage.getItem('anomalous_lang')),
onChange: applyLanguagePreference
},
{
id: ABYSSAL_SCARLET_SETTING_ID,
...translations[ABYSSAL_SCARLET_SETTING_ID],
type: 'boolean',
defaultValue: () => localStorage.getItem('anomalous_theme_abyssal_scarlet') === 'true',
onChange(value) {
setAbyssalScarletTheme(Boolean(value), true);
}
}
];
}
+1520 -200
View File
File diff suppressed because it is too large Load Diff
+106
View File
@@ -0,0 +1,106 @@
import { app } from '../../../scripts/app.js';
import { translate as t } from './locales.js';
import { text } from './ui_dom.js';
import { materialNodeHeading } from './material_inspector.js';
import { anomalousAlert } from './ui_dialog.js';
let activeDrag = null;
export function getCanvasPosition(event, canvas) {
if (!canvas) return null;
let position = null;
if (canvas.convertEventToCanvasOffset) position = canvas.convertEventToCanvasOffset(event);
else if (canvas.adjustMouseEvent) { canvas.adjustMouseEvent(event); position = [event.canvasX, event.canvasY]; }
return position && position.every(Number.isFinite) ? position : null;
}
export function materialDropNode(event, canvas, graph) {
const surface = canvas?.canvas;
if (!surface || !graph || canvas.graph !== graph) return null;
const rect = surface.getBoundingClientRect();
if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom) return null;
// DOM text widgets overlay the canvas, so accept their drop surface too.
if (event.target !== surface && !event.target?.closest?.('.dom-widget')) return null;
const position = getCanvasPosition(event, canvas);
if (!position) return null;
const node = graph.getNodeOnPos?.(position[0], position[1]);
return node && graph.getNodeById(node.id) === node ? node : null;
}
/** Only this page's active drag can mutate a node; transfer data is never trusted. */
export function bindMaterialDrag(element, owner, { payload, accepts, drop, dropOnCanvas }) {
element.draggable = true;
element.addEventListener('dragstart', event => {
if (event.target !== element && event.target?.closest?.('button, input, textarea, select')) { event.preventDefault(); return; }
const data = payload();
const graph = app.graph;
const canvas = app.canvas;
if (!data || !event.dataTransfer || !canvas?.canvas) { event.preventDefault(); return; }
activeDrag?.();
event.stopPropagation();
event.dataTransfer.setData('application/x-anomalous-material', 'local-drag');
event.dataTransfer.effectAllowed = 'copy';
const defaultHint = data.dragHint || t('materialDropHint');
const hint = text(document.body, 'div', defaultHint, 'anomalous-material-drag-hint');
hint.setAttribute('role', 'status');
const cleanup = () => {
clearTimeout(reveal);
owner.modal?.classList.remove('anomalous-material-dragging');
hint.remove();
for (const [name, fn] of listeners) window.removeEventListener(name, fn, true);
if (activeDrag === cleanup) activeDrag = null;
};
const isOverCanvasSurface = event => {
const surface = canvas?.canvas;
if (!surface || app.graph !== graph || app.canvas !== canvas || canvas.graph !== graph) return false;
if (event.target !== surface && !event.target?.closest?.('.dom-widget')) return false;
const rect = surface.getBoundingClientRect();
return event.clientX >= rect.left && event.clientX <= rect.right && event.clientY >= rect.top && event.clientY <= rect.bottom;
};
const target = event => app.graph === graph && app.canvas === canvas ? materialDropNode(event, canvas, graph) : null;
const over = event => {
event.preventDefault(); event.stopImmediatePropagation();
const node = target(event);
const validNode = !!node && accepts?.(node, data);
if (validNode) {
event.dataTransfer.dropEffect = 'copy';
hint.classList.add('is-target-valid');
hint.textContent = t('materialDropTarget', { name: materialNodeHeading(node) });
} else if (dropOnCanvas && isOverCanvasSurface(event)) {
event.dataTransfer.dropEffect = 'copy';
hint.classList.add('is-target-valid');
hint.textContent = data.dragTargetHint || defaultHint;
} else {
event.dataTransfer.dropEffect = 'none';
hint.classList.remove('is-target-valid');
hint.textContent = defaultHint;
}
hint.style.left = `${Math.max(8, Math.min(event.clientX + 16, window.innerWidth - 250))}px`;
hint.style.top = `${Math.max(8, event.clientY - 48)}px`;
};
const finish = async event => {
event.preventDefault(); event.stopImmediatePropagation();
const node = target(event);
const validNode = !!node && accepts?.(node, data);
const overCanvas = isOverCanvasSurface(event);
cleanup();
if (validNode) {
try { await drop(node, data, graph); }
catch (error) { await anomalousAlert(t(error.message) === error.message ? t('materialApplyFailed') : t(error.message)); }
return;
}
if (dropOnCanvas && overCanvas) {
const pos = getCanvasPosition(event, canvas);
try { await dropOnCanvas(event, data, graph, pos); }
catch (error) { await anomalousAlert(t(error.message) === error.message ? t('recipeOpenError') : t(error.message)); }
return;
}
};
const escape = event => { if (event.key === 'Escape') cleanup(); };
const listeners = [['dragover', over], ['drop', finish], ['dragend', cleanup], ['keydown', escape], ['blur', cleanup]];
// Let the browser capture the card's drag image before revealing the canvas.
const reveal = setTimeout(() => owner.modal?.classList.add('anomalous-material-dragging'), 0);
activeDrag = cleanup;
for (const [name, fn] of listeners) window.addEventListener(name, fn, true);
});
}
+37
View File
@@ -0,0 +1,37 @@
import { translate as t } from './locales.js';
import { anomalousAlert } from './ui_dialog.js';
/** Shared save receipt; navigation is owned by the browser instance. */
export function showMaterialSaved(owner, material, beforeOpen) {
document.querySelector('.anomalous-material-receipt')?.remove();
const receipt = document.createElement('div');
receipt.className = 'anomalous-material-receipt';
receipt.setAttribute('role', 'status');
const message = document.createElement('span');
message.textContent = t('materialSavedToLibrary');
receipt.appendChild(message);
if (typeof owner?.openSavedMaterial === 'function' && material?.filename) {
const view = document.createElement('button');
view.type = 'button';
view.textContent = t('materialViewSaved');
view.onclick = async () => {
view.disabled = true;
try {
beforeOpen?.();
await owner.openSavedMaterial(material);
receipt.remove();
} catch (error) {
await anomalousAlert(t('materialDetailLoadError'));
view.disabled = false;
}
};
receipt.appendChild(view);
}
const close = document.createElement('button');
close.type = 'button';
close.textContent = '×';
close.setAttribute('aria-label', t('materialDismissReceipt'));
close.onclick = () => receipt.remove();
receipt.appendChild(close);
document.body.appendChild(receipt);
}
+675
View File
@@ -0,0 +1,675 @@
import { text, jsonResponse } from './ui_dom.js';
/** Shared image metadata and node-parameter inspection for the library and workbench. */
import { translate } from './locales.js';
const t = (key, params) => translate(key, params);
export function sectionLabel(parent, value) {
return text(parent, 'div', value, 'anomalous-material-section-label');
}
export function fileBaseName(value) {
return String(value || '').replace(/\\/g, '/').split('/').pop();
}
function previewIsVideo(url) {
return /\.(?:mp4|webm)(?:$|\?|&|#)/i.test(url || '');
}
export function modelCustomNotes(model) {
return String(model?.metadata?.custom_notes || '').trim();
}
function bindHoverPreviewVideo(video) {
video.muted = true;
video.loop = true;
video.playsInline = true;
video.preload = 'metadata';
video.onpointerenter = () => video.play().catch(() => {});
video.onpointerleave = () => {
video.pause();
video.currentTime = 0;
};
}
export function appendLocalPreview(parent, url, className) {
if (!url) return null;
const wrap = document.createElement('div');
wrap.className = className;
if (previewIsVideo(url)) {
const video = document.createElement('video');
video.src = url;
bindHoverPreviewVideo(video);
wrap.appendChild(video);
} else {
const image = document.createElement('img');
image.src = url;
image.alt = '';
image.loading = 'lazy';
wrap.appendChild(image);
}
parent.appendChild(wrap);
return wrap;
}
export async function resolveLocalModels(paths) {
const unique = [...new Set((paths || []).filter(Boolean))];
if (!unique.length) return {};
const response = await fetch('/anomalous/resolve_paths_to_previews', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paths: unique, exact_only: false }),
});
const payload = await jsonResponse(response, 'preview resolve failed');
return payload.models || {};
}
export function lookupLocalModel(localModels, path) {
if (!path || !localModels) return null;
return localModels[path] || localModels[fileBaseName(path)] || null;
}
export async function parsePngMetadataFromUrl(url, options = {}) {
if (!url) return null;
try {
const response = await fetch(url, { cache: 'force-cache', signal: options.signal });
if (!response.ok) return null;
const buffer = await response.arrayBuffer();
const view = new DataView(buffer);
if (view.byteLength < 32) return null;
// PNG magic bytes: 137, 80, 78, 71, 13, 10, 26, 10
if (view.getUint32(0) !== 0x89504E47 || view.getUint32(4) !== 0x0D0A1A0A) return null;
let offset = 8;
const utf8 = new TextDecoder('utf-8');
const latin1 = new TextDecoder('iso-8859-1');
const result = {};
while (offset + 8 <= buffer.byteLength) {
const length = view.getUint32(offset);
const type = String.fromCharCode(
view.getUint8(offset + 4),
view.getUint8(offset + 5),
view.getUint8(offset + 6),
view.getUint8(offset + 7),
);
const dataOffset = offset + 8;
if (type === 'IDAT') break; // Reached image raster data, text metadata is prior
if (dataOffset + length > buffer.byteLength) break;
if (type === 'tEXt') {
const bytes = new Uint8Array(buffer, dataOffset, length);
const nullIdx = bytes.indexOf(0);
if (nullIdx > -1) {
const key = latin1.decode(bytes.subarray(0, nullIdx));
const val = latin1.decode(bytes.subarray(nullIdx + 1));
try { result[key] = JSON.parse(val); } catch { result[key] = val; }
}
} else if (type === 'iTXt') {
const bytes = new Uint8Array(buffer, dataOffset, length);
const nullIdx = bytes.indexOf(0);
if (nullIdx > -1) {
const key = latin1.decode(bytes.subarray(0, nullIdx));
let ptr = nullIdx + 1;
const compFlag = bytes[ptr++];
const compMethod = bytes[ptr++];
while (ptr < bytes.length && bytes[ptr] !== 0) ptr++;
ptr++;
while (ptr < bytes.length && bytes[ptr] !== 0) ptr++;
ptr++;
if (compFlag === 0 && ptr <= bytes.length) {
const val = utf8.decode(bytes.subarray(ptr));
try { result[key] = JSON.parse(val); } catch { result[key] = val; }
}
}
}
offset += 12 + length;
}
return result.workflow || result.prompt || null;
} catch (e) {
if (e?.name === 'AbortError') return null;
console.warn('Client-side PNG metadata read skipped:', e);
return null;
}
}
/**
* Extract generation parameters, full prompts, and categorized model references with details (e.g. LoRA strengths)
* directly from the workflow.
*/
export function extractWorkflowDetails(workflow) {
const params = {};
const positivePrompts = [];
const negativePrompts = [];
const allPrompts = [];
const loraDetailsMap = new Map();
const discoveredModels = {
checkpoints: [],
loras: [],
textEncoders: [],
vaes: [],
others: [],
};
if (!workflow || typeof workflow !== 'object') {
return { params, positivePrompts, negativePrompts, allPrompts, loraDetailsMap, discoveredModels };
}
const nodes = Array.isArray(workflow.nodes) ? workflow.nodes : [];
for (const node of nodes) {
if (!node || typeof node !== 'object') continue;
const ntype = String(node.type || '').trim();
const ntypeLower = ntype.toLowerCase();
const widgets = Array.isArray(node.widgets_values) ? node.widgets_values : [];
// 1. Sampling parameters
if (ntypeLower === 'ksampler' || ntypeLower === 'ksampleradvanced') {
const isAdv = ntypeLower === 'ksampleradvanced';
const offset = isAdv ? 1 : 0;
if (widgets[offset] != null && params.seed == null) params.seed = widgets[offset];
if (widgets[2 + offset] != null && params.steps == null) params.steps = widgets[2 + offset];
if (widgets[3 + offset] != null && params.cfg == null) params.cfg = widgets[3 + offset];
if (widgets[4 + offset] != null && params.sampler_name == null) params.sampler_name = widgets[4 + offset];
if (widgets[5 + offset] != null && params.scheduler == null) params.scheduler = widgets[5 + offset];
if (widgets[6 + offset] != null && params.denoise == null) params.denoise = widgets[6 + offset];
} else if (ntypeLower === 'emptylatentimage') {
if (widgets[0] && widgets[1] && params.resolution == null) {
params.resolution = `${widgets[0]} × ${widgets[1]}`;
}
}
// 2. Full un-truncated prompts
if (ntypeLower.includes('cliptextencode') || ntypeLower.includes('prompt')) {
const nodeTitle = String(node.title || ntype).toLowerCase();
for (const val of widgets) {
if (typeof val === 'string' && val.trim()) {
const textVal = val.trim();
if (!allPrompts.includes(textVal)) allPrompts.push(textVal);
if (nodeTitle.includes('neg') || nodeTitle.includes('负向')) {
if (!negativePrompts.includes(textVal)) negativePrompts.push(textVal);
} else {
if (!positivePrompts.includes(textVal)) positivePrompts.push(textVal);
}
}
}
}
// 3. Categorized model references & LoRA weights
if (ntypeLower.includes('checkpoint') || ntypeLower === 'unetloader' || ntypeLower.includes('diffusionmodel')) {
if (widgets[0] && typeof widgets[0] === 'string') {
const name = widgets[0];
if (!discoveredModels.checkpoints.some(c => c.name === name)) {
discoveredModels.checkpoints.push({
name,
category: ntypeLower.includes('unet') ? 'unet' : 'checkpoint',
});
}
}
} else if (ntypeLower.includes('lora')) {
if (widgets[0] && typeof widgets[0] === 'string') {
const name = widgets[0];
const strengthModel = widgets[1] != null ? Number(widgets[1]) : 1.0;
const strengthClip = widgets[2] != null ? Number(widgets[2]) : strengthModel;
loraDetailsMap.set(name, { strengthModel, strengthClip });
const baseName = name.replace(/\\/g, '/').split('/').pop();
loraDetailsMap.set(baseName, { strengthModel, strengthClip });
if (!discoveredModels.loras.some(l => l.name === name)) {
discoveredModels.loras.push({ name, strengthModel, strengthClip, category: 'lora' });
}
}
} else if (ntypeLower.includes('vaeloader') || (ntypeLower.includes('vae') && !ntypeLower.includes('encode') && !ntypeLower.includes('decode'))) {
if (widgets[0] && typeof widgets[0] === 'string') {
const name = widgets[0];
if (!discoveredModels.vaes.some(v => v.name === name)) {
discoveredModels.vaes.push({ name, category: 'vae' });
}
}
} else if (ntypeLower.includes('cliploader') || ntypeLower.includes('dualcliploader') || ntypeLower.includes('textencoder')) {
for (const w of widgets) {
if (typeof w === 'string' && (w.endsWith('.safetensors') || w.endsWith('.bin') || w.endsWith('.pt') || w.endsWith('.sft'))) {
if (!discoveredModels.textEncoders.some(t => t.name === w)) {
discoveredModels.textEncoders.push({ name: w, category: 'text_encoder' });
}
}
}
}
}
return { params, positivePrompts, negativePrompts, allPrompts, loraDetailsMap, discoveredModels };
}
export function detailedBlocksFromWorkflow(workflow, serverBlocks = []) {
const nodes = Array.isArray(workflow?.nodes) ? workflow.nodes : [];
if (!nodes.length) return Array.isArray(serverBlocks) ? serverBlocks : [];
const serverById = new Map((serverBlocks || []).map(block => [String(block?.node_id), block]));
const occurrences = {};
return nodes.filter(node => node && typeof node === 'object' && node.type).map(node => {
const nodeType = String(node.type);
occurrences[nodeType] = (occurrences[nodeType] || 0) + 1;
const server = serverById.get(String(node.id)) || {};
const lower = nodeType.toLowerCase();
const volatile = lower === 'ksampler' ? [0] : lower === 'ksampleradvanced' ? [1] : [];
return {
node_id: node.id,
type: nodeType,
title: node.title || server.title || nodeType,
occurrence: occurrences[nodeType],
widget_count: Array.isArray(node.widgets_values) ? node.widgets_values.length : 0,
widgets_values: Array.isArray(node.widgets_values) ? node.widgets_values : [],
volatile_widget_indexes: Array.isArray(server.volatile_widget_indexes)
? server.volatile_widget_indexes
: volatile,
properties: node.properties && typeof node.properties === 'object' ? node.properties : {},
mode: node.mode,
};
});
}
function materialWidgetLabels(nodeType) {
const type = String(nodeType || '').toLowerCase();
if (type === 'ksampler') return [
t('materialParamSeed'), t('materialParamSeedControl'), t('recipeCardSpecsSteps'),
'CFG', t('recipeCardSpecsSampler'), t('materialScheduler'), t('materialDenoise'),
];
if (type === 'ksampleradvanced') return [
t('materialParamAddNoise'), t('materialParamSeed'), t('materialParamSeedControl'),
t('recipeCardSpecsSteps'), 'CFG', t('recipeCardSpecsSampler'), t('materialScheduler'),
t('materialParamStartStep'), t('materialParamEndStep'), t('materialParamLeftoverNoise'),
];
if (type === 'emptylatentimage') return [t('materialParamWidth'), t('materialParamHeight'), t('materialParamBatchSize')];
if (/checkpointloader(simple)?$/.test(type)) return [t('materialParamCheckpoint')];
if (type.endsWith('unetloader')) return [t('materialParamUnet'), t('materialParamWeightDtype')];
if (type.includes('loraloader')) return [t('materialParamLora'), t('materialParamModelStrength'), t('materialParamClipStrength')];
if (type.endsWith('vaeloader')) return [t('materialParamVae')];
if (type.includes('cliptextencode')) return [t('materialPromptText')];
if (type.endsWith('clipvisionloader')) return [t('materialParamClipVision')];
if (type.endsWith('controlnetloader')) return [t('materialParamControlNet')];
if (type.endsWith('dualcliploader')) return [t('materialParamClipOne'), t('materialParamClipTwo'), t('materialParamClipType')];
if (type.endsWith('triplecliploader')) return [t('materialParamClipOne'), t('materialParamClipTwo'), t('materialParamClipThree')];
if (type.endsWith('cliploader')) return [t('materialParamClipOne'), t('materialParamClipType')];
if (type === 'saveimage') return [t('materialParamFilenamePrefix')];
return [];
}
const comfyWidgetLabelCache = new Map();
function comfyNodeTitle(nodeType) {
const ctor = globalThis.LiteGraph?.registered_node_types?.[nodeType];
return (ctor && ctor.title) || nodeType;
}
function comfyWidgetLabels(nodeType) {
if (comfyWidgetLabelCache.has(nodeType)) return comfyWidgetLabelCache.get(nodeType);
let labels = [];
try {
const node = globalThis.LiteGraph?.createNode?.(nodeType);
if (node?.widgets?.length) labels = node.widgets.map(widget => widget.label || widget.name || '');
} catch (error) { /* some node types cannot be instantiated off-canvas */ }
if (!labels.length) labels = materialWidgetLabels(nodeType);
comfyWidgetLabelCache.set(nodeType, labels);
return labels;
}
const NODE_TITLE_SUFFIX = /\s*(\((?:negative|positive|neg|pos)\)|((?:负向|正向|反向))|#\d+)\s*$/i;
function compactNodeToken(value) {
return String(value || '').toLowerCase().replace(/[^a-z0-9\u4e00-\u9fff]+/g, '');
}
function comfyNodeDefaultTitles(nodeType) {
const ctor = globalThis.LiteGraph?.registered_node_types?.[nodeType];
const data = ctor?.nodeData;
return [nodeType, ctor?.title, ctor?.comfyClass, data?.name, data?.display_name].filter(Boolean);
}
export function materialNodeHeading(block) {
const type = String(block?.type || '');
const saved = String(block?.title || '').trim();
const localized = comfyNodeTitle(type) || type;
const suffixMatch = saved.match(NODE_TITLE_SUFFIX);
const suffix = suffixMatch ? ` ${suffixMatch[1]}` : '';
if (!saved || saved === type) return localized + suffix;
const core = saved.replace(NODE_TITLE_SUFFIX, '').trim();
const compactCore = compactNodeToken(core);
const isDefault = [type, localized, ...comfyNodeDefaultTitles(type)].some(name => {
const compactName = compactNodeToken(name);
return core === name || compactCore === compactName || (compactName && compactCore.startsWith(compactName));
});
return isDefault ? localized + suffix : saved;
}
function formatMaterialParameterValue(value) {
if (value === null) return 'null';
if (value === undefined) return 'undefined';
if (typeof value === 'string') return value;
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
try { return JSON.stringify(value, null, 2); } catch (error) { return String(value); }
}
function isPromptBlock(block) {
const type = String(block?.type || '').toLowerCase();
return (type.includes('cliptextencode') || type.includes('prompt')) &&
Array.isArray(block.widgets_values) &&
block.widgets_values.length > 0 &&
typeof block.widgets_values[0] === 'string';
}
function promptRoleLabel(role) {
return t({
positive: 'recipePromptRolePositive',
negative: 'recipePromptRoleNegative',
both: 'recipePromptRoleBoth',
ignored: 'recipePromptRoleIgnored',
unknown: 'recipePromptRoleUnknown',
}[role] || 'recipePromptRoleUnknown');
}
export function applyPromptRolesToBlocks(blocks, promptRoles) {
if (!Array.isArray(blocks) || !promptRoles || typeof promptRoles !== 'object') return blocks;
for (const block of blocks) {
const info = promptRoles[String(block?.node_id)];
if (!info || typeof info !== 'object') continue;
block.promptRole = info.role;
block.promptRoleSource = info.source;
block.promptRoleManual = info.source === 'manual';
block.promptRoleAutomatic = info.automatic_role || (info.source === 'manual' ? 'unknown' : info.role);
}
return blocks;
}
export function mergePromptRoleOverrides(promptRoles, overrides) {
const roles = { ...(promptRoles || {}) };
if (!overrides || typeof overrides !== 'object') return roles;
for (const [nodeId, entry] of Object.entries(overrides)) {
if (entry?.role) {
const automatic = roles[String(nodeId)];
roles[String(nodeId)] = {
role: entry.role,
source: 'manual',
automatic_role: automatic?.automatic_role || automatic?.role || 'unknown',
};
}
}
return roles;
}
export function promptGroupsFromBlocks(blocks) {
const positive = [];
const negative = [];
for (const block of Array.isArray(blocks) ? blocks : []) {
const value = (block.widgets_values || []).find(item => typeof item === 'string' && item.trim());
if (!value) continue;
const textVal = value.trim();
if ((block.promptRole === 'positive' || block.promptRole === 'both') && !positive.includes(textVal)) positive.push(textVal);
if ((block.promptRole === 'negative' || block.promptRole === 'both') && !negative.includes(textVal)) negative.push(textVal);
}
return { positive, negative };
}
export function renderMaterialPromptGroups(parent, groups, options = {}) {
const positive = Array.isArray(groups?.positive) ? groups.positive.filter(Boolean) : [];
const negative = Array.isArray(groups?.negative) ? groups.negative.filter(Boolean) : [];
if (!positive.length && !negative.length) return false;
const renderCard = (titleText, promptStr, tone) => {
const card = document.createElement('div');
card.className = tone ? `anomalous-material-prompt-card is-${tone}` : 'anomalous-material-prompt-card';
const bar = document.createElement('div');
bar.className = 'anomalous-material-prompt-bar';
text(bar, 'span', titleText, 'anomalous-material-prompt-bar-label');
const copyBtn = text(bar, 'button', t('materialCopyPrompt'), 'anomalous-material-mini-btn');
copyBtn.type = 'button';
bindCopyButton(copyBtn, () => promptStr, 'materialCopied');
card.appendChild(bar);
text(card, 'div', promptStr, 'anomalous-material-prompt-content is-expanded');
parent.appendChild(card);
};
if (positive.length) renderCard(t('materialPositivePrompt'), positive.join('\n\n'), 'positive');
if (negative.length) renderCard(t('materialNegativePrompt'), negative.join('\n\n'), 'negative');
if (options.manual) text(parent, 'p', t('materialPromptRoleFromRecipe'), 'anomalous-material-muted');
return true;
}
function bindCopyButton(button, value, successKey) {
const label = button.textContent;
button.onclick = async event => {
event.stopPropagation();
try {
await navigator.clipboard.writeText(value());
button.textContent = t(successKey);
} catch (error) {
button.textContent = t('materialCopyError');
}
setTimeout(() => { if (button.isConnected) button.textContent = label; }, 1200);
};
}
function renderPromptBlock(content, block, widgetValues) {
const promptWrap = document.createElement('div');
promptWrap.className = 'anomalous-material-prompt-station';
const promptText = String(widgetValues[0] ?? '');
const charCount = promptText.length;
const bar = document.createElement('div');
bar.className = 'anomalous-material-prompt-bar';
const titleSec = document.createElement('div');
titleSec.className = 'anomalous-material-prompt-title-sec';
text(titleSec, 'span', `💬 ${t('materialPromptNode') || '提示词'}`, 'anomalous-material-prompt-title');
text(titleSec, 'span', t('materialChars', { count: charCount }), 'anomalous-material-prompt-count');
bar.appendChild(titleSec);
const copyPromptBtn = text(bar, 'button', t('materialCopyPromptText'), 'anomalous-material-copy-prompt-btn');
copyPromptBtn.type = 'button';
bindCopyButton(copyPromptBtn, () => promptText, 'materialPromptCopied');
promptWrap.appendChild(bar);
const body = document.createElement('div');
body.className = 'anomalous-material-prompt-body';
body.textContent = promptText || t('materialNoWidgetParameters');
promptWrap.appendChild(body);
content.appendChild(promptWrap);
}
function renderNodeParameterRows(content, block, widgetValues, offset = 0) {
const labels = comfyWidgetLabels(block.type);
const volatileIndexes = new Set(Array.isArray(block.volatile_widget_indexes) ? block.volatile_widget_indexes : []);
if (!widgetValues.length) {
text(content, 'p', t('materialNoWidgetParameters'), 'anomalous-material-muted');
return;
}
widgetValues.forEach((value, localIndex) => {
const index = localIndex + offset;
const row = document.createElement('div');
row.className = 'anomalous-material-parameter-row';
const labelWrap = document.createElement('div');
labelWrap.className = 'anomalous-material-parameter-label';
text(labelWrap, 'span', labels[index] || t('materialWidgetIndex', { index: index + 1 }));
text(labelWrap, 'code', `#${index}`);
if (volatileIndexes.has(index)) {
text(labelWrap, 'span', t('materialVolatileParameter'), 'anomalous-material-volatile-badge');
}
const valueText = text(row, 'pre', formatMaterialParameterValue(value), 'anomalous-material-parameter-value');
valueText.title = t('materialParameterFullValue');
row.prepend(labelWrap);
content.appendChild(row);
});
}
function renderNodeProperties(content, properties) {
const props = properties && typeof properties === 'object' ? properties : {};
if (!Object.keys(props).length) return;
const row = document.createElement('div');
row.className = 'anomalous-material-parameter-row';
const labelWrap = document.createElement('div');
labelWrap.className = 'anomalous-material-parameter-label';
text(labelWrap, 'span', t('materialNodeProperties'));
const propertyValue = document.createElement('pre');
propertyValue.className = 'anomalous-material-parameter-value';
propertyValue.textContent = formatMaterialParameterValue(props);
row.append(labelWrap, propertyValue);
content.appendChild(row);
}
function renderNodeCardContent(node, block, widgetValues) {
const content = document.createElement('div');
content.className = 'anomalous-material-node-parameter-content';
const meta = document.createElement('div');
meta.className = 'anomalous-material-node-meta';
text(meta, 'span', `${t('materialNodeId')}: ${block.node_id ?? '—'}`);
if (block.mode != null) text(meta, 'span', `${t('materialNodeMode')}: ${block.mode}`);
const copy = text(meta, 'button', t('materialCopyNodeParameters'), 'anomalous-material-mini-btn');
copy.type = 'button';
bindCopyButton(copy, () => JSON.stringify({
node_id: block.node_id, type: block.type, title: block.title,
widgets_values: widgetValues, properties: block.properties || {}, mode: block.mode,
}, null, 2), 'materialCopied');
const technical = document.createElement('details');
technical.className = 'anomalous-notebook-fold';
text(technical, 'summary', t('recipeAdvancedInfo'));
technical.appendChild(meta);
if (isPromptBlock(block)) {
renderPromptBlock(content, block, widgetValues);
if (widgetValues.length > 1) {
renderNodeParameterRows(content, block, widgetValues.slice(1), 1);
}
} else {
renderNodeParameterRows(content, block, widgetValues);
}
renderNodeProperties(technical, block.properties);
content.appendChild(technical);
node.appendChild(content);
}
function renderSingleNodeCard(block, options = {}) {
const node = document.createElement('details');
node.className = 'anomalous-material-node-detail';
const widgetValues = Array.isArray(block.widgets_values) ? block.widgets_values : [];
const summary = document.createElement('summary');
if (options.selectable) {
const select = document.createElement('input');
select.type = 'checkbox';
select.className = 'anomalous-material-node-select';
select.dataset.nodeId = String(block.node_id);
select.checked = options.selectedIds?.has(String(block.node_id)) || false;
select.title = t('materialSelectNodeForSaving');
select.setAttribute('aria-label', t('materialSelectNodeForSaving'));
select.addEventListener('click', event => event.stopPropagation());
select.addEventListener('change', () => {
options.onSelectionChange?.(block, select.checked);
node.classList.toggle('is-selected', select.checked);
});
node.classList.toggle('is-selected', select.checked);
summary.appendChild(select);
}
const heading = document.createElement('span');
heading.className = 'anomalous-material-node-heading';
text(heading, 'strong', materialNodeHeading(block));
const renderRoleBadge = () => {
heading.querySelector('.anomalous-recipe-prompt-role-badge')?.remove();
if (!block.promptRole) return;
const badge = text(heading, 'span', promptRoleLabel(block.promptRole), `anomalous-recipe-prompt-role-badge is-${block.promptRole}`);
badge.title = block.promptRoleManual ? t('recipePromptRoleManual') : t('recipePromptRoleAutomatic');
};
renderRoleBadge();
summary.appendChild(heading);
text(summary, 'span', t('materialParameterCount', { count: widgetValues.length }), 'anomalous-material-node-count');
if (isPromptBlock(block) && typeof options.onPromptRoleChange === 'function') {
const roleSelect = document.createElement('select');
roleSelect.className = 'anomalous-recipe-prompt-role-select anomalous-material-prompt-role-select';
roleSelect.setAttribute('aria-label', t('recipePromptRoleChoose'));
const automaticRole = block.promptRoleAutomatic || (block.promptRoleManual ? 'unknown' : block.promptRole) || 'unknown';
const choices = [
['auto', `${t('recipePromptRoleAutomatic')} · ${promptRoleLabel(automaticRole)}`],
['positive', t('recipePromptRolePositive')],
['negative', t('recipePromptRoleNegative')],
['both', t('recipePromptRoleBoth')],
['unknown', t('recipePromptRoleUnknown')],
['ignored', t('recipePromptRoleIgnored')],
];
for (const [value, label] of choices) {
const option = text(roleSelect, 'option', label);
option.value = value;
}
roleSelect.value = block.promptRoleManual ? block.promptRole : 'auto';
roleSelect.addEventListener('click', event => event.stopPropagation());
roleSelect.addEventListener('change', async event => {
event.stopPropagation();
const previous = block.promptRoleManual ? block.promptRole : 'auto';
roleSelect.disabled = true;
try {
await options.onPromptRoleChange(block, roleSelect.value, roleSelect);
renderRoleBadge();
} catch (error) {
roleSelect.value = previous;
console.error('Could not update prompt role:', error);
} finally {
if (roleSelect.isConnected) roleSelect.disabled = false;
}
});
roleSelect.hidden = true;
const adjust = text(summary, 'button', t('promptAdjustRole'), 'anomalous-btn-ghost');
adjust.type = 'button';
adjust.setAttribute('aria-expanded', 'false');
adjust.onclick = event => {
event.preventDefault();
event.stopPropagation();
roleSelect.hidden = !roleSelect.hidden;
adjust.setAttribute('aria-expanded', String(!roleSelect.hidden));
if (!roleSelect.hidden) roleSelect.focus();
};
summary.appendChild(roleSelect);
}
if (typeof options.onSaveBlock === 'function') {
const save = text(summary, 'button', t(isPromptBlock(block) ? 'materialSavePromptAction' : 'materialSaveNode'), 'anomalous-material-node-save');
save.type = 'button';
save.title = t('materialSaveNodeHint');
save.addEventListener('click', event => event.stopPropagation());
save.addEventListener('click', () => options.onSaveBlock(block, save));
}
node.appendChild(summary);
const shouldOpen = !options.selectable && isPromptBlock(block);
let rendered = false;
const renderContentIfNeeded = () => {
if (rendered) return;
rendered = true;
renderNodeCardContent(node, block, widgetValues);
};
if (shouldOpen) {
node.open = true;
renderContentIfNeeded();
}
node.addEventListener('toggle', () => {
if (node.open) renderContentIfNeeded();
});
return node;
}
export function renderDetailedNodeCards(parent, blocks, options = {}) {
const list = document.createElement('div');
list.className = 'anomalous-material-node-list';
blocks.forEach(block => {
list.appendChild(renderSingleNodeCard(block, options));
});
parent.appendChild(list);
return list;
}
+22
View File
@@ -0,0 +1,22 @@
import { composePromptPlan } from './prompt_composition.js';
import { jsonResponse } from './ui_dom.js';
export function materialPromptText(payload) {
const data = payload.data || {};
if (data.kind === 'prompt_plan') return composePromptPlan(data.plan || {});
if (['prompt_text', 'prompt_note_bundle'].includes(data.kind)) {
return { positive: data.note?.promptEn || '', negative: '' };
}
const groups = payload.prompt_groups || {};
return Object.fromEntries(['positive', 'negative'].map(role => [role,
(Array.isArray(groups[role]) ? groups[role] : []).filter(value => typeof value === 'string' && value.trim()).join('\n'),
]));
}
export async function loadMaterialPrompts(filename, signal) {
const response = await fetch(`/anomalous/material_full?include_workflow=0&filename=${encodeURIComponent(filename)}`, { signal });
const payload = await jsonResponse(response, 'material load failed');
if (payload.status !== 'success') throw new Error('materialDetailLoadError');
return materialPromptText(payload);
}
+42
View File
@@ -0,0 +1,42 @@
const PHYSICAL_RENAME_PROTECTED_TYPES = new Set([
'vae',
'vae_approx',
'clip',
'text_encoders',
'clip_vision',
]);
// Scanner coverage is separate. These categories may be scanned, but automatic
// Model Doctor redirection requires workflow-carried cryptographic identity.
const HASH_ONLY_RECOVERY_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 => HASH_ONLY_RECOVERY_TYPES.has(type));
}
+67
View File
@@ -0,0 +1,67 @@
import { isPhysicalRenameProtectedType } from './model_policies.js';
export function usableSourceUrl(value) {
const url = typeof value === 'string' ? value.trim() : '';
if (!url) return '';
const civitaiModel = url.match(/^https?:\/\/(?:www\.)?civitai\.(?:com|red)\/models\/(-?\d+)(?:[/?#]|$)/i);
if (civitaiModel && Number(civitaiModel[1]) <= 0) return '';
return url;
}
export function foundationModelType(model) {
const candidates = Array.isArray(model?.folderTypes) ? [...model.folderTypes] : [];
if (model?.type) candidates.push(model.type);
return candidates.find(isPhysicalRenameProtectedType) || '';
}
export function shapeLibrarySourceModels(rawList, detectPlatform) {
return (Array.isArray(rawList) ? rawList : []).map(m => {
const meta = m.metadata || {};
const sourceUrl = usableSourceUrl(meta.source_url);
const civitaiUrl = usableSourceUrl(meta.civitai_url);
const url = sourceUrl || civitaiUrl;
const relPath = m.subfolder ? `${m.subfolder}/${m.filename}` : m.filename;
return {
key: `lib_${m.type}_${m.path_idx}_${relPath}`,
type: m.type,
path_idx: m.path_idx,
subfolder: m.subfolder || '',
filename: m.filename,
basename: m.filename,
relPath,
size_mb: m.size_mb || 0,
hash: meta.hash || '',
civitai_url: civitaiUrl,
source_url: sourceUrl,
url,
initialUrl: url,
platform: detectPlatform(url),
hasResolved: Boolean(url.trim()),
};
});
}
function matchesSearch(model, keyword) {
if (!keyword) return true;
const haystack = `${model.nodeTitle || ''} ${model.nodeType || ''} ${model.type || ''} ${model.filename || ''} ${model.basename || ''}`.toLowerCase();
return haystack.includes(keyword);
}
export function partitionSourceModels(models, filter = 'all', searchKeyword = '') {
const allModels = Array.isArray(models) ? models : [];
const allComponents = allModels.filter(model => Boolean(foundationModelType(model)));
const allMain = allModels.filter(model => !foundationModelType(model));
const matchesFilter = model => {
if (filter === 'resolved' && !model.url) return false;
if (filter === 'unresolved' && model.url) return false;
return matchesSearch(model, searchKeyword);
};
const mainModels = allMain.filter(matchesFilter);
const componentModels = allComponents.filter(matchesFilter);
return {
mainModels,
componentModels,
allComponentCount: componentModels.length,
componentMissingCount: componentModels.filter(model => model.isMissing === true).length,
};
}
+125
View File
@@ -0,0 +1,125 @@
// Shared by Node Assistant and Material Library. No node creation or link edits.
const clone = value => value === undefined ? undefined : JSON.parse(JSON.stringify(value));
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
export function selectedMaterialNode(app) {
const nodes = Object.values(app.canvas?.selected_nodes || {});
if (nodes.length !== 1 || !nodes[0]) return null;
const node = nodes[0];
const graph = app.graph?.getNodeById(node.id) === node ? app.graph : (node.graph || app.canvas?.graph || app.graph);
return graph?.getNodeById(node.id) === node ? node : null;
}
function volatile(node, widget, index) {
return /(^|[_\s-])(seed|noise_seed|random_seed|variation_seed|last_seed)([_\s-]|$)/i.test(widget?.name || '')
|| (node.type === 'KSampler' && index === 0) || (node.type === 'KSamplerAdvanced' && index === 1);
}
function hashesFor(graph, id) {
return Object.fromEntries(Object.entries(graph.extra?.anomalous_hashes || {}).filter(([key]) => key.startsWith(`${id}_`)));
}
function replaceHashes(graph, id, records) {
if (!Object.keys(records).length && !graph.extra?.anomalous_hashes) return;
graph.extra ||= {};
graph.extra.anomalous_hashes ||= {};
for (const key of Object.keys(graph.extra.anomalous_hashes)) if (key.startsWith(`${id}_`)) delete graph.extra.anomalous_hashes[key];
Object.assign(graph.extra.anomalous_hashes, clone(records));
}
export function applyNodeMaterialValues(app, node, entries, options = {}) {
const graph = app.graph?.getNodeById(node?.id) === node ? app.graph : (node?.graph || app.canvas?.graph || app.graph);
if (!node || graph?.getNodeById(node.id) !== node || !Array.isArray(node.widgets)) throw new Error('materialTargetChanged');
const changes = entries.filter(({ index }) => !volatile(node, node.widgets[index], index));
if (!changes.length) throw new Error('materialNoCompatibleValues');
for (const { index, value } of changes) {
const widget = node.widgets[index];
if (typeof value === 'number' && !Number.isFinite(value)) throw new Error('materialNoCompatibleValues');
if (!Number.isInteger(index) || !widget || value === undefined) throw new Error('materialNoCompatibleValues');
const choices = typeof widget.options?.values === 'function' ? widget.options.values() : widget.options?.values;
if (Array.isArray(choices) && !choices.includes(value)) throw new Error('materialValueUnavailable');
if (widget.value != null && (typeof widget.value !== typeof value || Array.isArray(widget.value) !== Array.isArray(value))) throw new Error('materialNoCompatibleValues');
}
const previous = node.widgets.map(widget => clone(widget.value));
const serialized = clone(node.widgets_values);
const hadExtra = Object.hasOwn(graph, 'extra');
const hadHashes = !!graph.extra && Object.hasOwn(graph.extra, 'anomalous_hashes');
const oldHashes = clone(hashesFor(graph, node.id));
const transportsHashes = options.sourceNodeId !== undefined;
const mapped = {};
if (transportsHashes) {
const prefix = `${options.sourceNodeId}_`;
for (const [key, value] of Object.entries(options.workflowHashes || {})) {
if (key.startsWith(prefix)) mapped[`${node.id}_${key.slice(prefix.length)}`] = clone(value);
}
}
const notify = () => {
graph.change?.();
graph.setDirtyCanvas?.(true, true);
app.canvas?.setDirty?.(true, true);
if (typeof globalThis.CustomEvent === 'function') {
globalThis.window?.dispatchEvent(new CustomEvent('graphChanged'));
}
};
const restore = () => {
node.widgets.forEach((widget, index) => { widget.value = clone(previous[index]); });
if (serialized === undefined) delete node.widgets_values;
else node.widgets_values = clone(serialized);
if (transportsHashes) {
replaceHashes(graph, node.id, oldHashes);
if (!hadHashes && graph.extra?.anomalous_hashes && !Object.keys(graph.extra.anomalous_hashes).length) delete graph.extra.anomalous_hashes;
if (!hadExtra && graph.extra && !Object.keys(graph.extra).length) delete graph.extra;
}
};
graph.beforeChange?.();
try {
for (const { index, value } of changes) {
const widget = node.widgets[index];
widget.value = clone(value);
if (Array.isArray(node.widgets_values)) node.widgets_values[index] = clone(value);
widget.callback?.call(widget, widget.value, app.canvas, node);
node.onWidgetChanged?.(index, widget.value, previous[index], widget);
}
if (transportsHashes) replaceHashes(graph, node.id, mapped);
notify();
} catch (error) {
restore();
notify();
throw error;
} finally { graph.afterChange?.(); }
const appliedSerialized = clone(node.widgets_values);
const applied = node.widgets.map(widget => clone(widget.value));
const appliedHashes = clone(hashesFor(graph, node.id));
let undone = false;
return {
widgets: changes.length,
undo() {
if (undone || app.graph !== graph || graph.getNodeById(node.id) !== node
|| !same(node.widgets.map(widget => widget.value), applied)
|| !same(node.widgets_values, appliedSerialized)
|| !same(hashesFor(graph, node.id), appliedHashes)) throw new Error('materialUndoChanged');
graph.beforeChange?.();
try { restore(); notify(); undone = true; }
finally { graph.afterChange?.(); }
},
};
}
export function applyMaterialBlock(app, node, block, workflowHashes) {
if (node?.type !== block?.type || !Array.isArray(block.widgets_values)
|| block.widgets_values.length > (node.widgets?.length || 0)) throw new Error('materialNoCompatibleValues');
return applyNodeMaterialValues(app, node, block.widgets_values.map((value, index) => ({ index, value })),
{ sourceNodeId: block.node_id, workflowHashes });
}
export function promptWidgetTargets(node) {
const promptNameRegex = /^(text|text_g|text_l|prompt|positive|negative|caption|string|value|文本|提示词|正面|负面|正向|反向|正面提示词|负面提示词|正向提示词|反向提示词|描述|内容)$/i;
return (node?.widgets || []).flatMap((widget, index) => {
if (!widget) return [];
const name = String(widget.name || '');
const label = String(widget.label || '');
const isNotCombo = !widget.options?.values || !Array.isArray(widget.options.values);
const matchesName = promptNameRegex.test(name) || promptNameRegex.test(label) || widget.type === 'customtext' || widget.type === 'text' || !!widget.options?.multiline;
return typeof widget.value === 'string' && matchesName && isNotCombo ? [{ index, name: name || label || 'text' }] : [];
});
}
+132
View File
@@ -0,0 +1,132 @@
/** Create a movable LiteGraph group from the active Prompt Note. */
import { app } from '../../../scripts/app.js';
import { translate } from './locales.js';
const t = (key, params) => translate(key, params);
/** Base-model value the backend uses for models whose metadata names no base model. */
export const UNLABELED_BASE_MODEL = '__unlabeled__';
/** UNet / diffusion models carry no text encoder, so their LoRAs patch the model only. */
export function isUnetModel(model) {
return model?.type === 'unet' || model?.type === 'diffusion_models';
}
export function sendNotebookToCanvas() {
if (!this.currentNotebook) return;
const data = this.currentNotebook.data || {};
if (!data.mainModel) {
alert(t('notebookSelectMain'));
return;
}
const groupNodes = [];
const isUnet = isUnetModel(data.mainModel);
const ckptNode = LiteGraph.createNode(isUnet ? "UNETLoader" : "CheckpointLoaderSimple");
app.graph.add(ckptNode);
groupNodes.push({ node: ckptNode, relX: 0, relY: 0 });
const sub = data.mainModel.subfolder.replace(/^\/+/, '').replace(/\/+$/, '');
const relPath = sub ? `${sub}/${data.mainModel.filename}` : data.mainModel.filename;
this.setWidgetValuePath(ckptNode, relPath);
let lastNode = ckptNode;
let lastModelSlot = 0;
let lastClipSlot = isUnet ? null : 1;
let relX = 350;
let relY = 0;
data.loras.forEach((lora, idx) => {
const loraNode = LiteGraph.createNode(isUnet ? "LoraLoaderModelOnly" : "LoraLoader");
app.graph.add(loraNode);
groupNodes.push({ node: loraNode, relX: relX, relY: relY });
const lsub = lora.subfolder.replace(/^\/+/, '').replace(/\/+$/, '');
const lrelPath = lsub ? `${lsub}/${lora.filename}` : lora.filename;
this.setWidgetValuePath(loraNode, lrelPath);
lastNode.connect(lastModelSlot, loraNode, 0);
if (lastClipSlot !== null) lastNode.connect(lastClipSlot, loraNode, 1);
lastNode = loraNode;
lastModelSlot = 0;
if (!isUnet) lastClipSlot = 1;
relX += 350;
});
if (data.promptEn) {
const posNode = LiteGraph.createNode("CLIPTextEncode");
posNode.title = "CLIP Text Encode (Positive)";
app.graph.add(posNode);
groupNodes.push({ node: posNode, relX: relX, relY: 0 });
if (posNode.widgets && posNode.widgets.length > 0) {
const tw = posNode.widgets.find(w => w.name === 'text' || w.type === 'customtext');
if (tw) tw.value = data.promptEn;
}
if (lastClipSlot !== null) {
lastNode.connect(lastClipSlot, posNode, 0);
}
const negNode = LiteGraph.createNode("CLIPTextEncode");
negNode.title = "CLIP Text Encode (Negative)";
app.graph.add(negNode);
groupNodes.push({ node: negNode, relX: relX, relY: 250 });
if (negNode.widgets && negNode.widgets.length > 0) {
const tw = negNode.widgets.find(w => w.name === 'text' || w.type === 'customtext');
if (tw) tw.value = "text, watermark, ugly, bad anatomy";
}
if (lastClipSlot !== null) {
lastNode.connect(lastClipSlot, negNode, 0);
}
}
this.nbPanel.style.display = 'none';
this.close();
// Magnetic Sticking Logic
let isSticking = true;
const stickHandler = (e) => {
if (!isSticking || !app.canvas) return;
const canvas = app.canvas;
let canvasX, canvasY;
if (canvas.convertEventToCanvasOffset) {
const pos = canvas.convertEventToCanvasOffset(e);
canvasX = pos[0];
canvasY = pos[1];
} else {
const rect = canvas.canvas.getBoundingClientRect();
canvasX = (e.clientX - rect.left - canvas.ds.offset[0]) / canvas.ds.scale;
canvasY = (e.clientY - rect.top - canvas.ds.offset[1]) / canvas.ds.scale;
}
groupNodes.forEach(item => {
const w = (item.node.size && item.node.size[0]) ? item.node.size[0] : 200;
item.node.pos = [canvasX - (w / 2) + item.relX, canvasY - 20 + item.relY];
});
canvas.setDirty(true, true);
};
const dropHandler = (e) => {
if (!isSticking) return;
isSticking = false;
window.removeEventListener('mousemove', stickHandler, true);
window.removeEventListener('pointerdown', dropHandler, true);
window.removeEventListener('mousedown', dropHandler, true);
window.removeEventListener('click', dropHandler, true);
e.preventDefault();
e.stopPropagation();
};
window.addEventListener('mousemove', stickHandler, true);
setTimeout(() => {
window.addEventListener('pointerdown', dropHandler, true);
window.addEventListener('mousedown', dropHandler, true);
window.addEventListener('click', dropHandler, true);
}, 100);
}
+240
View File
@@ -0,0 +1,240 @@
/** Preserve fragment contents and order, including weights and commas. */
export function joinPromptText(existing, incoming, position) {
if (!['before', 'after'].includes(position)) throw new Error('materialNoCompatibleValues');
return (position === 'before' ? [incoming, existing] : [existing, incoming])
.filter(value => value && value.trim()).join('\n');
}
export function composePromptPlan(plan) {
if (!plan) return { positive: '', negative: '' };
const parts = Array.isArray(plan.parts) ? plan.parts : [];
const active = parts.filter(part => part && part.enabled !== false);
return Object.fromEntries(['positive', 'negative'].map(role => {
const activeParts = active.filter(p => (p.track || p.role) === role || (!p.track && !p.role && (role === 'positive' ? p.positive : p.negative)));
const activeAssembled = assemblePromptBlocks(activeParts, role);
const rawFieldText = String(plan[role] || '').trim();
if (plan.version >= 2) {
return [role, rawFieldText || activeAssembled];
}
if (activeParts.length > 0 && rawFieldText) {
const activeJoinedNewline = activeParts.map(p => String(p.content ?? p[role] ?? '').trim()).filter(Boolean).join('\n');
if (rawFieldText === activeAssembled || rawFieldText === activeJoinedNewline) {
return [role, rawFieldText];
}
}
const partsText = activeParts.map(part => part[role] || part.content || '').filter(Boolean);
return [role, [...partsText, rawFieldText].filter(Boolean).join('\n')];
}));
}
const BASE_QUALITY_KEYWORDS = [
'masterpiece', 'best quality', 'highly detailed', 'ultra-detailed', '8k', 'hdr',
'absurdres', 'highres', 'high resolution', 'perfect anatomy', 'clean background',
'worst quality', 'low quality', 'normal quality', 'lowres', 'bad anatomy',
'bad hands', 'missing fingers', 'extra digits', 'fewer digits', 'cropped',
'jpeg artifacts', 'blurry', 'watermark', 'signature', 'artist name'
];
const TRIGGER_KEYWORDS = [
'<lora:', 'trigger:', 'lora:', 'custom dress', 'specific', 'costume', 'outfit'
];
const STYLE_KEYWORDS = [
'style', 'lighting', 'cinematic', 'illustration', 'oil painting', 'concept art',
'unreal engine', 'octane render', 'vray', 'ray tracing', 'anime', 'photorealistic',
'pastel', 'watercolor', 'cyberpunk', 'steampunk', 'glow', 'volumetric'
];
/** Categorize a prompt block based on its keywords and content */
export function categorizePromptSnippet(text = '') {
const lower = String(text).toLowerCase();
if (BASE_QUALITY_KEYWORDS.some(k => lower.includes(k))) return 'base';
if (TRIGGER_KEYWORDS.some(k => lower.includes(k))) return 'trigger';
if (STYLE_KEYWORDS.some(k => lower.includes(k))) return 'style';
return 'subject';
}
/** Smart sort prompt blocks: Same-role blocks first, cross-role blocks at the tail */
export function smartSortPromptBlocks(blocks = [], trackRole = 'positive') {
const priority = {
base: 0,
style: 1,
subject: 2,
trigger: 3,
};
return [...blocks].sort((a, b) => {
const aCross = (a.role && a.role !== trackRole) ? 1 : 0;
const bCross = (b.role && b.role !== trackRole) ? 1 : 0;
if (aCross !== bCross) {
return aCross - bCross;
}
const catA = a.category || categorizePromptSnippet(a.content || a[trackRole] || '');
const catB = b.category || categorizePromptSnippet(b.content || b[trackRole] || '');
const scoreA = priority[catA] ?? 2;
const scoreB = priority[catB] ?? 2;
return scoreA - scoreB;
});
}
/** Cleanly join prompt blocks with commas and proper spacing */
export function assemblePromptBlocks(blocks = [], role = 'positive') {
return blocks
.filter(b => b && b.enabled !== false)
.map(b => {
const raw = String(b.content ?? b[role] ?? '').trim();
// remove trailing/leading commas from snippet
return raw.replace(/^[,,\s]+|[,,\s]+$/g, '');
})
.filter(Boolean)
.join(',\n');
}
/**
* Losslessly converts any prompt plan (legacy v1 with dual-role blocks or loose text, or v2)
* into the standardized workbench draft representation with role isolation.
*/
export function planToWorkbenchDraft(planData = {}, defaultName = '') {
const rawParts = Array.isArray(planData.parts) ? planData.parts : [];
const convertedBlocks = [];
const makeId = (prefix, idx) => `${prefix}_${Date.now()}_${idx}_${Math.random().toString(36).slice(2, 6)}`;
rawParts.forEach((part, idx) => {
if (!part) return;
const name = String(part.name || part.title || '').trim();
const enabled = part.enabled !== false;
const category = part.category || 'general';
const hasPos = typeof part.positive === 'string' && part.positive.trim().length > 0;
const hasNeg = typeof part.negative === 'string' && part.negative.trim().length > 0;
if (hasPos && hasNeg) {
convertedBlocks.push({
id: part.id ? `${part.id}_pos` : makeId('blk_pos', idx),
title: name ? `${name} (Pos)` : 'Positive',
content: part.positive.trim(),
role: 'positive',
category: category === 'general' ? categorizePromptSnippet(part.positive) : category,
enabled,
});
convertedBlocks.push({
id: part.id ? `${part.id}_neg` : makeId('blk_neg', idx),
title: name ? `${name} (Neg)` : 'Negative',
content: part.negative.trim(),
role: 'negative',
category: category === 'general' ? 'base' : category,
enabled,
});
} else if (hasNeg || part.role === 'negative') {
const content = String(part.content ?? part.negative ?? '').trim();
if (content) {
convertedBlocks.push({
id: part.id || makeId('blk', idx),
title: name || 'Negative',
content,
role: 'negative',
category: category === 'general' ? 'base' : category,
enabled,
});
}
} else {
const content = String(part.content ?? part.positive ?? '').trim();
if (content) {
convertedBlocks.push({
id: part.id || makeId('blk', idx),
title: name || 'Positive',
content,
role: 'positive',
category: category === 'general' ? categorizePromptSnippet(content) : category,
enabled,
});
}
}
});
// Check for legacy trailing unassigned text (for legacy v1 plans)
if (!planData.version || planData.version < 2) {
['positive', 'negative'].forEach(role => {
const rawText = String(planData[role] || '').trim();
if (!rawText) return;
const existingRoleBlocks = convertedBlocks.filter(b => b.role === role);
const assembledRoleText = assemblePromptBlocks(existingRoleBlocks, role);
const joinedRoleText = existingRoleBlocks.map(b => b.content).join('\n');
if (rawText !== assembledRoleText && rawText !== joinedRoleText) {
if (existingRoleBlocks.length === 0) {
convertedBlocks.push({
id: makeId(`blk_${role}_init`, convertedBlocks.length),
title: role === 'positive' ? '正向提示词' : '负向提示词',
content: rawText,
role,
category: role === 'positive' ? categorizePromptSnippet(rawText) : 'base',
enabled: true,
});
} else {
let trailing = rawText;
if (trailing.startsWith(assembledRoleText)) {
trailing = trailing.slice(assembledRoleText.length).replace(/^[,,\n\s]+/, '').trim();
} else if (trailing.startsWith(joinedRoleText)) {
trailing = trailing.slice(joinedRoleText.length).replace(/^[,,\n\s]+/, '').trim();
}
if (trailing) {
convertedBlocks.push({
id: makeId(`blk_${role}_tail`, convertedBlocks.length),
title: role === 'positive' ? '附加正向词' : '附加负向词',
content: trailing,
role,
category: role === 'positive' ? categorizePromptSnippet(trailing) : 'base',
enabled: true,
});
}
}
}
});
}
const draft = {
name: String(planData.name || defaultName || '').trim(),
tags: Array.isArray(planData.tags) ? planData.tags : [],
plan: {
version: 2,
parts: convertedBlocks,
positive: assemblePromptBlocks(convertedBlocks.filter(p => p.role === 'positive'), 'positive'),
negative: assemblePromptBlocks(convertedBlocks.filter(p => p.role === 'negative'), 'negative'),
},
};
return draft;
}
/**
* Converts a workbench draft into the standard backend-storable prompt plan object.
*/
export function workbenchDraftToSavedPlan(draft) {
const parts = Array.isArray(draft?.plan?.parts) ? draft.plan.parts : [];
const posParts = parts.filter(p => p.role === 'positive');
const negParts = parts.filter(p => p.role === 'negative');
return {
name: String(draft.name || '').trim(),
tags: Array.isArray(draft.tags) ? draft.tags : [],
plan: {
version: 2,
parts: parts.map(p => ({
name: String(p.title || p.name || '').trim().slice(0, 120),
positive: p.role === 'positive' ? String(p.content || '').trim() : '',
negative: p.role === 'negative' ? String(p.content || '').trim() : '',
category: p.category || (p.role === 'negative' ? 'base' : categorizePromptSnippet(p.content)),
enabled: p.enabled !== false,
})),
positive: assemblePromptBlocks(posParts, 'positive'),
negative: assemblePromptBlocks(negParts, 'negative'),
},
};
}
+46
View File
@@ -0,0 +1,46 @@
import { loadMaterialPrompts } from './material_prompt_data.js';
import { categorizePromptSnippet } from './prompt_composition.js';
import { jsonResponse } from './ui_dom.js';
// A complete snapshot is required before replacing library-owned cards.
export async function loadPromptSourceCards(signal) {
const cards = [];
const filenames = new Set();
let page = 1;
let pages = 1;
do {
signal.throwIfAborted();
const response = await fetch(`/anomalous/materials?category=prompts&limit=100&page=${page}`, { signal });
const payload = await jsonResponse(response, 'materialLoadError');
if (payload.status !== 'success' || !Array.isArray(payload.materials)) throw new Error('materialLoadError');
pages = Number(payload.pages) || 1;
for (const item of payload.materials) {
signal.throwIfAborted();
if (filenames.has(item.filename)) continue;
filenames.add(item.filename);
const prompts = await loadMaterialPrompts(item.filename, signal);
for (const role of ['positive', 'negative']) {
const content = prompts[role]?.trim();
if (!content) continue;
cards.push({
id: `mat_${role}_${item.filename}`, filename: item.filename,
title: item.name || item.filename, content, role,
category: role === 'negative' ? 'base' : categorizePromptSnippet(content),
persisted: true, sourceKind: 'material',
});
}
}
page++;
} while (page <= pages);
signal.throwIfAborted();
return cards;
}
export function mergePromptSourceCards(existing, incoming) {
const oldIds = new Set(existing.filter(card => card.sourceKind === 'material').map(card => card.id));
const local = existing.filter(card => card.sourceKind !== 'material');
const unique = [...new Map(incoming.map(card => [card.id, card])).values()];
const added = unique.filter(card => !oldIds.has(card.id)).length;
existing.splice(0, existing.length, ...local, ...unique);
return added;
}
+111
View File
@@ -0,0 +1,111 @@
import { categorizePromptSnippet, assemblePromptBlocks } from './prompt_composition.js';
export const newDraft = () => ({
name: '',
tags: [],
plan: {
version: 2,
parts: [],
positive: '',
negative: '',
},
});
export const CATEGORY_META = {
base: { zh: '通用底模', en: 'Base Quality', color: '#38bdf8', bg: 'rgba(56, 189, 248, 0.15)', border: 'rgba(56, 189, 248, 0.4)' },
style: { zh: '风格氛围', en: 'Art Style', color: '#c084fc', bg: 'rgba(192, 132, 252, 0.15)', border: 'rgba(192, 132, 252, 0.4)' },
subject: { zh: '主体内容', en: 'Subject', color: '#4ade80', bg: 'rgba(74, 222, 128, 0.15)', border: 'rgba(74, 222, 128, 0.4)' },
trigger: { zh: 'LoRA/触发', en: 'LoRA / Trigger', color: '#fb923c', bg: 'rgba(251, 146, 60, 0.15)', border: 'rgba(251, 146, 60, 0.4)' },
};
// Built-in starter prompt cards for left source deck
export const STARTER_SOURCE_PROMPTS = [
{
id: 'preset_base_quality',
title: '画质底模词 (Quality Base)',
content: 'masterpiece, best quality, highly detailed, ultra-detailed, 8k, hdr, absurdres',
role: 'positive',
category: 'base',
},
{
id: 'preset_real_detail',
title: '写实与材质增强 (Realistic Detail)',
content: 'highres, realistic skin texture, sharp focus, subsurface scattering, 35mm photograph',
role: 'positive',
category: 'base',
},
{
id: 'preset_cinematic_light',
title: '电影胶片光影 (Cinematic Lighting)',
content: 'cinematic lighting, dramatic shadows, soft volumetric glow, ray tracing, atmospheric',
role: 'positive',
category: 'style',
},
{
id: 'preset_anime_cyber',
title: '赛博霓虹风 (Cyberpunk Neon)',
content: 'anime style, cyberpunk aesthetic, vibrant neon reflections, futuristic metropolis backdrop',
role: 'positive',
category: 'style',
},
{
id: 'preset_girl_face',
title: '美少女面部特写 (Portrait 1girl)',
content: '1girl, beautiful detailed expressive eyes, smile, delicate face, soft wind-blown hair',
role: 'positive',
category: 'subject',
},
{
id: 'preset_neg_filter',
title: '通用负向过滤词 (Negative Base Filter)',
content: 'worst quality, low quality, normal quality, lowres, bad anatomy, bad hands, missing fingers, extra digits, cropped, blurry, watermark',
role: 'negative',
category: 'base',
},
];
export function normalizeBlock(part, index = 0) {
const id = part.id || `blk_${Date.now()}_${index}_${Math.random().toString(36).slice(2, 6)}`;
const role = part.role || (part.negative && !part.positive ? 'negative' : 'positive');
const track = part.track || role;
const content = String(part.content ?? (role === 'positive' ? part.positive : part.negative) ?? '').trim();
let category = part.category;
if (!CATEGORY_META[category]) {
category = categorizePromptSnippet(content);
}
const meta = CATEGORY_META[category] || CATEGORY_META.subject;
const defaultTitle = (window.anomalous_browser_lang === 'zh' ? meta.zh : meta.en) + (index ? ` #${index + 1}` : '');
return {
id,
title: part.name || part.title || defaultTitle,
content,
role,
track,
category,
enabled: part.enabled !== false,
};
}
export function syncDraftSynthesizedText(draft) {
if (!draft || !draft.plan) return;
draft.plan.parts ||= [];
const posParts = draft.plan.parts.filter(p => (p.track || p.role) === 'positive');
const negParts = draft.plan.parts.filter(p => (p.track || p.role) === 'negative');
draft.plan.positive = assemblePromptBlocks(posParts, 'positive');
draft.plan.negative = assemblePromptBlocks(negParts, 'negative');
}
// Full-text edits replace the enabled blocks of one role. Keep disabled blocks
// and the other role intact; synthesized fields are always derived from parts.
export function replaceDraftRoleText(draft, role, content) {
const parts = draft.plan.parts || [];
const first = parts.findIndex(part => (part.track || part.role) === role && part.enabled !== false);
const replacement = content.trim() ? normalizeBlock({ content, role, track: role }) : null;
const next = [];
parts.forEach((part, index) => {
if (index === first && replacement) next.push(replacement);
if ((part.track || part.role) !== role || part.enabled === false) next.push(part);
});
if (first < 0 && replacement) next.push(replacement);
draft.plan.parts = next;
syncDraftSynthesizedText(draft);
}
+11 -1
View File
@@ -1,4 +1,5 @@
import { app } from '../../../scripts/app.js';
import { mergeRecipeHashRecords } from './recipe_provenance.js';
function cloneJson(value) {
if (value === undefined) return undefined;
@@ -272,6 +273,8 @@ export function appendRecipeToCanvas(recipe) {
const offsetX = targetBounds ? targetBounds.maxX + 140 - sourceBounds.minX : 40 - sourceBounds.minX;
const offsetY = targetBounds ? targetBounds.minY - sourceBounds.minY : 40 - sourceBounds.minY;
const insertedGroups = [];
const hadHashRecords = Boolean(graph.extra && Object.prototype.hasOwnProperty.call(graph.extra, 'anomalous_hashes'));
const previousHashRecords = cloneJson(graph.extra?.anomalous_hashes);
graph.beforeChange?.();
try {
@@ -320,15 +323,22 @@ export function appendRecipeToCanvas(recipe) {
graph.add(group);
insertedGroups.push(group);
}
const hashRecords = mergeRecipeHashRecords(graph, recipe.workflow, idMap);
graph.change?.();
graph.setDirtyCanvas?.(true, true);
app.canvas?.setDirty?.(true, true);
app.canvas?.selectNodes?.(inserted);
app.canvas?.selectItems?.(inserted);
return { nodes: inserted.length, links: savedLinks.length, groups: insertedGroups.length };
return { nodes: inserted.length, links: savedLinks.length, groups: insertedGroups.length, hashRecords };
} catch (error) {
for (const group of insertedGroups) graph.remove?.(group);
for (const node of inserted) graph.remove?.(node);
if (hadHashRecords) {
if (!graph.extra || typeof graph.extra !== 'object') graph.extra = {};
graph.extra.anomalous_hashes = previousHashRecords;
} else if (graph.extra && typeof graph.extra === 'object') {
delete graph.extra.anomalous_hashes;
}
graph.change?.();
graph.setDirtyCanvas?.(true, true);
throw error;
+7 -1
View File
@@ -161,7 +161,13 @@ export function buildRecipeDiff(beforeRecipe, afterRecipe) {
before.params?.model_references || [],
after.params?.model_references || [],
(item, index) => itemKey(item, `model:${index}`),
(item) => item?.saved_value,
(item) => ({
saved_value: item?.saved_value,
user_note: item?.user_note || '',
identity_status: item?.identity?.status,
identity_sha256: item?.identity?.sha256,
identity_size: item?.identity?.size,
}),
(item) => `${item?.category || 'model'} · ${item?.node_title || item?.node_type || 'loader'}`,
);
+40 -1
View File
@@ -1,6 +1,15 @@
/** Pure helpers for recipe model references and provenance labels. */
const MODEL_FILE_PATTERN = /\.(?:safetensors|ckpt|pt|bin|sft)$/i;
const VERIFIABLE_RECIPE_MODEL_CATEGORIES = new Set([
'checkpoint',
'lora',
'unet',
'controlnet',
'vae',
'text_encoder',
'clip_vision',
]);
function nodeType(node) {
return String(node?.type || node?.class_type || '').trim();
@@ -40,6 +49,34 @@ export function normaliseIdentity(identity) {
return result;
}
function workflowIdentity(workflow, nodeId, savedValue) {
const hashes = workflow?.extra?.anomalous_hashes;
if (!hashes || typeof savedValue !== 'string') return null;
const normalized = savedValue.replace(/\\/g, '/');
const windowsPath = savedValue.replace(/\//g, '\\');
const record = hashes[`${nodeId}_${savedValue}`]
|| hashes[`${nodeId}_${normalized}`]
|| hashes[`${nodeId}_${windowsPath}`]
|| hashes[savedValue]
|| hashes[normalized]
|| hashes[windowsPath];
const sha256 = typeof record === 'string' ? record : record?.hash;
if (typeof sha256 !== 'string' || !/^[0-9a-f]{64}$/i.test(sha256)) return null;
const identity = {
status: 'verified',
sha256: sha256.toLowerCase(),
provenance: 'workflow snapshot',
};
if (record?.size !== null && record?.size !== '' && Number.isFinite(Number(record?.size))) {
identity.size = Number(record.size);
}
return identity;
}
export function canVerifyRecipeModelReference(reference) {
return VERIFIABLE_RECIPE_MODEL_CATEGORIES.has(String(reference?.category || '').toLowerCase());
}
export function deriveRecipeModelReferences(recipe) {
const stored = recipe?.params?.model_references;
if (Array.isArray(stored) && stored.length) {
@@ -56,6 +93,8 @@ export function deriveRecipeModelReferences(recipe) {
for (const [widgetIndex, category, widgetName] of modelSpecs(type)) {
const savedValue = values[widgetIndex];
if (typeof savedValue !== 'string' || !savedValue.trim()) continue;
const identity = workflowIdentity(recipe?.workflow, node?.id, savedValue)
|| { status: 'unverified' };
references.push({
node_id: node?.id ?? null,
node_type: type || 'Unknown',
@@ -65,7 +104,7 @@ export function deriveRecipeModelReferences(recipe) {
saved_value: savedValue,
category,
base_model: recipe?.params?.baseModel || null,
identity: { status: 'unverified' },
identity,
});
}
}
+63
View File
@@ -465,17 +465,80 @@ export function captureRecipeDraft(graph) {
const links = Array.isArray(workflow?.links)
? workflow.links
: (workflow?.links && typeof workflow.links === 'object' ? Object.values(workflow.links) : []);
const scope = analyseRecipeScope(graph);
return {
workflow,
metadata: extractRecipeMetadata(graph),
workflowScope: scope.scope,
stats: {
nodeCount: nodes.length,
linkCount: links.length,
groupCount: Array.isArray(workflow?.groups) ? workflow.groups.length : 0,
boundaryInputCount: scope.boundaryInputCount,
outputNodeCount: scope.outputNodeCount,
},
};
}
function nodeIsActive(node) {
return node?.mode !== 2 && node?.mode !== 4;
}
function nodeIsOutput(node) {
return node?.constructor?.nodeData?.output_node === true;
}
function inputIsOptional(node, input) {
const nodeData = node?.constructor?.nodeData;
const name = String(input?.name || '');
const modern = Array.isArray(nodeData?.inputs)
? nodeData.inputs.find((item) => String(item?.name || '') === name)
: null;
if (modern) return modern.isOptional === true || modern.optional === true;
if (nodeData?.input?.optional && Object.prototype.hasOwnProperty.call(nodeData.input.optional, name)) return true;
if (nodeData?.input?.required && Object.prototype.hasOwnProperty.call(nodeData.input.required, name)) return false;
const hollow = globalThis.LiteGraph?.HOLLOW_CIRCLE_SHAPE;
return hollow !== undefined && input?.shape === hollow;
}
function inputHasWidget(node, input) {
if (input?.widget || input?.widgetId) return true;
const name = String(input?.name || '');
return Boolean(name && (node?.widgets || []).some((widget) => String(widget?.name || '') === name));
}
function recipeGraphNodes(graph) {
const nodes = [];
const seen = new Set();
for (const node of graph?._nodes || []) {
for (const item of [node, ...(node?.getInnerNodes ? node.getInnerNodes(new Map()) : [])]) {
if (!item || seen.has(item)) continue;
seen.add(item);
nodes.push(item);
}
}
return nodes;
}
/** Classify a live graph as an independently runnable workflow or a reusable fragment. */
export function analyseRecipeScope(graph) {
const nodes = recipeGraphNodes(graph).filter(nodeIsActive);
const outputNodeCount = nodes.filter(nodeIsOutput).length;
let boundaryInputCount = 0;
for (const node of nodes) {
for (const input of node.inputs || []) {
if (input?.link !== null && input?.link !== undefined) continue;
if (inputHasWidget(node, input) || inputIsOptional(node, input)) continue;
boundaryInputCount += 1;
}
}
return {
scope: outputNodeCount > 0 && boundaryInputCount === 0 ? 'complete' : 'partial',
outputNodeCount,
boundaryInputCount,
};
}
/** Capture a bounded canvas preview; the optional element makes this testable. */
export function captureCanvasThumbnail(canvasEl = null) {
const source = canvasEl || document.getElementById('comfy-canvas-element') || document.querySelector('canvas');
+100
View File
@@ -0,0 +1,100 @@
/** UI-neutral helpers for Workflow Recipe model provenance records. */
const MODEL_FILE_PATTERN = /\.(?:safetensors|ckpt|pt|bin|sft)$/i;
const SHA256_PATTERN = /^[0-9a-f]{64}$/i;
function cloneJson(value) {
if (value === undefined) return undefined;
return JSON.parse(JSON.stringify(value));
}
function pathVariants(value) {
if (typeof value !== 'string') return [];
return [...new Set([
value,
value.replace(/\\/g, '/'),
value.replace(/\//g, '\\'),
])];
}
function nodeHashKeys(nodeId, value) {
return pathVariants(value).map((variant) => `${nodeId}_${variant}`);
}
function provenanceMap(workflow) {
const hashes = workflow?.extra?.anomalous_hashes;
return hashes && typeof hashes === 'object' && !Array.isArray(hashes) ? hashes : null;
}
export function findWorkflowHashRecord(workflow, nodeId, value) {
const hashes = provenanceMap(workflow);
if (!hashes) return null;
const keys = [
...nodeHashKeys(nodeId, value),
...pathVariants(value),
];
for (const key of keys) {
if (Object.prototype.hasOwnProperty.call(hashes, key)) return cloneJson(hashes[key]);
}
return null;
}
function writeNodeHashRecord(hashes, nodeId, value, record) {
for (const key of nodeHashKeys(nodeId, value)) hashes[key] = cloneJson(record);
}
/** Build node-ID-remapped provenance for appending a partial recipe. */
export function remapWorkflowHashRecords(workflow, idMap) {
const remapped = {};
if (!(idMap instanceof Map) || !provenanceMap(workflow)) return remapped;
for (const node of workflow?.nodes || []) {
const newId = idMap.get(String(node?.id));
if (newId === undefined || !Array.isArray(node?.widgets_values)) continue;
for (const value of node.widgets_values) {
if (typeof value !== 'string' || !MODEL_FILE_PATTERN.test(value)) continue;
const record = findWorkflowHashRecord(workflow, node.id, value);
if (record !== null) writeNodeHashRecord(remapped, newId, value, record);
}
}
return remapped;
}
/** Merge remapped recipe provenance into the live graph without deleting host data. */
export function mergeRecipeHashRecords(graph, workflow, idMap) {
const additions = remapWorkflowHashRecords(workflow, idMap);
const keys = Object.keys(additions);
if (!keys.length) return 0;
if (!graph.extra || typeof graph.extra !== 'object') graph.extra = {};
if (!graph.extra.anomalous_hashes || typeof graph.extra.anomalous_hashes !== 'object') {
graph.extra.anomalous_hashes = {};
}
Object.assign(graph.extra.anomalous_hashes, additions);
return keys.length;
}
/**
* Move one model reference's provenance to its new local dropdown value.
* A size-only confirmation deliberately writes an empty hash plus size; it
* must never inherit an unproven source hash.
*/
export function replaceWorkflowModelHashRecord(workflow, nodeId, oldValue, newValue, identity = {}) {
if (!workflow || typeof workflow !== 'object' || typeof newValue !== 'string' || !newValue) return false;
if (!workflow.extra || typeof workflow.extra !== 'object') workflow.extra = {};
if (!workflow.extra.anomalous_hashes || typeof workflow.extra.anomalous_hashes !== 'object') {
workflow.extra.anomalous_hashes = {};
}
const hashes = workflow.extra.anomalous_hashes;
for (const key of [...nodeHashKeys(nodeId, oldValue), ...nodeHashKeys(nodeId, newValue)]) delete hashes[key];
const sha256 = typeof identity.sha256 === 'string' && SHA256_PATTERN.test(identity.sha256)
? identity.sha256.toLowerCase()
: '';
const numericSize = Number(identity.size);
const hasSize = Number.isFinite(numericSize) && numericSize > 0;
if (!sha256 && !hasSize) return false;
writeNodeHashRecord(hashes, nodeId, newValue, {
hash: sha256,
size: hasSize ? numericSize : '',
});
return true;
}
+163
View File
@@ -0,0 +1,163 @@
import { translate } from './locales.js';
const PANEL_ID = 'anomalous-scan-progress';
let closeTimer = null;
function ensurePanel() {
let panel = document.getElementById(PANEL_ID);
if (panel) return panel;
panel = document.createElement('section');
panel.id = PANEL_ID;
panel.className = 'anomalous-scan-progress';
panel.setAttribute('role', 'status');
panel.setAttribute('aria-live', 'polite');
const title = document.createElement('strong');
title.className = 'anomalous-scan-progress-title';
panel.appendChild(title);
const track = document.createElement('div');
track.className = 'anomalous-scan-progress-track';
track.setAttribute('role', 'progressbar');
track.setAttribute('aria-valuemin', '0');
track.setAttribute('aria-valuemax', '100');
const fill = document.createElement('div');
fill.className = 'anomalous-scan-progress-fill';
track.appendChild(fill);
panel.appendChild(track);
const detail = document.createElement('div');
detail.className = 'anomalous-scan-progress-detail';
detail.style.whiteSpace = 'normal';
detail.style.lineHeight = '1.4';
detail.style.wordBreak = 'break-word';
panel.appendChild(detail);
const item = document.createElement('div');
item.className = 'anomalous-scan-progress-item';
panel.appendChild(item);
document.body.appendChild(panel);
return panel;
}
function progressRatio(status) {
const total = Number(status.total) || 0;
const current = Math.min(Number(status.current) || 0, total);
const folderTotal = Number(status.folder_total) || 0;
const folderCurrent = Math.min(Number(status.folder_current) || 0, folderTotal);
const fileRatio = total > 0 ? current / total : 0;
if (total === 0 && (status.phase === 'preparing' || status.phase === 'enumerating')) return null;
if (folderTotal > 0 && folderCurrent > 0) {
return Math.min(1, ((folderCurrent - 1) + fileRatio) / folderTotal);
}
return total > 0 ? fileRatio : null;
}
export function updateScanProgress(status, titleText = '') {
if (!status || (!status.scanning && !status.interrupted)) return;
if (closeTimer) {
clearTimeout(closeTimer);
closeTimer = null;
}
const panel = ensurePanel();
panel.classList.remove('is-complete', 'is-error');
const title = panel.querySelector('.anomalous-scan-progress-title');
const fill = panel.querySelector('.anomalous-scan-progress-fill');
const track = panel.querySelector('.anomalous-scan-progress-track');
const detail = panel.querySelector('.anomalous-scan-progress-detail');
const item = panel.querySelector('.anomalous-scan-progress-item');
title.textContent = titleText || translate('scanProgressTitle');
if (status.interrupted && !status.scanning) {
failScanProgress(translate('scanProgressInterrupted'));
return;
}
const ratio = progressRatio(status);
panel.setAttribute('aria-busy', 'true');
fill.classList.toggle('is-indeterminate', ratio === null);
fill.style.width = ratio === null ? '35%' : `${Math.max(2, Math.round(ratio * 100))}%`;
if (ratio === null) track.removeAttribute('aria-valuenow');
else track.setAttribute('aria-valuenow', String(Math.round(ratio * 100)));
const folderTotal = Number(status.folder_total) || 0;
const folderCurrent = Number(status.folder_current) || 0;
if (folderTotal > 0) {
detail.textContent = translate('scanProgressFolder', {
current: folderCurrent,
total: folderTotal,
folder: status.folder || '',
});
} else if (status.phase === 'enumerating' || status.phase === 'preparing') {
detail.textContent = translate('scanProgressPreparing');
} else {
detail.textContent = translate('sidebarScanning');
}
const total = Number(status.total) || 0;
item.textContent = total > 0
? translate('scanProgressModel', {
current: Number(status.current) || 0,
total,
filename: status.filename || '',
})
: translate('scanProgressCounting');
if (status.recovered && total === 0) item.textContent = translate('scanProgressInterrupted');
if (status.error) item.textContent = translate('scanProgressLastError', { error: status.error });
}
function closeLater(panel) {
if (closeTimer) clearTimeout(closeTimer);
closeTimer = setTimeout(() => {
if (typeof panel.remove === 'function') panel.remove();
else if (panel.parentNode) panel.parentNode.removeChild(panel);
closeTimer = null;
}, 4000);
}
export function finishScanProgress(detailText = '') {
const panel = ensurePanel();
panel.classList.remove('is-error');
panel.classList.add('is-complete');
panel.setAttribute('aria-busy', 'false');
panel.querySelector('.anomalous-scan-progress-title').textContent = translate('sidebarScanDone');
const fill = panel.querySelector('.anomalous-scan-progress-fill');
fill.classList.remove('is-indeterminate');
fill.style.width = '100%';
panel.querySelector('.anomalous-scan-progress-track').setAttribute('aria-valuenow', '100');
if (detailText) {
panel.querySelector('.anomalous-scan-progress-detail').textContent = detailText;
} else {
const currentDetail = panel.querySelector('.anomalous-scan-progress-detail').textContent;
if (!currentDetail || currentDetail === translate('sidebarScanning') || currentDetail === translate('scanProgressPreparing')) {
panel.querySelector('.anomalous-scan-progress-detail').textContent = translate('scanProgressFinished');
}
}
panel.querySelector('.anomalous-scan-progress-item').textContent = '';
closeLater(panel);
}
export function failScanProgress(message) {
const panel = ensurePanel();
panel.classList.remove('is-complete');
panel.classList.add('is-error');
panel.setAttribute('aria-busy', 'false');
panel.querySelector('.anomalous-scan-progress-title').textContent = translate('scanProgressProblem');
const fill = panel.querySelector('.anomalous-scan-progress-fill');
fill.classList.remove('is-indeterminate');
fill.style.width = '100%';
panel.querySelector('.anomalous-scan-progress-track').removeAttribute('aria-valuenow');
panel.querySelector('.anomalous-scan-progress-detail').textContent = message || translate('scanProgressInterrupted');
panel.querySelector('.anomalous-scan-progress-item').textContent = translate('scanProgressRetryReady');
closeLater(panel);
}
+141
View File
@@ -0,0 +1,141 @@
export const DEFAULT_BROWSER_SHORTCUT = Object.freeze({
key: 'm',
ctrl: true,
shift: true
});
export const DEFAULT_MATERIALS_SHORTCUT = Object.freeze({
key: 'l',
ctrl: true,
shift: true
});
function normalizedShortcutKey(value) {
const key = String(value || '').toLowerCase();
return key.startsWith('key') && key.length === 4 ? key.slice(3) : key;
}
export function shortcutEventMatches(event, combo) {
if (!event || !combo || event.isComposing) return false;
return normalizedShortcutKey(event.key || event.code) === normalizedShortcutKey(combo.key)
&& !!event.ctrlKey === !!combo.ctrl
&& !!event.shiftKey === !!combo.shift
&& !!event.altKey === !!combo.alt
&& !!event.metaKey === !!combo.meta;
}
export function installDeferredShortcutFallback({ target, getCombo, isHandled, onFallback, onError = console.error }) {
const handler = event => {
if (event.repeat || !shortcutEventMatches(event, getCombo?.())) return;
queueMicrotask(() => {
if (isHandled?.()) return;
Promise.resolve(onFallback?.()).catch(onError);
});
};
target.addEventListener('keydown', handler, true);
return () => target.removeEventListener('keydown', handler, true);
}
export function formatKeyCombo(combo) {
if (!combo) return '';
if (typeof combo.getKeySequences === 'function') {
return combo.getKeySequences().join(' + ');
}
const keys = [];
if (combo.ctrl || combo.meta) keys.push('Ctrl');
if (combo.alt) keys.push('Alt');
if (combo.shift) keys.push('Shift');
if (combo.key) {
keys.push(combo.key.length === 1 ? combo.key.toUpperCase() : combo.key);
}
return keys.join(' + ');
}
function getCommand(app, commandId) {
return app.extensionManager?.command?.commands?.find((command) => command.id === commandId);
}
function waitForElement(selector, timeout = 1500) {
return new Promise((resolve) => {
const existing = document.querySelector(selector);
if (existing) {
resolve(existing);
return;
}
const observer = new MutationObserver(() => {
const element = document.querySelector(selector);
if (!element) return;
observer.disconnect();
clearTimeout(timer);
resolve(element);
});
observer.observe(document.body, { childList: true, subtree: true });
const timer = setTimeout(() => {
observer.disconnect();
resolve(null);
}, timeout);
});
}
function setInputValue(input, value) {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
if (setter) setter.call(input, value);
else input.value = value;
input.dispatchEvent(new Event('input', { bubbles: true }));
}
export async function openNativeKeybindingEditor(commandId) {
const settingsDialog = document.querySelector('[data-testid="settings-dialog"]');
const keybindingNav = settingsDialog?.querySelector('[data-nav-id="keybinding"]');
if (!(keybindingNav instanceof HTMLElement)) return false;
keybindingNav.click();
const searchInput = await waitForElement('#keybinding-panel-header input');
if (!(searchInput instanceof HTMLInputElement)) return false;
setInputValue(searchInput, commandId);
const commandTitle = await waitForElement(`.keybinding-panel [title="${CSS.escape(commandId)}"]`);
const commandRow = commandTitle?.closest('tr');
if (!commandRow) return false;
const actionButtons = commandRow.querySelectorAll('.actions button');
const bindingCellText = commandRow.children[1]?.textContent?.trim();
const canOpenRecorderDirectly = actionButtons.length >= 4 || bindingCellText === '-';
if (canOpenRecorderDirectly && actionButtons[0] instanceof HTMLButtonElement) {
actionButtons[0].click();
}
return true;
}
export function createShortcutSettingControl({ app, commandId, translate, settingLabelKey = 'mainShortcutSetting' }) {
const container = document.createElement('div');
container.className = 'anomalous-shortcut-setting';
const current = document.createElement('kbd');
current.className = 'anomalous-shortcut-current';
const combo = getCommand(app, commandId)?.keybinding?.combo;
current.textContent = formatKeyCombo(combo) || translate('mainShortcutUnassigned');
const customize = document.createElement('button');
customize.type = 'button';
customize.className = 'anomalous-shortcut-customize';
customize.textContent = translate('mainShortcutCustomize');
customize.addEventListener('click', async () => {
customize.disabled = true;
const opened = await openNativeKeybindingEditor(commandId);
customize.disabled = false;
if (!opened) {
app.extensionManager?.toast?.add?.({
severity: 'warn',
summary: translate(settingLabelKey),
detail: translate('mainShortcutOpenFailed'),
life: 3500
});
}
});
container.append(current, customize);
return container;
}
+178
View File
@@ -0,0 +1,178 @@
/**
* shortcut_layout.js - State and Persistence for Customizable Shortcut Bar
*
* Implements schema validation, capacity limits, pinning, unpinning,
* reordering and reset logic with localStorage persistence.
*/
import { CATALOG_TOOLS } from './tool_registry.js';
export const SHORTCUT_STORAGE_KEY = 'anomalous_shortcut_layout_v1';
export const SHORTCUT_SCHEMA_VERSION = 1;
export const MAX_PINNED_SHORTCUTS = 4;
export const DEFAULT_PINNED_SHORTCUTS = Object.freeze([
'scan',
'doctor',
'assistant',
'materials',
]);
// In-memory fallback if localStorage fails or is blocked
let sessionMemoryLayout = null;
const VALID_CATALOG_IDS = new Set(CATALOG_TOOLS.map(t => t.id));
export function isValidCatalogToolId(id) {
return VALID_CATALOG_IDS.has(id);
}
/**
* Normalizes an array of tool IDs:
* - Drops invalid IDs, anchor IDs and non-strings
* - Deduplicates preserving first occurrence
* - Truncates to MAX_PINNED_SHORTCUTS
*/
export function normalizePinnedIds(ids) {
if (!Array.isArray(ids)) return [...DEFAULT_PINNED_SHORTCUTS];
const seen = new Set();
const result = [];
for (const id of ids) {
if (typeof id === 'string' && VALID_CATALOG_IDS.has(id) && !seen.has(id)) {
seen.add(id);
result.push(id);
if (result.length >= MAX_PINNED_SHORTCUTS) break;
}
}
return result;
}
/**
* Load shortcut layout from storage.
* Explicit empty array is valid and preserved.
*/
export function loadShortcutLayout(storage = (typeof localStorage !== 'undefined' ? localStorage : null)) {
if (sessionMemoryLayout !== null && !storage) {
return [...sessionMemoryLayout];
}
if (!storage) {
return sessionMemoryLayout !== null ? [...sessionMemoryLayout] : [...DEFAULT_PINNED_SHORTCUTS];
}
try {
const raw = storage.getItem(SHORTCUT_STORAGE_KEY);
if (raw === null || raw === undefined) {
return [...DEFAULT_PINNED_SHORTCUTS];
}
const parsed = JSON.parse(raw);
if (!parsed || parsed.schemaVersion !== SHORTCUT_SCHEMA_VERSION || !Array.isArray(parsed.pinned)) {
return [...DEFAULT_PINNED_SHORTCUTS];
}
return normalizePinnedIds(parsed.pinned);
} catch (err) {
console.warn('[AMB] Failed to read shortcut layout from storage, resetting to defaults:', err);
sessionMemoryLayout = [...DEFAULT_PINNED_SHORTCUTS];
return [...DEFAULT_PINNED_SHORTCUTS];
}
}
/**
* Persist shortcut layout to storage and memory.
*/
export function saveShortcutLayout(pinnedIds, storage = (typeof localStorage !== 'undefined' ? localStorage : null)) {
const normalized = normalizePinnedIds(pinnedIds);
sessionMemoryLayout = [...normalized];
if (!storage) {
return { success: true, layout: normalized, memoryOnly: true };
}
try {
const payload = JSON.stringify({
schemaVersion: SHORTCUT_SCHEMA_VERSION,
pinned: normalized,
});
storage.setItem(SHORTCUT_STORAGE_KEY, payload);
return { success: true, layout: normalized, memoryOnly: false };
} catch (err) {
console.warn('[AMB] Failed to write shortcut layout to storage, saved to session memory:', err);
return { success: true, layout: normalized, memoryOnly: true, error: err };
}
}
/**
* Pin a tool to the shortcut bar.
* If already pinned, reorder to targetIndex.
* If not pinned and at capacity, rejects with 'capacity_full'.
*/
export function pinTool(toolId, targetIndex = -1, storage = (typeof localStorage !== 'undefined' ? localStorage : null)) {
if (!isValidCatalogToolId(toolId)) {
return { success: false, reason: 'unknown_tool', layout: loadShortcutLayout(storage) };
}
const current = loadShortcutLayout(storage);
const existingIndex = current.indexOf(toolId);
if (existingIndex !== -1) {
// Already pinned: reorder to target position
current.splice(existingIndex, 1);
const insertAt = (targetIndex >= 0 && targetIndex <= current.length) ? targetIndex : current.length;
current.splice(insertAt, 0, toolId);
saveShortcutLayout(current, storage);
return { success: true, reordered: true, layout: current };
}
if (current.length >= MAX_PINNED_SHORTCUTS) {
return { success: false, reason: 'capacity_full', layout: current };
}
const insertAt = (targetIndex >= 0 && targetIndex <= current.length) ? targetIndex : current.length;
current.splice(insertAt, 0, toolId);
saveShortcutLayout(current, storage);
return { success: true, reordered: false, layout: current };
}
/**
* Unpin a tool from the shortcut bar (returns it to toolbox only).
*/
export function unpinTool(toolId, storage = (typeof localStorage !== 'undefined' ? localStorage : null)) {
const current = loadShortcutLayout(storage);
const index = current.indexOf(toolId);
if (index === -1) {
return { success: true, changed: false, layout: current };
}
current.splice(index, 1);
saveShortcutLayout(current, storage);
return { success: true, changed: true, layout: current };
}
/**
* Reorder an item in the shortcut bar.
*/
export function reorderShortcut(fromIndex, toIndex, storage = (typeof localStorage !== 'undefined' ? localStorage : null)) {
const current = loadShortcutLayout(storage);
if (fromIndex < 0 || fromIndex >= current.length || toIndex < 0 || toIndex >= current.length || fromIndex === toIndex) {
return { success: false, layout: current };
}
const [item] = current.splice(fromIndex, 1);
current.splice(toIndex, 0, item);
saveShortcutLayout(current, storage);
return { success: true, layout: current };
}
/**
* Reset shortcut layout to defaults.
*/
export function resetShortcutLayout(storage = (typeof localStorage !== 'undefined' ? localStorage : null)) {
saveShortcutLayout(DEFAULT_PINNED_SHORTCUTS, storage);
return { success: true, layout: [...DEFAULT_PINNED_SHORTCUTS] };
}
/**
* Check if a tool is currently pinned.
*/
export function isToolPinned(toolId, storage = (typeof localStorage !== 'undefined' ? localStorage : null)) {
const current = loadShortcutLayout(storage);
return current.includes(toolId);
}

Some files were not shown because too many files have changed in this diff Show More