Compare commits

..
167 Commits
Author SHA1 Message Date
DemonGatanjieu d3348ca40f chore: ignore local test scripts 2026-08-08 21:04:44 +08:00
DemonGatanjieu 51346120bd chore: untrack .agents directory 2026-08-08 21:02:39 +08:00
DemonGatanjieu f7c9af7064 Docs: Make introduction more objective and neutral 2026-08-08 20:30:25 +08:00
DemonGatanjieu 4821bbcc52 Docs: Update introduction taglines to reflect the Creative Workspace identity 2026-08-08 20:28:08 +08:00
DemonGatanjieu 9dd63e2ffa Merge remote-tracking branch 'origin/main' 2026-08-08 18:46:43 +08:00
DemonGatanjieu ede9788cd1 fix: isolate recipe model navigation 2026-08-08 18:41:11 +08:00
DemonGatanjieu 520a8b7a89 fix: scope v1.55 beta warning 2026-08-08 18:25:00 +08:00
DemonGatanjieu 0cd9a51336 docs: refresh beta workflow guidance 2026-08-08 18:02:34 +08:00
DemonGatanjieu 6cb8cddbe0 docs: separate MIT license from brand policy 2026-08-08 15:32:48 +08:00
DemonGatanjieu eb2f6b7c0e fix: allow deleting parameter notebooks 2026-08-08 15:27:16 +08:00
DemonGatanjieu f30f4e7181 feat: add conservative prompt role labels 2026-08-08 15:18:44 +08:00
DemonGatanjieu 6f671d402f docs(plan): add prompt role recognition hardening 2026-08-08 01:08:04 +08:00
DemonGatanjieu f816a8050d fix: recover legacy prompt roles in assistant 2026-08-07 19:59:29 +08:00
DemonGatanjieu 1929da70d9 fix: stabilize recipe and node assistant integration 2026-08-07 19:52:43 +08:00
DemonGatanjieu 5067ac52d8 Docs: Update ARCHITECTURE.md with topological tracing and dynamic role injection details 2026-08-07 19:36:08 +08:00
DemonGatanjieu a156701ce4 Fix: Add uncond support for topological tracing and backend dynamic role injection 2026-08-07 19:27:50 +08:00
DemonGatanjieu ad165a8188 Docs: Update architecture, changelog, and lessons for Topological Tracing and ES Module bug 2026-08-07 18:49:49 +08:00
DemonGatanjieu a849d12bb6 Fix syntax error caused by duplicated function block 2026-08-07 18:44:14 +08:00
DemonGatanjieu 7302532057 Core: Implement topological backward-tracing for positive/negative prompts 2026-08-07 18:31:10 +08:00
DemonGatanjieu 97dd6efb27 UI: Add single-level recipe grouping back to parameter presets while keeping the clean notebook button style 2026-08-07 17:05:15 +08:00
DemonGatanjieu 9d2406f263 UI: Refactor Node Assistant parameter presets, flatten list and hide unbound/deleted recipes 2026-08-07 16:57:55 +08:00
DemonGatanjieu 2ee14ecc96 style: clarify hierarchy in node assistant parameter presets UI 2026-08-07 16:31:30 +08:00
DemonGatanjieu 913a58b2aa fix: resolve node assistant layout and recipe name display issues 2026-08-07 03:14:32 +08:00
DemonGatanjieu 8698905b76 feat: merge Node Assistant and Parameter Notebook with node-type filtering 2026-08-07 03:01:08 +08:00
DemonGatanjieu 97bc9c6bc3 fix(recipe): pass live widget to change hooks 2026-08-06 22:36:22 +08:00
DemonGatanjieu 9b01dfb00f fix(recipe): harden parameter apply errors 2026-08-06 22:33:00 +08:00
DemonGatanjieu c753a6c30a UI Tweak: Remove aggressive parameter node collapse behavior (▼ button) 2026-08-06 22:02:35 +08:00
DemonGatanjieu 0f77e0b766 fix(recipe): apply notes to live canvas widgets 2026-08-06 21:54:03 +08:00
DemonGatanjieu a404a5064a Fix: Resolve SyntaxError causing frontend to silently crash 2026-08-06 21:46:50 +08:00
DemonGatanjieu df41733c02 UI Refactor: Eliminate top bar to prevent redundant titles and contextually irrelevant buttons across tabs 2026-08-06 21:42:19 +08:00
DemonGatanjieu 2a8009004b UI Tweak: Restructure snapshot sidebar buttons for cleaner layout 2026-08-06 21:35:53 +08:00
DemonGatanjieu f29374a658 UI Fix: Prevent parameter values from being aggressively squished by adjusting grid column widths 2026-08-06 21:30:31 +08:00
DemonGatanjieu aa53007df2 UI Optimization: Remove gallery count and always show button for lazy loading 2026-08-06 21:24:37 +08:00
DemonGatanjieu 3d03601f9d UI Fix: Allow gallery viewer to close properly 2026-08-06 21:19:48 +08:00
DemonGatanjieu e81b4dc6d1 UI Fix: Upgrade gallery viewer to native dialog to fix z-index occlusion 2026-08-06 21:13:26 +08:00
DemonGatanjieu e3025565da UI Tweak: Enable copy buttons for node widgets 2026-08-06 21:05:31 +08:00
DemonGatanjieu c294494ebb UI Fix: Prevent long parameter labels from overlapping values 2026-08-06 20:59:16 +08:00
DemonGatanjieu 6b26ddf729 Node Widget Layout Fix: Prevent squished prompts 2026-08-06 20:49:33 +08:00
DemonGatanjieu ea6f3c43ab Gallery Layout Refactor: Remove buttons and modernize grid 2026-08-06 20:45:44 +08:00
DemonGatanjieu 23f4dbb05b Parameter Notebook: Implement Dashboard Layout and Hero Gallery 2026-08-06 20:33:56 +08:00
DemonGatanjieu 469e510c5b Parameter Notebook UI Tweaks: Animation and LoRA formatting 2026-08-06 20:14:41 +08:00
DemonGatanjieu ae47164fb8 fix(recipe): align preview default in api 2026-08-06 19:45:48 +08:00
DemonGatanjieu c89456b456 refactor(recipe): simplify defaults and notebook switching 2026-08-06 19:45:12 +08:00
DemonGatanjieu 559fd2457b fix(recipe): enable model previews for sharing 2026-08-06 19:36:51 +08:00
DemonGatanjieu 7f50cbb898 fix(ui): hide browser trigger while open 2026-08-06 19:30:20 +08:00
DemonGatanjieu 54397e5411 Complete recipe and hash resolver i18n 2026-08-06 18:20:21 +08:00
DemonGatanjieu cbd7c81e26 Document deferred licensing and delivery 2026-08-06 18:16:27 +08:00
DemonGatanjieu cbe9932211 Migrate sidebar i18n 2026-08-06 18:12:02 +08:00
DemonGatanjieu cc48b0216c Migrate main workflow i18n 2026-08-06 17:14:22 +08:00
DemonGatanjieu 09d3eccf40 Migrate detail doctor and notebook i18n 2026-08-06 17:11:17 +08:00
DemonGatanjieu bc66782d90 Migrate grid and gallery i18n 2026-08-06 16:45:53 +08:00
DemonGatanjieu 00aefb9eed Establish shared i18n foundation 2026-08-06 16:20:53 +08:00
DemonGatanjieu 68a979630c Document staged i18n and licensing plan 2026-08-06 16:11:21 +08:00
DemonGatanjieu f9f52a6f65 fix(recipe): make current parameter capture one-click 2026-08-05 02:32:39 +08:00
DemonGatanjieu 9ba29f9bed feat(recipe): add safe parameter note canvas actions 2026-08-05 02:31:39 +08:00
DemonGatanjieu 06bfdaa5ae feat(recipe): add volatile parameter policy and note editor 2026-08-05 01:50:11 +08:00
DemonGatanjieu ecb192dcde fix(recipe): prevent vertical parameter summaries 2026-08-05 01:32:16 +08:00
DemonGatanjieu e266542305 feat(recipe): add bound parameter snapshots 2026-08-04 23:23:07 +08:00
DemonGatanjieu a81f95bce0 docs: add parameter notebook redesign plan and update architecture 2026-08-04 23:15:14 +08:00
DemonGatanjieu 2081589ee5 fix(recipe): merge parameter notebooks into recipe detail 2026-08-04 22:24:37 +08:00
DemonGatanjieu cd7b77fb24 fix(ui): restore browser entry after recipe parse error 2026-08-04 22:01:20 +08:00
DemonGatanjieu 36eb970893 fix(ui): preserve plugin trigger mark 2026-08-04 21:19:06 +08:00
DemonGatanjieu e13bf70296 fix(ui): isolate resolver startup failures 2026-08-04 21:18:27 +08:00
DemonGatanjieu 16466b32c6 fix(ui): keep plugin trigger visible 2026-08-04 18:19:41 +08:00
DemonGatanjieu 61f8ca0c82 docs(log): record parameter audit 2026-08-04 18:13:09 +08:00
DemonGatanjieu ce3f4dba1f fix(parameters): harden notebook and gallery lifecycle 2026-08-04 18:11:41 +08:00
DemonGatanjieu f485a46600 feat: extract parameter management to dedicated Parameter Notebooks and implement Parameter Gallery 2026-08-04 17:50:50 +08:00
DemonGatanjieu 9ad487b371 refactor(recipes): simplify gallery matching 2026-08-04 01:46:49 +08:00
DemonGatanjieu acfb24d0ce feat(recipes): add result gallery covers 2026-08-04 01:28:29 +08:00
DemonGatanjieu d6633212e0 refactor(gallery): refresh output images on demand 2026-08-04 01:14:30 +08:00
DemonGatanjieu f1d394b543 fix(ui): repair docked notes and live gallery 2026-08-04 01:07:50 +08:00
DemonGatanjieu d74390b6c5 refactor(recipes): remove save parameter picker 2026-08-04 00:44:10 +08:00
DemonGatanjieu 014c1fe4cf refactor(recipes): keep append as canvas action 2026-08-04 00:35:13 +08:00
DemonGatanjieu 9daa71ba2a fix(recipes): load into active workflow tab 2026-08-04 00:29:24 +08:00
DemonGatanjieu b56cd844e0 feat(recipes): match imported models locally 2026-08-03 22:34:59 +08:00
DemonGatanjieu ad15565a50 fix(recipes): repair origin editing and refresh 2026-08-03 21:33:37 +08:00
DemonGatanjieu b5b9134d21 UI Refactor: Add manual origin editing and auto-fetch from Civitai API 2026-08-03 21:25:27 +08:00
DemonGatanjieu fe79bb2109 fix(ui): redesign identity explanation 2026-08-03 20:56:29 +08:00
DemonGatanjieu 3d4af1cc97 fix(ui): improve identity help tooltip 2026-08-03 20:41:02 +08:00
DemonGatanjieu 1309889ee8 fix(ui): show availability refresh progress 2026-08-03 20:34:57 +08:00
DemonGatanjieu afd7d59b75 fix(ui): constrain recipe identity labels 2026-08-03 20:13:12 +08:00
DemonGatanjieu a05d81448a fix(recipes): stabilize origin refresh flow 2026-08-03 19:34:19 +08:00
DemonGatanjieu cc514f8c22 UI Refactor: Parameter Sorting & Native Localization 2026-08-03 19:30:07 +08:00
DemonGatanjieu dd80c89068 fix(ui): restore workspace after reopen 2026-08-03 19:16:43 +08:00
DemonGatanjieu cc5d9c11db feat(recipes): split model origins from saved values and improve UI tracking 2026-08-03 19:08:26 +08:00
DemonGatanjieu 6b4ca9e963 fix(ui): restore panel after workspace close 2026-08-03 18:24:49 +08:00
DemonGatanjieu f64871bcd2 fix(ui): preserve model grid return position 2026-08-03 18:20:11 +08:00
DemonGatanjieu 37f2bac512 fix(ui): keep recipe model navigation in overview 2026-08-03 18:14:56 +08:00
DemonGatanjieu 30c96c08d9 fix(ui): restore recipe interaction flows 2026-08-03 18:11:11 +08:00
DemonGatanjieu c75395f5c3 style: increase recipe compact card breakpoint and fix thumbnail height 2026-08-03 18:01:32 +08:00
DemonGatanjieu 2e7241ecca feat: group recipe model references by model type (LoRA, VAE, etc) 2026-08-03 05:54:43 +08:00
DemonGatanjieu ef172d890e feat(ui): responsive model cards for sidebar, extract parameters tab, prevent base model stretch 2026-08-03 05:50:50 +08:00
DemonGatanjieu ccaa4c1d0a style: fix advanced info focus outline and upgrade copy button design 2026-08-03 05:25:35 +08:00
DemonGatanjieu 50409182f5 feat: redesign advanced info to combine path and hash, and use modern copy svg icon 2026-08-03 05:21:25 +08:00
DemonGatanjieu 69e43b88d9 style: improve hitboxes for advanced info and copy buttons in recipes 2026-08-03 05:17:45 +08:00
DemonGatanjieu 8be2407635 feat: partition recipe model composition to show base models first 2026-08-03 05:14:40 +08:00
DemonGatanjieu 11dfa43914 fix: make appendRecipeOnCanvas async to support custom dialog await 2026-08-03 05:10:25 +08:00
DemonGatanjieu c217595f4e feat: refactor model composition and implement modern custom dialogs for recipes 2026-08-03 05:08:47 +08:00
DemonGatanjieu 6a91a18fb4 style: define modern css for recipe card actions 2026-08-03 04:54:18 +08:00
DemonGatanjieu b0eda4ef1f fix(ui): resolve recipe list render crash and improve inline edit UX 2026-08-03 04:50:57 +08:00
DemonGatanjieu efaee80cf1 docs: update changelog, ai_lessons, and ARCHITECTURE.md for recipe UI refactoring 2026-08-03 04:42:21 +08:00
DemonGatanjieu 8658e7afde refactor(ui): recipe workspace ui modernization and simplification 2026-08-03 04:41:09 +08:00
DemonGatanjieu 5208132473 Complete recipe model return cleanup 2026-08-03 03:58:41 +08:00
DemonGatanjieu 00f7010064 Guard recipe card actions 2026-08-03 03:58:05 +08:00
DemonGatanjieu e6f060a608 Stabilize recipe detail navigation 2026-08-03 03:56:50 +08:00
DemonGatanjieu 36ea570556 Preserve recipe identity across packages 2026-08-03 03:51:57 +08:00
DemonGatanjieu 978e9f7750 Harden recipe save integrity 2026-08-03 03:44:06 +08:00
DemonGatanjieu 425778924d Plan workflow recipe audit and hardening 2026-08-03 03:31:01 +08:00
DemonGatanjieu 8b6128a097 Keep native CLIP parameters together 2026-08-03 03:15:16 +08:00
DemonGatanjieu 9d273c4897 Preserve native CLIP prompt parameters 2026-08-03 02:59:53 +08:00
DemonGatanjieu 0cc84783a7 Show saved prompts in recipe parameters 2026-08-03 02:54:53 +08:00
DemonGatanjieu c7d9c2955b Split recipe overview model blocks 2026-08-03 02:47:57 +08:00
DemonGatanjieu 6874116776 Fix recipe model navigation and cards 2026-08-03 02:40:30 +08:00
DemonGatanjieu 4f6d26f59c Add recipe advanced asset features 2026-08-03 02:27:24 +08:00
DemonGatanjieu e9bd2c31f2 Refine recipe UI interactions 2026-08-03 01:57:39 +08:00
DemonGatanjieu fa5a2f7f95 Unify recipe canvas interactions 2026-08-03 01:37:49 +08:00
DemonGatanjieu cc735f29b3 Improve recipe detail readability 2026-08-02 23:02:05 +08:00
DemonGatanjieu 4946864f8b Fix recipe actions and quick queue contract 2026-08-02 22:30:41 +08:00
DemonGatanjieu a7f6333d00 Harden recipe append and quick queue runtime 2026-08-02 22:00:42 +08:00
DemonGatanjieu f26098780d Add secure recipe package transfer 2026-08-02 21:54:31 +08:00
DemonGatanjieu 8a65bb9ea5 Add recipe search diff and action foundations 2026-08-02 21:48:03 +08:00
DemonGatanjieu 9ecb733e64 Add workflow recipe preview foundation 2026-08-02 21:35:24 +08:00
DemonGatanjieu d61df08233 Organize agent logs and lessons 2026-08-02 19:35:27 +08:00
DemonGatanjieu a9d767e72f Add workflow recipe detail foundation 2026-08-02 19:09:37 +08:00
DemonGatanjieu 2d0eebcf77 feat: edit workflow recipes with history 2026-08-02 17:47:22 +08:00
DemonGatanjieu bb530c47bb Delete tests directory 2026-08-02 17:32:30 +08:00
DemonGatanjieu 8241584784 chore: keep development tests out of distribution 2026-08-02 17:04:38 +08:00
DemonGatanjieu db2e3f9f0b fix: simplify recipe scrolling 2026-08-02 05:25:19 +08:00
DemonGatanjieu 5fb01d1879 feat: bind recipe images and pin parameters 2026-08-02 05:11:24 +08:00
DemonGatanjieu b775058280 feat: integrate recipes with notebooks 2026-08-02 04:58:12 +08:00
DemonGatanjieu a35e1fa463 feat: add local workflow recipes 2026-08-02 04:40:18 +08:00
DemonGatanjieu b2784adfd4 chore: protect local credentials and caches 2026-08-02 00:29:06 +08:00
DemonGatanjieu 14a1946fe2 chore: ignore local diagnostic artifacts 2026-08-02 00:25:27 +08:00
DemonGatanjieu 4d4d0f4753 feat: polish node assistant model filtering 2026-08-01 23:42:38 +08:00
DemonGatanjieu 06fc379357 feat: expose picker model metadata in batch 2026-08-01 23:28:24 +08:00
DemonGatanjieu 52c327c920 feat: add filtered model picker and LoRA chain actions 2026-08-01 22:54:02 +08:00
DemonGatanjieu 6ecb7f6c06 feat: add transactional model-chain insertion 2026-08-01 22:35:34 +08:00
DemonGatanjieu 8340530e67 feat: add balanced model card settings 2026-08-01 21:41:59 +08:00
DemonGatanjieu e90e4e4f99 chore(snapshot): before model card settings 2026-08-01 21:30:24 +08:00
DemonGatanjieu 2af795f492 perf: cache metadata and batch model resolution 2026-08-01 21:07:05 +08:00
DemonGatanjieu c3132c2aaf fix: preserve cover backups and bound sidecar cleanup 2026-08-01 20:36:11 +08:00
DemonGatanjieu 05a3b5f4aa chore(snapshot): Auto AI snapshot 2026-08-01 20:22:33 2026-08-01 20:22:33 +08:00
DemonGatanjieu 0cd5f71061 chore(snapshot): Auto AI snapshot 2026-08-01 19:39:47 2026-08-01 19:39:47 +08:00
DemonGatanjieu f62df5a363 chore(snapshot): Auto AI snapshot 2026-08-01 18:36:17 2026-08-01 18:36:18 +08:00
DemonGatanjieu c1069ad766 chore(snapshot): Auto AI snapshot 2026-08-01 17:56:18 2026-08-01 17:56:18 +08:00
DemonGatanjieu 74f37ff0ec chore(snapshot): Auto AI snapshot 2026-08-01 17:52:38 2026-08-01 17:52:38 +08:00
DemonGatanjieu 91d4f0c901 chore(snapshot): Auto AI snapshot 2026-08-01 17:48:54 2026-08-01 17:48:54 +08:00
DemonGatanjieu 52a56acb88 chore(snapshot): Auto AI snapshot 2026-08-01 17:45:06 2026-08-01 17:45:07 +08:00
DemonGatanjieu f07459ce80 chore(snapshot): Auto AI snapshot 2026-08-01 15:32:22 2026-08-01 15:32:22 +08:00
DemonGatanjieu 05ff5b0677 chore(snapshot): Auto AI snapshot 2026-07-30 22:13:08 2026-07-30 22:13:08 +08:00
DemonGatanjieu ed01b182b8 chore(snapshot): Auto AI snapshot 2026-07-30 21:57:14 2026-07-30 21:57:14 +08:00
DemonGatanjieu c3a2e6fa1e chore(snapshot): Auto AI snapshot 2026-07-30 21:40:22 2026-07-30 21:40:23 +08:00
DemonGatanjieu 48526d211c chore(snapshot): Auto AI snapshot 2026-07-30 21:25:42 2026-07-30 21:25:42 +08:00
DemonGatanjieu 4de6691137 fix: 修复全局扫描不下载预览图及强制覆盖失效的问题
- 修正全局扫描默认跳过媒体下载的问题,使其能正常获取预览图。

- 修正 --force-overwrite 未生效的问题,开启强制覆盖时将主动清理旧的自定义预览文件。

- 调整前端扫描向导按钮逻辑,默认指向全局扫描以避免遗漏非当前页面的模型。
2026-07-26 21:17:02 +08:00
DemonGatanjieu 17a82a593d docs: release v1.5.1 with workflow exchange and deep scan fix 2026-07-26 19:56:25 +08:00
DemonGatanjieu bac0b8f757 chore: remove accidentally tracked test_main.js and update gitignore 2026-07-26 19:50:31 +08:00
DemonGatanjieu dee6e6ec79 chore(snapshot): Auto AI snapshot 2026-07-26 19:44:17 2026-07-26 19:44:17 +08:00
DemonGatanjieu ab86fceb35 chore(snapshot): Auto AI snapshot 2026-07-25 17:33:35 2026-07-25 17:33:36 +08:00
DemonGatanjieu f604ecd063 chore(snapshot): Auto AI snapshot 2026-07-25 17:29:36 2026-07-25 17:29:36 +08:00
DemonGatanjieu e91ce32cd5 chore(snapshot): Auto AI snapshot 2026-07-25 05:09:09 2026-07-25 05:09:09 +08:00
DemonGatanjieu 50b3c3250e chore(snapshot): Auto AI snapshot 2026-07-25 05:05:17 2026-07-25 05:05:17 +08:00
DemonGatanjieu c955903b5a Update .gitignore 2026-07-25 03:14:44 +08:00
DemonGatanjieu 17d47bb50b chore: update gitignore to perfectly match cloud files 2026-07-14 21:37:57 +08:00
DemonGatanjieu 2d10ee81be Add files via upload 2026-07-12 23:24:10 +08:00
DemonGatanjieu 14541bdfc9 Delete web/main.js.rebuilt 2026-07-12 22:11:01 +08:00
DemonGatanjieu fa61ae327d Delete api/notebooks/1.json 2026-07-12 22:10:21 +08:00
DemonGatanjieu e701072068 Delete api/__pycache__ directory 2026-07-12 22:09:53 +08:00
52 changed files with 22029 additions and 10710 deletions
+65
View File
@@ -1,3 +1,68 @@
# Local configuration and credentials
config.json
.env
.env.*
!.env.example
credentials*.json
secrets*.json
*.local.json
*.pem
*.key
*.p12
*.pfx
# Runtime caches, logs, databases, and temporary files
*.pyc
__pycache__/
.cache/
cache/
logs/
tmp/
temp/
*.cache
*.log
*.pid
*.tmp
*.temp
*.bak
*.backup
*.sqlite
*.sqlite3
*.db
# OS and editor-local state
.vscode/
.idea/
.DS_Store
Thumbs.db
Desktop.ini
*.swp
*~
# Node/JS environment junk
node_modules/
package.json
package-lock.json
web/main.js.rebuilt
# Local notebooks drafts
notebooks/
api/notebooks/
# AI Dev Scripts & Temporary text dumps
scripts/
doctor_code.txt
docs/
AI_SYSTEM_TEMPLATE.md
# AI Agents Directory
.agents/
# Test files
test_*.js
test*.mjs
tests/
# Local diagnostic/test artifacts
grep_doctor.txt
test_folders.json
+353
View File
@@ -0,0 +1,353 @@
# Anomalous Model Browser - Architecture Summary (架构指南)
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.
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.
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.
Origin refresh is an explicit enrichment mode passed into `_enrich_recipe`; it must not mutate the Python list of references with ad-hoc state. Origin refresh writes schema v5, while normal updates preserve previously imported identity/origin records when the current machine cannot re-resolve them.
Recipe identity badges use short, bounded labels in the card layout; detailed explanations belong in the help tooltip. Identity badges and model-name rows must remain shrinkable flex children so localized or imported text cannot expand the recipe card beyond its container.
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.
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.
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 (核心文件结构)
```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
```
## 3. Backend Architecture (Python API)
Modularized into the `api/` package. Registered with ComfyUI's internal `aiohttp` server.
All endpoints are prefixed with `/anomalous/`.
### 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.
### 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.
### 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.
## 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", ... })`.
### 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.
### 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.
### 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.
### 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.
### 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.
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.
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.
`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.
### 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.
+50 -1
View File
@@ -1,4 +1,53 @@
# 📈 Anomalous Model Browser Changelog
# 📈 Anomalous Model Browser Changelog
## 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.
### 🚀 Major Features
- **Workflow Recipes (工作流配方)**: Save a complete workflow with a cover, name, tags, notes, model references, prompts, sampler settings, safe node parameters, and a reproducibility fingerprint. Recipes append to the current canvas without replacing it.
- **Recipe Detail Workspace (配方详情)**: Added Overview, Parameter Notebook, Gallery, and Versions tabs. Long values can be expanded or copied, model requirements are shown as separate cards, and the overview avoids exposing local model paths as display names.
- **Parameter Notebooks (参数笔记本)**: Create edited parameter notes, capture a fresh note from a matching live canvas, switch between saved notes with clear feedback, delete individual notes, and apply one note back to a workflow only after the complete recipe skeleton matches. Volatile values such as seeds are intentionally ignored.
- **Node Assistant Recipe Presets (节点助手配方预设)**: The Parameter Presets tab reads same-type node values saved by Workflow Recipes. Select a node such as KSampler and apply sampler, scheduler, steps, CFG, denoise, and other safe values in one click without replacing the rest of the workflow.
- **Model Identity and Local Matching (模型身份与本地匹配)**: Recipe model cards retain hashes, size, model category, available Civitai origin names/links, and previews. Imported recipes can match another author's model references to local files without trusting their folder paths.
- **Recipe Gallery and Parameter Comparison (配方图库与参数对比)**: Find historical outputs with the same node structure, inspect embedded generation data, compare differing parameters, and promote an output image to the recipe cover.
- **Version History (版本历史)**: Recipe updates retain bounded local history. Compare meaningful changes, restore an older version safely, and preserve the current version before restoration.
- **Portable Recipe Packages (便携配方包)**: Import or export recipes with optional preview snapshots and history. Model identity hashes can be removed for privacy. Preview snapshots contain presentation images only and never include model files.
- **Conservative Prompt Roles (保守的正负提示词识别)**: Native supported prompt paths are classified without title guessing. Unknown third-party text nodes remain unclassified until the user labels them as positive, negative, shared, or ignored.
### ✨ Interface and Interaction
- Renamed the combined Notes and Recipes area to **Creative Workspace / 创作工作台**, with separate Prompt Notes and Workflow Recipes sections.
- Recipe models, LoRAs, prompt text, common parameters, and generic node controls use dedicated responsive layouts with copy and full-content expansion where needed.
- Returning from a model detail page restores the previous recipe position instead of jumping back to the top.
- The main output gallery refreshes when opened, avoiding continuous background polling.
- Beta and data-protection guidance is available in the Recipe list, the Node Assistant Parameter Presets tab, Settings Help, and README in both Chinese and English.
### 🐛 Reliability Fixes
- Fixed a cross-panel state leak where opening a model from a Recipe Overview could overlap Node Assistant, then leave the next Workflow Recipe visit with an empty list. Returning normally, switching directly to another main panel, reopening Workspace, and closing Workspace mid-return now all restore one coherent surface.
- Fixed blank Workspace/Parameter Notebook surfaces and restored the plugin trigger when frontend module errors prevented registration.
- Fixed parameter application against current ComfyUI widget callbacks and added transactional rollback when a node update fails.
- Fixed positive/negative prompt duplication and legacy role recovery while keeping ambiguous third-party prompt nodes visibly unknown.
- Fixed panel closing and canvas handoff behavior, docked notebook confirmation controls, recipe scroll restoration, long model status text, and narrow parameter layouts.
- Added backend validation and regression coverage for recipe round-tripping, linked inputs, parameter-note deletion, model matching, and prompt-role metadata.
## v1.5.1 (The Workflow Exchange & Deep Scan Update)
### 🚀 Major Features
- **Lossless Workflow Exchange (无损工作流交互)**: Added dedicated Import/Export buttons to the bottom-left of the plugin UI. This feature allows users to directly extract, share, and import pure workflow data without relying on original PNG images, completely bypassing the metadata-stripping compression used by social platforms (like WeChat or Discord) that causes workflow loss.
- **Force Overwrite Configs (强制覆盖已有配置)**: Added a new "Force Overwrite" toggle in the Scan Wizard. When enabled, the scanner will deliberately ignore existing local `.info` files and force a fresh SHA256 hash calculation and a complete metadata pull from Civitai. This is a lifesaver for repairing incorrectly matched or corrupted model profiles.
- **Model Card Settings (模型卡片设置)**: The Settings Hub now opens one focused panel for video-cover playback (`Always play` or recommended `Play on hover`) and card image quality (`Optimized thumbnail` or `Original cover`). Existing autoplay preferences remain compatible, while image-quality choice is intentionally simple and local to the browser.
### 🐛 Bug Fixes
- **Base Model Hash Misattribution**: Fixed a critical bug in the fast scanner where it would aggressively fuzzy-match any 64-character hex string in the metadata. This previously caused LoRAs (trained via Kohya) to inherit their Base Model's hash (`ss_sd_model_hash`), resulting in totally incorrect Civitai mappings. The scanner now strictly adheres to the official `modelspec.hash.sha256/blake3` standard.
- **Lossless Cover Reset**: Reset now restores the persistent `.civitai_bak.*` copy first, falls back to a bare original cover for non-Civitai models, and preserves the active preview with a visible warning when no recoverable source exists.
- **Safe Sidecar Lifecycle**: Deleting a model cleans recognized sidecars, while renaming migrates them—including the Civitai backup—to the new stem. Same-stem model files with different model extensions are never treated as sidecar garbage.
- **Bounded Sidecar I/O**: Repeated extension literals in the API and scanner were replaced with fixed tuples and exact-path checks. Rename, delete, and reset remain constant-bounded and never scan an entire model directory.
- **Reset/Rename Extension Integrity**: Fixed a reused loop variable that could change a model's extension when cover reset and physical rename were requested together.
- **Signature-Aware Metadata Cache**: Unchanged model metadata and embedded safetensors hashes are now served from bounded caches keyed by physical file signatures. Sidecar or model changes invalidate entries automatically, while defensive copies prevent cache pollution.
- **Stable Cover Caching**: Removed per-refresh random cover URLs and switched to nanosecond file-version tokens, allowing the browser to reuse unchanged images and videos while still updating changed covers immediately.
- **Responsive Large Libraries**: Folder/model disk work now runs outside the aiohttp event loop, current-folder discovery uses one `os.scandir()` inventory, stale requests are aborted, and cards render in animation-frame chunks with lazy media activation.
- **Batched Model Doctor Resolution**: Provenance-rich workflows skip a redundant full hash-cache walk, then resolve missing nodes through a type-grouped batch endpoint so each required category is walked once per repair pass. Legacy workflows still refresh the filename cache when needed. The original hash/size/type/ambiguity rules remain unchanged, with automatic fallback to the single-item endpoint.
- **Direct Preview Fast Path**: Visual replacement resolves contained relative model paths directly and performs a full library walk only for unresolved basename fallbacks.
- **Balanced Cover Memory Use**: Static grid covers default to cached 512px WebP derivatives while detail views retain the original. Derived files are stored in ComfyUI's temporary area, capped at 256 MiB, and never alter source covers. Closing the browser releases active media immediately and releases the warm card DOM after 90 seconds of inactivity.
## v1.5.0 (The Workflow & UI Evolution Update)
### 🚀 Major Features
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 DemonGatanjieu
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+96 -50
View File
@@ -6,45 +6,62 @@
---
<h2 id="english">English</h2>
<h2 id="english">🇬🇧 English</h2>
A highly professional, multi-functional ComfyUI model browser plugin. It integrates advanced model management, workflow healing, an intelligent drafting notebook, and a native output gallery.
> 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.
> [!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.
### 🌟 Core Capabilities
1. **Comprehensive Model Management**: Automatically extract Base Model architectures, cover images, trigger words, and author info by scanning model hashes. It allows you to customize model names, notes, and covers. You can quickly view detailed info, history gallery, and trigger words. Furthermore, you can replace models visually with cover icons instead of complex file paths, and safely delete unwanted models.
2. **Workflow Node Repair**: As long as a workflow or image was exported with this plugin active (Identity Hash enabled), the **Model Doctor (🩺)** can auto-detect missing nodes and replace them with the correct local paths. For stubborn models, it provides a "Deep Hash Scan" or links directly to Civitai.
3. **Smart Notebook (Drafting Area 📑)**: Leveraging scanned local architectures, the Notebook offers rapid matching between Checkpoints, UNet, and compatible LoRA models. It features a built-in translation tool, auto-chunks prompts into bilingual tags, and supports 1-click deployment to the ComfyUI canvas.
4. **History Gallery**: Natively reads your local `output` folder. Supports 1-click viewing, deleting, and mouse-wheel zooming. You can drag and drop any generated image directly onto the canvas to instantly load its embedded workflow.
5. **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.
| 🚀 Feature | 💡 Description |
| :--- | :--- |
| **Comprehensive Model Management** | Automatically extract Base Model architectures, cover images, trigger words, and author info by scanning model hashes. Customize model names, notes, and covers. Safely delete unwanted models or quickly replace them visually with cover icons. |
| **Workflow Node Repair (🩺)** | As long as a workflow/image was exported with this plugin active, the **Model Doctor (🩺)** can auto-detect missing nodes and replace them with correct local paths. Stubborn models? Use "Deep Hash Scan" or get Civitai links. |
| **Node Assistant & Recipe Presets (🤖)** | Select a canvas node to visually replace a model, safely insert a LoRA into a compatible chain, or load same-type node parameters saved by Workflow Recipes. A saved KSampler note, for example, can apply sampler, scheduler, steps, CFG, and denoise in one click while volatile seeds are ignored. |
| **Workflow Recipes (🧰)** | Save a complete workflow with a cover, notes, tags, model identity, prompts, sampler settings, node parameters, matching output images, and version history. Append recipes to the canvas, compare output/version parameters, match foreign model references to local files, and import or export portable recipe packages. |
| **Smart Notebook (📑)** | Leveraging scanned local architectures, it offers rapid matching between Checkpoints, UNet, and compatible LoRAs. Features a built-in translation tool, auto-chunks prompts into tags, and supports 1-click canvas deployment. |
| **History Gallery (🖼️)** | Natively reads your local `output` folder. Supports 1-click viewing, deleting, and mouse-wheel zooming. Drag and drop any generated image directly onto the canvas to instantly load its embedded workflow! |
| **Additional Features** | Click the top-right `➕` of any model card to deploy it to the canvas. The UI supports side-docking to leave space for your canvas operations. |
### 📖 Step-by-Step Operating Guide
#### 1. Initialization (Scan Wizard)
#### 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 (entering an API-KEY in the settings is recommended for fetching specific Civitai model data).
* **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.
#### 2. Workflow Repair (Model Doctor)
#### 2. Workflow Repair (Model Doctor) 🩺
* **Location**: The second Stethoscope icon (**🩺**) from the bottom left.
* **Operation**: When you import someone else's workflow (or an image exported with this plugin) 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.
* **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.
#### 3. Visual Swapping (Node Assistant)
#### 3. Visual Swapping (Node Assistant) 🤖
* **Location**: The third Robot icon (**🤖**) from the bottom left.
* **Operation**: After opening, select any model node on your ComfyUI canvas. The Assistant will recognize the node's info, displaying its history gallery, notes, cover, details, and trigger words. It also allows you to quickly visually replace it with other models based on cover images.
* **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.
#### 4. Settings Panel
#### 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.
#### 5. Settings Panel ⚙️
* **Location**: The Gear icon (**⚙️**) at the bottom left.
* **Operation**: Here you can adjust the UI language (Language is adjusted here), main UI font size, and video cover auto-play modes.
* **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.
#### 5. Top Navigation & Smart Notebook
* **Location**: The top tabs: **Models (📦)**, **Gallery (🖼️)**, **Notebook (📑)**, and **Dock Side (◧)**.
* **Notebook Operation**:
1. Click the **Notebook (📑)** tab, 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 of the input box for bilingual translation.
4. After confirming, the plugin will automatically chunk your prompt into tags. You can find/replace or directly edit individual tags.
5. Finally, click **🚀 Send to Canvas** to automatically wire and deploy the Checkpoint, LoRA, and CLIP Text Encode directly to your canvas!
#### 6. Top Navigation & Prompt Notes 📑
**Location**: The top tabs: **Models (📦)**, **Gallery (🖼️)**, **Creative Workspace (📑)**, and **Dock Side (◧)**.
**Prompt Notes Operation**:
1. Open **Creative Workspace (📑)**, choose **Prompt Notes**, then click the **➕ (New)** button and confirm.
2. Select a Base Model (e.g., SD1.5). You can then select compatible Main Models and LoRAs.
3. Click **📝 Edit Raw / Paste** to paste your prompts. Select the language at the top-left for bilingual translation.
4. After confirming, the plugin will automatically chunk your prompt into tags. You can find/replace or directly edit tags.
5. Click **🚀 Send to Canvas** to automatically wire and deploy the Checkpoint, LoRA, and CLIP Text Encode directly to your canvas!
---
@@ -55,51 +72,67 @@ Open your terminal, go to your ComfyUI `custom_nodes` folder, and run:
cd custom_nodes
git clone https://github.com/DemonGatanjieu/Anomalous_Model_Browser.git
```
*(Restart ComfyUI after cloning. Alternatively, search for `Anomalous Model Browser` inside the ComfyUI Manager and click Install!)*
> **Note**: Restart ComfyUI after cloning. Alternatively, search for `Anomalous Model Browser` inside the ComfyUI Manager and click Install!
---
<h2 id="中文">中文</h2>
<h2 id="中文">🇨🇳 中文</h2>
一个高度专业、多功能的 ComfyUI 模型浏览器插件。集成了模型管理、工作流自愈修复、智能草稿本与原生图库管理。
> 一个综合性的 ComfyUI 创作工作台与模型管家。集成零依赖的 C 站信息抓取、支持自动修复的模型医生、可视化节点助手、无损工作流交互,以及支持参数复用的工作流配方与笔记本体系。
> [!WARNING]
> **测试功能与数据保护:** 工作流配方以及节点助手中由配方驱动的“参数笔记本”目前属于测试功能;节点助手的“动作”页不在测试范围内。更新插件、导入他人配方、恢复或删除配方数据、应用已保存参数之前,请先保存当前画布,并备份 ComfyUI 用户目录中的 `workflows/anomalous_recipes` 与 `workflows/anomalous_parameters`。配方中的模型预览快照不包含模型文件,不能替代模型备份。
### 🌟 核心功能特性
1. **全方位模型管理**:通过扫描哈希值,自动获取模型的 Base Model (基础架构)、封面、提示词、作者简介等信息,方便您快速比对并调节操作流程。支持自定义模型名称、备注和封面。提供快捷查看详细信息与历史生成图片的功能。此外,点开模型即刻呈现封面图标,方便您快捷替换模型,彻底告别复杂的模型路径带来的头疼;遇到不想用的模型,还提供了极其便捷的快捷删除功能。
2. **节点智能修复功能**:凡是由本插件导出的工作流和图片(只要开启了哈希身份证),在载入他人或旧工作流时出现节点爆红,只需点开**模型医生 (🩺)**,即可自动根据工作流内嵌的身份信息识别出本地正确的模型路径并一键替换。遇到简单扫描未能识别的顽固模型,可选择“深度扫描”进一步识别,若本地确实缺失,还提供了相关的 Civitai C站下载链接。
3. **智能笔记本 (📑) 功能**:基于扫描获取的本地模型基础架构,笔记本提供了快速的架构匹配功能,让您极速选择兼容的 Checkpoint、UNet 模型与对应的 LoRA 模型。内置强大的翻译功能,能自动将提示词分块打上标签,实现多语言提示词完美对照,并支持一键发布组装到工作流画布中。
4. **原生图库功能**:支持无缝读取本地的 `output` 输出文件夹。支持鼠标滚轮放大缩小,一键查看和安全删除历史图像。更绝的是,您可以直接将图片拖动到画布上,瞬间加载内嵌的工作流。
5. **其它便捷功能**:点击模型卡片右上角的 `➕` 号,可以快捷将相关的节点发布粘贴到画布。支持侧边栏停靠功能,将界面吸附在左侧,为您的画布留出充足的操作空间。
| 🚀 功能板块 | 💡 详细说明 |
| :--- | :--- |
| **全方位模型管理** | 通过扫描哈希值,自动获取模型的基础架构、封面、提示词、作者简介等信息。支持自定义名称、备注和封面。点开模型即刻呈现封面图标,支持看图一键替换模型,彻底告别复杂的模型路径! |
| **节点智能修复 (🩺)** | 凡是由本插件导出的工作流和图片,在载入他人工作流时出现节点爆红,只需点开**模型医生 (🩺)**,即可自动识别正确的模型路径并一键替换。内置深度扫描与 C 站直达链接。 |
| **节点助手与配方参数 (🤖)** | 选中画布节点后,可视化替换模型、向兼容链路安全插入 LoRA,或读取工作流配方保存的同类型节点参数。例如保存过的 K 采样器笔记可一键应用采样器、调度器、步数、CFG 与降噪,同时忽略易变的种子。 |
| **工作流配方 (🧰)** | 保存完整工作流及封面、备注、标签、模型身份、提示词、采样参数、节点参数、匹配出图与版本历史。支持追加到画布、比较出图/版本参数、将他人的模型引用匹配到本地文件,以及导入导出便携配方包。 |
| **智能笔记本 (📑)** | 基于本地基础架构,提供快速的架构匹配功能,极速选择兼容的主模型与 LoRA。内置强大的翻译功能,自动将提示词分块打上标签,实现双语对照,并支持一键发布组装到工作流画布中。 |
| **原生图库管理 (🖼️)** | 无缝读取本地的 `output` 文件夹。支持鼠标滚轮缩放、一键查看和安全删除。更绝的是,您可以**直接将图片拖动到画布上,瞬间原地加载内嵌的工作流!** |
| **其它便捷功能** | 点击模型卡片右上角的 `➕` 号,快捷将节点发布到画布。支持侧边栏停靠 (Dock),将界面吸附在左侧,为您的画布留出充足的操作空间。 |
### 📖 标准操作说明
### 📖 标准操作指南
为了让插件发挥最大效能,请按照以下流程进行操作:
#### 1. 前置准备 (扫描向导)
#### 1. 前置准备 (扫描向导) 🔄
* **具体位置**:界面左侧底部的 **扫描图标 (🔄)**。
* **操作步骤**:这是最重要的前置说明!请先打开扫描向导,根据需求选择扫描配置并执行扫描。**切记保持网络畅通**。部分 C站(Civitai)限制级模型信息需要在设置中填入 API-KEY。
* **功能激活**:扫描完成后,插件的各项核心功能即被全面激活,您的本地数据库已建立完毕。
* **操作步骤**:这是最重要的一步!请先打开扫描向导,根据需求选择扫描配置并执行扫描(切记保持网络畅通)。扫描完成后,您的本地数据库建立完毕,插件的各项核心功能全面激活。
#### 2. 拯救爆红 (模型医生)
#### 2. 拯救爆红 (模型医生) 🩺
* **具体位置**:左侧底部第二个 **听诊器按钮 (🩺)**。
* **操作步骤**:当导入别人使用该插件输出的工作流或者图片时,若发现模型路径爆红报错,点击模型医生,它就能智能识别当前所有的报错节点,并一键实现正确路径的替换。
* **操作步骤**:当导入别人使用该插件输出的工作流或者图片时,若发现模型路径爆红报错,点击模型医生,它就能智能识别报错节点,并一键实现正确路径的替换。
#### 3. 选中交互 (节点助手)
#### 3. 选中交互 (节点助手) 🤖
* **具体位置**:左侧底部第三个 **机器人按钮 (🤖)**。
* **操作步骤**:打开节点助手后,在 ComfyUI 画布上点击选择任意一个模型节点。助手便会瞬间识别到该节点的各项信息,支持查看它的历史图库、备注、封面、模型详细页、触发词等。最强大的是,它能让您通过视觉化的模型封面,直接在侧边栏快捷替换其他模型。
* **动作**:打开后在 ComfyUI 画布上选中节点。对于模型节点,“动作”页可以可视化替换当前模型,或在兼容的 MODEL + CLIP 链前后插入 LoRA。选择器提供模型预览、模型类别/基础模型标记、文件夹、完整路径搜索、排序与多分支安全保护。
* **参数笔记本(测试)**:切换到“参数笔记本”,可以读取与工作流配方关联的参数笔记。内容会按配方分组,只展示和当前选中节点类型相同的保存记录。点击一条记录即可事务式应用到该节点;种子等每次运行都会变化的值会被忽略。
#### 4. 个性化配置 (设置面板)
#### 4. 工作流配方与参数笔记本 🧰
* **具体位置**:打开 **创作工作台 (📑)**,从“提示词笔记”切换到 **工作流配方**。
* **保存与复用**:保存当前完整节点图,并附带封面、名称、标签、备注、模型引用、提示词、采样设置与受限节点摘要。配方可以追加到当前画布,不会覆盖当前内容。
* **详细信息**:概览展示模型组成与复现信息。模型匹配根据哈希、文件大小与模型类别查找本地文件,不依赖原作者路径;可用时还会保留 C 站官方名称/链接与本地预览。
* **参数笔记本**:查看配方保存的参数,复制或展开长内容,新建可编辑参数笔记,或者从骨架匹配的当前画布读取全新参数。点击“应用到当前工作流”时会先检查配方骨架,再更新安全控件;种子等易变值会被忽略。保存的参数笔记可以单独删除;无法可靠识别的第三方提示词节点可以手动标注正负角色。
* **图库与版本**:查找节点结构相同的历史出图,打开图片比较参数差异,将出图设为配方封面,比较配方历史版本,或在自动归档当前版本后恢复旧版本。
* **分享**:导入导出便携配方包,可选择包含模型预览快照与历史版本,也可在导出时移除身份哈希。预览快照只是展示数据,绝不会包含模型文件。
#### 5. 个性化配置 (设置面板) ⚙️
* **具体位置**:左侧底部的 **齿轮按钮 (⚙️)**。
* **操作步骤**:在此可以调节语言(多语言切换在这里调节)、主页面字体大小、以及视频封面的播放方式(节能或自动播放)等。
* **操作步骤**:在此可以调节语言和主页面字体大小。进入 **模型设置** 后,可选择视频封面始终播放或悬停播放,也可选择卡片使用流畅缩略图或原始封面;模型详情页始终使用原始封面。**文件夹管理**可以让指定目录同时退出侧栏和后台读取,并支持调整顺序;**帮助**中会持续更新安全提醒与功能说明。
#### 5. 顶部导航与笔记本 (Notebook) 实战
* **具体位置**:右侧顶部的按钮分别为 **模型 (📦)**、**图库 (🖼️)**、**笔记本 (📑)**、**停靠侧边栏 (◧)**。
* **笔记本操作步骤**:
1. 点击**笔记本 (📑)** 按钮,点击新建 **➕** 号,确认后进入草稿本。
2. 首先选择基础模型(例如 SD1.5),随后系统会为你过滤出兼容的的主模型 (Checkpoint) 和对应的 LoRA 模型供你选择。
3. 点击下方的 **📝 纯文本/粘贴**,把你的提示词复制进去,选择输入框左上角的语言,即可实现双语对照翻译。
4. 确认后,插件会自动将提示词分块生成标签 (Tags),支持查找替换,也支持直接双击修改单个标签。
5. 最后,点击下方的 **🚀 发送到画布** 按钮,系统会将你打包好的主模型、LoRA 以及 CLIP 文本编码器,一键自动连线并发布到画布上!
#### 6. 顶部导航与提示词笔记 📑
**具体位置**:右侧顶部的按钮分别为 **模型 (📦)**、**图库 (🖼️)**、**创作工作台 (📑)**、**停靠侧边栏 (◧)**。
**提示词笔记操作步骤**:
1. 打开**创作工作台 (📑)**,进入“提示词笔记”,点击新建 **➕** 号并确认。
2. 首先选择基础模型(例如 SD1.5),系统会过滤出兼容的主模型与对应的 LoRA 模型供你选择。
3. 点击下方的 **📝 纯文本/粘贴** 复制提示词进去,选择左上角的语言即可实现双语对照翻译。
4. 确认后,插件会自动生成标签 (Tags),支持查找替换,也支持直接双击修改单个标签。
5. 最后,点击下方的 **🚀 发送到画布**,系统会将打包好的主模型、LoRA 和 CLIP,一键全自动连线发布到画布!
---
@@ -110,4 +143,17 @@ git clone https://github.com/DemonGatanjieu/Anomalous_Model_Browser.git
cd custom_nodes
git clone https://github.com/DemonGatanjieu/Anomalous_Model_Browser.git
```
*(克隆完成后,重启 ComfyUI 即可。您也可以直接在 ComfyUI Manager(管理器)中搜索 `Anomalous Model Browser` 并一键点击安装!)*
> **注意**:克隆完成后,重启 ComfyUI 即可。您也可以直接在 ComfyUI Manager(管理器)中搜索 `Anomalous Model Browser` 并一键点击安装!
---
### 📝 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)。
+93
View File
@@ -0,0 +1,93 @@
# Trademark and Brand Policy / 商标与品牌政策
This policy separates the project's open-source copyright license from use of its brand identifiers. It does not change or add restrictions to the MIT License.
本政策用于区分项目的开源版权许可与品牌标识的使用方式,不修改 MIT 许可证,也不向 MIT 许可证附加限制。
## Open-source materials / 开源内容
Unless a file states otherwise, the source code, documentation, stylesheets, ordinary interface resources, and other copyrightable materials distributed in this repository are licensed under the [MIT License](LICENSE). The MIT License permits use, copying, modification, publication, distribution, sublicensing, and sale subject to its notice requirement.
除非文件中另有明确说明,本仓库发布的源代码、文档、样式表、普通界面资源以及其他受版权保护的内容均采用 [MIT License](LICENSE) 授权。遵守许可证中的版权及许可声明保留要求后,MIT 许可证允许使用、复制、修改、发布、分发、再许可和销售。
## Project marks / 项目标识
The following are project brand identifiers (the "Project Marks"):
- the name **Anomalous Model Browser**;
- stylized presentations of that name used to identify an official release;
- any graphic expressly identified by the repository owner as the official project logo.
以下内容属于项目品牌标识(“项目标识”):
- 名称 **Anomalous Model Browser**;
- 用于识别官方版本的该名称之视觉化表现;
- 由仓库所有者明确指定为官方项目 Logo 的图形。
The Project Marks identify the source of official releases. Permission to use copyrightable material under the MIT License does not by itself imply that a modified distribution is an official release or is endorsed by the project owner.
项目标识用于辨认官方发布来源。依据 MIT 许可证使用受版权保护的内容,并不意味着修改后的发行版属于官方版本或获得项目所有者认可。
## Uses that do not require permission / 无需许可的使用
You may, without separate permission:
- truthfully refer to the official project by name, including in articles, reviews, compatibility statements, and dependency notices;
- link to the official repository;
- state that your software is based on, derived from, or compatible with Anomalous Model Browser, provided the statement is accurate and does not imply sponsorship or endorsement;
- redistribute an unmodified official release with its original notices and branding intact;
- use screenshots of the official software for documentation, discussion, reviews, or tutorials, provided the context does not imply that a modified product is official.
您无需另行取得许可即可:
- 在文章、评测、兼容性说明和依赖声明中如实提及官方项目名称;
- 链接到官方仓库;
- 在内容准确且不暗示赞助或认可的前提下,说明您的软件基于、衍生自或兼容 Anomalous Model Browser;
- 在保留原始声明和品牌标识的情况下,重新分发未经修改的官方版本;
- 将官方软件截图用于文档、讨论、评测或教程,但不得借此暗示修改后的产品属于官方版本。
## Publicly distributed modified versions / 公开分发的修改版本
If you publicly distribute a modified version or fork:
- use a distinct primary product name and a distinct logo so users can readily distinguish it from the official project;
- identify it clearly as an unofficial fork or modified version;
- you may use wording such as "Based on Anomalous Model Browser" to describe its origin;
- preserve the copyright and permission notices required by the MIT License.
如果您公开分发修改版本或分支:
- 应使用不同的主要产品名称和不同的 Logo,使用户能够清楚地区分其与官方项目;
- 应明确说明它属于非官方分支或修改版本;
- 可以使用“基于 Anomalous Model Browser”等文字如实说明来源;
- 必须保留 MIT 许可证要求的版权及许可声明。
These branding guidelines do not require renaming private modifications that are not distributed to others.
本品牌指引不要求仅供个人使用、未向他人分发的私有修改版本改名。
## Uses requiring permission / 需要许可的使用
Unless applicable law permits the use independently, written permission from the repository owner is required to:
- present a modified fork as an official release;
- use the Project Marks as the primary name or logo of a publicly distributed modified product;
- use a confusingly similar name, logo, repository listing, domain, or package identity that is likely to make users believe a third-party distribution is official;
- state or imply sponsorship, certification, partnership, or endorsement by the project owner when none exists.
除非适用法律独立允许该使用,否则下列情况需要获得仓库所有者的书面许可:
- 将修改后的分支作为官方版本发布;
- 将项目标识用作公开分发之修改产品的主要名称或 Logo;
- 使用可能使用户误认为第三方发行版属于官方版本的近似名称、Logo、仓库条目、域名或软件包标识;
- 在不存在相关关系时,宣称或暗示项目所有者提供了赞助、认证、合作或认可。
## Questions and permissions / 咨询与授权
For questions or permission requests, contact the repository owner through the official repository: <https://github.com/DemonGatanjieu/Anomalous_Model_Browser>.
如需咨询或申请许可,请通过官方仓库联系仓库所有者:<https://github.com/DemonGatanjieu/Anomalous_Model_Browser>。
This policy is a project branding notice, not legal advice. Rights that apply independently under local law are not restricted by this document.
本政策是项目品牌说明,不构成法律意见,也不限制依据当地法律独立享有的权利。
+71 -37
View File
@@ -1,37 +1,71 @@
from .models import *
from .scanner import *
from .config import *
from .notebooks import *
from .utils import *
def setup_routes(app):
app.router.add_get('/anomalous/folders', api_get_folders)
app.router.add_get('/anomalous/models', api_get_models)
app.router.add_get('/anomalous/image', 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/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/clean_civitai_info', api_clean_civitai_info)
app.router.add_get('/anomalous/compatible_models', 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)
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_get('/anomalous/all_hashes', 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_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/scan_missing_models', api_scan_missing_models)
from .models import *
from .scanner import *
from .config import *
from .notebooks import *
from .parameters import *
from .recipes import *
from .recipe_packages import *
from .utils import *
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)
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/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/clean_civitai_info', api_clean_civitai_info)
app.router.add_get('/anomalous/compatible_models', 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)
# Parameter Routes
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/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_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)
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/scan_missing_models', api_scan_missing_models)
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+43 -8
View File
@@ -13,19 +13,32 @@ async def api_get_config(request):
script_dir = os.path.dirname(os.path.abspath(__file__))
config_path = os.path.join(script_dir, "config.json")
has_key = False
folder_types = []
if os.path.exists(config_path):
try:
with open(config_path, 'r', encoding='utf-8') as f:
cfg = json.load(f)
has_key = bool(cfg.get("CIVITAI_API_KEY", "").strip())
except:
pass
return web.json_response({"has_api_key": has_key})
folder_types = cfg.get("folder_types_config", [])
except:
pass
if not has_key:
legacy_config_path = os.path.join(os.path.dirname(script_dir), "config.json")
if os.path.exists(legacy_config_path):
try:
with open(legacy_config_path, 'r', encoding='utf-8') as f:
legacy_cfg = json.load(f)
has_key = bool(legacy_cfg.get("CIVITAI_API_KEY", "").strip())
except Exception:
pass
return web.json_response({"has_api_key": has_key, "folder_types_config": folder_types})
async def api_save_config(request):
async def api_save_config(request):
try:
data = await request.json()
api_key = data.get("api_key", "").strip()
api_key = data.get("api_key")
folder_types_config = data.get("folder_types_config")
script_dir = os.path.dirname(os.path.abspath(__file__))
config_path = os.path.join(script_dir, "config.json")
@@ -37,9 +50,31 @@ async def api_save_config(request):
except:
pass
cfg["CIVITAI_API_KEY"] = api_key
with open(config_path, 'w', encoding='utf-8') as f:
json.dump(cfg, f, indent=4)
if api_key is not None and not isinstance(api_key, str):
return web.json_response({"status": "error", "message": "Invalid API key"}, status=400)
if folder_types_config is not None and not isinstance(folder_types_config, list):
return web.json_response({"status": "error", "message": "Invalid folder configuration"}, status=400)
if "physical_folders_config" in data and not isinstance(data.get("physical_folders_config"), list):
return web.json_response({"status": "error", "message": "Invalid physical folder configuration"}, status=400)
if "folder_view_mode" in data and data.get("folder_view_mode") not in {"abstract", "physical"}:
return web.json_response({"status": "error", "message": "Invalid folder view mode"}, status=400)
if api_key is not None:
cfg["CIVITAI_API_KEY"] = api_key.strip()
if folder_types_config is not None:
cfg["folder_types_config"] = folder_types_config
if "physical_folders_config" in data:
cfg["physical_folders_config"] = data.get("physical_folders_config")
if "folder_view_mode" in data:
cfg["folder_view_mode"] = data.get("folder_view_mode")
temp_path = config_path + ".tmp"
with open(temp_path, 'w', encoding='utf-8') as f:
json.dump(cfg, f, indent=4)
os.replace(temp_path, config_path)
return web.json_response({"status": "ok"})
except Exception as e:
+147 -39
View File
@@ -4,12 +4,14 @@ import json
import urllib.parse
import subprocess
import threading
import asyncio
from aiohttp import web
import asyncio
import copy
from functools import lru_cache
from aiohttp import web
import folder_paths
import struct
def _extract_safetensors_hash(file_path):
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:
@@ -33,15 +35,78 @@ def _extract_safetensors_hash(file_path):
if 'modelspec.hash.blake3' in metadata:
return metadata['modelspec.hash.blake3']
for k, v in metadata.items():
if ('hash' in k.lower() or 'civitai' in k.lower()) and isinstance(v, str):
if len(v) == 64 and all(c in '0123456789abcdefABCDEF' for c in v):
return v
except Exception:
pass
return None
def get_metadata(file_path):
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))
def _select_info_file(data, file_path):
"""Select the Civitai file entry that actually describes ``file_path``.
A model version may contain a checkpoint, text encoder, and VAE in the same
``files`` array. Taking the first SHA256 silently assigns the dependency's
hash to every local sidecar copied from that version. Physical byte size is
the strongest cheap discriminator available here and remains valid after a
local rename. Filenames are intentionally excluded: local renames are the
reason provenance recovery exists and cannot be identity evidence.
"""
entries = []
for file_info in data.get("files", []):
if not isinstance(file_info, dict):
continue
hashes = file_info.get("hashes", {})
sha256 = hashes.get("SHA256") if isinstance(hashes, dict) else None
if not sha256:
continue
size_bytes = None
try:
if file_info.get("sizeKB") is not None:
size_bytes = int(round(float(file_info["sizeKB"]) * 1024))
except (TypeError, ValueError, OverflowError):
pass
entries.append((file_info, str(sha256), size_bytes))
if not entries:
return None
try:
physical_size = os.path.getsize(file_path)
except OSError:
physical_size = None
if physical_size is not None:
size_matches = [entry for entry in entries if entry[2] == physical_size]
if len(size_matches) == 1:
return size_matches[0][0]
if len(size_matches) > 1:
return None
# Offline-generated metadata commonly contains one injected hash without
# Civitai size/name data. It is safe only when there is a single candidate.
if len(entries) == 1:
return entries[0][0]
return None
def _select_info_hash(data, file_path):
selected = _select_info_file(data, file_path)
if not selected:
return ""
hashes = selected.get("hashes", {})
return str(hashes.get("SHA256", "")) if isinstance(hashes, dict) else ""
def _read_metadata(file_path):
base_path = os.path.splitext(file_path)[0]
metadata = {
"name": os.path.basename(base_path),
@@ -50,8 +115,8 @@ def get_metadata(file_path):
"trainedWords": [],
"baseModel": "",
"civitai_url": "",
"hash": "",
"custom_name": "",
"hash": "",
"custom_name": "",
"custom_notes": ""
}
@@ -90,32 +155,75 @@ def get_metadata(file_path):
civitai_url += f"?modelVersionId={version_id}"
hash_val = ""
for file_info in data.get("files", []):
if isinstance(file_info, dict):
hashes = file_info.get("hashes", {})
if isinstance(hashes, dict) and "SHA256" in hashes:
hash_val = hashes["SHA256"]
break
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 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"]
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
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", ""))
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 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"]
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
def _file_signature(file_path):
try:
stat = os.stat(file_path)
return (stat.st_size, stat.st_mtime_ns, stat.st_ctime_ns)
except OSError:
return None
def _metadata_signature(file_path):
base_path = os.path.splitext(file_path)[0]
return (
_file_signature(file_path),
_file_signature(f"{base_path}.info"),
_file_signature(f"{base_path}.civitai.info"),
)
@lru_cache(maxsize=4096)
def _get_metadata_cached(file_path, signature):
# ``signature`` is deliberately unused by the parser itself; it is part of
# the cache key and changes whenever the model or either metadata sidecar
# changes. The cache is bounded so removed/renamed models cannot grow it
# without limit.
return _read_metadata(file_path)
def get_metadata(file_path):
normalized_path = os.path.realpath(file_path)
signature = _metadata_signature(normalized_path)
# Metadata contains mutable lists. Never expose the cached object directly.
return copy.deepcopy(_get_metadata_cached(normalized_path, signature))
def clear_metadata_cache():
_get_metadata_cached.cache_clear()
_extract_safetensors_hash_cached.cache_clear()
def get_metadata_cache_info():
return _get_metadata_cached.cache_info()
+899 -393
View File
File diff suppressed because it is too large Load Diff
+14 -9
View File
@@ -7,7 +7,8 @@ import threading
import asyncio
from aiohttp import web
import folder_paths
import struct
import struct
from .utils import require_filename, resolve_within
async def api_get_notebooks(request):
script_dir = os.path.dirname(os.path.abspath(__file__))
@@ -34,17 +35,19 @@ async def api_save_notebook(request):
try:
data = await request.json()
filename = data.get("filename", "")
if not filename or '..' in filename:
return web.json_response({"status": "error", "message": "Invalid filename"})
if not filename.endswith('.json'):
filename += '.json'
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 = os.path.join(nb_dir, filename)
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)
@@ -56,12 +59,14 @@ async def api_delete_notebook(request):
try:
data = await request.json()
filename = data.get("filename", "")
if not filename or '..' in filename:
return web.json_response({"status": "error", "message": "Invalid 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 = os.path.join(nb_dir, filename)
file_path = resolve_within(nb_dir, filename)
if os.path.exists(file_path):
os.remove(file_path)
-8
View File
@@ -1,8 +0,0 @@
{
"baseModel": "SD 1.5",
"mainModel": null,
"loras": [],
"promptEn": "",
"promptZh": "",
"translations": {}
}
+337
View File
@@ -0,0 +1,337 @@
import asyncio
import os
import json
import tempfile
import time
import uuid
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
def get_parameters_dir():
# Store parameter notebooks in the user directory
# ComfyUI/user/default/workflows/anomalous_parameters
user_dir = (
folder_paths.get_user_directory()
if hasattr(folder_paths, "get_user_directory")
else None
)
if not user_dir:
# Fallback if user_dir is not supported in this ComfyUI version
base_dir = folder_paths.base_path
user_dir = os.path.join(base_dir, "user", "default")
workflows_dir = os.path.join(user_dir, "workflows")
parameters_dir = os.path.join(workflows_dir, "anomalous_parameters")
if not os.path.exists(parameters_dir):
os.makedirs(parameters_dir, exist_ok=True)
return parameters_dir
async def api_get_parameters(request):
try:
recipe_filename = request.query.get("recipe_filename")
if recipe_filename:
recipe_filename = require_filename(recipe_filename)
if not recipe_filename.endswith(".json"):
raise ValueError("Invalid recipe filename")
except (AttributeError, ValueError):
return web.json_response({"status": "error", "message": "Invalid recipe filename"}, status=400)
parameters_dir = get_parameters_dir()
notebooks = await asyncio.to_thread(_read_parameter_notebooks, parameters_dir, recipe_filename)
return web.json_response({"notebooks": notebooks})
_parameter_notebooks_cache = None
_parameter_notebooks_cache_time = 0
def _invalidate_parameter_notebooks_cache():
global _parameter_notebooks_cache, _parameter_notebooks_cache_time
_parameter_notebooks_cache = None
_parameter_notebooks_cache_time = 0
def _prompt_role_for_node(node, params):
"""Return an explicit role, or conservatively recover one from legacy values."""
if not isinstance(node, dict) or not isinstance(params, dict):
return None
allowed_roles = {"positive", "negative", "both", "ignored", "unknown"}
node_id = node.get("id")
overrides = params.get("promptRoleOverrides")
if isinstance(overrides, dict):
override = overrides.get(str(node_id))
if override is None:
override = overrides.get(node_id)
if isinstance(override, dict) and override.get("role") in allowed_roles:
expected_type = override.get("nodeType")
if not expected_type or expected_type == node.get("type"):
return override["role"]
summaries = params.get("nodes")
if isinstance(summaries, list):
for summary in summaries:
if not isinstance(summary, dict) or str(summary.get("id")) != str(node_id):
continue
if summary.get("role") in allowed_roles:
return summary["role"]
break
values = node.get("widgets_values")
if not isinstance(values, list) or not values:
return None
value = values[0]
positive_values = params.get("promptPositive", [])
negative_values = params.get("promptNegative", [])
positive = set(item for item in positive_values if isinstance(item, str)) if isinstance(positive_values, list) else set()
negative = set(item for item in negative_values if isinstance(item, str)) if isinstance(negative_values, list) else set()
if value in negative and value not in positive:
return "negative"
if value in positive and value not in negative:
return "positive"
return None
def _get_all_notebooks_cached(parameters_dir, force_refresh=False):
global _parameter_notebooks_cache, _parameter_notebooks_cache_time
# Cache for 2 seconds to prevent rapid disk I/O on UI interactions
if not force_refresh and _parameter_notebooks_cache is not None and time.time() - _parameter_notebooks_cache_time < 2.0:
return _parameter_notebooks_cache
notebooks = _read_parameter_notebooks(parameters_dir)
_parameter_notebooks_cache = notebooks
_parameter_notebooks_cache_time = time.time()
return notebooks
async def api_get_parameters_by_type(request):
try:
node_type = request.query.get("type")
if not node_type:
raise ValueError("Missing node type")
except ValueError:
return web.json_response({"status": "error", "message": "Missing node type"}, status=400)
parameters_dir = get_parameters_dir()
force_refresh = request.query.get("refresh") == "1"
notebooks = await asyncio.to_thread(_get_all_notebooks_cached, parameters_dir, force_refresh)
# Filter and extract node data
# Structure: [ { "recipe_filename": ..., "notebooks": [ { "name": ..., "nodes": [...] } ] } ]
grouped = {}
for nb in notebooks:
workflow = nb.get("data", {}).get("workflow")
if not isinstance(workflow, dict):
continue
nodes = workflow.get("nodes", [])
if not isinstance(nodes, list):
continue
matched_nodes = []
for n in nodes:
if not isinstance(n, dict):
continue
if n.get("type") == node_type:
matched_nodes.append({
"id": n.get("id"),
"title": n.get("title") or n.get("type"),
"type": n.get("type"),
"widgets_values": n.get("widgets_values", []),
"role": _prompt_role_for_node(n, nb.get("data", {}).get("params")),
})
if not matched_nodes:
continue
recipe_fn = nb.get("data", {}).get("recipe_filename") or "unbound"
if recipe_fn not in grouped:
grouped[recipe_fn] = []
grouped[recipe_fn].append({
"filename": nb.get("filename"),
"name": nb.get("name"),
"timestamp": nb.get("timestamp"),
"nodes": matched_nodes
})
recipes_dir = get_recipes_dir()
recipe_name_cache = {}
recipe_role_cache = {}
result = []
for r_fn, nbs in grouped.items():
recipe_roles = {}
if r_fn == "unbound":
human_name = "Unbound"
else:
if r_fn not in recipe_name_cache:
try:
r_data = _read_recipe(resolve_within(recipes_dir, r_fn))
recipe_name_cache[r_fn] = r_data.get("name") if r_data else r_fn
recipe_params = r_data.get("params") if isinstance(r_data, dict) else None
if r_data and isinstance(recipe_params, dict):
role_map = {}
workflow = r_data.get("workflow")
workflow_nodes = workflow.get("nodes", []) if isinstance(workflow, dict) else []
for n in workflow_nodes:
if not isinstance(n, dict) or "id" not in n:
continue
role = _prompt_role_for_node(n, recipe_params)
if role is not None:
role_map[n["id"]] = role
recipe_role_cache[r_fn] = role_map
except Exception:
recipe_name_cache[r_fn] = r_fn
human_name = recipe_name_cache[r_fn] or r_fn
recipe_roles = recipe_role_cache.get(r_fn, {})
for nb in nbs:
for n in nb["nodes"]:
node_id = n.get("id")
role = recipe_roles.get(node_id)
if role is None:
role = recipe_roles.get(str(node_id))
if role is None:
role = n.get("role")
if role is not None:
n["role"] = role
result.append({
"recipe_filename": r_fn,
"recipe_name": human_name,
"notebooks": nbs
})
return web.json_response({"groups": result})
def _read_parameter_notebooks(parameters_dir, recipe_filename=None):
notebooks = []
try:
filenames = os.listdir(parameters_dir)
except OSError:
return notebooks
for filename in filenames:
if not filename.endswith(".json"):
continue
file_path = resolve_within(parameters_dir, filename)
try:
with open(file_path, "r", encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, dict):
continue
if recipe_filename and data.get("recipe_filename") != recipe_filename:
continue
notebooks.append({
"filename": filename,
"name": data.get("name", "Untitled Parameter Notebook"),
"data": data,
"timestamp": data.get("timestamp", 0),
})
except (OSError, ValueError, json.JSONDecodeError):
continue
notebooks.sort(key=lambda item: item.get("timestamp", 0), reverse=True)
return notebooks
async def api_save_parameter(request):
try:
data = await request.json()
except (ValueError, json.JSONDecodeError):
return web.json_response({"status": "error", "message": "Invalid parameter data"}, status=400)
if not isinstance(data, dict) or not isinstance(data.get("workflow"), dict):
return web.json_response({"status": "error", "message": "Invalid parameter workflow"}, status=400)
recipe_filename = data.get("recipe_filename")
if recipe_filename is not None:
try:
recipe_filename = require_filename(recipe_filename)
if not recipe_filename.endswith(".json"):
raise ValueError("Invalid recipe filename")
except (AttributeError, ValueError):
return web.json_response({"status": "error", "message": "Invalid recipe filename"}, status=400)
data["recipe_filename"] = recipe_filename
filename_stem = os.path.splitext(recipe_filename)[0] if recipe_filename else "unbound"
filename_stem = re.sub(r"[^A-Za-z0-9_-]+", "_", filename_stem)[:64]
filename = f"params_{filename_stem}_{int(time.time())}_{uuid.uuid4().hex[:8]}.json"
data["parameter_signature"] = _parameter_signature(data["workflow"])
data["timestamp"] = int(time.time() * 1000)
encoded = json.dumps(data, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
if len(encoded) > MAX_RECIPE_BYTES:
return web.json_response({"status": "error", "message": "Parameter notebook is too large"}, status=413)
try:
parameters_dir = get_parameters_dir()
file_path = resolve_within(parameters_dir, filename)
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(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 save parameter notebook"}, status=500)
return web.json_response({"status": "success", "filename": filename})
async def api_delete_parameter(request):
try:
data = await request.json()
filename = require_filename(data.get("filename", ""))
if not filename.endswith(".json"):
raise ValueError("Invalid filename")
except (ValueError, AttributeError):
return web.json_response({"status": "error", "message": "Invalid filename"}, status=400)
try:
parameters_dir = get_parameters_dir()
file_path = resolve_within(parameters_dir, filename)
if os.path.exists(file_path):
os.remove(file_path)
_invalidate_parameter_notebooks_cache()
except OSError:
return web.json_response({"status": "error", "message": "Could not delete parameter notebook"}, status=500)
return web.json_response({"status": "success"})
async def api_get_parameter_gallery(request):
"""Find recent output PNGs whose embedded parameters match one parameter notebook."""
try:
filename = request.query.get("filename")
fingerprint = request.query.get("fingerprint")
if filename:
filename = require_filename(filename)
if not filename.endswith(".json"):
raise ValueError("Invalid parameter notebook")
parameters_dir = get_parameters_dir()
file_path = resolve_within(parameters_dir, filename)
with open(file_path, "r", encoding="utf-8") as f:
data = json.load(f)
fingerprint = data.get("parameter_signature", {}).get("value")
if not fingerprint:
fingerprint = _parameter_signature(data.get("workflow"))["value"]
elif not isinstance(fingerprint, str) or not re.fullmatch(r"[0-9a-f]{64}", fingerprint, re.IGNORECASE):
raise ValueError("Invalid fingerprint")
images, scanned = await asyncio.to_thread(_parameter_gallery_images, fingerprint.lower())
except (AttributeError, ValueError, json.JSONDecodeError):
return web.json_response({"status": "error", "message": "Invalid parameter gallery request"}, status=400)
except FileNotFoundError:
return web.json_response({"status": "error", "message": "File not found"}, status=404)
except OSError:
return web.json_response({"status": "error", "message": "Could not read parameter gallery"}, status=500)
return web.json_response({
"status": "success",
"fingerprint": fingerprint.lower(),
"match_mode": "params",
"images": images,
"scanned": scanned,
})
+507
View File
@@ -0,0 +1,507 @@
"""Bounded import/export for self-contained Workflow Recipe packages."""
import hashlib
import io
import asyncio
import json
import os
import shutil
import stat
import tempfile
import time
import uuid
import zipfile
from aiohttp import web
from . import recipes as recipe_store
from .recipes import get_recipes_dir
from .utils import require_filename, resolve_within
PACKAGE_VERSION = 1
MAX_UPLOAD_BYTES = 32 * 1024 * 1024
MAX_ENTRY_COUNT = 256
MAX_ENTRY_BYTES = 16 * 1024 * 1024
MAX_EXPANDED_BYTES = 64 * 1024 * 1024
MAX_COMPRESSION_RATIO = 100
MAX_HISTORY_ENTRIES = 100
MAX_JSON_DEPTH = 64
MAX_INSPECTIONS = 4
INSPECTION_TTL_SECONDS = 10 * 60
ALLOWED_COMPRESSION = {zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED}
_INSPECTIONS = {}
def _json_depth(value, depth=0):
if depth > MAX_JSON_DEPTH:
return depth
if isinstance(value, dict):
return max((_json_depth(item, depth + 1) for item in value.values()), default=depth)
if isinstance(value, list):
return max((_json_depth(item, depth + 1) for item in value), default=depth)
return depth
def _sha256(data):
return hashlib.sha256(data).hexdigest()
def _safe_archive_name(name):
if not isinstance(name, str) or not name or len(name) > 240:
raise ValueError("Invalid package entry name")
if chr(92) in name or name.startswith("/") or ":" in name:
raise ValueError("Invalid package entry name")
parts = name.split("/")
if any(not part or part in (".", "..") for part in parts):
raise ValueError("Invalid package entry name")
return name
def _entry_is_symlink(info):
return stat.S_ISLNK((info.external_attr >> 16) & 0xFFFF)
def _read_zip_entry(archive, name, maximum=MAX_ENTRY_BYTES):
info = archive.getinfo(name)
if info.file_size > maximum:
raise ValueError("Package entry is too large")
data = archive.read(info)
if len(data) != info.file_size:
raise ValueError("Package entry size mismatch")
return data
def _validate_zip(raw):
if not isinstance(raw, bytes) or len(raw) > MAX_UPLOAD_BYTES:
raise ValueError("Package upload is too large")
try:
archive = zipfile.ZipFile(io.BytesIO(raw), "r")
except (OSError, zipfile.BadZipFile) as error:
raise ValueError("Invalid recipe package") from error
infos = archive.infolist()
if len(infos) > MAX_ENTRY_COUNT:
raise ValueError("Too many package entries")
names = []
expanded = 0
for info in infos:
name = _safe_archive_name(info.filename)
if name in names or info.is_dir() or _entry_is_symlink(info):
raise ValueError("Invalid or duplicate package entry")
if info.compress_type not in ALLOWED_COMPRESSION or info.flag_bits & 0x1:
raise ValueError("Unsupported package compression")
if info.file_size > MAX_ENTRY_BYTES:
raise ValueError("Package entry is too large")
if info.compress_size and info.file_size / info.compress_size > MAX_COMPRESSION_RATIO:
raise ValueError("Package compression ratio is unsafe")
expanded += info.file_size
if expanded > MAX_EXPANDED_BYTES:
raise ValueError("Package expands beyond the safety limit")
names.append(name)
if "manifest.json" not in names or "recipe.json" not in names:
raise ValueError("Package is missing manifest or recipe")
return archive, names
def _parse_json(data, label):
try:
value = json.loads(data.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as error:
raise ValueError(f"Invalid {label}") from error
if _json_depth(value) > MAX_JSON_DEPTH:
raise ValueError(f"{label} is nested too deeply")
return value
def _asset_id_from_reference(reference):
asset_id = reference.get("preview", {}).get("snapshot_asset_id") if isinstance(reference, dict) else None
if not isinstance(asset_id, str) or not asset_id.endswith(".webp"):
return None
return require_filename(asset_id)
def _cover_asset_id(recipe):
presentation = recipe.get("presentation", {}) if isinstance(recipe, dict) else {}
asset_id = presentation.get("cover_asset_id") if isinstance(presentation, dict) else None
if not isinstance(asset_id, str) or not asset_id.startswith("cover-") or not asset_id.endswith(".webp"):
return None
return require_filename(asset_id)
def _validate_manifest(manifest, archive, names):
if not isinstance(manifest, dict) or manifest.get("package_version") != PACKAGE_VERSION:
raise ValueError("Unsupported recipe package version")
declared = manifest.get("entries", [])
if not isinstance(declared, list) or len(declared) > MAX_ENTRY_COUNT:
raise ValueError("Invalid package manifest")
declared_map = {}
for item in declared:
if not isinstance(item, dict):
raise ValueError("Invalid package manifest entry")
name = _safe_archive_name(item.get("name"))
if name in declared_map or name not in names:
raise ValueError("Package manifest does not match archive")
declared_map[name] = item
for name in names:
if name == "manifest.json":
continue
item = declared_map.get(name)
if not item:
raise ValueError("Package contains undeclared data")
data = _read_zip_entry(archive, name)
if item.get("sha256") != _sha256(data) or item.get("size") != len(data):
raise ValueError("Package checksum mismatch")
def _sanitize_recipe_for_export(recipe, include_snapshots=True, include_identity=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_snapshots:
reference.pop("preview", None)
if not include_identity:
identity = reference.get("identity")
if isinstance(identity, dict):
for key in ("sha256", "size", "provenance"):
identity.pop(key, None)
identity["status"] = "unverified"
return value
def _referenced_asset_ids(recipe, include_model_previews=True):
result = set()
cover_asset = _cover_asset_id(recipe)
if cover_asset:
result.add(cover_asset)
if include_model_previews:
references = recipe.get("params", {}).get("model_references", [])
for reference in references if isinstance(references, list) else []:
asset_id = _asset_id_from_reference(reference)
if asset_id:
result.add(asset_id)
return result
def _add_zip_entry(archive, entries, name, data, media_type):
name = _safe_archive_name(name)
archive.writestr(name, data)
entries.append({"name": name, "size": len(data), "sha256": _sha256(data), "media_type": media_type})
def _recipe_history_files(recipes_dir, filename):
history_dir = recipe_store._history_dir(recipes_dir, filename)
files = []
if not os.path.isdir(history_dir):
return files
with os.scandir(history_dir) as entries:
for entry in entries:
if entry.is_file() and entry.name.endswith(".json"):
files.append(require_filename(entry.name))
files.sort()
return files[:MAX_HISTORY_ENTRIES]
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)
history_recipes = []
if include_history:
for history_name in _recipe_history_files(recipes_dir, filename):
history_path = resolve_within(recipe_store._history_dir(recipes_dir, filename), history_name)
with open(history_path, "rb") as history_file:
data = history_file.read(MAX_ENTRY_BYTES + 1)
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)))
asset_ids = _referenced_asset_ids(recipe, include_snapshots)
for _, history_recipe in history_recipes:
asset_ids.update(_referenced_asset_ids(history_recipe, include_snapshots))
entries = []
output = io.BytesIO()
with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=6) as archive:
recipe_bytes = json.dumps(recipe, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
_add_zip_entry(archive, entries, "recipe.json", recipe_bytes, "application/json")
if asset_ids:
assets_dir = recipe_store._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):
continue
with open(asset_path, "rb") as asset_file:
data = asset_file.read(MAX_ENTRY_BYTES + 1)
if len(data) > MAX_ENTRY_BYTES or not data.startswith(b"RIFF") or data[8:12] != b"WEBP":
continue
_add_zip_entry(archive, entries, f"assets/{asset_id}", data, "image/webp")
if include_history:
for history_name, history_recipe in history_recipes:
data = json.dumps(history_recipe, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
_add_zip_entry(archive, entries, f"history/{history_name}", data, "application/json")
manifest = {
"package_version": PACKAGE_VERSION,
"recipe_schema_version": recipe.get("schema_version", 1),
"exported_at": int(time.time() * 1000),
"options": {
"include_snapshots": include_snapshots,
"include_history": include_history,
"include_identity": include_identity,
},
"entries": entries,
}
manifest_bytes = json.dumps(manifest, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
archive.writestr("manifest.json", manifest_bytes)
return output.getvalue()
def _validate_recipe_assets(archive, recipes, names):
referenced = set()
for recipe in recipes:
referenced.update(_referenced_asset_ids(recipe))
for asset_id in referenced:
name = f"assets/{asset_id}"
if name not in names:
raise ValueError("Recipe references a missing snapshot asset")
data = _read_zip_entry(archive, name)
if not data.startswith(b"RIFF") or data[8:12] != b"WEBP":
raise ValueError("Snapshot asset is not a WebP image")
for name in names:
if name.startswith("assets/") and (name[7:] not in referenced or not name.endswith(".webp")):
raise ValueError("Package contains an unreferenced asset")
def _inspect_package(raw):
archive, names = _validate_zip(raw)
with archive:
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)
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)
history_recipes.append(history)
_validate_recipe_assets(archive, [recipe, *history_recipes], names)
return {
"manifest": manifest,
"recipe": recipe,
"history_names": history_names,
"asset_names": [name for name in names if name.startswith("assets/")],
}
def _prune_inspections():
now = time.time()
for token, record in list(_INSPECTIONS.items()):
if now - record["created"] > INSPECTION_TTL_SECONDS:
_INSPECTIONS.pop(token, None)
while len(_INSPECTIONS) >= MAX_INSPECTIONS:
oldest = min(_INSPECTIONS, key=lambda token: _INSPECTIONS[token]["created"])
_INSPECTIONS.pop(oldest, None)
def _existing_recipe_names(recipes_dir):
return [item["data"].get("name", "") for item in recipe_store._list_recipes(recipes_dir)]
def _unique_name(name, existing):
base = name.strip() or "Imported Workflow Recipe"
if base not in existing:
return base
for index in range(2, 1000):
candidate = f"{base} ({index})"
if candidate not in existing:
return candidate
raise ValueError("Could not create a unique recipe name")
def _copy_imported_assets(archive, inspection, staging, source_recipe, target_recipe):
asset_dir = os.path.join(staging, "assets")
os.makedirs(asset_dir, exist_ok=True)
for name in inspection["asset_names"]:
asset_id = require_filename(name.split("/", 1)[1])
data = _read_zip_entry(archive, name)
with open(resolve_within(asset_dir, asset_id), "wb") as asset_file:
asset_file.write(data)
source_refs = {
(
item.get("node_id"),
item.get("widget_index"),
item.get("category"),
item.get("saved_value"),
): item
for item in source_recipe.get("params", {}).get("model_references", [])
if isinstance(item, dict)
}
for target in target_recipe.get("params", {}).get("model_references", []):
source = source_refs.get((target.get("node_id"), target.get("widget_index"), target.get("category"), target.get("saved_value")))
asset_id = _asset_id_from_reference(source or {})
if asset_id and os.path.isfile(resolve_within(asset_dir, asset_id)):
target["preview"] = source["preview"]
def _write_staged_json(path, value):
with open(path, "w", encoding="utf-8", newline="\n") as output:
json.dump(value, output, ensure_ascii=False, separators=(",", ":"))
def _commit_import(record, payload):
recipes_dir = get_recipes_dir()
package_recipe = json.loads(json.dumps(record["recipe"], ensure_ascii=False))
collision = payload.get("collision", "rename")
name_override = payload.get("name")
if name_override is not None:
if not isinstance(name_override, str) or not name_override.strip():
raise ValueError("Invalid imported recipe name")
package_recipe["name"] = name_override.strip()
existing_names = _existing_recipe_names(recipes_dir)
target_filename = None
if collision == "replace":
target_filename = require_filename(payload.get("target_filename", ""))
if not target_filename.endswith(".json"):
raise ValueError("Invalid replacement target")
if not os.path.isfile(resolve_within(recipes_dir, target_filename)):
raise FileNotFoundError
elif collision != "rename":
raise ValueError("Unsupported collision choice")
else:
package_recipe["name"] = _unique_name(package_recipe.get("name", ""), existing_names)
normalized = recipe_store._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)
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_history = recipe_store._history_dir(recipes_dir, filename)
backup_recipe = None
backup_assets = None
backup_history = None
installed_assets = False
installed_history = False
try:
archive, _ = _validate_zip(record["raw"])
with archive:
_copy_imported_assets(archive, record, staging, record["recipe"], normalized)
_write_staged_json(os.path.join(staging, "recipe.json"), normalized)
if record["history_names"]:
history_stage = os.path.join(staging, "history")
os.makedirs(history_stage, exist_ok=True)
for name in record["history_names"]:
history = _parse_json(_read_zip_entry(archive, name), "historical recipe")
_write_staged_json(os.path.join(history_stage, require_filename(name.split("/", 1)[1])), history)
if collision == "replace":
backup_recipe = os.path.join(staging, "old-recipe.json")
os.replace(final_path, backup_recipe)
if os.path.isdir(final_assets):
backup_assets = os.path.join(staging, "old-assets")
shutil.move(final_assets, backup_assets)
if record["history_names"] and os.path.isdir(final_history):
backup_history = os.path.join(staging, "old-history")
shutil.move(final_history, backup_history)
os.replace(os.path.join(staging, "recipe.json"), final_path)
staged_assets = os.path.join(staging, "assets")
if os.path.isdir(staged_assets):
os.replace(staged_assets, final_assets)
installed_assets = True
if record["history_names"]:
history_stage = os.path.join(staging, "history")
if os.path.isdir(final_history):
shutil.rmtree(final_history)
os.replace(history_stage, final_history)
installed_history = True
except Exception:
if collision == "replace" and backup_recipe and os.path.isfile(backup_recipe):
if installed_assets and os.path.isdir(final_assets):
shutil.rmtree(final_assets, ignore_errors=True)
if installed_history and os.path.isdir(final_history):
shutil.rmtree(final_history, ignore_errors=True)
if os.path.isfile(final_path):
os.remove(final_path)
os.replace(backup_recipe, final_path)
if backup_assets and os.path.isdir(backup_assets):
shutil.move(backup_assets, final_assets)
if backup_history and os.path.isdir(backup_history):
shutil.move(backup_history, final_history)
raise
finally:
shutil.rmtree(staging, ignore_errors=True)
return filename, normalized.get("name", "")
async def api_export_recipe_package(request):
try:
payload = await request.json()
filename = require_filename(payload.get("filename", ""))
if not filename.endswith(".json"):
raise ValueError("Invalid recipe filename")
recipes_dir = get_recipes_dir()
recipe = await asyncio.to_thread(recipe_store._read_recipe, resolve_within(recipes_dir, filename))
package = await asyncio.to_thread(_build_export, recipe, recipes_dir, filename, payload)
except (AttributeError, TypeError, ValueError, json.JSONDecodeError):
return web.json_response({"status": "error", "message": "Invalid export request"}, status=400)
except FileNotFoundError:
return web.json_response({"status": "error", "message": "Recipe not found"}, status=404)
except OSError:
return web.json_response({"status": "error", "message": "Could not export recipe"}, status=500)
return web.Response(
body=package,
content_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="{os.path.splitext(filename)[0]}.anomalous-recipe.zip"'},
)
async def api_import_recipe_package_inspect(request):
try:
raw = await request.content.read(MAX_UPLOAD_BYTES + 1)
report = await asyncio.to_thread(_inspect_package, raw)
except (ValueError, OSError, zipfile.BadZipFile):
return web.json_response({"status": "error", "message": "Invalid recipe package"}, status=400)
_prune_inspections()
token = uuid.uuid4().hex
_INSPECTIONS[token] = {"created": time.time(), "raw": raw, **report}
return web.json_response({
"status": "success",
"token": token,
"recipe": {"name": report["recipe"].get("name", ""), "tags": report["recipe"].get("tags", [])},
"asset_count": len(report["asset_names"]),
"history_count": len(report["history_names"]),
"existing_names": _existing_recipe_names(get_recipes_dir()),
})
async def api_import_recipe_package_commit(request):
try:
payload = await request.json()
token = payload.get("token")
if not isinstance(token, str) or token not in _INSPECTIONS:
raise ValueError("Invalid inspection token")
record = _INSPECTIONS.pop(token)
if time.time() - record["created"] > INSPECTION_TTL_SECONDS:
raise ValueError("Inspection token expired")
filename, name = await asyncio.to_thread(_commit_import, record, payload)
except (AttributeError, TypeError, ValueError, json.JSONDecodeError):
return web.json_response({"status": "error", "message": "Could not commit recipe package"}, status=400)
except FileNotFoundError:
return web.json_response({"status": "error", "message": "Replacement recipe not found"}, status=404)
except OSError:
return web.json_response({"status": "error", "message": "Could not commit recipe package"}, status=500)
return web.json_response({"status": "success", "filename": filename, "name": name})
+1382
View File
File diff suppressed because it is too large Load Diff
+176 -151
View File
@@ -8,6 +8,7 @@ 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
async def api_scan_status(request):
folder_type = request.query.get('type', 'checkpoints')
@@ -20,14 +21,12 @@ async def api_scan_status(request):
paths = folder_paths.get_folder_paths(folder_type)
except Exception:
return web.json_response({"scanning": False})
if not paths or path_idx >= len(paths) or '..' in subfolder:
return web.json_response({"scanning": False})
base_dir = paths[path_idx]
if subfolder == '/':
target_dir = base_dir
else:
target_dir = os.path.join(base_dir, subfolder.strip('/'))
if not paths or path_idx < 0 or path_idx >= len(paths):
return web.json_response({"scanning": False})
try:
base_dir, target_dir = resolve_folder_subdir(folder_type, path_idx, subfolder)
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')
@@ -53,22 +52,10 @@ async def api_scan_folder(request):
except:
path_idx = 0
if '..' in subfolder:
return web.json_response({"status": "error", "message": "Invalid subfolder"})
try:
paths = folder_paths.get_folder_paths(folder_type)
except Exception:
return web.json_response({"status": "error", "message": "Invalid folder type"})
if not paths or path_idx >= len(paths):
return web.json_response({"status": "error", "message": "Invalid path index"})
base_dir = paths[path_idx]
if subfolder == '/':
target_dir = base_dir
else:
target_dir = os.path.join(base_dir, subfolder.strip('/'))
try:
base_dir, target_dir = resolve_folder_subdir(folder_type, path_idx, subfolder)
except (ValueError, KeyError):
return web.json_response({"status": "error", "message": "Invalid folder type"})
if not os.path.exists(target_dir):
return web.json_response({"status": "error", "message": "Directory does not exist"})
@@ -91,12 +78,28 @@ async def api_scan_folder(request):
skip_rename = data.get("skip_rename", False)
virtual_rename = data.get("virtual_rename", False)
physical_rename = data.get("physical_rename", False)
force_overwrite = data.get("force_overwrite", False)
try:
marker_file = os.path.join(target_dir, '.scan_in_progress')
with open(marker_file, 'w') as f: f.write('1')
def run_bg():
try:
target_files_list = data.get("target_files", [])
if not target_files_list:
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 []
try:
marker_file = os.path.join(target_dir, '.scan_in_progress')
try:
with open(marker_file, 'x') as f:
f.write('1')
except FileExistsError:
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]
if offline_only:
cmd.append("--offline-only")
@@ -106,18 +109,24 @@ async def api_scan_folder(request):
cmd.append("--virtual-rename")
if physical_rename:
cmd.append("--physical-rename")
if force_overwrite:
cmd.append("--force-overwrite")
subprocess.run(
cmd,
cwd=plugin_dir,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL
)
result = subprocess.run(
cmd,
cwd=plugin_dir
)
if result.returncode != 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:
if hasattr(folder_paths, "filename_list_cache"):
folder_paths.filename_list_cache.clear()
try: folder_paths.filename_list_cache.clear()
except: pass
if hasattr(folder_paths, "cache_helper") and hasattr(folder_paths.cache_helper, "clear"):
folder_paths.cache_helper.clear()
try: folder_paths.cache_helper.clear()
except: pass
if os.path.exists(marker_file):
try: os.remove(marker_file)
@@ -126,16 +135,16 @@ async def api_scan_folder(request):
threading.Thread(target=run_bg, daemon=True).start()
return web.json_response({"status": "ok", "message": "Scan started in background. Check console for details."})
except Exception as e:
return web.json_response({"status": "error", "message": str(e)})
except Exception as e:
if 'marker_file' in locals() and os.path.exists(marker_file):
try: os.remove(marker_file)
except OSError: pass
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")
marker_file = os.path.join(plugin_dir, '.global_scan_in_progress')
if os.path.exists(marker_file):
return web.json_response({"status": "error", "message": "Global scan already in progress"})
marker_file = os.path.join(plugin_dir, '.global_scan_in_progress')
try:
data = await request.json()
@@ -147,44 +156,53 @@ async def api_scan_all(request):
skip_rename = data.get("skip_rename", True)
virtual_rename = data.get("virtual_rename", False)
physical_rename = data.get("physical_rename", False)
force_overwrite = data.get("force_overwrite", False)
skip_media = data.get("skip_media", False)
try:
with open(marker_file, 'w') as f: f.write('1')
try:
try:
with open(marker_file, 'x') as f:
f.write('1')
except FileExistsError:
return web.json_response({"status": "error", "message": "Global scan already in progress"}, status=409)
def run_global_bg():
try:
types = ['checkpoints', 'loras', 'unet', 'diffusion_models', 'controlnet', 'vae']
for t in types:
paths_to_scan = get_active_scan_paths()
for base_dir in paths_to_scan:
if not os.path.exists(base_dir): continue
try:
paths = folder_paths.get_folder_paths(t)
if not paths: continue
for base_dir in paths:
if not os.path.exists(base_dir): continue
print(f"[Anomalous Browser] Global scan processing: {base_dir}")
cmd = [sys.executable, scraper_path, base_dir, "--skip-media"]
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 not use_local_metadata:
cmd.append("--skip-local-metadata")
subprocess.run(
cmd,
cwd=plugin_dir,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL
)
print(f"[Anomalous Browser] Global scan processing: {base_dir}")
cmd = [sys.executable, scraper_path, base_dir]
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 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 {t}: {e}")
print(f"[Anomalous Browser] Global scan error on {base_dir}: {e}")
finally:
if hasattr(folder_paths, "filename_list_cache"):
folder_paths.filename_list_cache.clear()
try: folder_paths.filename_list_cache.clear()
except: pass
if hasattr(folder_paths, "cache_helper") and hasattr(folder_paths.cache_helper, "clear"):
folder_paths.cache_helper.clear()
try: folder_paths.cache_helper.clear()
except: pass
if os.path.exists(marker_file):
try: os.remove(marker_file)
@@ -192,8 +210,11 @@ async def api_scan_all(request):
threading.Thread(target=run_global_bg, daemon=True).start()
return web.json_response({"status": "ok", "message": "Global scan started"})
except Exception as e:
return web.json_response({"status": "error", "message": str(e)})
except Exception as e:
if os.path.exists(marker_file):
try: os.remove(marker_file)
except OSError: pass
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__)))
@@ -201,49 +222,40 @@ async def api_global_scan_status(request):
return web.json_response({"scanning": os.path.exists(marker_file)})
async def api_clean_civitai_info(request):
try:
types = ['checkpoints', 'loras', 'unet', 'diffusion_models', 'controlnet', 'vae']
paths_to_clean = get_active_scan_paths()
deleted_count = 0
for t in types:
try:
paths = folder_paths.get_folder_paths(t)
except Exception:
continue
if not paths: continue
for base_dir in paths:
if not os.path.exists(base_dir): continue
for root, dirs, files in os.walk(base_dir):
for file in files:
if file.endswith('.civitai.info'):
file_path = os.path.join(root, file)
try:
os.remove(file_path)
deleted_count += 1
except Exception as e:
pass
for base_dir in paths_to_clean:
if not os.path.exists(base_dir): continue
for root, dirs, files in os.walk(base_dir):
for file in files:
if file.endswith('.civitai.info'):
file_path = os.path.join(root, file)
try:
os.remove(file_path)
deleted_count += 1
except Exception as e:
pass
return web.json_response({"status": "success", "count": deleted_count})
except Exception as e:
return web.json_response({"status": "error", "message": str(e)}, status=500)
GLOBAL_SCAN_STATE = {
GLOBAL_SCAN_STATE = {
"scanning": False,
"total": 0,
"current": 0,
"filename": "",
"error": ""
}
}
GLOBAL_SCAN_LOCK = threading.Lock()
async def api_global_scan_status(request):
async def api_scan_missing_models_status(request):
return web.json_response(GLOBAL_SCAN_STATE)
async def api_scan_missing_models(request):
if GLOBAL_SCAN_STATE["scanning"]:
return web.json_response({"status": "error", "message": "Scan already in progress"})
import sys
import threading
import traceback
async def api_scan_missing_models(request):
import sys
import threading
import traceback
plugin_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if plugin_dir not in sys.path:
@@ -251,53 +263,61 @@ async def api_scan_missing_models(request):
try:
from scraper import extract_safetensors_hash, calculate_sha256, fetch_civitai_info, infer_base_model_from_header
except ImportError:
return web.json_response({"status": "error", "message": "Failed to load scraper module"})
def run_deep_scan():
GLOBAL_SCAN_STATE["scanning"] = True
GLOBAL_SCAN_STATE["total"] = 0
GLOBAL_SCAN_STATE["current"] = 0
GLOBAL_SCAN_STATE["filename"] = "Initializing..."
GLOBAL_SCAN_STATE["error"] = ""
except ImportError:
return web.json_response({"status": "error", "message": "Failed to load scraper module"})
with GLOBAL_SCAN_LOCK:
if GLOBAL_SCAN_STATE["scanning"]:
return web.json_response({"status": "error", "message": "Scan already in progress"}, status=409)
GLOBAL_SCAN_STATE["scanning"] = True
GLOBAL_SCAN_STATE["total"] = 0
GLOBAL_SCAN_STATE["current"] = 0
GLOBAL_SCAN_STATE["filename"] = "Initializing..."
GLOBAL_SCAN_STATE["error"] = ""
try:
data = await request.json()
except Exception:
data = {}
force_overwrite = data.get("force_overwrite", False)
def run_deep_scan():
try:
types = ['checkpoints', 'loras', 'unet', 'diffusion_models', 'controlnet', 'vae']
paths_to_scan = get_active_scan_paths()
files_to_scan = []
for t in types:
try:
paths = folder_paths.get_folder_paths(t)
if not paths: continue
for base_dir in paths:
if not os.path.exists(base_dir): continue
for root, dirs, files in os.walk(base_dir):
for file in files:
if file.endswith('.safetensors'):
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 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
if not has_valid_info:
files_to_scan.append(file_path)
except Exception:
pass
for base_dir in paths_to_scan:
if not os.path.exists(base_dir): continue
for root, dirs, files in os.walk(base_dir):
for file in files:
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
if not has_valid_info:
files_to_scan.append(file_path)
GLOBAL_SCAN_STATE["total"] = len(files_to_scan)
@@ -359,8 +379,8 @@ async def api_scan_missing_models(request):
with open(info_path, 'w', encoding='utf-8') as f:
json.dump(civitai_data, f, ensure_ascii=True, indent=4)
except Exception as e:
pass
except Exception as e:
GLOBAL_SCAN_STATE["error"] = f"{filename}: {e}"
except Exception as e:
GLOBAL_SCAN_STATE["error"] = str(e)
@@ -375,5 +395,10 @@ async def api_scan_missing_models(request):
if hasattr(folder_paths, "cache_helper") and hasattr(folder_paths.cache_helper, "clear"):
folder_paths.cache_helper.clear()
threading.Thread(target=run_deep_scan, daemon=True).start()
return web.json_response({"status": "success", "message": "Deep scan started in background"})
try:
threading.Thread(target=run_deep_scan, daemon=True).start()
except Exception as e:
GLOBAL_SCAN_STATE["scanning"] = False
GLOBAL_SCAN_STATE["error"] = str(e)
return web.json_response({"status": "error", "message": str(e)}, status=500)
return web.json_response({"status": "success", "message": "Deep scan started in background"})
+449 -112
View File
@@ -2,14 +2,171 @@ 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
async def api_serve_image(request):
import struct
import hashlib
import tempfile
import time
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 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 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 _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:
@@ -19,21 +176,12 @@ async def api_serve_image(request):
subfolder = request.query.get('subfolder', '')
filename = request.query.get('filename', '')
if not filename or '..' in filename or '..' in subfolder:
return web.Response(status=400, text='Invalid request')
try:
paths = folder_paths.get_folder_paths(folder_type)
except Exception:
return web.Response(status=404, text='Folder type not found')
if not paths or path_idx >= len(paths):
return web.Response(status=404, text='Folder type not found')
base_dir = paths[path_idx]
if subfolder:
file_path = os.path.join(base_dir, subfolder, filename)
else:
file_path = os.path.join(base_dir, 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')
@@ -43,26 +191,44 @@ async def api_serve_image(request):
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.webp': 'image/webp',
'.gif': 'image/gif',
'.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, 'application/octet-stream')
content_type = content_types.get(ext)
if content_type is None:
return web.Response(status=415, text='Unsupported media type')
return web.FileResponse(file_path, headers={'Content-Type': content_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:
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()
return web.json_response({"status": "success"})
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)})
@@ -115,38 +281,39 @@ async def api_translate(request):
return web.json_response({"translated": text, "error": str(e)})
async def api_get_gallery_images(request):
try:
output_dir = folder_paths.get_output_directory()
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 = int(request.query.get('page', 1))
limit = int(request.query.get('limit', 50))
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'}
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:
mtime = 0
images.append({
"filename": f,
"subfolder": subfolder,
"type": "output",
"mtime": mtime
})
# Sort by mtime descending (newest first)
images.sort(key=lambda x: x['mtime'], reverse=True)
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
@@ -168,14 +335,13 @@ async def api_delete_gallery_image(request):
filename = data.get("filename")
subfolder = data.get("subfolder", "")
if not filename or '..' in filename or '..' in subfolder:
return web.json_response({"status": "error", "message": "Invalid parameters"})
output_dir = folder_paths.get_output_directory()
if subfolder:
file_path = os.path.join(output_dir, subfolder, filename)
else:
file_path = os.path.join(output_dir, filename)
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)
@@ -209,7 +375,7 @@ def read_png_text_fast(path):
return None
async def api_get_model_images(request):
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': []})
@@ -221,53 +387,224 @@ async def api_get_model_images(request):
if not os.path.exists(output_dir):
return web.json_response({'images': []})
matched_images = []
# Search all PNGs in output_dir
for root, _, files in os.walk(output_dir):
for file in files:
if file.lower().endswith('.png'):
full_path = os.path.join(root, file)
prompt_text = read_png_text_fast(full_path)
if prompt_text:
try:
prompt_data = json.loads(prompt_text)
matched = False
for node_id, node in prompt_data.items():
if 'class_type' in node:
inputs = node.get('inputs', {})
for k, v in inputs.items():
if isinstance(v, str):
try:
if os.path.basename(v).lower() == base_target:
matched = True
break
except Exception:
pass
if matched:
break
if matched:
# Match found
rel_path = os.path.relpath(root, output_dir).replace('\\', '/')
if rel_path == '.': rel_path = ''
# Standard comfyUI /view format:
# /view?filename=XX.png&type=output&subfolder=YY
url = f'/view?filename={urllib.parse.quote(file)}&type=output'
if rel_path:
url += f'&subfolder={urllib.parse.quote(rel_path)}'
mtime = os.path.getmtime(full_path)
matched_images.append({
'url': url,
'mtime': mtime
})
except Exception:
pass
# Sort by mtime descending (newest first)
matched_images.sort(key=lambda x: x['mtime'], reverse=True)
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})
-272
View File
@@ -1,272 +0,0 @@
# 📖 Error & Experience Summary (开发避坑与经验总结)
This document serves as an architectural retrospective and UX diagnostic log for the development of the Anomalous Model Browser.
这份文档旨在记录 Anomalous 插件开发过程中的架构演进、UX 设计教训以及核心 Bug 排查经验。
---
## 1. UX Scroll Traps (交互嵌套滚动陷阱)
**The Problem (问题)**: In the Notebook modal, the translation editor had a `max-height` and `overflow-y`. Since the parent modal was also scrollable, it created a "Scroll Trap". Hitting the bottom of the editor unpredictably transferred the scroll event to the outer body.
(在笔记本弹窗中,由于翻译编辑器固定了最大高度并允许滚动,导致与父级弹窗的滚动条发生冲突。用户滚动到底部时会突然把外层页面卷走,体验极差。)
**The Solution (方案)**: Removed the `max-height` and internal scrollbar. Letting the translation editor expand naturally utilizing the primary window scrollbar guarantees a continuous experience.
(彻底移除内部最大高度和滚动条。让编辑器自然撑开,统一使用最外层的主滚动条,保证心流不断档。)
## 2. Artificial Metadata Pollution (伪造数据的反噬)
**The Problem (问题)**: To make the "Base Model" dropdown look complete, the backend artificially injected hardcoded architectures (`SDXL`, `HunyuanVideo`) even if the user didn't own them. This confused users.
(为了让“模型架构”下拉菜单看起来更“丰满”,后端硬编码注入了一些用户根本没下载过的新架构。结果让用户极度困惑,以为自己眼花了。)
**The Solution (方案)**: Data truth must strictly reflect the local disk state. Dropped artificial padding; the UI dropdown now accurately mirrors only the physical files on disk.
(数据必须绝对忠于本地磁盘。删除了所有的硬编码注入,确保下拉菜单严格反映本地真实的生态。)
## 3. UI Strobe Effects on Grid Hovers (网格悬浮的“光污染”)
**The Problem (问题)**: Standard `transition: all 0.2s` meant quickly sweeping the mouse across the model grid caused all cards to rapidly light up like a strobe light.
(为卡片设置了标准的 0.2秒 悬浮动画,导致鼠标快速扫过网格时,所有卡片像迪厅频闪一样疯狂亮起,显得非常廉价。)
**The Solution (方案)**: Introduced a `transition-delay: 0.08s` low-pass filter. The recommended UX range for hover intent is 50ms - 100ms. This 80ms delay is imperceptible for intentional clicks but completely eliminates flashes from accidental sweeps.
(引入了 `0.08s` 动画延迟作为低通滤波器。根据人机交互体验,区分“无意滑过”与“有意悬停”的黄金推荐区间是 50ms - 100ms。我们采用的 80ms 延迟对正常点击毫无察觉,但完美过滤了鼠标无意扫过造成的闪烁。)
## 4. The ComfyUI Manager Auto-Update File Wipe (Manager自动更新导致文件丢失)
**The Problem (问题)**: A custom documentation file (`docs/核心按钮操作指南_大白话版.md`) generated by AI locally was silently deleted when clicking "Update" on the node inside ComfyUI Manager.
(本地让 AI 写的说明文件,在点击 ComfyUI Manager 的更新按钮后离奇失踪了。)
**The Solution (方案)**: ComfyUI Manager's update mechanism often performs a raw `git pull` or hard reset. If a local file is not tracked in the remote GitHub repository (i.e., you haven't committed and pushed it), it is highly likely to be overwritten or wiped during the sync process. Always commit and push your documentation, or keep personal untracked notes outside the extension directory!
(ComfyUI Manager 的更新机制依赖 Git 同步(类似 `git reset --hard` 或强制 pull)。如果你在本地创建了文件却没有提交并推送到 GitHub,更新时 Manager 会强行把本地状态与云端对齐,导致这些“未追踪(untracked)”的文件被直接抹杀。教训:重要的文档一定要及时 push,或者别放在插件目录下!)
## 5. "Hostage" Inline Confirmations (被“绑架”的内联确认)
**The Problem (问题)**: Clicking "Delete" changed the button to "Are you sure?" with a 3-second timeout, but lacked a "Cancel" button. Users misclicking were held hostage for 3 seconds.
(点击删除后会变成“确定删除吗?”并锁定 3 秒,但没有“取消”按钮。用户如果不小心点错,只能像被绑架一样干等 3 秒它恢复。)
**The Solution (方案)**: Wrapped the logic to instantly reveal a secondary `[✕]` cancel button upon the first click, giving control back immediately.
(在点击删除时,立马在旁边弹出 `[✕]` 取消按钮。点击取消直接清空倒计时,立刻把控制权还给用户。)
## 6. First-load "Dead Interface" Avoidance (首次加载的“白屏假死”)
**The Problem (问题)**: Initializing the sidebar to the root directory `/` presented a blank screen if users only had models nested deep in subfolders.
(插件默认打开根目录 `/`。如果用户的模型全都在很深的子文件夹里,打开插件就会看到一片空白,以为插件坏了。)
**The Solution (方案)**: Implemented `firstLoadDone` to automatically force the UI to open the very first directory possessing `model_count > 0`.
(加入了智能寻路逻辑,插件首次加载时自动扫描目录树,直接定位并打开第一个存有模型的文件夹。)
## 7. Structural Layout Breakages (流式布局的坍塌)
**The Problem (问题)**: Placing varying lengths of translation tags sequentially caused extreme layout jitter when hovering.
(把中英文标签简单并排放在一起,由于字符串长度不一,鼠标悬浮时极容易发生换行挤压,导致整个界面疯狂跳动。)
**The Solution (方案)**: Enforced a strict "Row-based" flex architecture (`align-items: stretch`). Each tag pair is locked into a geometrical row.
(采用极其严格的行级 Flex 布局,把中英文双语硬性锁死在同一个拉伸行内,无论字数多少,几何结构绝对稳定。)
## 8. Modal State Persistence (弹窗状态的幽灵)
**The Problem (问题)**: Closing and reopening the Notebook modal showed a blank editor because the JS object cached `this.currentNotebook` but the DOM was obliterated.
(关掉笔记本弹窗再打开,右侧编辑器全空。因为 JS 内存里还记着上一次的状态,但 DOM 节点已经被销毁重建了。)
**The Solution (方案)**: Explicitly check cached state during modal initialization and force `renderNotebookEditor()` if necessary.
(在弹窗初始化时主动校验缓存,强制触发重新渲染,实现了完美的“会话级记忆”。)
## 9. Missing Visual Feedback for Background Operations (无反馈的幽灵进程)
**The Problem (问题)**: The Notebook "Save" button executed a background `fetch` perfectly, but provided absolutely no visual feedback. Users assumed it was broken.
(点击“保存”按钮后,后台默默完成了请求,但按钮本身没有任何变化。用户以为没点上,引发数据丢失的焦虑。)
**The Solution (方案)**: Clicking Save instantly changes the button to a green `✅` state for 1.5 seconds.
(点击保存后强制变绿 `✅` 1.5 秒,给用户极强的心理安全感。)
## 10. Spatial Geometry for Multi-Node Spawning (多节点生成的空间重叠)
**The Problem (问题)**: Spawning an entire Notebook (Main Model + Loras + CLIPs) caused all nodes to clump perfectly at `(0,0)`.
(一键生成整个笔记本(主模型+N个Lora+提示词)到画布时,所有节点全叠在同一个坐标上,变成了一坨。)
**The Solution (方案)**: Implemented a "Relative Offset Matrix Layout" (`X + 350*N`, `Y + 250`) and bound it to the `mousemove` event (Magnetic Sticking).
(加入了偏移矩阵计算,并绑定鼠标移动。用户可以直观地拖着排成流水的节点序列在画布上移动,然后再点击放下。)
## 11. Localization Host-Environment Constraints (寄生环境的本地化陷阱)
**The Problem (问题)**: Reading `document.documentElement.lang` failed because ComfyUI forces the host HTML to `en-US`, breaking our Chinese translations.
(插件试图读取网页根节点的 lang 属性来决定语言。但 ComfyUI 强行把底座设成了纯英文,导致中国用户永远看不到中文提示。)
**The Solution (方案)**: Never trust the host document properties. Rerouted localization strictly to our internal `currentLang` variable.
(永远不要相信宿主环境。彻底切断外部依赖,纯靠插件内部自己的 `currentLang` 变量来统治中英文环境。)
## 12. Strict Path Separator Validation (路径分隔符的玄学崩溃)
**The Problem (问题)**: Windows uses backslashes (`\`), but assigning `anime/model` to a node's combo widget failed ComfyUI's strict string matching, causing red nodes.
(Windows 的底层模型路径带反斜杠,但前端组件要求精确匹配字符串,哪怕是一个斜杠方向不对,节点也会标红报错。)
**The Solution (方案)**: Built `setWidgetValuePath` to normalize both target paths and native options with `.replace(/\\/g, '/')` before assignment.
(做了一个拦截器,强行把系统内的选项和我们要赋的值全部统一转成正斜杠再进行比较,直接绕过操作系统的路径陷阱。)
## 13. Backend Starvation During Inference (算力挤兑导致的接口饿死)
**The Problem (问题)**: Fetching plugin data while ComfyUI is generating an image causes timeouts because of PyTorch GIL-locking.
(在 ComfyUI 画图满载的时候,点插件会导致接口超时。因为单线程的 Python 完全被 GPU 推理抢占了。)
**The Solution (方案)**: Accept it gracefully. Database management and intense GPU inference are mutually exclusive workflows.
(优雅地接受这个现实。不需要过度工程化地去做离线队列,画图和管理模型本来就不该同时进行。)
## 14. DOM Obliteration vs Virtual DOM Hoarding (物理毁灭 vs 虚拟回收)
**The Problem (问题)**: Maintaining performance when navigating folders containing thousands of models.
(当一个文件夹里有上千个模型时,频繁切换文件夹会导致浏览器内存爆炸。)
**The Solution (方案)**: Instead of `display: none` hoarding, strictly enforce `this.grid.innerHTML = ''` to physically obliterate nodes and trigger immediate Garbage Collection.
(放弃“隐藏重用”的传统思路。切文件夹时直接暴力清空内部的 HTML 节点,强迫浏览器立马回收内存,保持零负担。)
## 15. The "Missing Plugin Icon" Trap (LocalStorage 坐标越界飞天)
**The Problem (问题)**: If the floating trigger button was saved at `X=3000px` on a 4K monitor, loading it on a `1920px` laptop rendered it permanently off-screen.
(在大显示器上把插件按钮拖到了最右边,换到小笔记本上时,坐标超出了屏幕,按钮彻底消失,再也点不出来了。)
**The Solution (方案)**: Implemented a bounds clamp checking coordinates against `window.innerWidth`. If out of bounds, snap it to the bottom-right corner.
(在启动时强制对比当前屏幕大小,一旦发现越界,立刻把它抓回到右下角的安全区内。)
## 16. The `[vite:preloadError]` Template Literal Crash (一个反引号引发的血案)
**The Problem (问题)**: A missing backtick in a dynamic ES Module dictionary crashed the entire ComfyUI extension loader before execution.
(在汉化字典里不小心删掉了一个反引号,导致 Vite 打包工具报语法错误,整个插件直接在加载前暴毙,UI 按钮全都没了。)
**The Solution (方案)**: JS zero-tolerance for syntax errors requires extreme validation. Fixed the missing backtick.
(原生 JS 没有任何编译容错,任何语法拼写都必须如履薄冰,及时修复了那个反引号。)
## 17. Target Size Deduplication Bug (重叠目录的体积匹配悖论)
**The Problem (问题)**: Fixing models by file size required finding *exactly one* matching file. But users mapping `checkpoints` and `diffusion_models` to the same folder caused `os.walk` to find the same physical file twice, aborting the fix.
(物理寻址兜底算法要求“体积必须唯一”。但用户配置了多个虚拟路径指向同一个物理文件夹,导致同一个文件被扫出两次,系统误判为“体积不唯一”而放弃修复。)
**The Solution (方案)**: Added `os.path.realpath` deduplication matrix to condense multiple virtual paths back to a single hard drive sector.
(引入了绝对物理地址 `realpath` 去重机制,无论虚拟路径怎么重叠,物理层面上都将其归一化。)
## 18. The "Lightweight Scan" Re-Hash Catastrophe (轻量扫描的重算灾难)
**The Problem (问题)**: The lightweight scan (`--skip-media`) demanded *both* an `.info` file and a preview image to skip hashing. Since it didn't download images, it forcefully re-hashed hundreds of GBs of models every time.
(轻量化极速扫描因为不会下载图片,导致“是否跳过计算”的判断条件(既有 info 又有图)永远不成立。引发了灾难性的全盘重新计算 SHA256,慢如蜗牛。)
**The Solution (方案)**: Modified skip logic conditionally: if explicitly skipping media, the existence of `.info` is sufficient to bypass hashing.
(加入了情境判断:只要是在极速模式下,不管有没有图,只要有文字信息就无条件跳过,恢复了真正的极速。)
## 19. The Stale Dropdown Visual Residue (顽固的红框残留)
**The Problem (问题)**: Even after a model path was auto-fixed, ComfyUI maintained a permanent red error border on the node if the frontend cache was stale.
(底层其实已经修复并加载了正确的模型,但因为前端选项缓存没刷过来,ComfyUI 死死把节点标红,看着十分闹心。)
**The Solution (方案)**: Forcefully execute `delete node.color; delete node.bgcolor;` to violently strip native error states without requiring a refresh.
(既然修好了,就直接暴力删除节点的 `color` 属性,强行洗白红框,无需刷新网页。)
## 20. The Silent Subprocess Death (DEVNULL 黑洞)
**The Problem (问题)**: `scraper.py` crashed due to Python indentation, but piping `stderr` to `DEVNULL` made it totally invisible. The UI waited forever.
(后台脚本缩进写错了导致崩溃,但代码里把报错信息塞进了无尽深渊 `DEVNULL`,没有任何提示,前端界面只能干等着,陷入死局。)
**The Solution (方案)**: Never pipe `stderr` to DEVNULL during active development. Let it log.
(开发阶段绝对不要屏蔽底层报错,必须让 `stderr` 打印到控制台以便排查。)
## 21. Tiered Fallback Resolution for O(1) Scans (多级降级寻址打造 O(1) 扫描)
**The Problem (问题)**: Unscanned 10GB models required full disk read to calculate SHA256, taking minutes.
(即便是第一次扫描,遇到几十 GB 的新模型,完整计算 SHA256 也要几分钟,用户体验极度卡顿。)
**The Solution (方案)**:
1. 0ms: Read existing `.info`. (直接读取已有的 info)
2. 0.01s: Extract 64KB JSON header from `.safetensors` via `struct` for pre-computed hash. (用 struct 撬开文件头瞬间提取内置的 Hash,降维打击)
3. Fallback: Full SHA256 only for legacy models. (真找不到了才迫不得已做全量计算)
## 22. Zero-API Tensor Fingerprinting (零API脱机张量基因识别)
**The Problem (问题)**: HuggingFace or private models returned 404 from Civitai, failing to generate `.info` and completely breaking UI integration.
(从 HF 拿到的或者是自己练的私模,由于 C站 没有记录,API 直接报错 404,导致模型彻底被忽略,前端里连看都看不到。)
**The Solution (方案)**: Read the first 500 Tensor Keys via struct. Identifying `double_blocks.0.img_attn` strictly confirms Flux.1 D architecture completely offline.
(不管你有没有网,强行读取模型前 500 层的网络结构。只要看到里面有 `double_blocks`,直接盖章确认为 Flux 架构。完全脱机,无缝融入前端兼容雷达。)
## 23. Gemini-Style Popover UI (告别全屏遮罩,采用悬浮弹窗)
**The Problem (问题)**: Center-screen settings modals with blur backgrounds felt heavy and disconnected users from the workspace.
(原本的设置面板是个带有大面积模糊遮罩的全屏居中弹窗,显得特别笨重,像个垃圾网页游戏。)
**The Solution (方案)**: Refactored to a lightweight side-popover (`left: 100%; bottom: 15px;`) imitating Gemini's web UI, maintaining deep workflow immersion.
(改成绝对定位,像 Gemini 网页端那样从侧边栏右侧悄悄弹出一个悬浮气泡,点外侧自动收起,极大保证了沉浸感。)
## 24. Dock & Mode Lock (全宽底座与多状态锁死)
**The Problem (问题)**: Floating capsules looked cramped, and entering fullscreen Gallery while the Sidebar menu remained clickable caused DOM rendering conflicts.
(底部按钮挤在一起极其难看。而且进入全屏图库的时候,居然还能点左上角的汉堡菜单,导致页面状态直接崩溃交织在一起。)
**The Solution (方案)**: Extended to a 100% width solid Dock. Disabled sidebar triggers during fullscreen views via strict State Locks.
(把底部做成了满宽的实体底座,稳固了视觉重心。一旦进入图库这种排他性的全视图,强行给菜单上互斥锁,彻底断绝乱按引发的 Bug。)
## 25. Resolving "Missing" vs "Unscanned" Conflicts (防重叠逻辑拦截自相踩踏)
**The Problem (问题)**: Deleting a model and reloading the workflow popped BOTH the "Missing Model" dark modal and the "Unscanned Model" orange toast.
(把一个模型删掉后重新加载图片,系统一方面觉得“模型丢了”弹出黑框,一方面因为缓存里没它又觉得它是“没扫过的新模型”弹出橙黄色的警告,两个弹窗叠在一块极其弱智。)
**The Solution (方案)**: The unscanned detector now ignores nodes already flagged with red error colors (`node.color === "#FF3333"`), preventing system overlapping.
(加入了双轨防踩踏:未扫描检测器只要看到这个节点已经红了(彻底丢了),就主动闭嘴放弃拦截,边界极其清晰。)
## 26. The CSS `zoom` Devastation (Zoom 属性对坐标系的降维打击)
**The Problem (问题)**: Using `zoom` to scale the UI broke bounding boxes. `container.offsetHeight > window.innerHeight` caused `Y` coordinates to become negative, permanently launching the window off-screen into the void.
(早期为了偷懒,用 CSS `zoom` 属性来做 UI 缩放。结果它彻底破坏了浏览器的物理坐标系,一旦窗口过大,防越界系统就算出了个负数坐标,导致插件整个闪现飞出屏幕外,再也拖不回来了。)
**The Solution (方案)**: Banned `zoom`. Transitioned to relative scaling via `font-size: calc(16px * var(--anomalous-scale))`. Fixed bounding clamps to execute `< 0` checks last.
(彻底封杀 zoom。改用 `calc(16px * scale)` 的底层字体相对推算放大法。并且把防负数的保底检测代码调到了逻辑的最后一行,永远杜绝负数坐标。)
## 27. Vanilla JS "Fake" i18n Failure (原生状态管理漏斗下的假性汉化失效)
**The Problem (问题)**: Newly added permanent buttons ("UI Scale", "Reset") remained in Chinese when English was toggled. Since we lacked React/Vue two-way binding, they missed the global state update and got stuck.
(新加了“UI缩放”等按钮后,即使点了切换英文,它们还是中文。因为我们没有使用 Vue 的数据绑定,纯靠原生 JS。如果新加了常驻按钮,却忘了把它登记到顶层 `langBtn.onclick` 突变循环里,它就永远被隔离在旧时间线里了。)
(只要是常驻的按钮,必须强制在语言切换中枢里注册重写一遍。同时开发过程中如果遇到“代码没变”的灵异现象,第一反应永远是按 Ctrl+F5 强制破除浏览器静态缓存。)
## 28. The Image Cropping Dilemma (图库的裁切与留白博弈)
**The Problem (问题)**: Using `object-fit: cover` in the image gallery created perfectly uniform grid squares, but brutally decapitated tall images (like 9:16 vertical character portraits).
(在图库排版时,为了让网格看起来绝对整齐,使用了 `object-fit: cover`。结果导致偏长的图片(如 9:16 的人物竖图)顶部和底部被无情裁掉,经常出现“砍头”现象。)
**The Solution (方案)**: Changed to `object-fit: contain` and paired it with a deep black background (`#000`). The images now scale down to fit entirely, and the resulting letterboxing seamlessly blends into the dark UI as a natural frame.
(改为 `object-fit: contain`,完整显示图片。由于底层卡片背景本身就是极黑(`#000`),缩小后产生的黑边完美融入了 UI 背景,不仅没有任何违和感,反而像是一圈高级的画框,一举两得。)
## 29. Vanilla JS Global Click-Outside (原生悬浮面板的全局点击收起)
**The Problem (问题)**: The settings popover only closed if the user explicitly clicked the gear icon again. Clicking elsewhere on the canvas did nothing, making the popover feel rigid and intrusive.
(点击齿轮打开设置悬浮气泡后,必须再点一次齿轮才能关掉。如果随手点一下旁边或者点画布,面板居然不收回,交互显得非常死板和占眼。)
**The Solution (方案)**: Attached a temporary `mousedown` event listener to the `document` when the popover opens. If the click target is outside the modal, it triggers `display: none` and instantly `removeEventListener` to prevent memory leaks.
(面板弹出时,立刻给全局 `document` 挂载一个监听器。只要检测到鼠标点击了面板外部区域,立刻收缩面板,并且**顺手注销掉这个监听器**,实现了极度顺滑的 Gemini 式收放体验且毫无性能负担。)
## 30. Context Preservation vs UX Intention (上下文保留与用户意图的冲突)
**The Problem (问题)**: After picking a new cover from the Gallery while inside the Model Edit Modal, the UI destroyed the modal, showed the gallery, and then *restored* the modal upon completion to 'preserve context'. However, users who just wanted to change the cover felt confused, thinking they still needed to click 'Save' to apply it.
(在编辑面板中点击更换封面后,UI 会先销毁面板,进入图库,选完后再把编辑面板弹回来以“保留上下文”。但用户其实只想换个封面,重新弹出的面板反而让他们误以为必须再点一次“保存”才算数。)
**The Solution (方案)**: Respect the primary intention. When the cover is changed, directly return to the model card/grid. Do not stubbornly restore intermediate modals unless necessary.
(顺应主要意图。换完封面直接回到网格卡片,干脆利落。不要去死板地恢复中间态面板。)
## 31. The Missing Reference Crash (引用丢失导致的状态黑洞)
**The Problem (问题)**: A refactor failed to assign `this.models = data.models` in the load function. When a UI callback later tried to find the updated model using `this.models.find()`, JS threw an `undefined` error and aborted execution midway. This left the UI with both the gallery and grid hidden, resulting in a completely blank screen.
(重构时忘了一行赋值代码,导致 `this.models` 为空。后续 UI 切换时试图从中查找模型直接报错崩溃。由于崩溃发生在显示/隐藏面板的切换中途,导致所有面板都被隐藏,屏幕变成了彻底的“真空空白”。)
**The Solution (方案)**: Always ensure foundational state variables are populated before executing callbacks that depend on them.
(底层状态变量必须绝对可靠,一旦中途崩溃,UI 的半途状态是毁灭性的。)
## 32. Backup Entanglement of Non-Platform Models (非平台模型的备份污染)
**The Problem (问题)**: To ensure a 'Reset' always had a fallback, changing any cover backed up the original file to `.civitai_bak`. For non-Civitai models, this meant user's manual `.png` covers were forcefully renamed to `.civitai_bak`, causing severe semantic confusion.
(为了保证“重置”功能永远有退路,之前的逻辑会把任何被替换的封面强行改名为 `.civitai_bak`。这就导致用户自己放的非 C 站模型封面,也被莫名其妙打上了 C 站的烙印,逻辑极其混乱。)
**The Solution (方案)**: Decouple custom covers by strictly saving them as `.preview.*` and leaving original standard `.png` files untouched. Resetting now just deletes the `.preview.*` file, naturally falling back to the untouched original, or copying the true `.civitai_bak` if it actually came from Civitai.
(让自定义封面独占 `.preview.*` 后缀,绝不触碰原有的标准封面。重置时只需删掉自定义后缀,系统就会自然回退。彻底切断了非 C 站模型与 C 站备份标识的无理纠缠。)
## 33. DOM Video Audio Leaks on innerHTML Clear (DOM 销毁导致的幽灵音频泄漏)
**The Problem (问题)**: When navigating from a model detail view containing an actively playing <video> to another model, setting container.innerHTML = '' removed the video from the DOM, but its audio continued playing infinitely in the background because the browser engine did not immediately garbage-collect the orphaned playing media.
(在模型详细页如果有视频正在播放,当用户点击兼容模型跳转到另一个模型时,虽然通过 innerHTML = '' 清空了面板,DOM 节点被移除了,但由于浏览器没有立刻回收这个还在播放的媒体对象,导致旧视频的音频在后台像幽灵一样继续循环播放,并且再也无法通过 UI 停止。)
**The Solution (方案)**: Implemented a strict cleanup lifecycle. Before destroying or overwriting a container's DOM, explicitly query all ideo and udio elements within it, invoke .pause(), and remove their src attributes to force the browser to release the media resources instantly.
(引入了严格的 DOM 销毁生命周期。在清空任何面板前,必须先获取里面所有的音视频元素,显式调用 .pause() 并剥夺它们的 src,强制浏览器当场释放媒体资源,彻底杜绝后台泄漏。)
## 34. The Search Bar "Hidden State" Trap (搜索框的隐藏状态盲区)
**The Problem (问题)**: Users typing in the global search bar while viewing a Model Detail panel thought search was broken because the filtered results were successfully generated but the grid container was hidden (`display: none`).
(用户在模型详细页里直接使用全局搜索框搜索,后台成功过滤了网格卡片,但因为当前网格容器是隐藏状态,用户看不见结果,误以为搜索功能坏了。)
**The Solution (方案)**: Added an explicit state-exit in the `searchInput.oninput` event. If a detail panel is open, it destroys the panel and forces the grid back to `display: grid` so results are immediately visible.
(在搜索输入事件中强制介入。只要开始搜索,立刻销毁详情面板并强行把网格视图切换回可见状态,保证搜索结果永远“所见即所得”。)
## 35. The Metadata Overwrite Paradox (伪元数据强行覆盖的惨案)
**The Problem (问题)**: A background script updating a model's custom name without fetching full Civitai data wrote a `.civitai.info` file containing *only* the custom name. This obliterated the real Civitai data (description, tags) and caused the frontend to render an empty detail page.
(后台在更新自定义名字时,遇到了一个没有 `.info` 的模型,直接暴力生成了一个只包含自定义名字的 `.civitai.info`。结果反而把原本应有的全量 C 站详情数据给“排挤”掉了,导致详细页一片空白。)
**The Solution (方案)**: Never trust partial updates to structural files. If it's corrupted or just a stub, delete it so the frontend can freshly pull real data from the Civitai API via the Scan Wizard.
(对于结构性文件,宁缺毋滥。发现被污染成只有两个空字段的残疾文件后直接删除,让向导重新去 C 站抓取原汁原味的完整数据。)
## 36. The API Response Mismatch & Cache Deadlock (前后端格式割裂与缓存死锁)
**The Problem (问题)**: `api.py` was updated to send an array of strings (`['/view?...']`) but `main.js` expected objects (`[{url: '/view?...'}]`). When trying to fix `main.js`, the browser's aggressive caching of ComfyUI extensions meant the fix never propagated. Changing `api.py` to match the cached JS failed until the ComfyUI server was restarted, causing a "nothing works" deadlock.
(后端发送字符串,前端却按对象去读取,导致图片全部变成 `undefined` 的隐形方块。试图修改前端修复时,遇到浏览器死忠缓存;试图修改后端去迎合缓存时,又遇到 ComfyUI 不重启不重新加载后端的死锁,导致两头受堵。)
**The Solution (方案)**: Implement "Double-Blind Compatibility". The backend strictly returns structural objects `{'url': ...}` to satisfy legacy cached frontends, and the frontend JS falls back via `el.src = img.url || img;` to parse anything. Most importantly: ALWAYS restart ComfyUI completely when touching backend Python APIs.
(实施"双盲兼容"。后端退回发送对象格式来迎合那些死活不肯刷新的旧版缓存;前端同时加上 `img.url || img` 双轨读取能力。最核心的教训:只要改了后端的 Python 文件,别折腾了,必须彻底重启整个控制台黑框。)
## 37. The Identifier Collision Catastrophe (变量重名引发的插件暴毙)
**The Problem (问题)**: When moving the "Model Doctor" button from TopNav to the Sidebar, a new `const doctorBtn` declaration was injected without removing the original one. Since both lived in the same function scope, JavaScript threw `SyntaxError: Identifier 'doctorBtn' has already been declared` at parse time, which silently killed the **entire** plugin before any code executed — including the trigger icon.
(把"模型医生"按钮从顶部导航栏搬到底部侧边栏时,新代码里声明了一个新的 `const doctorBtn`,但旧的那个忘删了。由于两个 `const` 在同一个函数作用域里,JS 直接在编译阶段就报语法错误,整个插件的代码一行都不会执行——连那个 📦 入口图标都跟着蒸发了。)
**The Solution (方案)**: When relocating UI elements across DOM zones, always perform a full-text search for the old declaration and its associated event handlers. Remove **both** the element creation AND its `appendChild` call. A single leftover `const` can nuke an entire 5000-line module.
(搬家 UI 元素时,必须全局搜索旧的变量声明和所有关联调用(创建、事件绑定、appendChild),全部清理干净。一个多余的 `const` 就能让整个 5000 行模块原地爆炸。)
## 38. The `this` vs Closure Scope Mismatch (this 指向错误导致的静默失败)
**The Problem (问题)**: `hideAllPanels()` was defined as a plain `const` closure function inside `createDOM()`, but the doctor button's click handler called it as `this.hideAllPanels()`. Since it was never defined as a class method, `this.hideAllPanels` resolved to `undefined`, and calling `undefined()` threw a TypeError — silently aborting the entire click handler. The doctor panel never became visible, and the node listener never activated, making it look like the button "did nothing".
(`hideAllPanels()` 是 `createDOM()` 内部的一个普通闭包函数,但医生按钮的点击回调里错误地用 `this.hideAllPanels()` 去调用它。由于它并不是类方法,`this.hideAllPanels` 为 `undefined`,直接抛出 TypeError。这个错误静默中断了整个 onclick 执行链——面板没打开、节点监听没激活,用户只看到"按钮点了没反应"。)
**The Solution (方案)**: Audit every function reference in click handlers. If it's a closure-scoped `const`, call it directly (`hideAllPanels()`). If it's a class method, call via `this.`. Mixing the two is the most common silent-failure trap in Vanilla JS class patterns.
(必须逐一审计点击回调里的每个函数引用。闭包函数直接裸调,类方法才走 `this.`。两者混用是原生 JS 类模式中最常见的静默失败陷阱。)
## 39. The Tool-Escaped Backslash Corruption (编辑工具转义反斜杠的连环陷阱)
**The Problem (问题)**: When fixing a Python `str.replace('\\', '/')` call via code-editing tools, the tool's internal escaping mechanism quadrupled the backslashes: the file ended up containing `replace('\\\\', '/')`, which searches for a **four-character** literal string `\\\\` that never exists in real Windows paths. The fix was invisible because the diff output *looked* correct due to terminal rendering also escaping backslashes. This wasted two full restart-debug cycles.
(通过代码编辑工具修复 Python 的 `replace('\\', '/')` 时,工具内部的转义机制把反斜杠翻了四倍:文件里实际写入的是 `replace('\\\\\\\\', '/')`,也就是在搜索一个根本不存在的四字符字符串。更可怕的是,diff 输出看起来是对的,因为终端渲染本身也会做转义,所以肉眼完全看不出问题。白白浪费了两轮重启调试。)
**The Solution (方案)**: **Never use hardcoded backslash literals in Python path operations when editing through intermediary tools.** Use `os.sep` or `os.path.normpath()` instead. These are immune to any escaping chain and are also cross-platform correct. Always verify the actual file content with `view_file` after editing path-related strings.
(通过中间工具编辑路径代码时,**永远不要手写反斜杠字面量**。用 `os.sep` 或 `os.path.normpath()` 代替,它们对任何转义链路完全免疫,且天然跨平台。编辑完路径相关字符串后,务必用 `view_file` 验证文件里的实际内容。)
## 40. The Uninitialized State Guard (未初始化状态的跨入口崩溃)
**The Problem (问题)**: When a user clicked "View Model Profile" from the Model Doctor panel *without ever having opened the main browser grid*, `showDetail()` → `renderSidebar()` was called. But `renderSidebar()` internally accessed `this.foldersData.length`, and since `loadFolders()` (which populates `foldersData`) only runs when the main grid initializes, `foldersData` was `undefined`. The resulting `Cannot read properties of undefined (reading 'length')` error aborted the entire transition. Similarly, `this.historyStack` could be undefined if accessed before the class constructor's initialization path completed.
(用户直接从模型医生面板点击"查看模型档案",但此时**从未打开过主浏览器网格**。`showDetail()` 内部调用了 `renderSidebar()`,而 `renderSidebar()` 访问了 `this.foldersData.length`。由于 `loadFolders()`(负责填充 `foldersData`)只在主网格初始化时运行,此时 `foldersData` 还是 `undefined`,直接报错崩溃。类似地,`historyStack` 如果在构造函数初始化路径完成前被访问,也会 undefined。)
**The Solution (方案)**: Any cross-entry-point function (accessible from multiple UI paths like Doctor → Detail, or Grid → Detail) must defensively guard ALL state dependencies. Use `this.historyStack = this.historyStack || [];` and `if (this.foldersData) this.renderSidebar();`. Never assume the "happy path" initialization order.
(任何可以从多个 UI 入口调用的函数,必须对所有状态依赖做防御性守卫。用 `this.x = this.x || []` 和 `if (this.x)` 兜底。永远不要假设"正常流程"的初始化顺序。)
## 41. Ternary String Replace Crash (暴力替换引发的连锁崩溃)
**The Problem (问题)**: Using Python `code.replace()` to globally replace localized strings with `window.anomalous_browser_lang === 'zh' ? ... : ...` was efficient but dangerous. A previous script accidentally deleted the declaration of `const checkUnscannedBtnRef` in the language toggle handler while doing a replacement. The missing declaration caused a `ReferenceError`, which crashed the `langBtn.onclick` event silently halfway through execution. This left the user permanently stuck in an English interface with no way to switch back, as the re-render code was never reached.
(在使用脚本暴力全局替换双语三元运算符时,不小心干掉了某一个不相干按钮的 const 声明,导致点击语言切换时直接触发 `ReferenceError` 崩溃。最终结果是,界面再也无法切换回中文,用户被死死锁在英文界面里且没有任何明显报错。)
**The Solution (方案)**: Always test interactive elements (like Language toggles) thoroughly after bulk string replacements. If a bulk replace is needed, verify syntax with `node -c web/main.js` and double-check variable scopes.
(每次用脚本跑完正则替换后,一定要检查所有的点击事件是否还能完整跑完。或者用 `node -c` 检查语法。)
## 42. The CSS ID "Display None" Trap (幽灵般的 CSS ID 冲突)
**The Problem (问题)**: We tried to fix a dynamic translation bug by adding an ID to a DOM element (`settingsBtn.id = 'anomalous-settings-btn'`). However, the original developer had previously written a CSS rule `#anomalous-settings-btn { display: none !important; }` in `styles.css` (inside a narrow-mode `@container` query). By adding the ID, we unintentionally activated the CSS trap, causing the settings button to vanish completely!
(为了让 JS 能够抓取齿轮按钮更新中英文,我们给它加上了 `anomalous-settings-btn` 这个 ID。没想到 CSS 样式表里竟然潜伏着一条专门针对这个 ID 的 `display: none` 隐藏规则。加上 ID 的瞬间,齿轮按钮就因为命中规则而原地蒸发了!)
**The Solution (方案)**: Never assign generic DOM IDs without `grep` searching the `.css` files first! We changed the ID to `anomalous-global-settings-btn`, completely sidestepping the hidden CSS rule.
(在给已有组件加上新的 ID 时,必须先搜索整个 `.css` 文件确认是否撞名!最终我们用 `anomalous-global-settings-btn` 作为新 ID 绕过了这个地雷。)
## 43. The Hash Injection Toggle Misunderstanding (哈希注入总闸的认知错位)
**The Problem (问题)**: The system relied on `anomalous_inject_hash` (Model Provenance Binding) to inject and read hashes. Users misunderstood this toggle, thinking they could turn it off and still use the Model Doctor to auto-fix nodes. Without the injected hashes, the Model Doctor was completely blind.
(用户误以为关掉“模型溯源绑定”不影响模型医生的工作,结果关掉后生成的工作流完全没有哈希值,导致不论怎么用深度扫描,医生都无法替换爆红节点,形成了一个逻辑认知死锁。)
**The Solution (方案)**: Explicitly documented in the README and Help Panel that the Model Doctor STRICTLY REQUIRES workflows to be exported with this plugin's hash injection enabled.
(在所有文档和帮助面板中加上极其醒目的警告,明确指出模型医生的前提条件,打破用户的认知盲区。)
## 44. UI Documentation Factual Disconnect (UI 文档的物理错位)
**The Problem (问题)**: The `Deep Hash Scan` button was physically moved to the `Model Doctor` panel, but the README and in-app Help Panel still told users to find it in the `Scan Wizard` / `Settings` panel.
(把“深度哈希扫描”按钮从设置面板搬到了模型医生面板里,但说明书和 UI 帮助文本没跟着改,还在让用户去设置面板里找,导致用户完全找不到。)
**The Solution (方案)**: Restructured the documentation into a strict visual-flow-based SOP (Standard Operating Procedure). Every UI feature mentioned in the docs is now paired with its exact corresponding Emoji icon (e.g., 🩺, 🤖) and location, completely eliminating navigational ambiguity.
(按视觉动线重构了整份说明书。给文档里提到的每一个按钮都加上了精准的 Emoji 图标(如 🩺、🤖)和精确位置定位,实现了文档与界面的 100% 物理映射。)
+106 -43
View File
@@ -17,24 +17,46 @@ import urllib.error
import urllib.parse
import argparse
import shutil
from typing import Dict, Optional
from typing import Dict, Optional
# Fixed tuples avoid rebuilding long extension lists for every scanned model.
# Exact-path checks are intentionally used instead of directory-wide globbing.
MEDIA_EXTENSIONS = (".png", ".jpg", ".jpeg", ".webp", ".gif", ".avif", ".mp4", ".webm", ".mov", ".avi")
PREVIEW_SUFFIXES = tuple(f".preview{ext}" for ext in MEDIA_EXTENSIONS)
CIVITAI_BACKUP_SUFFIXES = tuple(f".civitai_bak{ext}" for ext in MEDIA_EXTENSIONS)
COVER_SUFFIXES = MEDIA_EXTENSIONS + PREVIEW_SUFFIXES
ACTIVE_COVER_SUFFIXES = PREVIEW_SUFFIXES + MEDIA_EXTENSIONS
SIDECAR_SUFFIXES = (
".info", ".civitai.info", ".json", ".txt", ".yaml",
*MEDIA_EXTENSIONS,
*PREVIEW_SUFFIXES,
*CIVITAI_BACKUP_SUFFIXES,
)
# ==============================================================================
# CIVITAI API 配置读取
# 请在插件目录 (Anomalous_Model_Browser) 下新建 config.json 文件:
# { "CIVITAI_API_KEY": "你的KEY" }
# ==============================================================================
CIVITAI_API_KEY = None
config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json")
if os.path.exists(config_path):
try:
with open(config_path, 'r', encoding='utf-8') as f:
cfg = json.load(f)
CIVITAI_API_KEY = cfg.get("CIVITAI_API_KEY", "").strip()
except Exception as e:
print(f"[-] 读取 config.json 失败: {e}")
else:
print("[!] 未找到 config.json,本次请求将不使用 API Key。部分限制级模型或将无法获取图片。")
CIVITAI_API_KEY = None
plugin_dir = os.path.dirname(os.path.abspath(__file__))
config_paths = [os.path.join(plugin_dir, "api", "config.json"), os.path.join(plugin_dir, "config.json")]
for config_path in config_paths:
if not os.path.exists(config_path):
continue
try:
with open(config_path, 'r', encoding='utf-8') as f:
cfg = json.load(f)
configured_key = cfg.get("CIVITAI_API_KEY", "")
if isinstance(configured_key, str) and configured_key.strip():
CIVITAI_API_KEY = configured_key.strip()
break
except Exception as e:
print(f"[-] 读取 config.json 失败: {e}")
if not CIVITAI_API_KEY:
print("[!] 未配置 Civitai API Key。部分限制级模型或将无法获取图片。")
def calculate_sha256(file_path: str) -> str:
"""计算文件的 SHA256 哈希值 (用于 Civitai 匹配)"""
@@ -72,11 +94,6 @@ def extract_safetensors_hash(file_path: str) -> Optional[str]:
if 'modelspec.hash.blake3' in metadata:
return metadata['modelspec.hash.blake3']
# 优先级2: 其他带 hash 的键
for k, v in metadata.items():
if ('hash' in k.lower() or 'civitai' in k.lower()) and isinstance(v, str):
if len(v) == 64 and all(c in '0123456789abcdefABCDEF' for c in v):
return v
except Exception as e:
pass
return None
@@ -200,6 +217,9 @@ def main():
parser.add_argument("--physical-rename", action="store_true", help="物理重命名:真实修改底层的 safetensors 及其附属文件名")
parser.add_argument("--skip-media", action="store_true", help="不下载预览图或视频")
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()
target_folder = args.folder
@@ -232,7 +252,7 @@ def main():
old_base = os.path.splitext(old_path)[0]
new_base = os.path.splitext(new_path)[0]
for ext in [".info", ".civitai.info", ".json", ".txt", ".yaml", ".png", ".jpg", ".jpeg", ".webp", ".gif", ".avif", ".mp4", ".webm", ".mov", ".avi", ".preview.png", ".preview.jpg", ".preview.jpeg", ".preview.webp", ".preview.gif", ".preview.avif", ".preview.mp4", ".preview.webm", ".preview.mov", ".preview.avi", ".civitai_bak.png", ".civitai_bak.jpg", ".civitai_bak.jpeg", ".civitai_bak.webp", ".civitai_bak.gif", ".civitai_bak.avif", ".civitai_bak.mp4", ".civitai_bak.webm", ".civitai_bak.mov", ".civitai_bak.avi"]:
for ext in SIDECAR_SUFFIXES:
new_ext_path = new_base + ext
old_ext_path = old_base + ext
if os.path.exists(new_ext_path):
@@ -253,6 +273,19 @@ def main():
with open(backup_log_path, 'r', encoding='utf-8') as f:
rename_log = json.load(f)
target_files_basenames = []
target_file_path = os.path.join(target_folder, '.scan_targets.json')
if os.path.exists(target_file_path):
try:
with open(target_file_path, 'r', encoding='utf-8') as f:
target_files_basenames = [os.path.basename(t.strip()) for t in __import__('json').load(f)]
os.remove(target_file_path)
except:
pass
if args.target_files:
target_files_basenames.extend([os.path.basename(t.strip()) for t in args.target_files.split(',')])
print(f"[*] 开始扫描文件夹: {target_folder}")
if args.dry_run:
print("==================================================")
@@ -264,13 +297,19 @@ def main():
if not filename.endswith(".safetensors"):
continue
if target_files_basenames and filename not in target_files_basenames:
continue
file_path = os.path.join(root, filename)
old_base = os.path.splitext(file_path)[0]
info_exists = os.path.exists(old_base + ".info") or os.path.exists(old_base + ".civitai.info")
if args.force_overwrite:
info_exists = False
preview_exists = args.skip_media
if not preview_exists:
for ext in [".png", ".jpg", ".jpeg", ".webp", ".gif", ".avif", ".mp4", ".webm", ".mov", ".avi", ".preview.png", ".preview.jpg", ".preview.jpeg", ".preview.webp", ".preview.gif", ".preview.avif", ".preview.mp4", ".preview.webm", ".preview.mov", ".preview.avi"]:
for ext in COVER_SUFFIXES:
if os.path.exists(old_base + ext):
preview_exists = True
break
@@ -392,10 +431,10 @@ def main():
else:
print(f"[Dry-Run] 拟生成标准描述信息 -> .info")
images = civitai_data.get("images", [])
if images and len(images) > 0:
media_url = None
for img_obj in images:
media_url = None
images = civitai_data.get("images", [])
if images and len(images) > 0:
for img_obj in images:
if not args.skip_media:
if img_obj.get("url"):
media_url = img_obj.get("url")
@@ -409,14 +448,24 @@ def main():
print(f"[+] 媒体下载成功 -> {os.path.basename(saved_path)}")
# Promote to .preview if no custom cover exists
has_custom = False
for c_ext in ['.preview.png', '.preview.jpg', '.preview.jpeg', '.preview.webp', '.preview.gif', '.preview.avif', '.preview.mp4', '.preview.webm', '.preview.mov', '.preview.avi', '.png', '.jpg', '.jpeg', '.webp', '.gif', '.avif', '.mp4', '.webm', '.mov', '.avi']:
p = old_base + c_ext
if os.path.exists(p) and not p.endswith('.civitai_bak' + c_ext):
has_custom = True
break
if not args.force_overwrite:
for c_ext in ACTIVE_COVER_SUFFIXES:
p = old_base + c_ext
if os.path.exists(p) and not p.endswith('.civitai_bak' + c_ext):
has_custom = True
break
if not has_custom:
ext = os.path.splitext(saved_path)[1]
import shutil
if args.force_overwrite:
for c_ext in ACTIVE_COVER_SUFFIXES:
p = old_base + c_ext
if os.path.exists(p) and not p.endswith('.civitai_bak' + c_ext):
try:
os.remove(p)
print(f"[*] 强制覆盖: 已删除旧预览文件 {os.path.basename(p)}")
except:
pass
preview_ext = ext if ext.startswith('.preview.') else f".preview{ext}"
shutil.copy2(saved_path, old_base + preview_ext)
else:
@@ -426,31 +475,45 @@ def main():
print(f"[*] 物理重命名已跳过。仅保存 .info 及其可能包含的虚拟重命名。")
success_count += 1
elif file_path != new_file_path and new_filename != filename:
if os.path.exists(new_file_path):
print(f"\033[91m[-] 目标版本已存在 (重复模型),执行永久删除多余副本: {filename}\033[0m")
if not args.dry_run:
try:
os.remove(file_path)
for ext in [".info", ".civitai.info", ".json", ".txt", ".yaml", ".png", ".jpg", ".jpeg", ".webp", ".gif", ".avif", ".mp4", ".webm", ".mov", ".avi", ".preview.png", ".preview.jpg", ".preview.jpeg", ".preview.webp", ".preview.gif", ".preview.avif", ".preview.mp4", ".preview.webm", ".preview.mov", ".preview.avi", ".civitai_bak.png", ".civitai_bak.jpg", ".civitai_bak.jpeg", ".civitai_bak.webp", ".civitai_bak.gif", ".civitai_bak.avif", ".civitai_bak.mp4", ".civitai_bak.webm", ".civitai_bak.mov", ".civitai_bak.avi"]:
if os.path.exists(new_file_path):
print(f"[*] 目标文件名已存在,正在验证内容是否完全相同: {filename}")
try:
files_identical = os.path.getsize(file_path) == os.path.getsize(new_file_path)
if files_identical:
files_identical = calculate_sha256(file_path) == calculate_sha256(new_file_path)
except OSError:
files_identical = False
if not files_identical:
print(f"\033[91m[-] 目标名称冲突但文件内容不同,已保留两个模型并跳过重命名: {filename}\033[0m")
fail_count += 1
elif not args.dry_run:
try:
print(f"[*] Hash 一致,删除已确认的重复副本: {filename}")
os.remove(file_path)
for ext in SIDECAR_SUFFIXES:
old_ext = old_base + ext
if os.path.exists(old_ext):
os.remove(old_ext)
except Exception as e:
print(f"[-] 删除多余副本失败 (可能文件被占用): {e}")
else:
print(f"[Dry-Run] 拟永久删除多余副本及其附属文件: {filename}")
else:
os.remove(old_ext)
success_count += 1
except Exception as e:
print(f"[-] 删除多余副本失败 (可能文件被占用): {e}")
fail_count += 1
else:
print(f"[Dry-Run] Hash 一致,拟删除重复副本及其附属文件: {filename}")
else:
if not args.dry_run:
os.rename(file_path, new_file_path)
rename_log[file_path] = new_file_path
for ext in [".info", ".civitai.info", ".json", ".txt", ".yaml", ".png", ".jpg", ".jpeg", ".webp", ".gif", ".avif", ".mp4", ".webm", ".mov", ".avi", ".preview.png", ".preview.jpg", ".preview.jpeg", ".preview.webp", ".preview.gif", ".preview.avif", ".preview.mp4", ".preview.webm", ".preview.mov", ".preview.avi", ".civitai_bak.png", ".civitai_bak.jpg", ".civitai_bak.jpeg", ".civitai_bak.webp", ".civitai_bak.gif", ".civitai_bak.avif", ".civitai_bak.mp4", ".civitai_bak.webm", ".civitai_bak.mov", ".civitai_bak.avi"]:
for ext in SIDECAR_SUFFIXES:
old_ext = old_base + ext
new_ext = new_base + ext
if os.path.exists(old_ext):
os.replace(old_ext, new_ext)
print(f"[+] 物理重命名完成: {filename} ==> {new_filename}")
print(f"[+] 物理重命名完成: {filename} ==> {new_filename}")
success_count += 1
else:
print(f"[Dry-Run] 拟物理重命名文件: {filename} ==> {new_filename}")
print(f"[Dry-Run] 拟连带重命名附属文件 (.info / .png 等)")
@@ -474,4 +537,4 @@ def main():
print(f"\n[+] 重命名映射日志已保存至: {backup_log_path}")
if __name__ == "__main__":
main()
main()
+190 -80
View File
@@ -1,10 +1,11 @@
import { app } from "../../scripts/app.js";
import { app } from "../../scripts/app.js";
import { translate } from './modules/locales.js';
// Global cache for hashes: filename -> hash
window.anomalous_hash_cache = window.anomalous_hash_cache || {};
// Function to update cache, can be called from main.js
window.anomalous_update_hash_cache = function (models) {
window.anomalous_update_hash_cache = function (models) {
if (!models) return;
for (const m of models) {
if (m.filename && m.metadata && m.metadata.hash) {
@@ -13,8 +14,21 @@ window.anomalous_update_hash_cache = function (models) {
size: m.size_bytes || ""
};
}
}
};
}
};
function inferExpectedModelTypes(node, widget) {
const widgetName = String(widget?.name || '').toLowerCase();
const nodeType = String(node?.type || '').toLowerCase();
const key = `${widgetName} ${nodeType}`;
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 '';
}
window.anomalous_resolve_all_missing_nodes = async function (is_manual = false, silent = false) {
if (!is_manual && localStorage.getItem('anomalous_auto_scan_enabled') !== 'true') return;
@@ -52,20 +66,95 @@ window.anomalous_resolve_all_missing_nodes = async function (is_manual = false,
}
}
// Always fetch latest hashes before trying to resolve (using cache-buster to avoid stale hashes)
try {
const res = await fetch('/anomalous/all_hashes?t=' + Date.now());
const data = await res.json();
const hashesObj = data.hashes ? data.hashes : data;
Object.assign(window.anomalous_hash_cache, hashesObj);
} catch (e) {
console.warn("[Anomalous] Failed to fetch hashes for manual resolution", e);
}
let fixed_count = 0;
const hashes = (app.graph.extra && app.graph.extra.anomalous_hashes) || {};
for (const node of app.graph._nodes) {
const hashes = (app.graph.extra && app.graph.extra.anomalous_hashes) || {};
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}`]) {
needsGlobalHashRefresh = true;
break provenanceCheck;
}
}
}
// Provenance-rich workflows already carry the identity data needed by the
// doctor. Refresh the full local filename->hash cache only for legacy items
// that actually need that fallback.
if (needsGlobalHashRefresh) {
try {
const res = await fetch('/anomalous/all_hashes?t=' + Date.now());
const data = await res.json();
const hashesObj = data.hashes ? data.hashes : data;
Object.assign(window.anomalous_hash_cache, hashesObj);
} catch (e) {
console.warn("[Anomalous] Failed to fetch hashes for manual resolution", e);
}
}
let fixed_count = 0;
const findHashData = (node, widget, value) => {
let hashData = hashes[`${node.id}_${value}`];
if (!hashData && window.anomalous_hash_cache) {
const parts = value.split(/[/\\]/);
const basename = parts[parts.length - 1];
const cacheData = window.anomalous_hash_cache[value] || window.anomalous_hash_cache[basename];
if (cacheData) hashData = typeof cacheData === 'string' ? { hash: cacheData, size: "" } : cacheData;
}
return hashData;
};
// Resolve all model references in one request. The backend groups items by
// required model category and scans each category once. Native slash-only
// normalization remains in the existing loop below and needs no disk scan.
const batchItems = [];
for (const node of app.graph._nodes) {
if (!node.widgets) continue;
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 (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);
if (nativeMatch && nativeMatch !== value) continue;
}
const hashData = findHashData(node, widget, value);
if (!hashData) continue;
const hash = typeof hashData === 'string' ? hashData : (hashData.hash || "");
const size = typeof hashData === 'string' ? "" : (hashData.size || "");
if (!hash && !size) continue;
batchItems.push({
key: `${node.id}:${i}:${value}`,
hash,
size,
type: inferExpectedModelTypes(node, widget)
});
}
}
const batchResults = new Map();
if (batchItems.length) {
try {
for (let offset = 0; offset < batchItems.length; offset += 256) {
const batchResponse = await fetch('/anomalous/resolve_hash_batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items: batchItems.slice(offset, offset + 256) })
});
if (!batchResponse.ok) throw new Error(`HTTP ${batchResponse.status}`);
const batchData = await batchResponse.json();
for (const entry of (batchData.results || [])) batchResults.set(entry.key, entry.result);
}
} catch (error) {
console.warn('[Anomalous Hash Resolver] Batch resolution failed; falling back to single-item requests.', error);
}
}
for (const node of app.graph._nodes) {
if (node.widgets) {
for (let i = 0; i < node.widgets.length; i++) {
const w = node.widgets[i];
@@ -79,36 +168,41 @@ window.anomalous_resolve_all_missing_nodes = async function (is_manual = false,
if (exactMatch && exactMatch !== val) {
console.log(`[Anomalous Hash Resolver] Fixed slash mismatch: ${val} -> ${exactMatch}`);
w.value = exactMatch;
if (node.color === "#FF3333" || node.bgcolor === "#FF3333" || node.color === "#f66") {
delete node.color;
delete node.bgcolor;
const wIdx = node.widgets.indexOf(w);
if (wIdx !== -1 && node.widgets_values) {
node.widgets_values[wIdx] = exactMatch;
}
delete node.color;
delete node.bgcolor;
node.has_errors = false;
if (app.lastNodeErrors && app.lastNodeErrors[node.id]) {
delete app.lastNodeErrors[node.id];
}
if (w.callback) {
w.callback(w.value, app.canvas, node, app.canvas.graph_mouse, null);
}
app.graph.setDirtyCanvas(true, true);
fixed_count++; // Mark as fixed so the global graph update is triggered!
continue; // Already fixed natively, skip backend lookup
}
}
let hashData = hashes[`${node.id}_${val}`];
const hashData = findHashData(node, w, val);
// Fallback: If graph wasn't saved with hashes, check if the global cache knows this filename
if (!hashData && window.anomalous_hash_cache) {
const parts = val.split(/[/\\]/);
const basename = parts[parts.length - 1];
const cache_data = window.anomalous_hash_cache[basename] || window.anomalous_hash_cache[val];
if (cache_data) {
hashData = typeof cache_data === 'string' ? { hash: cache_data, size: "" } : cache_data;
}
}
if (hashData) {
let h = typeof hashData === 'string' ? hashData : hashData.hash;
let s = typeof hashData === 'string' ? "" : (hashData.size || "");
try {
const res = await fetch(`/anomalous/resolve_hash?hash=${encodeURIComponent(h)}&size=${encodeURIComponent(s)}`);
const resData = await res.json();
if (hashData) {
let h = typeof hashData === 'string' ? hashData : hashData.hash;
let s = typeof hashData === 'string' ? "" : (hashData.size || "");
try {
const expectedTypes = inferExpectedModelTypes(node, w);
const typeQuery = expectedTypes ? `&type=${encodeURIComponent(expectedTypes)}` : '';
const batchKey = `${node.id}:${i}:${val}`;
let resData = batchResults.get(batchKey);
if (!batchResults.has(batchKey)) {
const res = await fetch(`/anomalous/resolve_hash?hash=${encodeURIComponent(h)}&size=${encodeURIComponent(s)}${typeQuery}`);
resData = await res.json();
}
if (resData.found) {
const normVal = val.replace(/\\/g, '/');
@@ -126,24 +220,32 @@ window.anomalous_resolve_all_missing_nodes = async function (is_manual = false,
}
if (finalValue !== val || normRes !== normVal || optionsCacheStale || node.has_errors || node.color) {
if (optionsCacheStale) {
console.log(`[Anomalous Hash Resolver] Found ${finalValue} on disk, but ComfyUI frontend dropdown is stale. Forcing backend cache clear...`);
try {
await fetch('/anomalous/clear_cache', { method: 'POST' });
await app.refreshComboInNodes();
// Re-evaluate exact match after refreshing
if (w.options && w.options.values) {
const newMatch = w.options.values.find(v => typeof v === 'string' && v.replace(/\\/g, '/') === normRes);
if (newMatch) finalValue = newMatch;
else w.options.values.push(finalValue); // Absolute fallback
}
} catch (e) {
console.warn("Failed to clear cache:", e);
}
}
if (optionsCacheStale) {
console.log(`[Anomalous Hash Resolver] Found ${finalValue} on disk, but ComfyUI frontend dropdown is stale. Forcing backend cache clear...`);
let refreshedMatch = null;
try {
await fetch('/anomalous/clear_cache', { method: 'POST' });
await app.refreshComboInNodes();
// Re-evaluate exact match after refreshing
if (w.options && w.options.values) {
refreshedMatch = w.options.values.find(v => typeof v === 'string' && v.replace(/\\/g, '/') === normRes) || null;
}
} catch (e) {
console.warn("Failed to clear cache:", e);
}
if (!refreshedMatch) {
console.warn(`[Anomalous Hash Resolver] Rejected cross-type or invalid model path for ${node.type}.${w.name}: ${finalValue}`);
continue;
}
finalValue = refreshedMatch;
}
console.log(`[Anomalous Hash Resolver] Auto-fixed missing model: ${val} -> ${finalValue}`);
w.value = finalValue;
const wIdx = node.widgets.indexOf(w);
if (wIdx !== -1 && node.widgets_values) {
node.widgets_values[wIdx] = finalValue;
}
delete node.color;
delete node.bgcolor;
node.has_errors = false;
@@ -158,7 +260,7 @@ window.anomalous_resolve_all_missing_nodes = async function (is_manual = false,
}
} catch (err) {
console.error("[Anomalous Hash Resolver] Error:", err);
}
}
}
@@ -174,7 +276,9 @@ window.anomalous_resolve_all_missing_nodes = async function (is_manual = false,
} catch (e) { }
// Deep clear ComfyUI native error caches
if (app.lastNodeErrors) app.lastNodeErrors = null;
if (app.lastNodeErrors) {
Object.keys(app.lastNodeErrors).forEach(key => delete app.lastNodeErrors[key]);
}
if (typeof app.clearErrors === 'function') app.clearErrors();
// Note: We removed the aggressive "ghost clicker" that automatically clicked the Refresh button in the Vue side panel.
@@ -183,17 +287,9 @@ window.anomalous_resolve_all_missing_nodes = async function (is_manual = false,
}
if (is_manual && !silent) {
// Detect current UI language (Default to 'zh' if not set)
// 获取当前界面语言(未设置时默认使用中文)
const lang = (window.anomalous_browser_lang === 'en') ? 'en' : 'zh';
if (fixed_count > 0) {
if (lang === 'en') {
alert(`🪄 Anomalous successfully fixed ${fixed_count} missing model(s)!\n\n💡 Tip: If you still see errors in the "Workflow Overview" side panel, simply click its [Refresh] button to clear them.`);
} else {
alert(`🪄 Anomalous 成功修复了 ${fixed_count} 个缺失的模型!\n\n💡 提示:如果侧边栏【工作流总览】中依然显示红色报错,顺手点击一下该面板里的【刷新】按钮即可清除。`);
}
}
if (fixed_count > 0) {
alert(translate('hashResolverFixed', { count: fixed_count }));
}
}
};
@@ -204,7 +300,7 @@ app.registerExtension({
async setup() {
// Expose global reload function so scans can trigger it
window.anomalous_reload_hashes = async function() {
window.anomalous_reload_hashes = async function () {
try {
const resp = await fetch('/anomalous/all_hashes');
const data = await resp.json();
@@ -220,11 +316,25 @@ app.registerExtension({
// Pre-fetch all hashes on startup so that dragging generated images (without opening UI) still intercepts
await window.anomalous_reload_hashes();
// Intercept graph serialization to inject hashes
const origSerialize = LGraph.prototype.serialize;
window.anomalous_has_warned_unscanned = false;
window.anomalous_unscanned_models = [];
LGraph.prototype.serialize = function () {
// Intercept graph serialization to inject hashes. ComfyUI 0.27 no
// longer guarantees a global LGraph symbol, so prefer the graph's
// actual constructor and fail closed if the graph API is unavailable.
// This resolver is optional and must never prevent the main browser
// extension from registering its visible entry point.
const graphClass = [app.graph?.constructor, globalThis.LGraph].find((candidate) => (
candidate?.prototype && typeof candidate.prototype.serialize === 'function'
));
if (!graphClass) {
console.warn('[Anomalous Hash Resolver] Graph serialization API is unavailable; resolver disabled for this session.');
return;
}
if (graphClass.prototype.__anomalousHashResolverPatched) return;
const origSerialize = graphClass.prototype.serialize;
window.anomalous_has_warned_unscanned = false;
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') {
@@ -256,7 +366,7 @@ app.registerExtension({
}
if (!valIsMissing) {
const cache_data = window.anomalous_hash_cache[basename] || window.anomalous_hash_cache[val];
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: "" };
@@ -284,12 +394,12 @@ app.registerExtension({
// Intercept loadGraphData to resolve missing models
const origLoadGraphData = app.loadGraphData;
app.loadGraphData = function (graphData) {
// Proceed with original loadGraphData synchronously first
const ret = origLoadGraphData.apply(this, arguments);
return ret;
};
if (typeof app.loadGraphData === 'function') {
const origLoadGraphData = app.loadGraphData;
app.loadGraphData = function (graphData) {
// Proceed with original loadGraphData synchronously first
return origLoadGraphData.apply(this, arguments);
};
}
}
});
+625 -5038
View File
File diff suppressed because it is too large Load Diff
-4373
View File
File diff suppressed because it is too large Load Diff
+214
View File
@@ -0,0 +1,214 @@
const MODEL_CHAIN_TYPES = Object.freeze(['MODEL', 'CLIP']);
function normalizeSlotType(type) {
return String(type ?? '').trim().toUpperCase();
}
function findSlotIndex(slots, requiredType) {
const wanted = normalizeSlotType(requiredType);
return Array.isArray(slots)
? slots.findIndex(slot => normalizeSlotType(slot?.type) === wanted)
: -1;
}
function getGraphLink(graph, linkId) {
if (linkId === null || linkId === undefined) return null;
return graph?.links?.[linkId] || graph?._links?.[linkId] || null;
}
function getGraphNode(graph, nodeId) {
if (graph?.getNodeById) return graph.getNodeById(nodeId) || null;
return Array.isArray(graph?._nodes)
? graph._nodes.find(node => node?.id === nodeId) || null
: null;
}
function unsupported(direction, code, details = {}) {
return { supported: false, direction, code, channels: [], ...details };
}
export function analyzeModelChainInsertion(graph, anchorNode, direction) {
if (!graph || !anchorNode) {
return unsupported(direction, 'missing_graph_or_node');
}
if (direction !== 'before' && direction !== 'after') {
return unsupported(direction, 'invalid_direction');
}
const channels = [];
for (const type of MODEL_CHAIN_TYPES) {
const anchorSlot = direction === 'before'
? findSlotIndex(anchorNode.inputs, type)
: findSlotIndex(anchorNode.outputs, type);
if (anchorSlot < 0) {
return unsupported(direction, direction === 'before' ? 'missing_chain_inputs' : 'missing_chain_outputs', { missingType: type });
}
if (direction === 'before') {
const linkId = anchorNode.inputs[anchorSlot]?.link;
const link = getGraphLink(graph, linkId);
const sourceNode = link ? getGraphNode(graph, link.origin_id) : null;
if (!link || !sourceNode) {
return unsupported(direction, 'unconnected_chain_inputs', { missingType: type });
}
channels.push({
type,
anchorSlot,
originalLinks: [{
originNode: sourceNode,
originSlot: link.origin_slot,
targetNode: anchorNode,
targetSlot: anchorSlot,
}],
});
continue;
}
const linkIds = Array.isArray(anchorNode.outputs[anchorSlot]?.links)
? anchorNode.outputs[anchorSlot].links.filter(id => id !== null && id !== undefined)
: [];
if (linkIds.length > 1) {
return unsupported(direction, 'ambiguous_downstream_branches', { ambiguousType: type, branchCount: linkIds.length });
}
const originalLinks = [];
for (const linkId of linkIds) {
const link = getGraphLink(graph, linkId);
const targetNode = link ? getGraphNode(graph, link.target_id) : null;
if (!link || !targetNode) {
return unsupported(direction, 'invalid_downstream_link', { invalidType: type });
}
originalLinks.push({
originNode: anchorNode,
originSlot: anchorSlot,
targetNode,
targetSlot: link.target_slot,
});
}
channels.push({ type, anchorSlot, originalLinks });
}
return { supported: true, direction, code: 'ready', channels };
}
export function getModelChainInsertionCapabilities(graph, anchorNode) {
return {
before: analyzeModelChainInsertion(graph, anchorNode, 'before'),
after: analyzeModelChainInsertion(graph, anchorNode, 'after'),
};
}
function assertInsertedNodeSlots(insertedNode) {
const slots = {};
for (const type of MODEL_CHAIN_TYPES) {
const input = findSlotIndex(insertedNode?.inputs, type);
const output = findSlotIndex(insertedNode?.outputs, type);
if (input < 0 || output < 0) {
const error = new Error(`Inserted node does not expose ${type} input/output slots.`);
error.code = 'inserted_node_missing_chain_slots';
throw error;
}
slots[type] = { input, output };
}
return slots;
}
function connectOrThrow(originNode, originSlot, targetNode, targetSlot, type) {
const link = originNode?.connect?.(originSlot, targetNode, targetSlot);
if (!link) {
const error = new Error(`Failed to connect ${type} while inserting the model node.`);
error.code = 'connection_failed';
error.channelType = type;
throw error;
}
}
function restoreConnections(originalConnections) {
for (const connection of originalConnections) {
connection.originNode?.connect?.(
connection.originSlot,
connection.targetNode,
connection.targetSlot,
);
}
}
function placeInsertedNode(graph, anchorNode, insertedNode, direction) {
const anchorX = Number(anchorNode?.pos?.[0]) || 0;
const anchorY = Number(anchorNode?.pos?.[1]) || 0;
const anchorWidth = Number(anchorNode?.size?.[0]) || 220;
const insertedWidth = Number(insertedNode?.size?.[0]) || 220;
const horizontalGap = 90;
let x = direction === 'before'
? anchorX - insertedWidth - horizontalGap
: anchorX + anchorWidth + horizontalGap;
let y = anchorY;
const overlaps = (candidateX, candidateY) => (graph?._nodes || []).some(node => {
if (!node || node === anchorNode || node === insertedNode) return false;
const nodeX = Number(node.pos?.[0]) || 0;
const nodeY = Number(node.pos?.[1]) || 0;
const nodeWidth = Number(node.size?.[0]) || 220;
const nodeHeight = Number(node.size?.[1]) || 120;
const insertedHeight = Number(insertedNode?.size?.[1]) || 120;
return candidateX < nodeX + nodeWidth + 20
&& candidateX + insertedWidth + 20 > nodeX
&& candidateY < nodeY + nodeHeight + 20
&& candidateY + insertedHeight + 20 > nodeY;
});
for (const offset of [0, 80, -80, 160, -160, 240, -240]) {
if (!overlaps(x, anchorY + offset)) {
y = anchorY + offset;
break;
}
}
insertedNode.pos = [x, y];
}
export function spliceModelChainNode({ graph, anchorNode, insertedNode, direction }) {
const analysis = analyzeModelChainInsertion(graph, anchorNode, direction);
if (!analysis.supported) {
const error = new Error(`Model-chain insertion is not available: ${analysis.code}`);
error.code = analysis.code;
error.analysis = analysis;
throw error;
}
const insertedSlots = assertInsertedNodeSlots(insertedNode);
const originalConnections = analysis.channels.flatMap(channel => channel.originalLinks);
let added = false;
graph.beforeChange?.(anchorNode);
try {
graph.add(insertedNode);
added = true;
placeInsertedNode(graph, anchorNode, insertedNode, direction);
for (const channel of analysis.channels) {
const slots = insertedSlots[channel.type];
if (direction === 'before') {
const original = channel.originalLinks[0];
connectOrThrow(original.originNode, original.originSlot, insertedNode, slots.input, channel.type);
connectOrThrow(insertedNode, slots.output, anchorNode, channel.anchorSlot, channel.type);
continue;
}
connectOrThrow(anchorNode, channel.anchorSlot, insertedNode, slots.input, channel.type);
for (const original of channel.originalLinks) {
connectOrThrow(insertedNode, slots.output, original.targetNode, original.targetSlot, channel.type);
}
}
graph.afterChange?.(anchorNode);
graph.change?.();
graph.setDirtyCanvas?.(true, true);
return insertedNode;
} catch (error) {
if (added) graph.remove?.(insertedNode);
restoreConnections(originalConnections);
graph.afterChange?.(anchorNode);
graph.change?.();
graph.setDirtyCanvas?.(true, true);
throw error;
}
}
+1275 -93
View File
File diff suppressed because it is too large Load Diff
+103
View File
@@ -0,0 +1,103 @@
const MODEL_FILE_PATTERN = /\.(safetensors|ckpt|pt|bin|pth|sft)$/i;
export function inferPickerModelType(node, widget, options = {}) {
const widgetName = String(widget?.name || '').toLowerCase();
const nodeType = String(node?.type || '').toLowerCase();
const explicitLabel = String(options.modelTypeLabel || '');
const key = `${widgetName} ${nodeType} ${explicitLabel.toLowerCase()}`;
if (widgetName.includes('vae') || nodeType === 'vaeloader' || key.includes('vae loader')) {
return { label: 'VAE', folderTypes: ['vae'], isLora: false };
}
if (widgetName.includes('lora') || key.includes('lora')) {
return { label: 'LoRA', folderTypes: ['loras'], isLora: true };
}
if (widgetName.includes('control_net') || widgetName.includes('controlnet') || key.includes('controlnet')) {
return { label: 'ControlNet', folderTypes: ['controlnet'], isLora: false };
}
if (widgetName.includes('ckpt') || widgetName.includes('checkpoint') || key.includes('checkpoint')) {
return { label: 'Checkpoint', folderTypes: ['checkpoints'], isLora: false };
}
if (widgetName.includes('unet') || widgetName.includes('diffusion_model') || key.includes('unet loader')) {
return { label: 'UNET', folderTypes: ['diffusion_models', 'unet'], isLora: false };
}
return {
label: explicitLabel || node?.type || widget?.name || 'Model',
folderTypes: [],
isLora: false,
};
}
export function getBaseModelFamily(value) {
const normalized = String(value || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '');
if (!normalized) return '';
if (normalized.includes('illustrious')) return 'illustrious';
if (normalized.includes('pony')) return 'pony';
if (normalized.includes('flux')) return 'flux';
if (normalized.includes('sdxl') || normalized.includes('stablediffusionxl')) return 'sdxl';
if (normalized.includes('sd35') || normalized.includes('stablediffusion35')) return 'sd35';
if (normalized.includes('sd3') || normalized.includes('stablediffusion3')) return 'sd3';
if (normalized.includes('sd21') || normalized.includes('stablediffusion21')) return 'sd21';
if (normalized.includes('sd15') || normalized.includes('stablediffusion15')) return 'sd15';
return normalized;
}
function getGraphNodeById(graph, nodeId) {
return graph?.getNodeById?.(nodeId)
|| graph?._nodes_by_id?.[nodeId]
|| graph?._nodes?.find?.(candidate => candidate?.id === nodeId)
|| null;
}
export function collectMainModelContextRequests(graph, startNode) {
if (!graph || !startNode) return [];
const queue = [startNode];
const visited = new Set();
let depth = 0;
while (queue.length && depth < 12) {
const levelSize = queue.length;
const found = [];
for (let index = 0; index < levelSize; index += 1) {
const current = queue.shift();
if (!current || visited.has(current.id)) continue;
visited.add(current.id);
for (const widget of current.widgets || []) {
if (typeof widget?.value !== 'string' || !MODEL_FILE_PATTERN.test(widget.value)) continue;
const inferred = inferPickerModelType(current, widget);
if (!inferred.isLora) {
const request = { path: widget.value };
if (inferred.folderTypes.length) request.folder_types = inferred.folderTypes;
found.push(request);
}
}
for (const input of current.inputs || []) {
if (!['MODEL', 'CLIP'].includes(input?.type) || input.link == null) continue;
const link = graph.links?.[input.link] || graph._links?.[input.link];
const origin = getGraphNodeById(graph, link?.origin_id);
if (origin && !visited.has(origin.id)) queue.push(origin);
}
}
if (found.length) {
const seen = new Set();
return found.filter(item => {
const key = `${(item.folder_types || []).join(',')}|${item.path}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
depth += 1;
}
return [];
}
export function formatModelTypeLabel(type, fallback = 'Model') {
const labels = {
loras: 'LoRA',
checkpoints: 'Checkpoint',
diffusion_models: 'UNET',
unet: 'UNET',
controlnet: 'ControlNet',
vae: 'VAE',
};
return labels[String(type || '').toLowerCase()] || fallback;
}
+338
View File
@@ -0,0 +1,338 @@
import { app } from '../../../scripts/app.js';
function cloneJson(value) {
if (value === undefined) return undefined;
return JSON.parse(JSON.stringify(value));
}
function nodeType(node) {
return String(node?.type || node?.class_type || '').trim();
}
function savedNodeRecords(workflow) {
return Array.isArray(workflow?.nodes) ? workflow.nodes.filter((node) => node && node.id !== undefined) : [];
}
function nodeShape(node) {
const inputs = Array.isArray(node?.inputs) ? node.inputs.length : -1;
const outputs = Array.isArray(node?.outputs) ? node.outputs.length : -1;
return `${nodeType(node)}|${inputs}|${outputs}`;
}
function nodeTitle(node) {
return String(node?.title || node?.properties?.['Node name for S&R'] || '').trim().toLowerCase();
}
function liveNodeById(graph, id) {
return graph?.getNodeById?.(id)
|| graph?.getNodeById?.(Number(id))
|| graph?._nodes?.find((node) => String(node?.id) === String(id))
|| null;
}
function volatileWidget(node, widget, index) {
const name = String(widget?.name || '').toLowerCase();
if (/(^|[_\s-])(seed|noise_seed|random_seed|variation_seed|last_seed)([_\s-]|$)/i.test(name)) return true;
const type = nodeType(node).toLowerCase();
if (type === 'ksampler') return index === 0;
if (type === 'ksampleradvanced') return index === 1;
return false;
}
function matchSkeleton(sourceWorkflow, candidateWorkflow) {
const sourceNodes = savedNodeRecords(sourceWorkflow);
const candidateNodes = savedNodeRecords(candidateWorkflow);
const available = new Set(candidateNodes);
const matches = new Map();
const missing = [];
for (const source of sourceNodes) {
const exact = [...available].find((candidate) => (
String(candidate.id) === String(source.id)
&& nodeType(candidate) === nodeType(source)
&& nodeShape(candidate) === nodeShape(source)
));
const sameTitle = [...available].find((candidate) => (
nodeShape(candidate) === nodeShape(source)
&& nodeTitle(candidate) === nodeTitle(source)
&& nodeTitle(source)
));
const sameShape = [...available].find((candidate) => nodeShape(candidate) === nodeShape(source));
const sameType = [...available].find((candidate) => nodeType(candidate) === nodeType(source));
const target = exact || sameTitle || sameShape || sameType;
if (!target) {
missing.push(nodeType(source) || String(source.id));
continue;
}
available.delete(target);
matches.set(String(source.id), target);
}
return { matches, missing, sourceCount: sourceNodes.length, candidateCount: candidateNodes.length };
}
export function assertRecipeSkeleton(sourceWorkflow, candidateWorkflow) {
const result = matchSkeleton(sourceWorkflow, candidateWorkflow);
if (result.missing.length) {
const error = new Error(`Missing recipe skeleton nodes: ${result.missing.join(', ')}`);
error.code = 'recipe_parameter_skeleton_mismatch';
error.missing = result.missing;
throw error;
}
return result;
}
/** Apply only saved widget values to a matching local graph, with rollback. */
export function applyRecipeParametersToCanvas(source) {
const graph = app.graph;
const sourceWorkflow = source?.workflow;
const candidateWorkflow = graph?.serialize?.();
if (!graph || !sourceWorkflow || !candidateWorkflow) throw new Error('recipe_parameter_apply_unavailable');
const skeleton = assertRecipeSkeleton(sourceWorkflow, candidateWorkflow);
const sourceNodes = savedNodeRecords(sourceWorkflow);
const changes = [];
for (const sourceNode of sourceNodes) {
const targetRecord = skeleton.matches.get(String(sourceNode.id));
if (!targetRecord || !Array.isArray(sourceNode.widgets_values)) continue;
// `candidateWorkflow` is a serialized graph and intentionally has no
// live widget objects. Resolve the matched record back to ComfyUI's
// runtime node before reading callbacks or writing widget values.
const target = liveNodeById(graph, targetRecord.id);
if (!target) {
const error = new Error(`Could not resolve live node ${targetRecord.id} on ${nodeType(targetRecord)}`);
error.code = 'recipe_parameter_node_unavailable';
throw error;
}
const targetWidgets = Array.isArray(target.widgets) ? target.widgets : [];
for (let index = 0; index < sourceNode.widgets_values.length; index += 1) {
if (volatileWidget(sourceNode, targetWidgets[index], index)) continue;
if (!targetWidgets[index]) {
const error = new Error(`Missing widget ${index} on ${nodeType(target)}`);
error.code = 'recipe_parameter_widget_mismatch';
throw error;
}
changes.push({
sourceNode,
target,
targetWidget: targetWidgets[index],
index,
value: cloneJson(sourceNode.widgets_values[index]),
previousValue: cloneJson(targetWidgets[index].value),
});
}
}
const previous = changes.map((change) => ({ ...change, previousValue: cloneJson(change.previousValue) }));
graph.beforeChange?.();
try {
for (const change of changes) {
change.targetWidget.value = cloneJson(change.value);
if (Array.isArray(change.target.widgets_values)) change.target.widgets_values[change.index] = cloneJson(change.value);
if (typeof change.targetWidget.callback === 'function') {
change.targetWidget.callback.call(change.targetWidget, change.targetWidget.value, app.canvas, change.target);
}
if (typeof change.target.onWidgetChanged === 'function') {
// Current ComfyUI uses the fourth argument as the live widget
// for its error-clearing hooks. Omitting it makes those hooks
// evaluate `sourceNodeId in undefined`.
change.target.onWidgetChanged(
change.index,
change.targetWidget.value,
change.previousValue,
change.targetWidget,
);
}
}
graph.change?.();
graph.setDirtyCanvas?.(true, true);
app.canvas?.setDirty?.(true, true);
return { nodes: sourceNodes.length, widgets: changes.length };
} catch (error) {
for (const change of previous) {
change.targetWidget.value = cloneJson(change.previousValue);
if (Array.isArray(change.target.widgets_values)) change.target.widgets_values[change.index] = cloneJson(change.previousValue);
}
graph.change?.();
graph.setDirtyCanvas?.(true, true);
throw error;
} finally {
graph.afterChange?.();
}
}
function linkRecords(workflow) {
if (Array.isArray(workflow?.links)) return workflow.links.filter((link) => Array.isArray(link) || link && typeof link === 'object');
if (workflow?.links instanceof Map) return [...workflow.links.values()].filter(Boolean);
if (workflow?.links && typeof workflow.links === 'object') return Object.values(workflow.links).filter(Boolean);
return [];
}
function savedGroupRecords(workflow) {
return Array.isArray(workflow?.groups) ? workflow.groups.filter((group) => group && typeof group === 'object') : [];
}
function graphNode(graph, id) {
return graph?.getNodeById?.(id)
|| graph?._nodes?.find((node) => String(node?.id) === String(id))
|| null;
}
function nextNodeId(graph, usedIds) {
let max = 0;
for (const node of graph?._nodes || []) max = Math.max(max, Number(node?.id) || 0);
for (const id of usedIds) max = Math.max(max, Number(id) || 0);
return max + 1;
}
function liveBounds(graph) {
const nodes = (graph?._nodes || []).filter(Boolean);
if (!nodes.length) return null;
return nodes.reduce((bounds, node) => {
const x = Number(node.pos?.[0]) || 0;
const y = Number(node.pos?.[1]) || 0;
const width = Number(node.size?.[0]) || 220;
const height = Number(node.size?.[1]) || 120;
bounds.minX = Math.min(bounds.minX, x);
bounds.minY = Math.min(bounds.minY, y);
bounds.maxX = Math.max(bounds.maxX, x + width);
bounds.maxY = Math.max(bounds.maxY, y + height);
return bounds;
}, { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity });
}
function savedBounds(nodes) {
if (!nodes.length) return { minX: 0, minY: 0, maxX: 0, maxY: 0 };
return nodes.reduce((bounds, node) => {
const x = Number(node.pos?.[0]) || 0;
const y = Number(node.pos?.[1]) || 0;
const width = Number(node.size?.[0]) || 220;
const height = Number(node.size?.[1]) || 120;
bounds.minX = Math.min(bounds.minX, x);
bounds.minY = Math.min(bounds.minY, y);
bounds.maxX = Math.max(bounds.maxX, x + width);
bounds.maxY = Math.max(bounds.maxY, y + height);
return bounds;
}, { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity });
}
function instantiateNode(nodeData) {
const type = nodeType(nodeData);
const creator = globalThis.LiteGraph?.createNode;
if (typeof creator !== 'function' || !type) return null;
const node = creator.call(globalThis.LiteGraph, type);
if (!node) return null;
node.configure?.(cloneJson(nodeData));
return node;
}
function instantiateGroup(groupData) {
const GroupClass = globalThis.LiteGraph?.LGraphGroup;
if (typeof GroupClass !== 'function') return null;
const group = new GroupClass(String(groupData?.title || ''));
group.configure?.(cloneJson(groupData));
return group;
}
function linkField(link, arrayIndex, objectKey) {
return Array.isArray(link) ? link[arrayIndex] : link?.[objectKey];
}
function connectLink(link, idMap, graph) {
const originId = linkField(link, 1, 'origin_id');
const targetId = linkField(link, 3, 'target_id');
const originSlot = linkField(link, 2, 'origin_slot');
const targetSlot = linkField(link, 4, 'target_slot');
if (originId === undefined || targetId === undefined || originSlot === undefined || targetSlot === undefined) return false;
const origin = graphNode(graph, idMap.get(String(originId)));
const target = graphNode(graph, idMap.get(String(targetId)));
if (!origin || !target) return false;
const result = origin.connect?.(Number(originSlot), target, Number(targetSlot));
return Boolean(result);
}
/** Merge one serialized workflow into the live graph as one rollback-able action. */
export function appendRecipeToCanvas(recipe) {
const graph = app.graph;
const savedNodes = savedNodeRecords(recipe?.workflow);
const savedLinks = linkRecords(recipe?.workflow);
const savedGroups = savedGroupRecords(recipe?.workflow);
if (!graph || !savedNodes.length) throw new Error('recipe_append_empty');
if (typeof globalThis.LiteGraph?.createNode !== 'function') throw new Error('recipe_append_unsupported');
if (recipe?.workflow?.definitions?.subgraphs?.length) {
throw new Error('recipe_append_subgraphs_unsupported');
}
if (savedGroups.length && typeof globalThis.LiteGraph?.LGraphGroup !== 'function') {
throw new Error('recipe_append_groups_unsupported');
}
const idMap = new Map();
const inserted = [];
const existingIds = new Set((graph._nodes || []).map((node) => String(node?.id)));
let id = nextNodeId(graph, savedNodes.map((node) => node.id));
const sourceBounds = savedBounds(savedNodes);
const targetBounds = liveBounds(graph);
const offsetX = targetBounds ? targetBounds.maxX + 140 - sourceBounds.minX : 40 - sourceBounds.minX;
const offsetY = targetBounds ? targetBounds.minY - sourceBounds.minY : 40 - sourceBounds.minY;
const insertedGroups = [];
graph.beforeChange?.();
try {
for (const nodeData of savedNodes) {
const node = instantiateNode(nodeData);
if (!node) {
const error = new Error(`Missing node type: ${nodeType(nodeData)}`);
error.code = 'recipe_append_missing_node';
throw error;
}
while (existingIds.has(String(id))) id += 1;
const oldId = String(nodeData.id);
idMap.set(oldId, id);
node.id = id;
existingIds.add(String(id));
const x = Number(nodeData.pos?.[0]) || 0;
const y = Number(nodeData.pos?.[1]) || 0;
node.pos = [x + offsetX, y + offsetY];
graph.add(node);
inserted.push(node);
id += 1;
}
for (const link of savedLinks) {
if (!connectLink(link, idMap, graph)) {
const error = new Error('Invalid link in recipe');
error.code = 'recipe_append_invalid_link';
throw error;
}
}
for (const groupData of savedGroups) {
const group = instantiateGroup(groupData);
if (!group) {
const error = new Error('Could not restore a recipe group.');
error.code = 'recipe_append_groups_unsupported';
throw error;
}
if (Array.isArray(group.bounding) && group.bounding.length >= 4) {
group.bounding = [
Number(group.bounding[0]) + offsetX,
Number(group.bounding[1]) + offsetY,
group.bounding[2],
group.bounding[3],
];
}
graph.add(group);
insertedGroups.push(group);
}
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 };
} catch (error) {
for (const group of insertedGroups) graph.remove?.(group);
for (const node of inserted) graph.remove?.(node);
graph.change?.();
graph.setDirtyCanvas?.(true, true);
throw error;
} finally {
graph.afterChange?.();
}
}
+202
View File
@@ -0,0 +1,202 @@
/** Pure, bounded semantic comparison for saved Workflow Recipes. */
const MAX_VALUE_LENGTH = 320;
const MAX_CHANGES = 160;
function cloneSafe(value) {
if (value === null || value === undefined || ['string', 'number', 'boolean'].includes(typeof value)) return value;
try {
const encoded = JSON.stringify(value);
if (!encoded || encoded.length > MAX_VALUE_LENGTH * 8) return '[complex value]';
return JSON.parse(encoded);
} catch (error) {
return '[complex value]';
}
}
function stable(value) {
const cloned = cloneSafe(value);
try { return JSON.stringify(cloned); } catch (error) { return String(cloned); }
}
export function formatDiffValue(value) {
if (value === null || value === undefined || value === '') return '—';
let text;
if (typeof value === 'string') text = value;
else {
try { text = JSON.stringify(value); } catch (error) { text = String(value); }
}
text = String(text).replace(/\s+/g, ' ').trim();
return text.length > MAX_VALUE_LENGTH ? `${text.slice(0, MAX_VALUE_LENGTH - 1)}…` : text;
}
function itemKey(item, fallback) {
if (item?.key) return String(item.key);
const node = item?.nodeId ?? item?.node_id ?? '';
const index = item?.widgetIndex ?? item?.widget_index ?? '';
const name = item?.widgetName ?? item?.widget_name ?? '';
return `${node}:${index}:${name}:${fallback}`;
}
function addMapChanges(changes, category, before, after, getKey, getValue, getLabel) {
const left = new Map((before || []).map((item, index) => [getKey(item, index), item]));
const right = new Map((after || []).map((item, index) => [getKey(item, index), item]));
const keys = [...new Set([...left.keys(), ...right.keys()])].sort();
for (const key of keys) {
const leftItem = left.get(key);
const rightItem = right.get(key);
const leftValue = leftItem === undefined ? undefined : getValue(leftItem);
const rightValue = rightItem === undefined ? undefined : getValue(rightItem);
const same = leftItem !== undefined && rightItem !== undefined && stable(leftValue) === stable(rightValue);
if (same) continue;
changes.push({
kind: leftItem === undefined ? 'added' : rightItem === undefined ? 'removed' : 'changed',
category,
key,
label: getLabel(rightItem || leftItem),
before: cloneSafe(leftValue),
after: cloneSafe(rightValue),
});
}
}
function valueAt(recipe, path, fallback = null) {
const parts = path.split('.');
let value = recipe;
for (const part of parts) {
if (!value || typeof value !== 'object') return fallback;
value = value[part];
}
return value === undefined ? fallback : value;
}
function summaryValue(recipe, key) {
const params = recipe?.params || {};
const values = {
baseModel: params.baseModel,
steps: params.steps,
cfg: params.cfg,
sampler_name: params.sampler_name,
scheduler: params.scheduler,
denoise: params.denoise,
seed: params.seed,
resolution: params.resolution,
nodeCount: params.nodeCount,
loras: (params.loras || []).map((item) => ({
name: item?.name,
strength_model: item?.strength_model,
strength_clip: item?.strength_clip,
})),
};
return values[key];
}
function addScalarChanges(changes, category, entries) {
for (const entry of entries) {
const before = cloneSafe(entry.before);
const after = cloneSafe(entry.after);
if (stable(before) === stable(after)) continue;
changes.push({
kind: before === undefined || before === null ? 'added' : after === undefined || after === null ? 'removed' : 'changed',
category,
key: entry.key,
label: entry.label,
before,
after,
});
}
}
function graphSummary(recipe) {
const graph = recipe?.workflow || {};
return {
nodes: Array.isArray(graph.nodes) ? graph.nodes.length : null,
links: Array.isArray(graph.links) ? graph.links.length : graph.links && typeof graph.links === 'object' ? Object.keys(graph.links).length : null,
fingerprint: recipe?.workflow_fingerprint?.value || null,
};
}
/** Return deterministic UI-neutral changes between two recipe records. */
export function buildRecipeDiff(beforeRecipe, afterRecipe) {
const before = beforeRecipe || {};
const after = afterRecipe || {};
const changes = [];
addScalarChanges(changes, 'pinned', [
{
key: 'pinned',
label: 'Pinned parameters',
before: (before.params?.pinned || []).map((item) => ({
key: itemKey(item, 'pinned'),
value: item?.value,
})),
after: (after.params?.pinned || []).map((item) => ({
key: itemKey(item, 'pinned'),
value: item?.value,
})),
},
]);
addMapChanges(
changes,
'prompts',
before.params?.promptPositive || [],
after.params?.promptPositive || [],
(_item, index) => `positive:${index}`,
(item) => item,
() => 'Positive prompt',
);
addMapChanges(
changes,
'prompts',
before.params?.promptNegative || [],
after.params?.promptNegative || [],
(_item, index) => `negative:${index}`,
(item) => item,
() => 'Negative prompt',
);
addMapChanges(
changes,
'models',
before.params?.model_references || [],
after.params?.model_references || [],
(item, index) => itemKey(item, `model:${index}`),
(item) => item?.saved_value,
(item) => `${item?.category || 'model'} · ${item?.node_title || item?.node_type || 'loader'}`,
);
const summaryKeys = ['baseModel', 'steps', 'cfg', 'sampler_name', 'scheduler', 'denoise', 'seed', 'resolution', 'nodeCount', 'loras'];
addScalarChanges(changes, 'parameters', summaryKeys.map((key) => ({
key,
label: key,
before: summaryValue(before, key),
after: summaryValue(after, key),
})));
const beforeGraph = graphSummary(before);
const afterGraph = graphSummary(after);
addScalarChanges(changes, 'workflow', [
{ key: 'fingerprint', label: 'Workflow fingerprint', before: beforeGraph.fingerprint, after: afterGraph.fingerprint },
{ key: 'node_count', label: 'Node count', before: beforeGraph.nodes, after: afterGraph.nodes },
{ key: 'link_count', label: 'Link count', before: beforeGraph.links, after: afterGraph.links },
]);
addScalarChanges(changes, 'presentation', [
{ key: 'name', label: 'Recipe name', before: before.name, after: after.name },
{ key: 'notes', label: 'Notes', before: before.notes, after: after.notes },
{ key: 'tags', label: 'Tags', before: before.tags || [], after: after.tags || [] },
{ key: 'cover', label: 'Cover', before: Boolean(before.thumbnail || before.source_image), after: Boolean(after.thumbnail || after.source_image) },
{
key: 'preview_snapshots',
label: 'Model preview snapshots',
before: (before.params?.model_references || []).filter((item) => item?.preview?.snapshot_asset_id).length,
after: (after.params?.model_references || []).filter((item) => item?.preview?.snapshot_asset_id).length,
},
]);
return changes.slice(0, MAX_CHANGES);
}
export function diffIsEmpty(changes) {
return !Array.isArray(changes) || changes.length === 0;
}
+100
View File
@@ -0,0 +1,100 @@
/** Pure helpers for recipe model references and provenance labels. */
const MODEL_FILE_PATTERN = /\.(?:safetensors|ckpt|pt|bin|sft)$/i;
function nodeType(node) {
return String(node?.type || node?.class_type || '').trim();
}
function nodeTitle(node) {
return String(node?._meta?.title || node?.title || nodeType(node) || 'Unknown node').trim();
}
function modelSpecs(type) {
const lowered = type.toLowerCase();
if (/checkpointloader(simple)?$/.test(lowered)) return [[0, 'checkpoint', 'checkpoint']];
if (lowered.endsWith('unetloader')) return [[0, 'unet', 'unet']];
if (/loraloader/.test(lowered)) return [[0, 'lora', 'lora']];
if (lowered.endsWith('vaeloader')) return [[0, 'vae', 'vae']];
if (lowered.endsWith('clipvisionloader')) return [[0, 'clip_vision', 'clip_vision']];
if (lowered.endsWith('controlnetloader')) return [[0, 'controlnet', 'controlnet']];
if (/(^|[^a-z])(?:dual|triple)?cliploader$/.test(lowered)) {
const specs = [[0, 'text_encoder', 'clip']];
if (lowered.includes('dualclip') || lowered.includes('tripleclip')) specs.push([1, 'text_encoder', 'clip']);
if (lowered.includes('tripleclip')) specs.push([2, 'text_encoder', 'clip']);
return specs;
}
return [];
}
function statusFor(identity) {
const status = identity?.status;
return ['verified', 'unverified', 'unavailable'].includes(status) ? status : 'unavailable';
}
export function normaliseIdentity(identity) {
const result = { ...(identity || {}) };
result.status = statusFor(identity);
if (typeof result.sha256 !== 'string' || !/^[0-9a-f]{64}$/i.test(result.sha256)) delete result.sha256;
if (!Number.isFinite(Number(result.size))) delete result.size;
return result;
}
export function deriveRecipeModelReferences(recipe) {
const stored = recipe?.params?.model_references;
if (Array.isArray(stored) && stored.length) {
return stored.map((reference) => ({
...reference,
identity: normaliseIdentity(reference.identity),
}));
}
const references = [];
for (const node of recipe?.workflow?.nodes || []) {
const type = nodeType(node);
const values = Array.isArray(node?.widgets_values) ? node.widgets_values : [];
for (const [widgetIndex, category, widgetName] of modelSpecs(type)) {
const savedValue = values[widgetIndex];
if (typeof savedValue !== 'string' || !savedValue.trim()) continue;
references.push({
node_id: node?.id ?? null,
node_type: type || 'Unknown',
node_title: nodeTitle(node),
widget_index: widgetIndex,
widget_name: widgetName,
saved_value: savedValue,
category,
base_model: recipe?.params?.baseModel || null,
identity: { status: 'unverified' },
});
}
}
return references;
}
export function shortHash(value) {
if (typeof value !== 'string' || !value) return '';
return value.length > 16 ? `${value.slice(0, 12)}...${value.slice(-4)}` : value;
}
export function recipeReferenceKey(reference) {
return [
reference?.node_id ?? '',
reference?.widget_index ?? '',
reference?.category ?? '',
reference?.saved_value ?? '',
].join('\u001f');
}
export function formatIdentitySize(value) {
const size = Number(value);
if (!Number.isFinite(size) || size < 0) return '';
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
if (size < 1024 * 1024 * 1024) return `${(size / (1024 * 1024)).toFixed(1)} MB`;
return `${(size / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
export function isModelReference(reference) {
return Boolean(reference?.saved_value && MODEL_FILE_PATTERN.test(reference.saved_value));
}
+497
View File
@@ -0,0 +1,497 @@
/**
* Read-only helpers for turning the live LiteGraph canvas into a compact
* Workflow Recipe summary. The saved workflow itself remains authoritative:
* an unfamiliar custom node must never prevent a recipe from being saved.
*/
const MAX_UPSTREAM_NODES = 96;
const MAX_SUMMARY_NODES = 120;
const MAX_WIDGETS_PER_NODE = 16;
const MAX_WIDGET_TEXT = 320;
const MAX_PINNED_VALUE_JSON = 2400;
const MAX_PARAMETER_CHOICES = 5000;
const SENSITIVE_WIDGET_NAME = /(?:api.?key|access.?token|auth|password|passwd|secret|credential)/i;
const SUPPORTED_PROMPT_NODE_TYPES = new Set([
'cliptextencode',
]);
const SUPPORTED_PROMPT_CONSUMERS = new Map([
['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' }],
]);
const SUPPORTED_CONDITIONING_PASSTHROUGH = new Set([
'conditioningaverage',
'conditioningcombine',
'conditioningconcat',
'conditioningmultiply',
'conditioningsetarea',
'conditioningsetareapercentage',
'conditioningsetareastrength',
'conditioningsetmask',
'conditioningsettimesteprange',
'conditioningzeroout',
]);
function nodeType(node) {
return String(node?.type || node?.comfyClass || '');
}
function normaliseName(value) {
return String(value || '').trim().toLowerCase();
}
export function isSupportedPromptNodeType(value) {
return SUPPORTED_PROMPT_NODE_TYPES.has(normaliseName(value));
}
function widgetValue(node, names, fallbackIndex = -1) {
const wanted = new Set(names.map(normaliseName));
const widgets = Array.isArray(node?.widgets) ? node.widgets : [];
const named = widgets.find((widget) => wanted.has(normaliseName(widget?.name)));
if (named && named.value !== undefined && named.value !== null) return named.value;
if (fallbackIndex >= 0) {
const fallback = widgets[fallbackIndex]?.value;
if (fallback !== undefined && fallback !== null) return fallback;
const serialised = node?.widgets_values?.[fallbackIndex];
if (serialised !== undefined && serialised !== null) return serialised;
}
return null;
}
function textValue(value) {
return typeof value === 'string' ? value.trim() : '';
}
function numberValue(value) {
if (value === null || value === undefined || value === '') return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function summariseWidgetValue(value) {
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed.length > MAX_WIDGET_TEXT ? `${trimmed.slice(0, MAX_WIDGET_TEXT - 1)}…` : trimmed;
}
if (typeof value === 'number' || typeof value === 'boolean') return value;
if (Array.isArray(value) && value.length <= 12 && value.every((item) => ['string', 'number', 'boolean'].includes(typeof item))) {
return value.map((item) => typeof item === 'string' && item.length > 80 ? `${item.slice(0, 79)}…` : item);
}
return null;
}
function clonePinnableValue(value) {
if (value === null || ['string', 'number', 'boolean'].includes(typeof value)) {
if (typeof value === 'string' && value.length > MAX_PINNED_VALUE_JSON) return null;
return value;
}
try {
const encoded = JSON.stringify(value);
if (!encoded || encoded.length > MAX_PINNED_VALUE_JSON) return null;
return JSON.parse(encoded);
} catch (error) {
return null;
}
}
function extractGenericNodeSummary(node) {
const widgets = [];
for (const [index, widget] of (node?.widgets || []).entries()) {
const name = textValue(widget?.name) || `widget_${index + 1}`;
if (SENSITIVE_WIDGET_NAME.test(name) || normaliseName(widget?.type) === 'button') continue;
const value = summariseWidgetValue(widget?.value);
if (value === null || value === '') continue;
widgets.push({ name, value, index });
if (widgets.length >= MAX_WIDGETS_PER_NODE) break;
}
const nodeData = node?.constructor?.nodeData || {};
return {
id: node?.id ?? null,
type: nodeType(node) || 'Unknown',
title: textValue(node?.title) || null,
category: textValue(nodeData.category || node?.category) || null,
module: textValue(nodeData.python_module || nodeData.module) || null,
widgets,
widgetCount: Array.isArray(node?.widgets) ? node.widgets.length : 0,
};
}
/** Return safe, user-selectable widget values for manual key-parameter pins. */
export function extractRecipeParameterChoices(graph) {
const choices = [];
if (!graph || !Array.isArray(graph._nodes)) return choices;
for (const node of graph._nodes) {
for (const [widgetIndex, widget] of (node?.widgets || []).entries()) {
const widgetName = textValue(widget?.name) || `widget_${widgetIndex + 1}`;
if (SENSITIVE_WIDGET_NAME.test(widgetName) || normaliseName(widget?.type) === 'button') continue;
const value = clonePinnableValue(widget?.value);
if (value === null || value === '') continue;
choices.push({
key: `${node?.id ?? 'unknown'}:${widgetIndex}:${widgetName}`,
nodeId: node?.id ?? null,
nodeType: nodeType(node) || 'Unknown',
nodeTitle: textValue(node?.title) || null,
widgetName,
value,
});
if (choices.length >= MAX_PARAMETER_CHOICES) return choices;
}
}
return choices;
}
/**
* Return editable/pinnable choices from an already saved recipe summary.
* New summaries retain their original widget index; older recipes gracefully
* fall back to the visible widget order.
*/
export function extractRecipeParameterChoicesFromMetadata(params, workflow = null) {
const choices = [];
const workflowNodes = new Map((workflow?.nodes || []).filter(Boolean).map((node) => [node.id, node]));
for (const node of params?.nodes || []) {
for (const [visibleIndex, widget] of (node?.widgets || []).entries()) {
const widgetName = textValue(widget?.name) || `widget_${visibleIndex + 1}`;
if (SENSITIVE_WIDGET_NAME.test(widgetName)) continue;
const widgetIndex = Number.isInteger(widget?.index) ? widget.index : visibleIndex;
const workflowValues = workflowNodes.get(node?.id)?.widgets_values;
const fullValue = Array.isArray(workflowValues) && widgetIndex >= 0 && widgetIndex < workflowValues.length
? workflowValues[widgetIndex]
: widget?.value;
const value = clonePinnableValue(fullValue);
if (value === null || value === '') continue;
choices.push({
key: `${node?.id ?? 'unknown'}:${widgetIndex}:${widgetName}`,
nodeId: node?.id ?? null,
nodeType: node?.type || 'Unknown',
nodeTitle: node?.title || null,
widgetName,
widgetIndex,
value,
});
if (choices.length >= MAX_PARAMETER_CHOICES) return choices;
}
}
return choices;
}
function valuesMatch(left, right) {
if (left === right) return true;
try { return JSON.stringify(left) === JSON.stringify(right); } catch (error) { return false; }
}
function findSummaryWidget(params, change) {
const node = (params?.nodes || []).find((item) => item?.id === change.nodeId);
if (!node) return null;
const widget = (node.widgets || []).find((item) => Number.isInteger(change.widgetIndex) && item.index === change.widgetIndex)
|| (node.widgets || []).find((item) => item.name === change.widgetName && valuesMatch(item.value, change.previousValue));
return widget ? { node, widget } : null;
}
function syncCommonRecipeMetadata(params, change) {
const type = normaliseName(change.nodeType);
const widget = normaliseName(change.widgetName);
const value = change.value;
if (/^(checkpointloader(simple)?|unetloader)$/.test(type) && /^(ckpt_name|checkpoint|unet_name|unet|model_name)$/.test(widget)) {
params.baseModel = textValue(value) || null;
if (params.baseModel) params.baseModels = [params.baseModel, ...(params.baseModels || []).filter((item) => item !== params.baseModel)];
}
if (/lora.*loader|loader.*lora/.test(type)) {
const loraIndex = (params.nodes || []).filter((item) => /lora.*loader|loader.*lora/i.test(item?.type || '')).findIndex((item) => item.id === change.nodeId);
const lora = params.loras?.[loraIndex];
if (lora) {
if (/^(lora_name|lora|model_name)$/.test(widget)) lora.name = textValue(value);
if (/^(strength_model|model_strength)$/.test(widget)) lora.strength_model = numberValue(value);
if (/^(strength_clip|clip_strength)$/.test(widget)) lora.strength_clip = numberValue(value);
}
}
if (/^ksampler(advanced)?$|samplercustom/.test(type)) {
const field = { seed: 'seed', noise_seed: 'seed', steps: 'steps', cfg: 'cfg', cfg_scale: 'cfg', sampler_name: 'sampler_name', sampler: 'sampler_name', scheduler: 'scheduler', denoise: 'denoise' }[widget];
if (field) params[field] = ['steps', 'cfg', 'denoise'].includes(field) ? numberValue(value) : value;
}
if (isSupportedPromptNodeType(type) && /^(text|prompt)$/.test(widget)) {
const positive = Array.isArray(params.promptPositive) ? params.promptPositive : (params.promptPositive = []);
const negative = Array.isArray(params.promptNegative) ? params.promptNegative : (params.promptNegative = []);
const position = positive.findIndex((item) => item === change.previousValue);
if (position >= 0) {
positive[position] = textValue(value);
return;
}
const negativePosition = negative.findIndex((item) => item === change.previousValue);
if (negativePosition >= 0) {
negative[negativePosition] = textValue(value);
return;
}
const summary = (params.nodes || []).find((item) => String(item?.id) === String(change.nodeId));
const override = params.promptRoleOverrides?.[String(change.nodeId)]?.role;
const role = ['positive', 'negative', 'both'].includes(override) ? override : summary?.role;
const prompt = textValue(value);
if (!prompt) return;
if ((role === 'positive' || role === 'both') && !positive.includes(prompt)) positive.push(prompt);
if ((role === 'negative' || role === 'both') && !negative.includes(prompt)) negative.push(prompt);
}
}
/**
* Apply direct safe-widget edits to a serialized recipe without instantiating
* its graph. Unknown nodes stay opaque; only known widget slots are touched.
*/
export function applyRecipeWidgetChanges(params, workflow, changes) {
for (const change of changes || []) {
const found = findSummaryWidget(params, change);
if (!found) continue;
const { widget } = found;
const workflowNode = (workflow?.nodes || []).find((node) => node?.id === change.nodeId);
let widgetIndex = Number.isInteger(change.widgetIndex) ? change.widgetIndex : widget.index;
if (!Number.isInteger(widgetIndex) && Array.isArray(workflowNode?.widgets_values)) {
widgetIndex = workflowNode.widgets_values.findIndex((value) => valuesMatch(value, change.previousValue));
}
if (!Number.isInteger(widgetIndex) || widgetIndex < 0 || !Array.isArray(workflowNode?.widgets_values)) continue;
widget.value = change.value;
widget.index = widgetIndex;
workflowNode.widgets_values[widgetIndex] = change.value;
syncCommonRecipeMetadata(params, change);
}
return { params, workflow };
}
function buildNodeIndex(graph) {
return new Map((graph?._nodes || []).filter(Boolean).map((node) => [String(node.id), node]));
}
function linkOriginId(link) {
if (Array.isArray(link)) return link[1];
return link?.origin_id;
}
function findLink(graph, linkId) {
if (linkId === null || linkId === undefined) return null;
const links = graph?.links;
if (Array.isArray(links)) return links.find((link) => Array.isArray(link) && link[0] === linkId) || null;
if (links instanceof Map) return links.get(linkId) || links.get(String(linkId)) || null;
return links?.[linkId] || null;
}
function inputLinkOrigins(graph, node, inputNames) {
const wanted = new Set(inputNames.map(normaliseName));
const origins = [];
for (const input of node?.inputs || []) {
if (!wanted.has(normaliseName(input?.name)) || input?.link === null || input?.link === undefined) continue;
const origin = linkOriginId(findLink(graph, input.link));
if (origin !== null && origin !== undefined && !origins.includes(origin)) origins.push(origin);
}
return origins;
}
function collectConditioningNodes(graph, startNodeId, nodeIndex) {
const queue = [startNodeId];
const visited = new Set();
const clipNodes = [];
while (queue.length && visited.size < MAX_UPSTREAM_NODES) {
const nodeId = queue.shift();
if (nodeId === null || nodeId === undefined || visited.has(nodeId)) continue;
visited.add(nodeId);
const node = nodeIndex.get(String(nodeId));
if (!node) continue;
if (isSupportedPromptNodeType(nodeType(node))) {
if (!clipNodes.includes(node)) clipNodes.push(node);
continue;
}
// Unknown third-party conditioning nodes are deliberately opaque.
// Users can label their text nodes manually in the recipe detail view.
if (!SUPPORTED_CONDITIONING_PASSTHROUGH.has(normaliseName(nodeType(node)))) continue;
for (const input of node.inputs || []) {
const inputType = normaliseName(input?.type);
const inputName = normaliseName(input?.name);
if (inputType !== 'conditioning' && !inputName.includes('conditioning')) continue;
const originId = linkOriginId(findLink(graph, input?.link));
if (originId !== null && originId !== undefined && !visited.has(originId)) {
queue.push(originId);
}
}
}
return clipNodes;
}
function appendUnique(target, values) {
for (const value of values) {
if (value && !target.includes(value)) target.push(value);
}
}
function recipeSampler(node) {
const type = nodeType(node);
const isStandardSampler = /^ksampler$/i.test(type);
const isAdvancedSampler = /^ksampleradvanced$/i.test(type);
return {
type,
seed: widgetValue(node, ['seed', 'noise_seed'], (isStandardSampler || isAdvancedSampler) ? 0 : -1),
steps: numberValue(widgetValue(node, ['steps'], (isStandardSampler || isAdvancedSampler) ? 2 : -1)),
cfg: numberValue(widgetValue(node, ['cfg', 'cfg_scale'], (isStandardSampler || isAdvancedSampler) ? 3 : -1)),
sampler_name: textValue(widgetValue(node, ['sampler_name', 'sampler'], (isStandardSampler || isAdvancedSampler) ? 4 : -1)) || null,
scheduler: textValue(widgetValue(node, ['scheduler'], (isStandardSampler || isAdvancedSampler) ? 5 : -1)) || null,
denoise: numberValue(widgetValue(node, ['denoise'], isStandardSampler ? 6 : -1)),
};
}
function mergeSampler(metadata, sampler) {
metadata.samplers.push(sampler);
for (const field of ['seed', 'steps', 'cfg', 'sampler_name', 'scheduler', 'denoise']) {
if (metadata[field] === null && sampler[field] !== null && sampler[field] !== '') {
metadata[field] = sampler[field];
}
}
}
/**
* Extract a best-effort summary from the live LiteGraph graph without
* modifying nodes, links, widgets, or graph state.
*/
export function extractRecipeMetadata(graph) {
const metadata = {
baseModel: null,
baseModels: [],
loras: [],
promptPositive: [],
promptNegative: [],
nodes: [],
nodeCount: graph?._nodes?.length || 0,
samplers: [],
seed: null,
steps: null,
cfg: null,
sampler_name: null,
scheduler: null,
denoise: null,
resolution: null,
};
if (!graph || !Array.isArray(graph._nodes)) return metadata;
const nodeIndex = buildNodeIndex(graph);
const positiveNodes = [];
const negativeNodes = [];
for (const node of graph._nodes) {
const type = nodeType(node);
if (metadata.nodes.length < MAX_SUMMARY_NODES) metadata.nodes.push(extractGenericNodeSummary(node));
if (/^(checkpointloader(simple)?|unetloader)$/i.test(type)) {
const model = textValue(widgetValue(
node,
['ckpt_name', 'checkpoint', 'unet_name', 'unet', 'model_name'],
0,
));
if (model && !metadata.baseModels.includes(model)) metadata.baseModels.push(model);
if (!metadata.baseModel && model) metadata.baseModel = model;
}
if (/lora.*loader|loader.*lora/i.test(type)) {
const name = textValue(widgetValue(node, ['lora_name', 'lora', 'model_name'], 0));
if (name) {
metadata.loras.push({
name,
strength_model: numberValue(widgetValue(node, ['strength_model', 'model_strength'], 1)),
strength_clip: numberValue(widgetValue(node, ['strength_clip', 'clip_strength'], 2)),
});
}
}
if (/^ksampler(advanced)?$/i.test(type) || /samplercustom/i.test(type)) {
mergeSampler(metadata, recipeSampler(node));
}
const roleInputs = SUPPORTED_PROMPT_CONSUMERS.get(normaliseName(type));
if (roleInputs) {
for (const [inputName, role] of Object.entries(roleInputs)) {
for (const origin of inputLinkOrigins(graph, node, [inputName])) {
const clips = collectConditioningNodes(graph, origin, nodeIndex);
const target = role === 'negative' ? negativeNodes : positiveNodes;
for (const clip of clips) if (!target.includes(clip)) target.push(clip);
}
}
}
if (/empty.*latent/i.test(type) && !metadata.resolution) {
const width = numberValue(widgetValue(node, ['width'], 0));
const height = numberValue(widgetValue(node, ['height'], 1));
if (width && height) metadata.resolution = { width, height };
}
}
for (const summary of metadata.nodes) {
if (!isSupportedPromptNodeType(summary.type)) continue;
const node = nodeIndex.get(String(summary.id));
const prompt = textValue(widgetValue(node, ['text', 'prompt'], 0));
const isPositive = positiveNodes.includes(node);
const isNegative = negativeNodes.includes(node);
if (isPositive && isNegative) {
summary.role = 'both';
summary.roleSource = 'topology';
if (prompt) {
appendUnique(metadata.promptPositive, [prompt]);
appendUnique(metadata.promptNegative, [prompt]);
}
} else if (isPositive) {
summary.role = 'positive';
summary.roleSource = 'topology';
if (prompt) appendUnique(metadata.promptPositive, [prompt]);
} else if (isNegative) {
summary.role = 'negative';
summary.roleSource = 'topology';
if (prompt) appendUnique(metadata.promptNegative, [prompt]);
} else {
summary.role = 'unknown';
summary.roleSource = 'unresolved';
}
}
return metadata;
}
/**
* Capture the graph once at save time. The serialized workflow is the source
* of truth; metadata is only a bounded presentation layer.
*/
export function captureRecipeDraft(graph) {
const workflow = graph?.serialize?.();
const nodes = Array.isArray(workflow?.nodes) ? workflow.nodes : [];
const links = Array.isArray(workflow?.links)
? workflow.links
: (workflow?.links && typeof workflow.links === 'object' ? Object.values(workflow.links) : []);
return {
workflow,
metadata: extractRecipeMetadata(graph),
stats: {
nodeCount: nodes.length,
linkCount: links.length,
groupCount: Array.isArray(workflow?.groups) ? workflow.groups.length : 0,
},
};
}
/** 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');
if (!source?.width || !source?.height) return null;
try {
const maxEdge = 720;
const scale = Math.min(1, maxEdge / Math.max(source.width, source.height));
const thumbnail = document.createElement('canvas');
thumbnail.width = Math.max(1, Math.round(source.width * scale));
thumbnail.height = Math.max(1, Math.round(source.height * scale));
const context = thumbnail.getContext('2d');
if (!context) return null;
context.drawImage(source, 0, 0, thumbnail.width, thumbnail.height);
return thumbnail.toDataURL('image/jpeg', 0.65);
} catch (error) {
console.warn('Failed to capture recipe thumbnail:', error);
return null;
}
}
+48
View File
@@ -0,0 +1,48 @@
const ALLOWED_RICH_TAGS = new Set([
'A', 'B', 'BLOCKQUOTE', 'BR', 'CODE', 'DEL', 'DIV', 'EM', 'H1', 'H2', 'H3',
'H4', 'H5', 'H6', 'HR', 'I', 'LI', 'OL', 'P', 'PRE', 'S', 'SMALL', 'SPAN',
'STRONG', 'SUB', 'SUP', 'U', 'UL'
]);
export function escapeHtml(value) {
return String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
export function setSafeRichHtml(element, value) {
const template = document.createElement('template');
template.innerHTML = String(value ?? '');
for (const node of Array.from(template.content.querySelectorAll('*'))) {
if (!ALLOWED_RICH_TAGS.has(node.tagName)) {
node.replaceWith(document.createTextNode(node.textContent || ''));
continue;
}
for (const attribute of Array.from(node.attributes)) {
const name = attribute.name.toLowerCase();
const allowed = node.tagName === 'A' && (name === 'href' || name === 'title');
if (!allowed) node.removeAttribute(attribute.name);
}
if (node.tagName === 'A') {
const href = node.getAttribute('href');
if (href) {
try {
const url = new URL(href, window.location.href);
if (url.protocol !== 'http:' && url.protocol !== 'https:') node.removeAttribute('href');
} catch (_) {
node.removeAttribute('href');
}
}
node.setAttribute('target', '_blank');
node.setAttribute('rel', 'noopener noreferrer');
}
}
element.replaceChildren(template.content.cloneNode(true));
}
File diff suppressed because it is too large Load Diff
+152
View File
@@ -0,0 +1,152 @@
import { translate } from './locales.js';
const t = (key, params) => translate(key, params);
export function anomalousAlert(message, title = 'Anomalous') {
return new Promise((resolve) => {
const overlay = document.createElement('div');
overlay.style.position = 'fixed';
overlay.style.inset = '0';
overlay.style.zIndex = '999999';
overlay.style.display = 'flex';
overlay.style.alignItems = 'center';
overlay.style.justifyContent = 'center';
overlay.style.background = 'rgba(0, 0, 0, 0.6)';
overlay.style.backdropFilter = 'blur(4px)';
overlay.style.padding = '20px';
overlay.style.boxSizing = 'border-box';
const dialog = document.createElement('div');
dialog.style.background = 'linear-gradient(145deg, rgba(48, 49, 55, 0.98), rgba(27, 28, 33, 0.98))';
dialog.style.border = '1px solid rgba(255, 255, 255, 0.12)';
dialog.style.borderRadius = '16px';
dialog.style.padding = '24px';
dialog.style.maxWidth = '400px';
dialog.style.width = '100%';
dialog.style.maxHeight = '90vh';
dialog.style.overflowY = 'auto';
dialog.style.boxShadow = '0 24px 70px rgba(0, 0, 0, 0.55)';
dialog.style.color = '#f3f4f6';
dialog.style.display = 'flex';
dialog.style.flexDirection = 'column';
dialog.style.gap = '16px';
const heading = document.createElement('h3');
heading.textContent = title;
heading.style.margin = '0';
heading.style.fontSize = '1.25rem';
const text = document.createElement('p');
text.textContent = message;
text.style.margin = '0';
text.style.lineHeight = '1.5';
text.style.whiteSpace = 'pre-wrap';
text.style.color = '#ccc';
const footer = document.createElement('div');
footer.style.display = 'flex';
footer.style.justifyContent = 'flex-end';
footer.style.marginTop = '8px';
const okBtn = document.createElement('button');
okBtn.textContent = t('dialogOk');
okBtn.className = 'anomalous-btn-primary';
okBtn.style.padding = '8px 24px';
const cleanup = () => {
overlay.remove();
resolve();
};
okBtn.onclick = cleanup;
footer.appendChild(okBtn);
dialog.append(heading, text, footer);
overlay.appendChild(dialog);
document.body.appendChild(overlay);
okBtn.focus();
});
}
export function anomalousConfirm(message, title = 'Anomalous', options = {}) {
return new Promise((resolve) => {
const overlay = document.createElement('div');
overlay.style.position = 'fixed';
overlay.style.inset = '0';
overlay.style.zIndex = '999999';
overlay.style.display = 'flex';
overlay.style.alignItems = 'center';
overlay.style.justifyContent = 'center';
overlay.style.background = 'rgba(0, 0, 0, 0.6)';
overlay.style.backdropFilter = 'blur(4px)';
overlay.style.padding = '20px';
overlay.style.boxSizing = 'border-box';
const dialog = document.createElement('div');
dialog.style.background = 'linear-gradient(145deg, rgba(48, 49, 55, 0.98), rgba(27, 28, 33, 0.98))';
dialog.style.border = '1px solid rgba(255, 255, 255, 0.12)';
dialog.style.borderRadius = '16px';
dialog.style.padding = '24px';
dialog.style.maxWidth = '400px';
dialog.style.width = '100%';
dialog.style.maxHeight = '90vh';
dialog.style.overflowY = 'auto';
dialog.style.boxShadow = '0 24px 70px rgba(0, 0, 0, 0.55)';
dialog.style.color = '#f3f4f6';
dialog.style.display = 'flex';
dialog.style.flexDirection = 'column';
dialog.style.gap = '16px';
const heading = document.createElement('h3');
heading.textContent = title;
heading.style.margin = '0';
heading.style.fontSize = '1.25rem';
const text = document.createElement('p');
text.textContent = message;
text.style.margin = '0';
text.style.lineHeight = '1.5';
text.style.whiteSpace = 'pre-wrap';
text.style.color = '#ccc';
const footer = document.createElement('div');
footer.style.display = 'flex';
footer.style.justifyContent = 'flex-end';
footer.style.gap = '12px';
footer.style.marginTop = '8px';
const cancelBtn = document.createElement('button');
cancelBtn.textContent = t('dialogCancel');
cancelBtn.className = 'anomalous-btn-ghost';
cancelBtn.style.padding = '8px 24px';
const okBtn = document.createElement('button');
okBtn.textContent = t('dialogOk');
okBtn.className = 'anomalous-btn-danger';
okBtn.style.padding = '8px 24px';
const noBtn = options.noLabel ? document.createElement('button') : null;
if (noBtn) {
noBtn.textContent = options.noLabel;
noBtn.className = 'anomalous-btn-ghost';
noBtn.style.padding = '8px 24px';
}
const finish = (result) => {
overlay.remove();
resolve(result);
};
cancelBtn.onclick = () => finish(null);
noBtn?.addEventListener('click', () => finish(false));
okBtn.onclick = () => finish(true);
footer.append(cancelBtn);
if (noBtn) footer.append(noBtn);
footer.append(okBtn);
dialog.append(heading, text, footer);
overlay.appendChild(dialog);
document.body.appendChild(overlay);
cancelBtn.focus();
});
}
File diff suppressed because it is too large Load Diff
+589
View File
@@ -0,0 +1,589 @@
/**
* ui_gallery.js
* Extracted Gallery Viewer methods.
*/
import { app } from "../../../scripts/app.js";
import { translate } from './locales.js';
const t = (key, params) => translate(key, params);
export async function refreshGalleryImages() {
if (this.galleryLoading || this.galleryRefreshLoading) return;
this.galleryRefreshLoading = true;
try {
const scrollTop = this.galleryGrid?.scrollTop || 0;
await this.loadGalleryImages(1, true);
if (this.galleryGrid) this.galleryGrid.scrollTop = scrollTop;
} catch (error) {
console.warn('Could not refresh output gallery:', error);
} finally {
this.galleryRefreshLoading = false;
}
}
export async function loadGalleryImages(page = 1, reset = false) {
if (this.galleryLoading) return;
this.galleryLoading = true;
this.gallerySentinel.textContent = t('galleryLoading');
try {
const res = await fetch(`/anomalous/gallery_images?page=${page}&limit=50`);
const data = await res.json();
if (reset) {
// Clear existing cards
const cards = this.galleryGrid.querySelectorAll('.anomalous-gallery-card');
cards.forEach(c => c.remove());
this.galleryLoaded = true;
}
if (data.images && data.images.length > 0) {
data.images.forEach(imgData => {
const card = document.createElement('div');
card.className = 'anomalous-gallery-card';
const q_sub = encodeURIComponent(imgData.subfolder);
const q_file = encodeURIComponent(imgData.filename);
const imgUrl = `/view?filename=${q_file}&subfolder=${q_sub}&type=output`;
const img = document.createElement('img');
img.src = imgUrl;
img.loading = 'lazy';
img.draggable = true;
// Drag and drop support for ComfyUI
img.addEventListener('dragstart', (e) => {
const fullUrl = new URL(imgUrl, window.location.href).href;
e.dataTransfer.setData('text/uri-list', fullUrl);
e.dataTransfer.setData('text/plain', fullUrl);
// Fix for Chromium failing to initiate drag for extremely large (Hires Fix) images
if (window.anomalousDragGhostImg) {
e.dataTransfer.setDragImage(window.anomalousDragGhostImg, 40, 40);
}
});
// Click to view
img.onclick = () => {
if (this.gallerySelectModel) {
const model = this.gallerySelectModel;
fetch('/anomalous/set_custom_cover', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: this.currentType,
path_idx: this.currentPathIdx,
subfolder: this.currentSubfolder,
filename: model.filename,
source_image: imgData.subfolder ? imgData.subfolder + '/' + imgData.filename : imgData.filename
})
}).then(res => res.json()).then(async data => {
if (data.status === 'success') {
const tempModel = this.gallerySelectModel;
this.gallerySelectModel = null;
const banner = document.getElementById('anomalous-gallery-select-banner');
if (banner) banner.style.display = 'none';
this.galleryPanel.style.display = 'none';
await this.loadModels();
const updatedModel = this.models.find(m => m.filename === model.filename);
if (this.currentDetailModel && this.currentDetailModel.filename === model.filename) {
this.detailPanel.style.display = 'flex';
if (updatedModel) this.showDetail(updatedModel);
} else {
this.grid.style.display = 'grid';
// Grid was already refreshed by loadModels
}
} else {
alert(t('galleryErrorPrefix') + data.message);
}
});
return;
}
this.showGalleryViewer(imgUrl);
};
const delBtn = document.createElement('button');
delBtn.className = 'anomalous-gallery-delete';
delBtn.innerHTML = '🗑️';
delBtn.title = t('galleryDelete');
delBtn.onclick = (e) => {
e.stopPropagation();
const overlay = document.createElement('div');
overlay.style.position = 'absolute';
overlay.style.top = '0';
overlay.style.left = '0';
overlay.style.width = '100%';
overlay.style.height = '100%';
overlay.style.background = 'rgba(0,0,0,0.85)';
overlay.style.display = 'flex';
overlay.style.flexDirection = 'column';
overlay.style.alignItems = 'center';
overlay.style.justifyContent = 'center';
overlay.style.gap = '15px';
overlay.style.zIndex = '10';
const msg = document.createElement('div');
const msgTitle = document.createElement('div');
msgTitle.textContent = t('galleryDeleteConfirm');
const msgHint = document.createElement('div');
msgHint.textContent = t('galleryDeleteConfirmHint');
msgHint.style.fontSize = '0.8em';
msgHint.style.color = '#aaa';
msg.append(msgTitle, msgHint);
msg.style.color = '#fff';
msg.style.fontWeight = 'bold';
msg.style.textAlign = 'center';
const btnRow = document.createElement('div');
btnRow.style.display = 'flex';
btnRow.style.gap = '12px';
const confirmBtn = document.createElement('button');
confirmBtn.textContent = `🗑️ ${t('galleryDelete')}`;
confirmBtn.style.background = '#dc3545';
confirmBtn.style.color = '#fff';
confirmBtn.style.border = 'none';
confirmBtn.style.padding = '10px 16px';
confirmBtn.style.borderRadius = '6px';
confirmBtn.style.cursor = 'pointer';
confirmBtn.style.fontWeight = 'bold';
confirmBtn.style.transition = 'background 0.2s';
confirmBtn.onmouseover = () => confirmBtn.style.background = '#ff0000';
confirmBtn.onmouseout = () => confirmBtn.style.background = '#dc3545';
const cancelBtn = document.createElement('button');
cancelBtn.textContent = t('galleryCancel');
cancelBtn.style.background = '#444';
cancelBtn.style.color = '#fff';
cancelBtn.style.border = 'none';
cancelBtn.style.padding = '10px 16px';
cancelBtn.style.borderRadius = '6px';
cancelBtn.style.cursor = 'pointer';
cancelBtn.style.transition = 'background 0.2s';
cancelBtn.onmouseover = () => cancelBtn.style.background = '#666';
cancelBtn.onmouseout = () => cancelBtn.style.background = '#444';
cancelBtn.onclick = (ce) => {
ce.stopPropagation();
overlay.remove();
};
confirmBtn.onclick = async (ce) => {
ce.stopPropagation();
confirmBtn.textContent = t('galleryDeleting');
confirmBtn.disabled = true;
try {
const dr = await fetch('/anomalous/delete_gallery_image', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: imgData.filename, subfolder: imgData.subfolder })
});
const dd = await dr.json();
if (dd.status === 'success') {
card.remove();
} else {
alert(t('galleryDeleteFailed') + dd.message);
overlay.remove();
}
} catch (err) {
alert(t('galleryErrorPrefix') + err);
overlay.remove();
}
};
btnRow.appendChild(cancelBtn);
btnRow.appendChild(confirmBtn);
overlay.appendChild(msg);
overlay.appendChild(btnRow);
card.appendChild(overlay);
};
card.appendChild(img);
card.appendChild(delBtn);
this.galleryGrid.insertBefore(card, this.gallerySentinel);
});
this.galleryCurrentPage = page;
this.galleryHasMore = page < data.pages;
if (!this.galleryHasMore) {
this.gallerySentinel.textContent = t('galleryNoMore');
} else {
this.gallerySentinel.textContent = t('galleryScrollMore');
}
} else {
this.galleryHasMore = false;
this.gallerySentinel.textContent = reset ? t('galleryEmpty') : t('galleryNoMore');
}
} catch (e) {
console.error('Failed to load gallery images', e);
this.gallerySentinel.textContent = t('galleryLoadFailed');
}
this.galleryLoading = false;
}
export async function showGeneratedGallery(model) {
let overlay = document.getElementById('anomalous-generated-gallery-overlay');
if (!overlay) {
overlay = document.createElement('div');
overlay.id = 'anomalous-generated-gallery-overlay';
overlay.style.position = 'absolute';
overlay.style.top = '0';
overlay.style.left = '0';
overlay.style.width = '100%';
overlay.style.height = '100%';
overlay.style.backgroundColor = 'rgba(0, 0, 0, 0.85)';
overlay.style.zIndex = '999999';
overlay.style.display = 'flex';
overlay.style.alignItems = 'center';
overlay.style.justifyContent = 'center';
const modalBox = document.createElement('div');
modalBox.id = 'anomalous-generated-gallery-modal';
modalBox.style.width = '95%';
modalBox.style.maxHeight = '95%';
modalBox.style.backgroundColor = 'var(--comfy-menu-bg, #222)';
modalBox.style.borderRadius = '12px';
modalBox.style.display = 'flex';
modalBox.style.flexDirection = 'column';
modalBox.style.overflow = 'hidden';
modalBox.style.boxShadow = '0 10px 40px rgba(0, 0, 0, 0.8)';
const header = document.createElement('div');
header.style.padding = '15px 25px';
header.style.background = '#333';
header.style.display = 'flex';
header.style.justifyContent = 'space-between';
header.style.alignItems = 'center';
header.style.borderBottom = '1px solid #444';
const title = document.createElement('h2');
title.id = 'anomalous-generated-gallery-title';
title.style.margin = '0';
title.style.color = '#fff';
const closeBtn = document.createElement('button');
closeBtn.textContent = `✖ ${t('galleryClose')}`;
closeBtn.style.padding = '8px 15px';
closeBtn.style.background = '#dc3545';
closeBtn.style.color = '#fff';
closeBtn.style.border = 'none';
closeBtn.style.borderRadius = '5px';
closeBtn.style.cursor = 'pointer';
closeBtn.style.fontWeight = 'bold';
closeBtn.onmouseover = () => closeBtn.style.background = '#c82333';
closeBtn.onmouseout = () => closeBtn.style.background = '#dc3545';
closeBtn.onclick = () => {
overlay.style.display = 'none';
};
header.appendChild(title);
header.appendChild(closeBtn);
modalBox.appendChild(header);
const contentCont = document.createElement('div');
contentCont.id = 'anomalous-generated-gallery-content';
contentCont.style.flex = '1';
contentCont.style.overflowY = 'auto';
contentCont.style.padding = '20px';
contentCont.style.display = 'grid';
contentCont.style.gridTemplateColumns = 'repeat(auto-fill, minmax(220px, 1fr))';
contentCont.style.gap = '25px';
contentCont.style.rowGap = '40px';
contentCont.style.alignContent = 'start';
modalBox.appendChild(contentCont);
overlay.appendChild(modalBox);
document.getElementById('anomalous-container').appendChild(overlay);
}
const title = document.getElementById('anomalous-generated-gallery-title');
title.textContent = t('galleryHistoryTitle', { name: model.name || model.filename });
const contentCont = document.getElementById('anomalous-generated-gallery-content');
contentCont.innerHTML = '';
const loading = document.createElement('div');
loading.textContent = t('galleryScanning');
loading.style.textAlign = 'center';
loading.style.gridColumn = '1 / -1';
loading.style.padding = '50px';
loading.style.color = '#aaa';
contentCont.appendChild(loading);
overlay.style.display = 'flex';
try {
const res = await fetch('/anomalous/model_images?model_name=' + encodeURIComponent(model.filename) + '&t=' + Date.now());
const data = await res.json();
contentCont.innerHTML = '';
if (!data.images || data.images.length === 0) {
const emptyMsg = document.createElement('div');
emptyMsg.textContent = t('galleryNoModelImages');
emptyMsg.style.textAlign = 'center';
emptyMsg.style.gridColumn = '1 / -1';
emptyMsg.style.padding = '50px';
emptyMsg.style.color = '#888';
contentCont.appendChild(emptyMsg);
return;
}
data.images.forEach(img => {
const imgCont = document.createElement('div');
imgCont.className = 'anomalous-card';
imgCont.style.cursor = 'pointer';
const el = document.createElement('img');
el.src = img.url || img; // Support both just in case
el.loading = 'lazy';
el.draggable = true;
el.addEventListener('dragstart', (e) => {
const fullUrl = new URL(el.src, window.location.href).href;
e.dataTransfer.setData('text/uri-list', fullUrl);
e.dataTransfer.setData('text/plain', fullUrl);
if (window.anomalousDragGhostImg) {
e.dataTransfer.setDragImage(window.anomalousDragGhostImg, 40, 40);
}
});
let source_image = "";
let filenameText = "";
if (img.url) {
try {
const urlParams = new URLSearchParams(img.url.split('?')[1]);
filenameText = urlParams.get('filename') || '';
const sub = urlParams.get('subfolder') || '';
source_image = sub ? sub + '/' + filenameText : filenameText;
} catch (e) { }
} else {
filenameText = img.split('/').pop().split('?')[0];
source_image = filenameText;
}
const titleDiv = document.createElement('div');
titleDiv.className = 'anomalous-card-title';
titleDiv.innerText = filenameText;
const setCoverBtn = document.createElement('button');
setCoverBtn.textContent = t('gallerySetCover');
setCoverBtn.style.position = 'absolute';
setCoverBtn.style.bottom = '40px';
setCoverBtn.style.right = '5px';
setCoverBtn.style.background = 'rgba(40, 167, 69, 0.85)';
setCoverBtn.style.color = '#fff';
setCoverBtn.style.border = '1px solid rgba(255,255,255,0.3)';
setCoverBtn.style.borderRadius = '4px';
setCoverBtn.style.padding = '4px 8px';
setCoverBtn.style.cursor = 'pointer';
setCoverBtn.style.zIndex = '10';
setCoverBtn.style.fontSize = '12px';
setCoverBtn.onclick = (e) => {
e.stopPropagation();
if (!confirm(t('gallerySetCoverConfirm'))) return;
fetch('/anomalous/set_custom_cover', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: this.currentType,
path_idx: this.currentPathIdx,
subfolder: this.currentSubfolder,
filename: model.filename,
source_image: source_image
})
}).then(res => res.json()).then(async result => {
if (result.status === 'success') {
alert(t('galleryCoverSuccess'));
await this.loadModels();
const updatedModel = this.models.find(m => m.filename === model.filename);
if (updatedModel && this.currentDetailModel && this.currentDetailModel.filename === model.filename) {
this.showDetail(updatedModel);
}
} else {
alert(t('galleryErrorPrefix') + result.message);
}
}).catch(err => {
alert(t('galleryErrorPrefix') + err.message);
});
};
imgCont.onclick = () => {
this.showGalleryViewer(img.url || img);
};
imgCont.appendChild(el);
imgCont.appendChild(setCoverBtn);
imgCont.appendChild(titleDiv);
contentCont.appendChild(imgCont);
});
} catch (e) {
const error = document.createElement('div');
error.textContent = t('galleryLoadImagesError');
error.style.color = 'red';
error.style.textAlign = 'center';
error.style.gridColumn = '1 / -1';
error.style.padding = '50px';
contentCont.replaceChildren(error);
}
}
export function showGallerySelectMode(model) {
this.gallerySelectModel = model;
this.grid.style.display = 'none';
this.detailPanel.style.display = 'none';
this.galleryPanel.style.display = 'flex';
let banner = document.getElementById('anomalous-gallery-select-banner');
if (!banner) {
banner = document.createElement('div');
banner.id = 'anomalous-gallery-select-banner';
banner.style.background = '#28a745';
banner.style.color = '#fff';
banner.style.padding = '10px';
banner.style.textAlign = 'center';
banner.style.fontWeight = 'bold';
banner.style.position = 'sticky';
banner.style.top = '0';
banner.style.zIndex = '1000';
this.galleryPanel.insertBefore(banner, this.galleryPanel.firstChild);
}
banner.style.display = 'block';
banner.replaceChildren();
const bannerPrefix = document.createTextNode(t('gallerySelectingCoverPrefix'));
const bannerModel = document.createElement('span');
bannerModel.textContent = model.filename;
bannerModel.style.color = '#ff0';
const bannerSuffix = document.createTextNode(t('gallerySelectingCoverSuffix'));
const cancelSelect = document.createElement('button');
cancelSelect.id = 'anomalous-cancel-select';
cancelSelect.textContent = t('galleryCancel');
cancelSelect.style.marginLeft = '15px';
cancelSelect.style.color = '#000';
cancelSelect.style.background = '#fff';
cancelSelect.style.border = 'none';
cancelSelect.style.padding = '2px 8px';
cancelSelect.style.borderRadius = '4px';
cancelSelect.style.cursor = 'pointer';
banner.append(bannerPrefix, bannerModel, bannerSuffix, cancelSelect);
cancelSelect.onclick = () => {
const tempModel = this.gallerySelectModel;
this.gallerySelectModel = null;
banner.style.display = 'none';
this.galleryPanel.style.display = 'none';
if (this.currentDetailModel) {
this.detailPanel.style.display = 'flex';
} else {
this.grid.style.display = 'grid';
}
if (tempModel) {
this.showEditModal(tempModel);
}
};
if (!this.galleryLoaded) {
this.loadGalleryImages(1, true);
}
}
export function showGalleryViewer(src) {
let viewer = document.getElementById('anomalous-gallery-viewer');
if (!viewer) {
viewer = document.createElement('dialog');
viewer.id = 'anomalous-gallery-viewer';
viewer.className = 'anomalous-gallery-viewer';
const closeBtn = document.createElement('div');
closeBtn.className = 'anomalous-gallery-viewer-close';
closeBtn.innerHTML = '&times;';
const img = document.createElement('img');
img.id = 'anomalous-gallery-viewer-img';
viewer.appendChild(img);
viewer.appendChild(closeBtn);
let scale = 1;
let translateX = 0;
let translateY = 0;
let isDragging = false;
let startX = 0, startY = 0;
const resetImgTransform = () => {
scale = 1; translateX = 0; translateY = 0;
img.style.transform = `translate(0px, 0px) scale(1)`;
img.style.cursor = 'grab';
};
closeBtn.onclick = () => {
viewer.close();
resetImgTransform();
};
viewer.onclick = (e) => {
if (e.target === viewer) {
viewer.close();
resetImgTransform();
}
};
viewer.addEventListener('wheel', (e) => {
e.preventDefault();
const zoomFactor = 0.1;
if (e.deltaY < 0) scale += zoomFactor;
else scale = Math.max(0.1, scale - zoomFactor);
img.style.transform = `translate(${translateX}px, ${translateY}px) scale(${scale})`;
});
img.addEventListener('mousedown', (e) => {
e.preventDefault();
isDragging = true;
startX = e.clientX - translateX;
startY = e.clientY - translateY;
img.style.cursor = 'grabbing';
});
window.addEventListener('mousemove', (e) => {
if (!isDragging) return;
translateX = e.clientX - startX;
translateY = e.clientY - startY;
img.style.transform = `translate(${translateX}px, ${translateY}px) scale(${scale})`;
});
window.addEventListener('mouseup', () => {
isDragging = false;
img.style.cursor = 'grab';
});
document.body.appendChild(viewer);
}
const img = document.getElementById('anomalous-gallery-viewer-img');
img.src = src;
// Reset scale and translation when opening a new image
img.style.transform = `translate(0px, 0px) scale(1)`;
img.style.cursor = 'grab';
if (!viewer.open) viewer.showModal();
}
+326
View File
@@ -0,0 +1,326 @@
/**
* ui_grid.js
* Extracted Grid methods.
*/
import { app } from "../../../scripts/app.js";
import { translate } from './locales.js';
const t = (key, params) => translate(key, params);
export function cardPreviewUrl(previewUrl, thumbnailMode) {
if (!previewUrl || thumbnailMode === 'original') return previewUrl;
return `${previewUrl}${previewUrl.includes('?') ? '&' : '?'}variant=card`;
}
export async function loadModels() {
try {
if (this._modelLoadController) this._modelLoadController.abort();
this._modelRenderGeneration = (this._modelRenderGeneration || 0) + 1;
const loadController = new AbortController();
this._modelLoadController = loadController;
const params = new URLSearchParams({ type: this.currentType, path_idx: this.currentPathIdx, subfolder: this.currentSubfolder });
const res = await fetch('/anomalous/models?' + params.toString(), { signal: loadController.signal });
const data = await res.json();
if (this._modelLoadController !== loadController) return;
if (window.anomalous_update_hash_cache && data.models) {
window.anomalous_update_hash_cache(data.models);
}
this.models = data.models || [];
const renderGeneration = this._modelRenderGeneration;
if (this._modelMediaObserver) this._modelMediaObserver.disconnect();
this._modelMediaObserver = typeof IntersectionObserver === 'function'
? new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
const media = entry.target;
const shouldAutoplay = media.tagName === 'VIDEO' && media.dataset.anomalousAutoplay === 'true';
if (!entry.isIntersecting) {
if (media.tagName === 'VIDEO') {
media.pause();
if (media.getAttribute('src')) {
media.removeAttribute('src');
media.load();
}
}
return;
}
if (media.tagName === 'VIDEO' && !shouldAutoplay) return;
const pendingSrc = media.dataset.anomalousSrc;
if (pendingSrc && !media.getAttribute('src')) media.src = pendingSrc;
if (shouldAutoplay) media.play().catch(() => {});
});
}, { rootMargin: '300px' })
: null;
stopMediaInContainer(this.grid);
this.grid.replaceChildren();
if (!data.models || data.models.length === 0) {
this.grid.innerHTML = `<div style="color:white; padding:20px;">${t('noModels')}</div>`;
return;
}
let renderIndex = 0;
const renderChunk = () => {
if (this._modelRenderGeneration !== renderGeneration) return;
const fragment = document.createDocumentFragment();
const end = Math.min(renderIndex + 40, data.models.length);
for (; renderIndex < end; renderIndex++) {
const model = data.models[renderIndex];
const card = document.createElement('div');
card.className = 'anomalous-card';
if (model.preview_url) {
const isVideo = model.preview_url.match(/\.mp4(?:&|$)/i) || model.preview_url.match(/\.webm(?:&|$)/i);
if (isVideo) {
const video = document.createElement('video');
video.dataset.anomalousSrc = model.preview_url;
video.muted = true; video.loop = true; video.playsInline = true;
video.preload = 'metadata';
const ensureVideoSource = () => {
if (!video.getAttribute('src')) video.src = video.dataset.anomalousSrc;
};
if (this.energySaving) {
video.pause();
card.addEventListener('mouseenter', () => {
ensureVideoSource();
video.play().catch(() => {});
});
card.addEventListener('mouseleave', () => { video.pause(); video.currentTime = 0; });
} else {
video.dataset.anomalousAutoplay = 'true';
}
if (this._modelMediaObserver) {
this._modelMediaObserver.observe(video);
} else {
ensureVideoSource();
if (!this.energySaving) video.autoplay = true;
}
card.appendChild(video);
} else {
const img = document.createElement('img');
img.loading = 'lazy';
img.decoding = 'async';
img.src = cardPreviewUrl(model.preview_url, this.cardThumbnailMode);
card.appendChild(img);
}
} else {
const ph = document.createElement('div');
ph.className = 'anomalous-card-placeholder';
ph.innerHTML = `<div style="text-align:center;color:#666;margin-top:80px;">${t('noPreview')}</div><div style="font-size:0.8em;text-align:center;opacity:0.5;margin-top:5px">${t('clickScan')}</div>`;
card.appendChild(ph);
}
const title = document.createElement('div');
if (model.metadata && model.metadata.baseModel) {
const badge = document.createElement('div');
badge.innerText = model.metadata.baseModel;
badge.style.position = 'absolute';
badge.style.top = '6px';
badge.style.left = '6px';
badge.style.background = 'rgba(0,0,0,0.85)';
badge.style.color = '#00ffcc';
badge.style.padding = '3px 6px';
badge.style.borderRadius = '4px';
badge.style.fontSize = '0.75em';
badge.style.fontWeight = 'bold';
badge.style.border = '1px solid rgba(0,255,204,0.3)';
badge.style.pointerEvents = 'none';
badge.style.zIndex = '10';
card.appendChild(badge);
}
title.className = 'anomalous-card-title';
title.innerText = model.filename;
card.appendChild(title);
card.onclick = () => {
this.recipeModelReturn = null;
this.historyStack = [];
this.currentDetailModel = model;
this.showDetail(model);
};
const applyBtn = document.createElement('button');
applyBtn.innerHTML = '➕';
applyBtn.title = t('applyToCanvas');
applyBtn.style.position = 'absolute';
applyBtn.style.top = '6px';
applyBtn.style.right = '6px';
applyBtn.style.background = 'rgba(0,0,0,0.7)';
applyBtn.style.color = '#fff';
applyBtn.style.border = '1px solid rgba(255,255,255,0.2)';
applyBtn.style.borderRadius = '4px';
applyBtn.style.cursor = 'pointer';
applyBtn.style.padding = '4px 6px';
applyBtn.style.zIndex = '20';
applyBtn.style.fontSize = '1em';
applyBtn.style.display = 'none';
const singleScanBtn = document.createElement('button');
singleScanBtn.innerHTML = '🎯';
singleScanBtn.title = t('scanModelPrecisely');
singleScanBtn.style.position = 'absolute';
singleScanBtn.style.top = '6px';
singleScanBtn.style.right = '40px';
singleScanBtn.style.background = 'rgba(0,0,0,0.7)';
singleScanBtn.style.color = '#fff';
singleScanBtn.style.border = '1px solid rgba(255,255,255,0.2)';
singleScanBtn.style.borderRadius = '4px';
singleScanBtn.style.cursor = 'pointer';
singleScanBtn.style.padding = '4px 6px';
singleScanBtn.style.zIndex = '20';
singleScanBtn.style.fontSize = '1em';
singleScanBtn.style.display = 'none';
singleScanBtn.onclick = (e) => {
e.stopPropagation();
createWizardModal(false, model.filename);
};
card.appendChild(singleScanBtn);
const editBtn = document.createElement('button');
editBtn.innerHTML = '⚙️';
editBtn.title = t('editModel');
editBtn.style.position = 'absolute';
editBtn.style.top = '6px';
editBtn.style.right = '74px'; // shifted left for new button
editBtn.style.width = '26px';
editBtn.style.height = '26px';
editBtn.style.borderRadius = '50%';
editBtn.style.border = 'none';
editBtn.style.background = 'rgba(0,0,0,0.7)';
editBtn.style.color = '#fff';
editBtn.style.cursor = 'pointer';
editBtn.style.display = 'none';
editBtn.style.alignItems = 'center';
editBtn.style.justifyContent = 'center';
editBtn.style.fontSize = '14px';
editBtn.onclick = (e) => {
e.stopPropagation();
this.showEditModal(model);
};
card.appendChild(editBtn);
card.addEventListener('mouseenter', () => {
applyBtn.style.display = 'block';
editBtn.style.display = 'flex';
singleScanBtn.style.display = 'block';
});
card.addEventListener('mouseleave', () => {
applyBtn.style.display = 'none';
editBtn.style.display = 'none';
singleScanBtn.style.display = 'none';
});
applyBtn.onclick = (e) => {
e.stopPropagation();
this.applyModelToCanvas(this.currentType, this.currentSubfolder, model);
};
card.appendChild(applyBtn);
fragment.appendChild(card);
}
this.grid.appendChild(fragment);
if (renderIndex < data.models.length) requestAnimationFrame(renderChunk);
};
requestAnimationFrame(renderChunk);
} catch (e) {
if (e && e.name !== 'AbortError') console.error('[Anomalous] Failed to load models', e);
}
}
export function applyModelToCanvas(type, subfolder, model) {
const nodeTypeMap = {
'checkpoints': 'CheckpointLoaderSimple',
'loras': 'LoraLoader',
'unet': 'UNETLoader',
'diffusion_models': 'UNETLoader'
};
const nodeType = nodeTypeMap[type];
if (!nodeType) {
alert(t('unsupportedAutoApply'));
return;
}
const node = LiteGraph.createNode(nodeType);
if (!node) {
alert(t('createNodeFailed') + nodeType);
return;
}
if (app.canvas && app.canvas.graph_mouse) {
node.pos = [
app.canvas.graph_mouse[0] || (window.innerWidth / 2),
app.canvas.graph_mouse[1] || (window.innerHeight / 2)
];
} else {
node.pos = [window.innerWidth / 2, window.innerHeight / 2];
}
app.graph.add(node);
const sub = subfolder.replace(/^\/+/, '').replace(/\/+$/, '');
const relPath = sub ? `${sub}/${model.filename}` : model.filename;
this.setWidgetValuePath(node, relPath);
this.close();
// 粘到鼠标上的逻辑
let isSticking = true;
const stickHandler = (e) => {
if (!isSticking || !app.canvas) return;
const canvas = app.canvas;
// LiteGraph内置了坐标转换,它会完美处理缩放和偏移带来的坐标偏移问题
let canvasX, canvasY;
if (canvas.convertEventToCanvasOffset) {
const pos = canvas.convertEventToCanvasOffset(e);
canvasX = pos[0];
canvasY = pos[1];
} else {
// 如果API不可用,使用标准降级计算
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;
}
const w = (node.size && node.size[0]) ? node.size[0] : 200;
node.pos = [canvasX - w / 2, canvasY - 20];
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);
}
export function stopMediaInContainer(container) {
if (!container) return;
const mediaElements = container.querySelectorAll('video, audio');
mediaElements.forEach(media => {
media.pause();
media.removeAttribute('src');
media.load();
});
}
+830
View File
@@ -0,0 +1,830 @@
/**
* ui_notebooks.js
* Extracted Notebooks methods.
*/
import { app } from "../../../scripts/app.js";
import { translate } from './locales.js';
import { escapeHtml } from './safe_dom.js';
const t = (key, params) => translate(key, params);
function restoreWorkspaceReturnPanel(owner) {
const state = owner.workspaceReturnState;
owner.workspaceReturnState = null;
const panels = [
['grid', owner.grid],
['detail', owner.detailPanel],
['gallery', owner.galleryPanel],
['doctor', owner.doctorPanel],
['assistant', owner.assistantPanel],
];
if (state) {
for (const [key, panel] of panels) {
if (panel && Object.prototype.hasOwnProperty.call(state, key)) panel.style.display = state[key];
}
}
const hasVisiblePanel = panels.some(([, panel]) => panel && panel.style.display !== 'none');
if (!hasVisiblePanel && owner.grid) owner.grid.style.display = 'grid';
}
export function closeWorkspace() {
this.recipeDetailFinish?.('closed');
const abandonedRecipeModel = typeof this.recipeModelReturn === 'function';
this.recipeModelReturn = null;
if (abandonedRecipeModel) {
this.recipeReturnState = null;
delete this.recipeDetailPayload;
if (this.recipeListContainer) this.recipeListContainer.style.display = '';
const actionbar = this.recipeView?.querySelector('.anomalous-recipe-actionbar');
if (actionbar) actionbar.style.display = '';
if (this.detailPanel) {
this.stopMediaInContainer?.(this.detailPanel);
this.detailPanel.replaceChildren();
this.detailPanel.style.display = 'none';
}
this.currentDetailModel = null;
this.historyStack = [];
}
if (this.paramPanel) this.paramPanel.style.display = 'none';
if (this.recipeView) this.recipeView.style.display = 'none';
if (this.notebookBody) this.notebookBody.style.display = 'none';
if (this.nbPanel) this.nbPanel.style.display = 'none';
restoreWorkspaceReturnPanel(this);
}
export async function showNotebooks() {
if (this.nbInitialized) {
this.nbPanel.style.display = 'flex';
if (this.notebookBody) this.notebookBody.style.display = 'flex';
if (this.recipeView) this.recipeView.style.display = 'none';
this.notebookNotesTab?.classList.add('active');
this.notebookRecipesTab?.classList.remove('active');
this.refreshNotebooks(true);
return;
}
this.nbInitialized = true;
this.nbPanel.innerHTML = '';
const nbContainer = document.createElement('div');
nbContainer.className = 'anomalous-nb-container';
const nbHeader = document.createElement('div');
nbHeader.className = 'anomalous-nb-header';
const headerMain = document.createElement('div');
headerMain.className = 'anomalous-nb-header-main';
const heading = document.createElement('h2');
heading.textContent = t('workspaceTitle');
const sectionTabs = document.createElement('div');
sectionTabs.className = 'anomalous-nb-section-tabs';
const notesTab = document.createElement('button');
notesTab.type = 'button';
notesTab.className = 'anomalous-nb-section-tab active';
notesTab.textContent = t('promptNotes');
const recipesTab = document.createElement('button');
recipesTab.type = 'button';
recipesTab.className = 'anomalous-nb-section-tab';
recipesTab.textContent = t('recipeTitle');
sectionTabs.append(notesTab, recipesTab);
headerMain.append(heading, sectionTabs);
nbHeader.appendChild(headerMain);
const closeNb = document.createElement('span');
closeNb.className = 'anomalous-nb-close';
closeNb.innerHTML = '&times;';
closeNb.onclick = () => this.closeWorkspace();
nbHeader.appendChild(closeNb);
const body = document.createElement('div');
body.className = 'anomalous-nb-body';
this.notebookBody = body;
this.notebookContainer = nbContainer;
this.notebookNotesTab = notesTab;
this.notebookRecipesTab = recipesTab;
notesTab.onclick = () => {
this.notebookBody.style.display = 'flex';
if (this.recipeView) this.recipeView.style.display = 'none';
notesTab.classList.add('active');
recipesTab.classList.remove('active');
this.refreshNotebooks(true);
};
recipesTab.onclick = () => this.showRecipes();
// Sidebar for notebooks list
const sidebar = document.createElement('div');
sidebar.className = 'anomalous-nb-sidebar';
const nbList = document.createElement('div');
nbList.className = 'anomalous-nb-list';
const btnRow = document.createElement('div');
btnRow.className = 'anomalous-nb-create-row';
btnRow.style.padding = '10px';
btnRow.style.display = 'flex';
btnRow.style.gap = '5px';
const createBtn = document.createElement('button');
createBtn.innerHTML = `➕ <span class="anomalous-nb-create-text">${t('createNotebook')}</span>`;
createBtn.className = 'anomalous-btn-primary';
const createInput = document.createElement('input');
createInput.className = 'anomalous-nb-create-input';
createInput.type = 'text';
createInput.placeholder = t('newNotebookName');
createInput.style.display = 'none';
createInput.style.flex = '1';
createInput.style.padding = '4px';
createInput.style.background = '#222';
createInput.style.color = '#fff';
createInput.style.border = '1px solid #555';
createInput.style.borderRadius = '4px';
createBtn.onclick = () => {
if (createInput.style.display === 'none') {
createInput.style.display = 'block';
createBtn.innerHTML = '✓';
createInput.focus();
} else {
const name = createInput.value.trim();
if (name) {
this.currentNotebook = { filename: name + '.json', name: name, data: { baseModel: '', mainModel: null, loras: [], promptEn: '', promptZh: '' } };
this.saveCurrentNotebook();
this.renderNotebookEditor();
createInput.value = '';
}
createInput.style.display = 'none';
createBtn.innerHTML = `➕ <span class="anomalous-nb-create-text">${t('createNotebook')}</span>`;
}
};
btnRow.appendChild(createInput);
btnRow.appendChild(createBtn);
sidebar.appendChild(btnRow);
sidebar.appendChild(nbList);
// Editor area
this.nbEditor = document.createElement('div');
this.nbEditor.className = 'anomalous-nb-editor';
body.appendChild(sidebar);
body.appendChild(this.nbEditor);
nbContainer.appendChild(nbHeader);
nbContainer.appendChild(body);
this.nbPanel.appendChild(nbContainer);
this.nbListEl = nbList;
this.refreshNotebooks(true);
}
export async function refreshNotebooks(autoOpenFirst = false) {
try {
const res = await fetch('/anomalous/notebooks');
const data = await res.json();
this.nbListEl.innerHTML = '';
if (data.notebooks && data.notebooks.length > 0) {
if (autoOpenFirst) {
if (!this.currentNotebook) {
this.currentNotebook = data.notebooks[0];
}
if (this.currentNotebook) {
this.renderNotebookEditor();
}
}
data.notebooks.forEach(nb => {
const item = document.createElement('div');
item.className = 'anomalous-nb-item';
if (this.currentNotebook && this.currentNotebook.filename === nb.filename) {
item.classList.add('active');
}
item.innerHTML = `<span class="anomalous-nb-item-icon">📄&nbsp;</span><span class="anomalous-nb-item-text">${escapeHtml(nb.name)}</span>`;
item.onclick = () => {
this.currentNotebook = nb;
this.renderNotebookEditor();
this.refreshNotebooks();
};
this.nbListEl.appendChild(item);
});
}
} catch (e) { }
}
export async function saveCurrentNotebook() {
if (!this.currentNotebook) return;
try {
await fetch('/anomalous/save_notebook', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.currentNotebook)
});
this.refreshNotebooks();
} catch (e) { }
}
export async function deleteCurrentNotebook(skipConfirm = false) {
if (!this.currentNotebook) return;
if (!skipConfirm && !confirm(t('deleteNotebook') + ' ?')) return;
try {
await fetch('/anomalous/delete_notebook', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: this.currentNotebook.filename })
});
this.currentNotebook = null;
this.nbEditor.innerHTML = '';
this.refreshNotebooks();
} catch (e) { }
}
export function renderNotebookEditor() {
this.nbEditor.innerHTML = '';
if (!this.currentNotebook) return;
const data = this.currentNotebook.data || {};
if (!data.loras) data.loras = [];
// Toolbar
const tb = document.createElement('div');
tb.className = 'anomalous-nb-toolbar';
const titleArea = document.createElement('h3');
titleArea.textContent = this.currentNotebook.name;
titleArea.style.margin = '0';
const rightBtns = document.createElement('div');
const saveBtn = document.createElement('button');
saveBtn.innerHTML = t('saveNotebook');
saveBtn.className = 'anomalous-btn-primary';
saveBtn.onclick = async () => {
const orig = saveBtn.innerHTML;
saveBtn.innerHTML = '⏳...';
await this.saveCurrentNotebook();
saveBtn.innerHTML = '✅';
saveBtn.style.background = '#2e8b57';
setTimeout(() => {
saveBtn.innerHTML = orig;
saveBtn.style.background = '';
}, 1500);
};
let delTimer = null;
const delContainer = document.createElement('span');
delContainer.style.display = 'inline-flex';
delContainer.style.alignItems = 'center';
const delBtn = document.createElement('button');
delBtn.innerHTML = t('deleteNotebook');
delBtn.className = 'anomalous-btn-danger';
const cancelDelBtn = document.createElement('button');
cancelDelBtn.innerHTML = '✕';
cancelDelBtn.className = 'anomalous-btn-danger';
cancelDelBtn.style.display = 'none';
cancelDelBtn.style.background = '#555';
cancelDelBtn.style.marginLeft = '2px';
cancelDelBtn.style.padding = '6px 8px';
delContainer.appendChild(delBtn);
delContainer.appendChild(cancelDelBtn);
const resetDel = () => {
clearTimeout(delTimer);
delBtn.innerHTML = t('deleteNotebook');
delBtn.style.background = '';
cancelDelBtn.style.display = 'none';
};
delBtn.onclick = () => {
if (delBtn.innerHTML === t('deleteNotebook')) {
delBtn.innerHTML = t('delSure');
delBtn.style.background = '#800';
cancelDelBtn.style.display = 'block';
delTimer = setTimeout(resetDel, 4000);
} else {
resetDel();
this.deleteCurrentNotebook(true);
}
};
cancelDelBtn.onclick = resetDel;
const sendBtn = document.createElement('button');
sendBtn.innerHTML = t('sendToCanvas');
sendBtn.className = 'anomalous-btn-success';
sendBtn.onclick = () => this.sendNotebookToCanvas();
rightBtns.appendChild(saveBtn);
rightBtns.appendChild(sendBtn);
rightBtns.appendChild(delContainer);
tb.appendChild(titleArea);
tb.appendChild(rightBtns);
// Settings / Models
const modelSection = document.createElement('div');
modelSection.className = 'anomalous-nb-section';
// Base Model
const baseRow = document.createElement('div');
baseRow.className = 'anomalous-nb-row';
baseRow.innerHTML = `<strong>${t('baseModel')}</strong>`;
const baseSelect = document.createElement('select');
baseSelect.className = 'anomalous-nb-select';
const buildSelect = (bases) => {
baseSelect.innerHTML = '';
bases.forEach(b => {
const opt = document.createElement('option');
opt.value = b; opt.text = b;
if (data.baseModel === b) opt.selected = true;
baseSelect.appendChild(opt);
});
if (!data.baseModel && bases.length > 0) data.baseModel = bases[0];
};
if (this.baseModelsCache) {
buildSelect(this.baseModelsCache);
} else {
const tempBases = ['SD 1.5', 'SD 2.1', 'SDXL', 'SD 3.0', 'SD 3.5', 'Flux.1', 'Pony', 'HunyuanVideo', 'LTX-Video', 'OmniGen'];
buildSelect(tempBases);
fetch('/anomalous/base_models').then(r => r.json()).then(d => {
if (d.base_models && d.base_models.length > 0) {
this.baseModelsCache = d.base_models;
buildSelect(this.baseModelsCache);
}
}).catch(e => { });
}
if (!data.baseModel) data.baseModel = 'SDXL';
baseSelect.onchange = () => {
data.baseModel = baseSelect.value;
data.mainModel = null;
data.loras = [];
this.saveCurrentNotebook();
this.renderNotebookEditor();
};
baseRow.appendChild(baseSelect);
// Main Model (Card Selection)
const mainBox = document.createElement('div');
mainBox.className = 'anomalous-nb-gallery-box';
const mainRow = document.createElement('div');
mainRow.className = 'anomalous-nb-row';
mainRow.innerHTML = `<strong>${t('mainModel')}</strong>`;
const mainGallery = document.createElement('div');
mainGallery.className = 'anomalous-nb-gallery-wrap';
mainBox.appendChild(mainRow);
mainBox.appendChild(mainGallery);
// Loras (Card Selection)
const loraBox = document.createElement('div');
loraBox.className = 'anomalous-nb-gallery-box';
const loraRow = document.createElement('div');
loraRow.className = 'anomalous-nb-row';
loraRow.innerHTML = `<strong>Loras</strong>`;
const loraGallery = document.createElement('div');
loraGallery.className = 'anomalous-nb-gallery-wrap';
loraBox.appendChild(loraRow);
loraBox.appendChild(loraGallery);
modelSection.appendChild(baseRow);
modelSection.appendChild(mainBox);
modelSection.appendChild(loraBox);
// Prompt Section
const promptSec = document.createElement('div');
promptSec.className = 'anomalous-nb-section';
// Toolbar
const pToolbar = document.createElement('div');
pToolbar.className = 'anomalous-nb-prompt-toolbar';
const langSelect = document.createElement('select');
langSelect.className = 'anomalous-nb-select';
const langs = [
{ v: 'zh-CN', l: '🇨🇳 中文 (zh-CN)' }, { v: 'en', l: '🇬🇧 English (en)' },
{ v: 'ja', l: '🇯🇵 日本语 (ja)' }, { v: 'ko', l: '🇰🇷 한국어 (ko)' },
{ v: 'fr', l: '🇫🇷 Français (fr)' }, { v: 'de', l: '🇩🇪 Deutsch (de)' },
{ v: 'es', l: '🇪🇸 Español (es)' }, { v: 'ru', l: '🇷🇺 Русский (ru)' }
];
langs.forEach(lg => {
const opt = document.createElement('option');
opt.value = lg.v; opt.text = lg.l;
if ((data.targetLang || 'zh-CN') === lg.v) opt.selected = true;
langSelect.appendChild(opt);
});
langSelect.onchange = () => {
data.targetLang = langSelect.value;
data.translations = {}; // Clear translation cache on lang change
this.saveCurrentNotebook();
updateVisualTags();
};
const findInput = document.createElement('input');
findInput.className = 'anomalous-nb-select';
findInput.placeholder = t('findPlaceholder');
findInput.style.flex = '1';
const replaceInput = document.createElement('input');
replaceInput.className = 'anomalous-nb-select';
replaceInput.placeholder = t('replacePlaceholder');
replaceInput.style.flex = '1';
const replaceBtn = document.createElement('button');
replaceBtn.className = 'anomalous-btn-primary';
replaceBtn.innerHTML = t('replaceAll');
pToolbar.appendChild(langSelect);
pToolbar.appendChild(findInput);
pToolbar.appendChild(replaceInput);
pToolbar.appendChild(replaceBtn);
// Raw Input Toggle
const toggleRow = document.createElement('div');
toggleRow.style.display = 'flex';
toggleRow.style.justifyContent = 'space-between';
toggleRow.style.marginBottom = '5px';
toggleRow.innerHTML = `<strong>Prompt Editor</strong>`;
const rawBtn = document.createElement('button');
rawBtn.className = 'anomalous-btn-primary';
rawBtn.innerHTML = t('editRaw');
toggleRow.appendChild(rawBtn);
// Raw Textarea
const rawArea = document.createElement('textarea');
rawArea.className = 'anomalous-nb-textarea';
rawArea.value = data.promptEn || '';
rawArea.style.display = 'none';
rawArea.style.height = '150px';
// Visual Dual Pane
const dualPane = document.createElement('div');
dualPane.className = 'anomalous-nb-dual-pane';
if (!data.translations) data.translations = {};
const updateVisualTags = () => {
dualPane.innerHTML = '';
const txt = rawArea.value;
data.promptEn = txt;
this.saveCurrentNotebook();
if (!txt.trim()) return;
const tags = txt.split(',').map(s => s.trim()).filter(s => s);
tags.forEach((tag, idx) => {
const tagRow = document.createElement('div');
tagRow.className = 'anomalous-nb-tag-row';
const tagL = document.createElement('div');
tagL.className = 'anomalous-nb-visual-tag';
tagL.style.flex = '1';
tagL.style.justifyContent = 'space-between';
const txtL = document.createElement('span');
txtL.innerText = tag;
const copyL = document.createElement('span');
copyL.className = 'anomalous-nb-copy-btn';
copyL.innerHTML = '📋';
copyL.onclick = (e) => {
e.stopPropagation();
navigator.clipboard.writeText(tag).then(() => { copyL.innerHTML = '✅'; setTimeout(() => copyL.innerHTML = '📋', 1000); });
};
tagL.appendChild(txtL);
tagL.appendChild(copyL);
const tagR = document.createElement('div');
tagR.className = 'anomalous-nb-visual-tag';
tagR.style.flex = '1';
tagR.style.justifyContent = 'space-between';
const transTxt = data.translations[tag] ? data.translations[tag] : '...';
const txtR = document.createElement('span');
txtR.innerText = transTxt;
const copyR = document.createElement('span');
copyR.className = 'anomalous-nb-copy-btn';
copyR.innerHTML = '📋';
copyR.onclick = (e) => {
e.stopPropagation();
navigator.clipboard.writeText(txtR.innerText).then(() => { copyR.innerHTML = '✅'; setTimeout(() => copyR.innerHTML = '📋', 1000); });
};
tagR.appendChild(txtR);
tagR.appendChild(copyR);
tagL.onmouseenter = () => { tagL.classList.add('hover'); tagR.classList.add('hover'); };
tagL.onmouseleave = () => { tagL.classList.remove('hover'); tagR.classList.remove('hover'); };
tagR.onmouseenter = () => { tagL.classList.add('hover'); tagR.classList.add('hover'); };
tagR.onmouseleave = () => { tagL.classList.remove('hover'); tagR.classList.remove('hover'); };
tagL.onclick = () => {
const inp = document.createElement('input');
inp.value = tag; inp.className = 'anomalous-nb-tag-edit';
tagL.innerHTML = ''; tagL.appendChild(inp); inp.focus();
const finish = () => {
tags[idx] = inp.value.trim();
rawArea.value = tags.join(', ');
updateVisualTags();
};
inp.onblur = finish;
inp.onkeydown = (e) => { if (e.key === 'Enter') inp.blur(); };
};
tagR.onclick = () => {
const inp = document.createElement('input');
inp.value = data.translations[tag] || ''; inp.className = 'anomalous-nb-tag-edit';
tagR.innerHTML = ''; tagR.appendChild(inp); inp.focus();
const finish = () => {
data.translations[tag] = inp.value.trim();
this.saveCurrentNotebook();
updateVisualTags();
};
inp.onblur = finish;
inp.onkeydown = (e) => { if (e.key === 'Enter') inp.blur(); };
};
tagRow.appendChild(tagL);
tagRow.appendChild(tagR);
dualPane.appendChild(tagRow);
if (!data.translations[tag]) {
fetch('/anomalous/translate', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: tag, target_lang: data.targetLang || 'zh-CN' })
}).then(r => r.json()).then(d => {
if (d.translated) {
data.translations[tag] = d.translated;
txtR.innerText = d.translated;
this.saveCurrentNotebook();
}
}).catch(() => { });
}
});
};
rawBtn.onclick = () => {
if (rawArea.style.display === 'none') {
rawArea.style.display = 'block';
dualPane.style.display = 'none';
rawBtn.innerHTML = '👁️ Done Editing';
pToolbar.style.display = 'none';
} else {
rawArea.style.display = 'none';
dualPane.style.display = 'flex';
rawBtn.innerHTML = '📝 Edit Raw / Paste';
pToolbar.style.display = 'flex';
updateVisualTags();
}
};
replaceBtn.onclick = () => {
const findStr = findInput.value;
const repStr = replaceInput.value;
if (!findStr) return;
const newTxt = rawArea.value.split(findStr).join(repStr);
rawArea.value = newTxt;
updateVisualTags();
};
rawArea.oninput = () => {
clearTimeout(this.pTimeout);
this.pTimeout = setTimeout(() => { data.promptEn = rawArea.value; this.saveCurrentNotebook(); }, 500);
};
updateVisualTags();
promptSec.appendChild(pToolbar);
promptSec.appendChild(toggleRow);
promptSec.appendChild(rawArea);
promptSec.appendChild(dualPane);
this.nbEditor.appendChild(tb);
this.nbEditor.appendChild(modelSection);
this.nbEditor.appendChild(promptSec);
// Fetch compatible models and fill galleries
this.fillNotebookGalleries(data.baseModel, mainGallery, loraGallery, data);
}
export function fillNotebookGalleries(baseModel, mainGallery, loraGallery, data) {
if (!baseModel) return;
const buildThumbHtml = (m) => {
let thumb = '';
if (m.preview_url) {
const isVid = m.preview_url.match(/\.mp4(?:&|$)/i) || m.preview_url.match(/\.webm(?:&|$)/i);
if (isVid) thumb = `<video src="${m.preview_url}" muted loop playsinline></video>`;
else thumb = `<img src="${m.preview_url}" />`;
} else {
thumb = `<div style="width:30px; height:30px; background:#222; border-radius:4px; display:flex; align-items:center; justify-content:center; font-size:10px; color:#555;">?</div>`;
}
return thumb;
};
fetch(`/anomalous/compatible_models?base_model=${encodeURIComponent(baseModel)}&target_type=checkpoints,unet,diffusion_models`)
.then(r => r.json()).then(d => {
const buildMainDOM = (models) => {
mainGallery.innerHTML = '';
if (!models || !models.length) {
mainGallery.innerHTML = '<span style="color:#666;">No compatible main models found.</span>';
} else {
models.forEach(m => {
const isSelected = (data.mainModel && data.mainModel.filename === m.filename);
const card = document.createElement('div');
card.className = 'anomalous-nb-minicheck ' + (isSelected ? 'selected' : '');
card.innerHTML = `${buildThumbHtml(m)}<div class="anomalous-nb-minicheck-name" title="${escapeHtml(m.filename)}">${escapeHtml(m.filename)}</div>`;
if (m.preview_url && (m.preview_url.match(/\.mp4(?:&|$)/i) || m.preview_url.match(/\.webm(?:&|$)/i))) {
card.onmouseenter = () => { const v = card.querySelector('video'); if (v) v.play().catch(e => { }); };
card.onmouseleave = () => { const v = card.querySelector('video'); if (v) { v.pause(); v.currentTime = 0; } };
}
card.onclick = () => {
data.mainModel = m;
this.saveCurrentNotebook();
buildMainDOM(models); // re-render just the main gallery
};
mainGallery.appendChild(card);
});
}
};
buildMainDOM(d.models || []);
});
fetch(`/anomalous/compatible_models?base_model=${encodeURIComponent(baseModel)}&target_type=loras`)
.then(r => r.json()).then(d => {
const buildLoraDOM = (models) => {
loraGallery.innerHTML = '';
if (!models || !models.length) {
loraGallery.innerHTML = '<span style="color:#666;">No compatible Loras found.</span>';
} else {
models.forEach(m => {
const loraIndex = data.loras.findIndex(l => l.filename === m.filename);
const isSelected = loraIndex !== -1;
const card = document.createElement('div');
card.className = 'anomalous-nb-minilora ' + (isSelected ? 'selected' : '');
card.style.position = 'relative'; // for badge positioning
let badgeHtml = '';
if (isSelected) {
badgeHtml = `<div style="position:absolute; top:-5px; right:-5px; background:#00ffcc; color:#000; border-radius:50%; width:20px; height:20px; font-size:12px; display:flex; align-items:center; justify-content:center; font-weight:bold; z-index:10; box-shadow: 0 2px 4px rgba(0,0,0,0.5);">${loraIndex + 1}</div>`;
}
card.innerHTML = `${badgeHtml}${buildThumbHtml(m)}<div class="anomalous-nb-minilora-name" title="${escapeHtml(m.filename)}">${escapeHtml(m.filename)}</div>`;
if (m.preview_url && (m.preview_url.match(/\.mp4(?:&|$)/i) || m.preview_url.match(/\.webm(?:&|$)/i))) {
card.onmouseenter = () => { const v = card.querySelector('video'); if (v) v.play().catch(e => { }); };
card.onmouseleave = () => { const v = card.querySelector('video'); if (v) { v.pause(); v.currentTime = 0; } };
}
card.onclick = () => {
if (isSelected) {
data.loras = data.loras.filter(l => l.filename !== m.filename);
} else {
data.loras.push(m);
}
this.saveCurrentNotebook();
buildLoraDOM(models); // re-render just the lora gallery
};
loraGallery.appendChild(card);
});
}
};
buildLoraDOM(d.models || []);
});
}
export function sendNotebookToCanvas() {
if (!this.currentNotebook) return;
const data = this.currentNotebook.data || {};
if (!data.mainModel) {
alert(t('notebookSelectMain'));
return;
}
const groupNodes = [];
const isUnet = data.mainModel.type === 'unet' || data.mainModel.type === 'diffusion_models';
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 = isUnet ? 0 : 0;
let lastClipSlot = isUnet ? null : 1;
let relX = 350;
let relY = 0;
data.loras.forEach((lora, idx) => {
const loraNode = LiteGraph.createNode("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;
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);
}
File diff suppressed because it is too large Load Diff
+981
View File
@@ -0,0 +1,981 @@
/** Workflow Recipes UI, built on the same modal/card language as Notebooks. */
import { app } from '../../../scripts/app.js';
import { translate } from './locales.js';
import { anomalousAlert, anomalousConfirm } from './ui_dialog.js';
import {
captureCanvasThumbnail,
captureRecipeDraft,
} from './recipe_parser.js';
import {
appendRecipeOnCanvas,
showRecipeDetail,
} from './ui_recipe_detail.js';
const t = (key, params) => translate(key, params);
const RECIPE_PRESENTATION_DEFAULTS = Object.freeze({
saveModelPreviewSnapshots: true,
});
function formatRecipeText(key, values = {}) {
return Object.entries(values).reduce((text, [name, value]) => text.replaceAll(`{${name}}`, String(value)), t(key));
}
async function runRecipeCardAction(actionButton, action, errorKey) {
if (!actionButton || actionButton.disabled) return;
actionButton.disabled = true;
actionButton.classList.add('is-busy');
try {
await action();
} catch (error) {
console.error('Workflow Recipe action failed:', error);
await anomalousAlert(t(errorKey));
} finally {
actionButton.disabled = false;
actionButton.classList.remove('is-busy');
}
}
function appendText(parent, tagName, text, className = '') {
const element = document.createElement(tagName);
if (className) element.className = className;
element.textContent = text;
parent.appendChild(element);
return element;
}
async function copyCardValueWithFeedback(buttonElement, value) {
const original = buttonElement.textContent;
try {
await navigator.clipboard.writeText(String(value));
buttonElement.textContent = `✓ ${t('recipeCopied')}`;
buttonElement.classList.add('copied-success');
} catch (error) {
console.warn('Could not copy recipe parameter:', error);
buttonElement.textContent = `! ${t('recipeCopyFailed')}`;
buttonElement.classList.add('copied-failure');
}
window.setTimeout(() => {
buttonElement.textContent = original;
buttonElement.classList.remove('copied-success', 'copied-failure');
}, 1200);
}
function safeThumbnail(value) {
return typeof value === 'string' && /^data:image\/(?:png|jpeg|webp);base64,/i.test(value)
? value
: null;
}
function outputImageUrl(image) {
if (!image || image.type !== 'output' || typeof image.filename !== 'string') return null;
const query = new URLSearchParams({ filename: image.filename, type: 'output' });
if (image.subfolder) query.set('subfolder', image.subfolder);
return `/view?${query.toString()}`;
}
function recipeAssetUrl(filename, assetId) {
if (!filename || !assetId) return null;
return `/anomalous/recipe_asset?filename=${encodeURIComponent(filename)}&asset=${encodeURIComponent(assetId)}`;
}
function previewIsVideo(url) {
return /\.(?:mp4|webm)(?:$|\?|&|#)/i.test(url || '');
}
function appendRecipeCover(parent, url, alt) {
if (!url) return;
if (previewIsVideo(url)) {
const video = document.createElement('video');
video.className = 'anomalous-recipe-thumbnail';
video.src = url;
video.muted = true;
video.loop = true;
video.playsInline = true;
video.preload = 'metadata';
video.onpointerenter = () => video.play().catch(() => {});
video.onpointerleave = () => {
video.pause();
video.currentTime = 0;
};
parent.appendChild(video);
return;
}
const image = document.createElement('img');
image.className = 'anomalous-recipe-thumbnail';
image.src = url;
image.alt = alt;
image.loading = 'lazy';
parent.appendChild(image);
}
async function exportRecipePackage(filename) {
const choice = { noLabel: t('recipeDialogNo') };
const includeSnapshots = await anomalousConfirm(t('recipeExportSnapshotsConfirm'), 'Anomalous', choice);
if (includeSnapshots === null) return;
const includeHistory = await anomalousConfirm(t('recipeExportHistoryConfirm'), 'Anomalous', choice);
if (includeHistory === null) return;
const redactIdentity = await anomalousConfirm(t('recipeExportRedactIdentityConfirm'), 'Anomalous', choice);
if (redactIdentity === null) return;
const includeIdentity = !redactIdentity;
const response = await fetch('/anomalous/export_recipe_package', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
filename,
include_snapshots: includeSnapshots,
include_history: includeHistory,
include_identity: includeIdentity,
}),
});
if (!response.ok) throw new Error('recipe export failed');
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${filename.replace(/\.json$/i, '')}.anomalous-recipe.zip`;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}
async function importRecipePackage(owner, file) {
const inspectResponse = await fetch('/anomalous/import_recipe_package_inspect', {
method: 'POST',
headers: { 'Content-Type': 'application/zip' },
body: file,
});
const inspectPayload = await inspectResponse.json();
if (!inspectResponse.ok || inspectPayload.status !== 'success') throw new Error('recipe import inspection failed');
const recipeName = inspectPayload.recipe?.name || t('recipeUntitled');
const summary = `${t('recipeImportSummary')}\n\n${recipeName}\n${t('recipeImportAssets')}: ${inspectPayload.asset_count || 0}\n${t('recipeImportHistory')}: ${inspectPayload.history_count || 0}`;
if (!await anomalousConfirm(summary)) return;
let name = recipeName;
if ((inspectPayload.existing_names || []).includes(name)) {
name = prompt(t('recipeImportRenamePrompt'), `${name} (Imported)`);
if (!name?.trim()) return;
}
const commitResponse = await fetch('/anomalous/import_recipe_package_commit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: inspectPayload.token, collision: 'rename', name: name.trim() }),
});
const commitPayload = await commitResponse.json();
if (!commitResponse.ok || commitPayload.status !== 'success') throw new Error('recipe import commit failed');
await owner.refreshRecipes();
}
async function captureOutputThumbnail(image) {
const url = outputImageUrl(image);
if (!url) return null;
const response = await fetch(url);
if (!response.ok) return null;
const blob = await response.blob();
if (previewIsVideo(url) || /^video\//i.test(blob.type)) {
const objectUrl = URL.createObjectURL(blob);
const video = document.createElement('video');
video.src = objectUrl;
video.muted = true;
video.playsInline = true;
video.preload = 'auto';
try {
await new Promise((resolve, reject) => {
video.onloadeddata = resolve;
video.onerror = reject;
video.load();
});
if (video.duration > 0.2) {
await new Promise((resolve) => {
video.onseeked = resolve;
video.currentTime = 0.1;
});
}
const maxEdge = 720;
const scale = Math.min(1, maxEdge / Math.max(video.videoWidth, video.videoHeight));
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(video.videoWidth * scale));
canvas.height = Math.max(1, Math.round(video.videoHeight * scale));
const context = canvas.getContext('2d');
if (!context) return null;
context.drawImage(video, 0, 0, canvas.width, canvas.height);
return canvas.toDataURL('image/webp', 0.72);
} finally {
URL.revokeObjectURL(objectUrl);
}
}
const bitmap = await createImageBitmap(blob);
try {
const maxEdge = 720;
const scale = Math.min(1, maxEdge / Math.max(bitmap.width, bitmap.height));
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(bitmap.width * scale));
canvas.height = Math.max(1, Math.round(bitmap.height * scale));
const context = canvas.getContext('2d');
if (!context) return null;
context.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
return canvas.toDataURL('image/jpeg', 0.72);
} finally {
bitmap.close?.();
}
}
function compactText(value, limit = 110) {
const text = String(value || '').trim().replace(/\s+/g, ' ');
return text.length > limit ? `${text.slice(0, limit - 1)}…` : text;
}
function modelDisplayName(value) {
const path = String(value || '').replace(/\\/g, '/');
const filename = path.split('/').pop() || '';
return filename.replace(/\.(?:safetensors|ckpt|pt|bin|sft)$/i, '');
}
function normaliseSearchText(value) {
const text = String(value || '').trim().toLocaleLowerCase();
try { return text.normalize('NFKC'); } catch (error) { return text; }
}
function recipeMatchesFilter(data, query, selectedTags) {
const terms = normaliseSearchText(query).split(/\s+/).filter(Boolean);
const haystack = normaliseSearchText([
data?.name || '',
data?.notes || '',
...(Array.isArray(data?.tags) ? data.tags : []),
].join(' '));
if (terms.some((term) => !haystack.includes(term))) return false;
const tags = new Set((Array.isArray(data?.tags) ? data.tags : []).map(normaliseSearchText));
for (const tag of selectedTags || []) if (!tags.has(normaliseSearchText(tag))) return false;
return true;
}
function updateRecipeFilterControls(owner, recipes) {
if (!owner.recipeTagBar) return;
owner.recipeTagBar.replaceChildren();
const tags = [...new Set((recipes || []).flatMap((item) => item?.data?.tags || []))]
.filter(Boolean)
.sort((left, right) => String(left).localeCompare(String(right)));
for (const tag of tags) {
const chip = appendText(owner.recipeTagBar, 'button', tag, 'anomalous-recipe-filter-tag');
chip.type = 'button';
chip.classList.toggle('active', owner.recipeSelectedTags?.has(tag));
chip.onclick = () => {
if (!owner.recipeSelectedTags) owner.recipeSelectedTags = new Set();
if (owner.recipeSelectedTags.has(tag)) owner.recipeSelectedTags.delete(tag);
else owner.recipeSelectedTags.add(tag);
owner.renderRecipeList(owner.recipeRecords || []);
};
}
if (!tags.length) appendText(owner.recipeTagBar, 'small', t('recipeNoTags'), 'anomalous-recipe-detail-muted');
}
function summaryValue(value, fallback = '—') {
return value === null || value === undefined || value === '' ? fallback : String(value);
}
function createBadge(text, kind = '') {
const badge = document.createElement('span');
badge.className = `anomalous-recipe-badge${kind ? ` anomalous-recipe-badge-${kind}` : ''}`;
badge.textContent = text;
return badge;
}
function displayWidgetValue(value) {
if (typeof value === 'object' && value !== null) {
try { return JSON.stringify(value); } catch (error) { return String(value); }
}
return String(value);
}
async function fetchRecipeBundle(filename) {
const [fullResponse, historyResponse] = await Promise.all([
fetch(`/anomalous/recipe_full?filename=${encodeURIComponent(filename)}`),
fetch(`/anomalous/recipe_history?filename=${encodeURIComponent(filename)}`),
]);
const payload = await fullResponse.json();
const historyPayload = historyResponse.ok ? await historyResponse.json() : { versions: [] };
if (!fullResponse.ok || payload.status !== 'success' || !payload.data?.workflow) throw new Error('recipe missing workflow');
return { data: JSON.parse(JSON.stringify(payload.data)), history: historyPayload.versions || [] };
}
async function fetchRecipeData(filename) {
const response = await fetch(`/anomalous/recipe_full?filename=${encodeURIComponent(filename)}`);
const payload = await response.json();
if (!response.ok || payload.status !== 'success' || !payload.data?.workflow) throw new Error('recipe missing workflow');
return JSON.parse(JSON.stringify(payload.data));
}
async function editRecipe(owner, recipe, filename, history = null) {
try {
const bundle = history ? { data: JSON.parse(JSON.stringify(recipe)), history } : await fetchRecipeBundle(filename);
const editable = bundle.data;
const result = await showRecipeEditDialog(owner, editable, filename, bundle.history);
if (!result || result.mode === 'restored') return;
if (result.mode === 'canvas') {
if (!await anomalousConfirm(t('recipeEditCanvasConfirm'))) return;
app.loadGraphData(editable.workflow);
app.canvas?.setDirty?.(true, true);
owner.recipeEditing = { filename, data: editable };
const saveButton = owner.recipeView?.querySelector('[data-recipe-save-current]');
if (saveButton) saveButton.textContent = t('recipeUpdateCurrent');
return;
}
editable.name = result.name;
editable.tags = result.tags;
editable.notes = result.notes;
const response = await fetch('/anomalous/update_recipe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename, ...editable }),
});
const updatedPayload = await response.json();
if (!response.ok || updatedPayload.status !== 'success') throw new Error('recipe update failed');
await owner.refreshRecipes();
} catch (error) {
console.error('Could not edit Workflow Recipe:', error);
await anomalousAlert(t('recipeUpdateError'));
}
}
function showRecipeSaveDialog(owner, canvasThumbnail, initial = null) {
return new Promise((resolve) => {
const selection = {
thumbnail: safeThumbnail(initial?.thumbnail) || safeThumbnail(canvasThumbnail),
sourceImage: initial?.source_image || null,
// A saved snapshot is what makes the cover portable in an export.
// Preserve an explicit opt-out, but enable the sharing-oriented
// behavior for new and legacy recipes without a stored preference.
saveModelPreviewSnapshots: initial?.presentation?.save_model_preview_snapshots
?? RECIPE_PRESENTATION_DEFAULTS.saveModelPreviewSnapshots,
};
const overlay = document.createElement('div');
overlay.className = 'anomalous-recipe-dialog-overlay';
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
const dialog = document.createElement('div');
dialog.className = 'anomalous-recipe-dialog';
appendText(dialog, 'h3', t('recipeSaveTitle'));
const nameLabel = appendText(dialog, 'label', t('recipeName'));
const nameInput = document.createElement('input');
nameInput.className = 'anomalous-nb-select';
nameInput.type = 'text';
nameInput.maxLength = 120;
nameInput.value = initial?.name || t('recipeDefaultName');
nameLabel.appendChild(nameInput);
const tagsLabel = appendText(dialog, 'label', t('recipeTags'));
const tagsInput = document.createElement('input');
tagsInput.className = 'anomalous-nb-select';
tagsInput.type = 'text';
tagsInput.maxLength = 300;
tagsInput.placeholder = t('recipeTagsHint');
tagsInput.value = Array.isArray(initial?.tags) ? initial.tags.join(', ') : '';
tagsLabel.appendChild(tagsInput);
const notesLabel = appendText(dialog, 'label', t('recipeNotes'));
const notesInput = document.createElement('textarea');
notesInput.className = 'anomalous-nb-textarea';
notesInput.maxLength = 3000;
notesInput.placeholder = t('recipeNotesHint');
notesInput.value = initial?.notes || '';
notesLabel.appendChild(notesInput);
const coverSection = document.createElement('section');
coverSection.className = 'anomalous-recipe-save-section';
appendText(coverSection, 'strong', t('recipeBindImage'));
const coverChoices = document.createElement('div');
coverChoices.className = 'anomalous-recipe-cover-choices';
const coverPreview = document.createElement('img');
coverPreview.className = 'anomalous-recipe-dialog-preview';
coverPreview.alt = t('recipeThumbnail');
const initialPreview = selection.thumbnail || outputImageUrl(selection.sourceImage);
if (initialPreview) coverPreview.src = initialPreview;
else coverPreview.style.display = 'none';
const choiceButtons = [];
const selectCover = (button, sourceImage, previewUrl, thumbnailValue) => {
for (const choice of choiceButtons) choice.classList.toggle('selected', choice === button);
selection.sourceImage = sourceImage;
selection.thumbnail = thumbnailValue;
if (previewUrl) {
coverPreview.src = previewUrl;
coverPreview.style.display = 'block';
} else {
coverPreview.removeAttribute('src');
coverPreview.style.display = 'none';
}
};
const noneChoice = appendText(coverChoices, 'button', t('recipeNoImage'), 'anomalous-recipe-cover-choice');
noneChoice.type = 'button';
choiceButtons.push(noneChoice);
noneChoice.onclick = () => selectCover(noneChoice, null, null, null);
if (safeThumbnail(initial?.thumbnail) || initial?.source_image) {
const existingChoice = appendText(coverChoices, 'button', t('recipeKeepImage'), 'anomalous-recipe-cover-choice selected');
existingChoice.type = 'button';
choiceButtons.push(existingChoice);
existingChoice.onclick = () => selectCover(
existingChoice,
initial?.source_image || null,
safeThumbnail(initial?.thumbnail) || outputImageUrl(initial?.source_image),
safeThumbnail(initial?.thumbnail),
);
}
if (safeThumbnail(canvasThumbnail)) {
const canvasChoice = document.createElement('button');
canvasChoice.type = 'button';
canvasChoice.className = `anomalous-recipe-cover-choice${initial ? '' : ' selected'}`;
const canvasImage = document.createElement('img');
canvasImage.src = canvasThumbnail;
canvasImage.alt = t('recipeCanvasPreview');
appendText(canvasChoice, 'span', t('recipeCanvasPreview'));
canvasChoice.prepend(canvasImage);
choiceButtons.push(canvasChoice);
canvasChoice.onclick = () => selectCover(canvasChoice, null, canvasThumbnail, safeThumbnail(canvasThumbnail));
} else if (!initial) {
noneChoice.classList.add('selected');
}
const recentStatus = appendText(coverSection, 'small', t('recipeLoadingRecentImages'), 'anomalous-recipe-node-hint');
coverSection.append(coverChoices, coverPreview);
dialog.appendChild(coverSection);
fetch('/anomalous/gallery_images?page=1&limit=12')
.then((response) => response.ok ? response.json() : Promise.reject(new Error('image list failed')))
.then((payload) => {
recentStatus.textContent = t('recipeRecentImages');
for (const imageData of payload.images || []) {
const url = outputImageUrl(imageData);
if (!url) continue;
const choice = document.createElement('button');
choice.type = 'button';
choice.className = 'anomalous-recipe-cover-choice anomalous-recipe-output-choice';
const image = document.createElement('img');
image.src = url;
image.loading = 'lazy';
image.alt = imageData.filename;
choice.appendChild(image);
choice.title = imageData.filename;
choiceButtons.push(choice);
choice.onclick = () => selectCover(choice, {
filename: imageData.filename,
subfolder: imageData.subfolder || '',
type: 'output',
}, url, null);
coverChoices.appendChild(choice);
}
})
.catch((error) => {
console.warn('Could not load recent recipe images:', error);
recentStatus.textContent = t('recipeRecentImagesUnavailable');
});
const error = appendText(dialog, 'div', '', 'anomalous-recipe-dialog-error');
const actions = document.createElement('div');
actions.className = 'anomalous-recipe-actions';
const cancel = appendText(actions, 'button', t('recipeCancel'), 'anomalous-btn-danger');
const save = appendText(actions, 'button', t('recipeSave'), 'anomalous-btn-primary');
cancel.type = 'button';
save.type = 'button';
const close = (value) => {
overlay.remove();
resolve(value);
};
cancel.onclick = () => close(null);
overlay.onclick = (event) => {
if (event.target === overlay) close(null);
};
save.onclick = () => {
const name = nameInput.value.trim();
if (!name) {
error.textContent = t('recipeNameRequired');
nameInput.focus();
return;
}
const tags = [...new Set(tagsInput.value.split(',').map((tag) => tag.trim()).filter(Boolean))].slice(0, 20);
close({
name,
tags,
notes: notesInput.value.trim(),
thumbnail: selection.thumbnail,
sourceImage: selection.sourceImage,
saveModelPreviewSnapshots: selection.saveModelPreviewSnapshots,
});
};
dialog.appendChild(actions);
overlay.appendChild(dialog);
(owner.nbPanel || document.body).appendChild(overlay);
nameInput.focus();
nameInput.select();
});
}
function showRecipeEditDialog(owner, recipeData, filename, history) {
return new Promise((resolve) => {
const overlay = document.createElement('div');
overlay.className = 'anomalous-recipe-dialog-overlay';
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
const dialog = document.createElement('div');
dialog.className = 'anomalous-recipe-dialog anomalous-recipe-edit-dialog';
appendText(dialog, 'h3', t('recipeEditTitle'));
const nameLabel = appendText(dialog, 'label', t('recipeName'));
const nameInput = document.createElement('input');
nameInput.className = 'anomalous-nb-select';
nameInput.type = 'text';
nameInput.maxLength = 120;
nameInput.value = recipeData.name || '';
nameLabel.appendChild(nameInput);
const tagsLabel = appendText(dialog, 'label', t('recipeTags'));
const tagsInput = document.createElement('input');
tagsInput.className = 'anomalous-nb-select';
tagsInput.type = 'text';
tagsInput.maxLength = 300;
tagsInput.value = Array.isArray(recipeData.tags) ? recipeData.tags.join(', ') : '';
tagsInput.placeholder = t('recipeTagsHint');
tagsLabel.appendChild(tagsInput);
const notesLabel = appendText(dialog, 'label', t('recipeNotes'));
const notesInput = document.createElement('textarea');
notesInput.className = 'anomalous-nb-textarea';
notesInput.maxLength = 3000;
notesInput.value = recipeData.notes || '';
notesInput.placeholder = t('recipeNotesHint');
notesLabel.appendChild(notesInput);
const historyDetails = document.createElement('details');
historyDetails.className = 'anomalous-recipe-node-details';
const historySummary = document.createElement('summary');
historySummary.textContent = `${t('recipeHistory')} (${history.length})`;
historyDetails.appendChild(historySummary);
const historyList = document.createElement('div');
historyList.className = 'anomalous-recipe-history-list';
if (!history.length) {
appendText(historyList, 'small', t('recipeHistoryEmpty'), 'anomalous-recipe-node-hint');
} else {
for (const version of history) {
const row = document.createElement('div');
row.className = 'anomalous-recipe-history-row';
const date = Number.isFinite(Number(version.timestamp))
? new Date(Number(version.timestamp)).toLocaleString()
: t('recipeUnknownVersion');
appendText(row, 'span', `${date} · ${version.name || t('recipeUntitled')}`);
const restore = appendText(row, 'button', t('recipeRestoreVersion'), 'anomalous-btn-danger');
restore.type = 'button';
restore.onclick = async () => {
if (!await anomalousConfirm(t('recipeRestoreVersionConfirm'))) return;
try {
const response = await fetch('/anomalous/restore_recipe_version', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename, version: version.version }),
});
if (!response.ok) throw new Error('recipe history restore failed');
overlay.remove();
await owner.refreshRecipes();
resolve({ mode: 'restored' });
} catch (error) {
console.error('Could not restore Workflow Recipe version:', error);
await anomalousAlert(t('recipeUpdateError'));
}
};
row.appendChild(restore);
historyList.appendChild(row);
}
}
historyDetails.appendChild(historyList);
dialog.appendChild(historyDetails);
const error = appendText(dialog, 'div', '', 'anomalous-recipe-dialog-error');
const actions = document.createElement('div');
actions.className = 'anomalous-recipe-actions';
const cancel = appendText(actions, 'button', t('recipeCancel'), 'anomalous-btn-danger');
const canvas = appendText(actions, 'button', t('recipeEditCanvas'), 'anomalous-btn-primary');
const save = appendText(actions, 'button', t('recipeUpdate'), 'anomalous-btn-success');
for (const button of [cancel, canvas, save]) button.type = 'button';
const close = (value) => { overlay.remove(); resolve(value); };
cancel.onclick = () => close(null);
overlay.onclick = (event) => { if (event.target === overlay) close(null); };
canvas.onclick = () => close({ mode: 'canvas' });
save.onclick = () => {
const name = nameInput.value.trim();
if (!name) {
error.textContent = t('recipeNameRequired');
nameInput.focus();
return;
}
close({
mode: 'save',
name,
tags: [...new Set(tagsInput.value.split(',').map((tag) => tag.trim()).filter(Boolean))].slice(0, 20),
notes: notesInput.value.trim(),
});
};
dialog.appendChild(actions);
overlay.appendChild(dialog);
(owner.nbPanel || document.body).appendChild(overlay);
nameInput.focus();
nameInput.select();
});
}
export async function showRecipes() {
if (!this.notebookContainer) {
this.nbPanel.style.display = 'flex';
await this.showNotebooks();
}
if (typeof this.recipeModelReturn === 'function') {
const returnToRecipe = this.recipeModelReturn;
this.recipeModelReturn = null;
returnToRecipe();
return;
}
this.recipeDetailFinish?.('closed');
this.notebookBody.style.display = 'none';
this.notebookNotesTab?.classList.remove('active');
this.notebookRecipesTab?.classList.add('active');
if (this.recipeDetailView) {
this.recipeDetailView.remove();
this.recipeDetailView = null;
this.recipeListContainer.style.display = '';
this.recipeView.querySelector('.anomalous-recipe-actionbar').style.display = '';
}
if (this.recipeView) {
this.recipeView.style.display = 'flex';
if (!this.recipeDetailView) {
if (this.recipeListContainer) this.recipeListContainer.style.display = '';
const actionbar = this.recipeView.querySelector('.anomalous-recipe-actionbar');
if (actionbar) actionbar.style.display = '';
this.recipeReturnState = null;
delete this.recipeDetailPayload;
}
}
if (this.recipesInitialized) {
await this.refreshRecipes();
return;
}
this.recipesInitialized = true;
this.recipeSelectedTags = this.recipeSelectedTags || new Set();
this.recipeSearchQuery = this.recipeSearchQuery || '';
this.recipeView = document.createElement('div');
this.recipeView.className = 'anomalous-recipe-body';
const actionBar = document.createElement('div');
actionBar.className = 'anomalous-recipe-actionbar';
const search = document.createElement('input');
search.type = 'search';
search.className = 'anomalous-recipe-search';
search.placeholder = t('recipeSearchPlaceholder');
search.value = this.recipeSearchQuery || '';
search.oninput = () => {
this.recipeSearchQuery = search.value;
this.renderRecipeList(this.recipeRecords || []);
};
this.recipeSearchInput = search;
actionBar.appendChild(search);
const clearFilters = appendText(actionBar, 'button', t('recipeClearFilters'), 'anomalous-btn-danger');
clearFilters.type = 'button';
clearFilters.onclick = () => {
this.recipeSearchQuery = '';
this.recipeSelectedTags = new Set();
search.value = '';
updateRecipeFilterControls(this, this.recipeRecords || []);
this.renderRecipeList(this.recipeRecords || []);
};
this.recipeTagBar = document.createElement('div');
this.recipeTagBar.className = 'anomalous-recipe-filter-tags';
actionBar.appendChild(this.recipeTagBar);
this.recipeFilterSummary = appendText(actionBar, 'small', '0/0', 'anomalous-recipe-filter-summary');
const save = appendText(actionBar, 'button', t('recipeSaveCurrent'), 'anomalous-btn-primary');
save.dataset.recipeSaveCurrent = 'true';
save.type = 'button';
save.onclick = () => this.handleSaveRecipe();
this.recipeSaveStatus = appendText(actionBar, 'small', '', 'anomalous-recipe-save-status');
const importButton = appendText(actionBar, 'button', t('recipeImport'), 'anomalous-btn-primary');
importButton.type = 'button';
importButton.onclick = () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.zip,.anomalous-recipe.zip,application/zip';
input.onchange = async () => {
const file = input.files?.[0];
if (!file) return;
try {
await importRecipePackage(this, file);
} catch (error) {
console.error('Could not import Workflow Recipe package:', error);
await anomalousAlert(t('recipeImportError'));
}
};
input.click();
};
this.recipeView.appendChild(actionBar);
const betaNotice = document.createElement('div');
betaNotice.className = 'anomalous-beta-notice anomalous-recipe-beta-notice';
const betaBadge = appendText(betaNotice, 'strong', t('betaFeature'), 'anomalous-beta-badge');
betaBadge.dataset.anomalousI18nKey = 'betaFeature';
const betaText = appendText(betaNotice, 'span', t('recipeBetaNotice'));
betaText.dataset.anomalousI18nKey = 'recipeBetaNotice';
this.recipeView.appendChild(betaNotice);
this.recipeListContainer = document.createElement('div');
this.recipeListContainer.className = 'anomalous-recipe-list';
this.recipeView.appendChild(this.recipeListContainer);
this.notebookContainer.appendChild(this.recipeView);
await this.refreshRecipes();
}
export async function refreshRecipes() {
if (!this.recipeListContainer) return;
try {
const response = await fetch('/anomalous/recipes');
if (!response.ok) throw new Error('recipe list request failed');
const payload = await response.json();
this.recipeRecords = payload.recipes || [];
updateRecipeFilterControls(this, this.recipeRecords);
this.renderRecipeList(this.recipeRecords);
} catch (error) {
console.error('Could not load Workflow Recipes:', error);
this.recipeListContainer.replaceChildren();
appendText(this.recipeListContainer, 'p', t('recipeLoadError'), 'anomalous-recipe-empty');
}
}
export function renderRecipeList(recipes) {
this.recipeListContainer.replaceChildren();
const records = Array.isArray(recipes) ? recipes : [];
const selectedTags = this.recipeSelectedTags || new Set();
const filtered = records.filter((recipe) => recipeMatchesFilter(
recipe?.data || {},
this.recipeSearchQuery || '',
selectedTags,
));
if (this.recipeFilterSummary) this.recipeFilterSummary.textContent = `${filtered.length}/${records.length}`;
if (!records.length) {
appendText(this.recipeListContainer, 'p', t('recipeEmpty'), 'anomalous-recipe-empty');
return;
}
if (!filtered.length) {
appendText(this.recipeListContainer, 'p', t('recipeNoMatches'), 'anomalous-recipe-empty');
return;
}
for (const recipe of filtered) {
const data = recipe?.data || {};
const card = document.createElement('article');
card.className = 'anomalous-recipe-card';
appendText(card, 'h3', data.name || t('recipeUntitled'));
const sourceImageUrl = outputImageUrl(data.source_image);
const savedCover = recipeAssetUrl(recipe.filename, data.presentation?.cover_asset_id);
const thumbnail = savedCover || (previewIsVideo(sourceImageUrl)
? sourceImageUrl
: safeThumbnail(data.thumbnail) || sourceImageUrl);
appendRecipeCover(card, thumbnail, data.name || t('recipeThumbnail'));
if (Array.isArray(data.tags) && data.tags.length) {
const tags = document.createElement('div');
tags.className = 'anomalous-recipe-tags';
for (const tag of data.tags.slice(0, 8)) {
const tagButton = appendText(tags, 'button', compactText(tag, 32), 'anomalous-recipe-badge anomalous-recipe-badge-tag');
tagButton.type = 'button';
tagButton.title = t('recipeFilterByTag');
tagButton.onclick = (event) => {
event.stopPropagation();
if (!this.recipeSelectedTags) this.recipeSelectedTags = new Set();
this.recipeSelectedTags.add(tag);
this.renderRecipeList(this.recipeRecords || []);
updateRecipeFilterControls(this, this.recipeRecords || []);
};
}
card.appendChild(tags);
}
if (data.notes) appendText(card, 'p', compactText(data.notes, 180), 'anomalous-recipe-notes');
const actions = document.createElement('div');
actions.className = 'anomalous-recipe-actions';
// Make card clickable for details
card.style.cursor = 'pointer';
card.onclick = () => runRecipeCardAction(card, async () => {
const bundle = await fetchRecipeBundle(recipe.filename);
const result = await showRecipeDetail(this, {
recipe: bundle.data,
filename: recipe.filename,
history: bundle.history,
});
if (result?.mode === 'edit') await editRecipe(this, bundle.data, recipe.filename, bundle.history);
}, 'recipeLoadError');
// Secondary actions (Icon buttons)
const secondaryActions = document.createElement('div');
secondaryActions.className = 'anomalous-recipe-actions-secondary';
const edit = appendText(secondaryActions, 'button', '✏️', 'anomalous-btn-icon anomalous-btn-edit');
edit.type = 'button';
edit.title = t('recipeEdit');
edit.onclick = (e) => { e.stopPropagation(); runRecipeCardAction(edit, () => editRecipe(this, recipe?.data || {}, recipe.filename), 'recipeUpdateError'); };
const exportButton = appendText(secondaryActions, 'button', '📥', 'anomalous-btn-icon anomalous-btn-export');
exportButton.type = 'button';
exportButton.title = t('recipeExport');
exportButton.onclick = (e) => { e.stopPropagation(); runRecipeCardAction(exportButton, () => exportRecipePackage(recipe.filename), 'recipeExportError'); };
const remove = appendText(secondaryActions, 'button', '🗑️', 'anomalous-btn-icon anomalous-btn-delete');
remove.type = 'button';
remove.title = t('recipeDelete');
remove.onclick = (e) => {
e.stopPropagation();
runRecipeCardAction(remove, async () => {
if (!await anomalousConfirm(t('recipeDeleteConfirm'))) return;
const response = await fetch('/anomalous/delete_recipe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: recipe.filename }),
});
if (!response.ok) throw new Error('recipe deletion failed');
await this.refreshRecipes();
}, 'recipeDeleteError');
};
// Primary actions (Main buttons)
const primaryActions = document.createElement('div');
primaryActions.className = 'anomalous-recipe-actions-primary';
const append = appendText(primaryActions, 'button', t('recipeAppendCanvas'), 'anomalous-btn-ghost');
append.type = 'button';
append.onclick = (e) => { e.stopPropagation(); runRecipeCardAction(append, async () => {
const data = await fetchRecipeData(recipe.filename);
if (!await appendRecipeOnCanvas(this, data)) throw new Error('recipe append failed');
}, 'recipeAppendError'); };
secondaryActions.append(edit, exportButton, remove);
primaryActions.append(append);
actions.append(secondaryActions, primaryActions);
card.appendChild(actions);
this.recipeListContainer.appendChild(card);
}
}
export async function handleSaveRecipe() {
if (!app.graph?.serialize) {
await anomalousAlert(t('recipeSaveError'));
return;
}
const draft = captureRecipeDraft(app.graph);
if (!draft.workflow || !Array.isArray(draft.workflow.nodes)) {
await anomalousAlert(t('recipeSaveError'));
return;
}
const canvasThumbnail = captureCanvasThumbnail(app.canvas?.canvas);
const editing = this.recipeEditing || null;
const details = await showRecipeSaveDialog(this, canvasThumbnail, editing?.data || null);
if (!details) return;
// Prompt-role labels belong to the recipe skeleton. Preserve labels whose
// node id and type still match when an existing recipe is refreshed from
// the live canvas; stale labels are discarded safely.
const previousPromptRoles = editing?.data?.params?.promptRoleOverrides;
if (previousPromptRoles && typeof previousPromptRoles === 'object') {
const workflowNodes = new Map((draft.workflow.nodes || []).map((node) => [String(node?.id), node]));
const retained = {};
for (const [nodeId, override] of Object.entries(previousPromptRoles)) {
const node = workflowNodes.get(String(nodeId));
if (!node || !override || typeof override !== 'object') continue;
if (override.nodeType && override.nodeType !== node.type) continue;
retained[nodeId] = { ...override, nodeType: node.type || override.nodeType || 'Unknown' };
}
if (Object.keys(retained).length) draft.metadata.promptRoleOverrides = retained;
}
const saveButton = this.recipeView?.querySelector('[data-recipe-save-current]');
if (saveButton) {
saveButton.disabled = true;
saveButton.textContent = t('recipeSaving');
}
try {
let thumbnail = details.thumbnail;
if (details.sourceImage) {
try {
thumbnail = await captureOutputThumbnail(details.sourceImage);
} catch (error) {
console.warn('Could not persist bound recipe image thumbnail:', error);
}
}
const response = await fetch(editing ? '/anomalous/update_recipe' : '/anomalous/save_recipe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...(editing ? { filename: editing.filename } : {}),
name: details.name,
tags: details.tags,
notes: details.notes,
params: draft.metadata,
workflow: draft.workflow,
thumbnail,
source_image: details.sourceImage,
presentation: { save_model_preview_snapshots: details.saveModelPreviewSnapshots },
}),
});
const payload = await response.json();
if (!response.ok || payload.status !== 'success') throw new Error('recipe save request failed');
const recipeFilename = payload.filename || editing?.filename;
if (recipeFilename) {
try {
const parameterResponse = await fetch('/anomalous/save_parameter', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: details.name,
tags: details.tags,
notes: details.notes,
params: draft.metadata,
workflow: draft.workflow,
recipe_filename: recipeFilename,
}),
});
if (!parameterResponse.ok) throw new Error('parameter snapshot request failed');
} catch (error) {
// Recipe persistence is already successful; a snapshot failure
// must not make the user retry and create a duplicate recipe.
console.warn('Could not save the recipe parameter snapshot:', error);
}
}
const receipt = payload.receipt || {};
const receiptMatchesDraft = receipt.node_count === draft.stats.nodeCount
&& receipt.link_count === draft.stats.linkCount
&& receipt.group_count === draft.stats.groupCount;
if (this.recipeSaveStatus) {
this.recipeSaveStatus.textContent = receiptMatchesDraft
? formatRecipeText('recipeSaveReceipt', {
nodes: receipt.node_count,
links: receipt.link_count,
groups: receipt.group_count,
})
: t('recipeSaveReceiptMismatch');
this.recipeSaveStatus.classList.toggle('is-warning', !receiptMatchesDraft);
}
this.recipeEditing = null;
await this.refreshRecipes();
} catch (error) {
console.error('Could not save Workflow Recipe:', error);
await anomalousAlert(t('recipeSaveError'));
} finally {
if (saveButton) {
saveButton.disabled = false;
saveButton.textContent = this.recipeEditing ? t('recipeUpdateCurrent') : t('recipeSaveCurrent');
}
}
}
File diff suppressed because it is too large Load Diff
+2337 -3
View File
File diff suppressed because it is too large Load Diff