Compare commits

...

46 Commits

Author SHA1 Message Date
Will Miao
b6dd6938b0 docs: add v1.0.2 release notes, bump version to 1.0.2 2026-04-06 20:14:26 +08:00
Will Miao
727d0ef043 feat(misc): add model download status aggregation 2026-04-03 22:17:09 +08:00
Will Miao
9344d86332 test(misc): cover model existence download status 2026-04-03 22:16:09 +08:00
Will Miao
d36b16c213 feat(settings): skip previously downloaded model versions 2026-04-03 19:01:19 +08:00
Will Miao
33a7f07558 feat(download-history): track downloaded model versions 2026-04-03 16:13:14 +08:00
Will Miao
4f599aeced fix(trigger-words): propagate LORA_STACK updates through combiners (#881) 2026-04-03 15:01:02 +08:00
Will Miao
30db8c3d1d fix(csp): support CivitAI CDN subdomains for example images (#822)
- Update CSP whitelist to use wildcard *.civitai.com for all CDN subdomains
- Fix hostname parsing to use parsed.hostname instead of parsed.netloc (handles ports)
- Update rewrite_preview_url() to support all CivitAI CDN subdomains
- Update rewriteCivitaiUrl() frontend function to support subdomains
- Add comprehensive tests for edge cases (ports, subdomains, invalid URLs)
- Add security note explaining wildcard CSP design decision

Fixes CSP blocking of images from image-b2.civitai.com and other CDN subdomains
2026-04-03 09:40:15 +08:00
Will Miao
05636712f0 docs: fix formatting in v1.0.1 release notes 2026-04-02 11:59:29 +08:00
Will Miao
d8e5fe1247 docs: add v1.0.1 release notes, bump version to 1.0.1 2026-04-02 11:54:04 +08:00
Will Miao
3e9210394a feat(settings): Improve Extra Folder Paths UX with restart indicators
- Replace tooltip with restart-required icon for better visibility
- Update descriptions to accurately reflect feature purpose
- Fix toast message to show correct restart notification
- Sync i18n keys across all supported languages
2026-04-02 08:57:04 +08:00
Will Miao
4dd2c0526f chore(supporters): Update supporters 2026-04-01 22:56:20 +08:00
Will Miao
9bdb337962 fix(settings): enforce valid default model roots 2026-04-01 20:36:37 +08:00
Will Miao
f93baf5fc0 chore(workflow): Update example workflows 2026-04-01 15:39:20 +08:00
Will Miao
14cb7fec47 feat(cycler): add preset strength scale (#865) 2026-04-01 11:05:38 +08:00
Will Miao
f3b3e0adad fix(randomizer): defer UI updates until workflow completion (fixes #824) 2026-04-01 10:29:27 +08:00
Will Miao
ba3f15dbc6 feat(checkpoints): add 'Send to Workflow' option in context menu
- Add 'Send to Workflow' menu item to checkpoint context menu (templates/checkpoints.html)
- Implement sendCheckpointToWorkflow() method in CheckpointContextMenu.js
- Use unified 'Model' terminology for toast messages instead of differentiating checkpoint/diffusion model
- Add translation keys: checkpoints.contextMenu.sendToWorkflow, uiHelpers.workflow.modelUpdated, modelFailed
- Complete translations for all 10 locales (en, zh-CN, zh-TW, ja, ko, de, fr, es, ru, he)
2026-03-31 19:52:20 +08:00
Will Miao
8dc2a2f76b fix(recipe): show checkpoint-linked recipes in model modal (#851) 2026-03-31 16:45:01 +08:00
Will Miao
316f17dd46 fix(recipe): Import LoRAs from Civitai image URLs using modelVersionIds (#868)
When importing recipes from Civitai image URLs, the API returns modelVersionIds
at the root level instead of inside the meta object. This caused LoRA information
to not be recognized and imported.

Changes:
- analysis_service.py: Merge modelVersionIds from image_info into metadata
- civitai_image.py: Add modelVersionIds field recognition and processing logic
- test_civitai_image_parser.py: Add test for modelVersionIds handling
2026-03-31 14:34:13 +08:00
Will Miao
3dc10b1404 feat(recipe): add editable prompts in recipe modal (#869) 2026-03-31 14:11:56 +08:00
Will Miao
331889d872 chore(i18n): improve recursive toggle button labels for clarity (#875)
Update translations for sidebar recursive toggle from 'Search subfolders'
to 'Include subfolders' / 'Current folder only' across all 10 languages.

This better describes the actual functionality - controlling whether
models/recipes from subfolders are included in the current view.

Related to #875
2026-03-30 15:26:15 +08:00
Will Miao
06f1a82d4c fix(tests): add missing MODEL_TYPES mock in ModelModal tests
Add mock for apiConfig.js MODEL_TYPES constant in test files to fix
'Cannot read properties of undefined' errors when running npm test.

- tests/frontend/components/modelMetadata.renamePath.test.js
- tests/frontend/components/modelModal.licenseIcons.test.js
2026-03-30 08:37:12 +08:00
Will Miao
267082c712 feat: add 'Send to ComfyUI' button to ModelModal and RecipeModal
- Add send button to ModelModal header for all model types (LoRA, Checkpoint, Embedding)
- Add send button to RecipeModal header for sending entire recipes
- Style buttons to match existing modal action buttons
- Add translations for all supported languages
2026-03-29 20:35:08 +08:00
Will Miao
a4cb51e96c fix(nodes): preserve autocomplete widget values across workflow restore 2026-03-29 19:25:30 +08:00
Will Miao
ca44c367b3 fix(recipe): improve Civitai URL generation for missing LoRAs
Use model-versions endpoint (https://civitai.com/model-versions/{id}) which
auto-redirects to the correct model page when only versionId is available.

This fixes the UX issue where clicking on 'Not in Library' LoRA entries in
Recipe Modal would open a search page instead of the actual model page.

Changes:
- uiHelpers.js: Prioritize versionId over modelId for Civitai URLs
- RecipeModal.js: Include versionId in navigation condition checks
2026-03-29 15:33:30 +08:00
Will Miao
301ab14781 fix(nodes): restore autocomplete widget sync after metadata insertion (#879) 2026-03-29 10:09:39 +08:00
Will Miao
2626dbab8e feat: add lora stack combiner node 2026-03-29 08:28:00 +08:00
Will Miao
12bbb0572d fix: Add missing mock for getMappableBaseModelsDynamic in tests (#854)
- Add getMappableBaseModelsDynamic to constants.js mocks in test files
- Remove refs/enums.json temporary file from repository

Fixes test failures introduced in previous commit.
2026-03-29 00:24:20 +08:00
Will Miao
00f5c1e887 feat: Dynamic base model fetching from Civitai API (#854)
Implement automatic fetching of base models from Civitai API to keep
data up-to-date without manual updates.

Backend:
- Add CivitaiBaseModelService with 7-day TTL caching
- Add /api/lm/base-models endpoints for fetching and refreshing
- Merge hardcoded and remote models for backward compatibility
- Smart abbreviation generation for unknown models

Frontend:
- Add civitaiBaseModelApi client for API communication
- Dynamic base model loading on app initialization
- Update SettingsManager to use merged model lists
- Add support for 8 new models: Anima, CogVideoX, LTXV 2.3, Mochi,
  Pony V7, Wan Video 2.5 T2V/I2V

API Endpoints:
- GET /api/lm/base-models - Get merged models
- POST /api/lm/base-models/refresh - Force refresh
- GET /api/lm/base-models/categories - Get categories
- GET /api/lm/base-models/cache-status - Check cache status

Closes #854
2026-03-29 00:18:15 +08:00
Will Miao
89b1675ec7 fix: wheel zoom behavior for LoRA Manager widgets
- Add forwardWheelToCanvas() utility for vanilla JS widgets
- Implement wheel event handling in Vue widgets (LoraCyclerWidget, LoraRandomizerWidget, LoraPoolWidget)
- Update SingleSlider and DualRangeSlider to stop event propagation after value adjustment
- Ensure consistent behavior: slider adjusts value only, other areas trigger canvas zoom
- Support pinch-to-zoom (Ctrl+wheel) and horizontal scroll forwarding
2026-03-28 22:42:26 +08:00
Will Miao
dcc7bd33b5 fix(autocomplete): make accept key behavior configurable (#863) 2026-03-28 20:21:23 +08:00
Will Miao
e5152108ba fix(autocomplete): treat newline as a hard boundary 2026-03-28 19:29:30 +08:00
Will Miao
1ed5eef985 feat(autocomplete): support Tab accept and configurable suffix behavior (#863) 2026-03-28 19:18:23 +08:00
Will Miao
a82f89d14a fix(nodes): expose save image outputs to generated assets 2026-03-28 14:28:48 +08:00
Will Miao
16e30ea689 fix(nodes): add save_with_metadata toggle to save image 2026-03-28 11:17:36 +08:00
pixelpaws
ad3bdddb72 Merge pull request #876 from willmiao/codex/analyze-issue-869-on-github
Handle Enter on tag input to add tags and add unit tests
2026-03-27 19:55:12 +08:00
pixelpaws
9121306b06 Guard Enter tag add during IME composition 2026-03-27 19:52:53 +08:00
Will Miao
ca0baf9462 fix(nodes): lazy load qwen lora helper 2026-03-27 19:44:05 +08:00
pixelpaws
20e50156a2 fix(recipes): allow Enter to add import tags 2026-03-27 19:28:58 +08:00
Will Miao
0b66bf5479 chore: update AGENTS commit guidance 2026-03-27 19:26:13 +08:00
Will Miao
1e8aca4787 Add experimental Nunchaku Qwen LoRA support (#873) 2026-03-27 19:24:43 +08:00
Will Miao
76ee59cdb9 fix(paths): deduplicate LoRA path overlap (#871) 2026-03-27 17:35:24 +08:00
Will Miao
a5191414cc feat(download): add configurable base model download exclusions 2026-03-26 23:07:12 +08:00
Will Miao
5b065b47d4 feat(i18n): complete translations for mature blur threshold setting
Add translations for the new mature_blur_level setting across all
supported languages:
- zh-CN: 成人内容模糊阈值
- zh-TW: 成人內容模糊閾值
- ja: 成人コンテンツぼかし閾値
- ko: 성인 콘텐츠 블러 임계값
- de: Schwelle für Unschärfe bei jugendgefährdenden Inhalten
- fr: Seuil de floutage pour contenu adulte
- es: Umbral de difuminado para contenido adulto
- ru: Порог размытия взрослого контента
- he: סף טשטוש תוכן מבוגרים

Completes TODOs from previous commit.
2026-03-26 18:40:33 +08:00
Will Miao
ceeab0c998 feat: add configurable mature blur threshold setting
Add new setting 'mature_blur_level' with options PG13/R/X/XXX to control
which NSFW rating level triggers blur filtering when NSFW blur is enabled.

- Backend: update preview selection logic to respect threshold
- Frontend: update UI components to use configurable threshold
- Settings: add validation and normalization for mature_blur_level
- Tests: add coverage for new threshold behavior
- Translations: add keys for all supported languages

Fixes #867
2026-03-26 18:24:47 +08:00
Will Miao
3b001a6cd8 fix(tests): update tests to match current download implementation
- Remove calculate_sha256 mocking from download_manager tests since
  SHA256 now comes from API metadata (not recalculated during download)
- Update chunk_size assertion from 4MB to 16MB in downloader config test
2026-03-26 18:00:04 +08:00
Will Miao
95e5bc26d1 feat: Add bulk download missing LoRAs feature for recipes
- Add BulkMissingLoraDownloadManager.js for handling bulk LoRA downloads
- Add context menu item to bulk mode for downloading missing LoRAs
- Add confirmation modal with deduplicated LoRA list preview
- Implement sequential downloading with WebSocket progress updates
- Fix CSS class naming conflicts to avoid import-modal.css collision
- Update translations for 9 languages (en, zh-CN, zh-TW, ja, ko, ru, de, fr, es, he)
- Style modal without internal scrolling for better UX
2026-03-26 17:46:53 +08:00
143 changed files with 11695 additions and 1470 deletions

1
.gitignore vendored
View File

@@ -15,6 +15,7 @@ model_cache/
# agent # agent
.opencode/ .opencode/
.claude/ .claude/
.codex
# Vue widgets development cache (but keep build output) # Vue widgets development cache (but keep build output)
vue-widgets/node_modules/ vue-widgets/node_modules/

View File

@@ -138,6 +138,13 @@ npm run test:coverage # Generate coverage report
- Run `python scripts/sync_translation_keys.py` after adding UI strings to `locales/en.json` - Run `python scripts/sync_translation_keys.py` after adding UI strings to `locales/en.json`
- Symlinks require normalized paths - Symlinks require normalized paths
## Git / Commit Messages
- Follow the style of recent repository commits when writing commit messages
- Prefer the repo's existing `feat(...)`, `fix(...)`, `chore:` style where applicable
- If the user has provided a GitHub issue link or issue ID for the task, mention that issue in the commit message, for example `(#871)`
- When unrelated local changes exist, stage and commit only the files relevant to the requested task
## Frontend UI Architecture ## Frontend UI Architecture
### 1. Standalone Web UI ### 1. Standalone Web UI

File diff suppressed because one or more lines are too long

View File

@@ -7,6 +7,7 @@ try: # pragma: no cover - import fallback for pytest collection
from .py.nodes.prompt import PromptLM from .py.nodes.prompt import PromptLM
from .py.nodes.text import TextLM from .py.nodes.text import TextLM
from .py.nodes.lora_stacker import LoraStackerLM from .py.nodes.lora_stacker import LoraStackerLM
from .py.nodes.lora_stack_combiner import LoraStackCombinerLM
from .py.nodes.save_image import SaveImageLM from .py.nodes.save_image import SaveImageLM
from .py.nodes.debug_metadata import DebugMetadataLM from .py.nodes.debug_metadata import DebugMetadataLM
from .py.nodes.wanvideo_lora_select import WanVideoLoraSelectLM from .py.nodes.wanvideo_lora_select import WanVideoLoraSelectLM
@@ -39,6 +40,9 @@ except (
"py.nodes.trigger_word_toggle" "py.nodes.trigger_word_toggle"
).TriggerWordToggleLM ).TriggerWordToggleLM
LoraStackerLM = importlib.import_module("py.nodes.lora_stacker").LoraStackerLM LoraStackerLM = importlib.import_module("py.nodes.lora_stacker").LoraStackerLM
LoraStackCombinerLM = importlib.import_module(
"py.nodes.lora_stack_combiner"
).LoraStackCombinerLM
SaveImageLM = importlib.import_module("py.nodes.save_image").SaveImageLM SaveImageLM = importlib.import_module("py.nodes.save_image").SaveImageLM
DebugMetadataLM = importlib.import_module("py.nodes.debug_metadata").DebugMetadataLM DebugMetadataLM = importlib.import_module("py.nodes.debug_metadata").DebugMetadataLM
WanVideoLoraSelectLM = importlib.import_module( WanVideoLoraSelectLM = importlib.import_module(
@@ -63,6 +67,7 @@ NODE_CLASS_MAPPINGS = {
UNETLoaderLM.NAME: UNETLoaderLM, UNETLoaderLM.NAME: UNETLoaderLM,
TriggerWordToggleLM.NAME: TriggerWordToggleLM, TriggerWordToggleLM.NAME: TriggerWordToggleLM,
LoraStackerLM.NAME: LoraStackerLM, LoraStackerLM.NAME: LoraStackerLM,
LoraStackCombinerLM.NAME: LoraStackCombinerLM,
SaveImageLM.NAME: SaveImageLM, SaveImageLM.NAME: SaveImageLM,
DebugMetadataLM.NAME: DebugMetadataLM, DebugMetadataLM.NAME: DebugMetadataLM,
WanVideoLoraSelectLM.NAME: WanVideoLoraSelectLM, WanVideoLoraSelectLM.NAME: WanVideoLoraSelectLM,

View File

@@ -9,17 +9,17 @@
"Insomnia Art Designs", "Insomnia Art Designs",
"megakirbs", "megakirbs",
"Brennok", "Brennok",
"wackop",
"2018cfh", "2018cfh",
"W+K+White",
"wackop",
"Takkan", "Takkan",
"stone9k", "Carl G.",
"$MetaSamsara", "$MetaSamsara",
"itismyelement", "itismyelement",
"onesecondinosaur", "onesecondinosaur",
"Carl G.", "stone9k",
"Rosenthal", "Rosenthal",
"Francisco Tatis", "Francisco Tatis",
"Tobi_Swagg",
"Andrew Wilson", "Andrew Wilson",
"Greybush", "Greybush",
"Gooohokrbe", "Gooohokrbe",
@@ -29,18 +29,16 @@
"VantAI", "VantAI",
"runte3221", "runte3221",
"FreelancerZ", "FreelancerZ",
"Julian V",
"Edgar Tejeda", "Edgar Tejeda",
"Birdy",
"Liam MacDougal", "Liam MacDougal",
"Fraser Cross", "Fraser Cross",
"Polymorphic Indeterminate", "Polymorphic Indeterminate",
"Birdy",
"Marc Whiffen", "Marc Whiffen",
"Kiba",
"Jorge Hussni", "Jorge Hussni",
"Reno Lam", "Kiba",
"Skalabananen", "Skalabananen",
"esthe", "Reno Lam",
"sig", "sig",
"Christian Byrne", "Christian Byrne",
"DM", "DM",
@@ -49,24 +47,22 @@
"J\\B/ 8r0wns0n", "J\\B/ 8r0wns0n",
"Snaggwort", "Snaggwort",
"Arlecchino Shion", "Arlecchino Shion",
"Charles Blakemore",
"Rob Williams",
"ClockDaemon", "ClockDaemon",
"KD", "KD",
"Omnidex", "Omnidex",
"Tyler Trebuchon", "Tyler Trebuchon",
"Release Cabrakan", "Release Cabrakan",
"confiscated Zyra", "Tobi_Swagg",
"SG", "SG",
"carozzz", "carozzz",
"James Dooley", "James Dooley",
"zenbound", "zenbound",
"Buzzard", "Buzzard",
"jmack", "jmack",
"Adam Shaw",
"Tee Gee",
"Mark Corneglio", "Mark Corneglio",
"SarcasticHashtag", "SarcasticHashtag",
"Anthony Rizzo",
"tarek helmi",
"Cosmosis", "Cosmosis",
"iamresist", "iamresist",
"RedrockVP", "RedrockVP",
@@ -75,45 +71,34 @@
"James Todd", "James Todd",
"Steven Pfeiffer", "Steven Pfeiffer",
"Tim", "Tim",
"Timmy",
"Johnny",
"Lisster", "Lisster",
"Michael Wong", "Michael Wong",
"Illrigger", "Illrigger",
"whudunit",
"Tom Corrigan", "Tom Corrigan",
"JackieWang", "JackieWang",
"fnkylove", "fnkylove",
"Julian V",
"Steven Owens", "Steven Owens",
"Yushio", "Yushio",
"Vik71it", "Vik71it",
"lh qwe",
"Echo", "Echo",
"Lilleman", "Lilleman",
"Robert Stacey", "Robert Stacey",
"PM", "PM",
"Todd Keck", "Todd Keck",
"Briton Heilbrun",
"Mozzel", "Mozzel",
"Gingko Biloba", "Gingko Biloba",
"Felipe dos Santos",
"Penfore",
"BadassArabianMofo",
"Sterilized", "Sterilized",
"BadassArabianMofo",
"Pascal Dahle", "Pascal Dahle",
"Markus",
"quarz", "quarz",
"Greg", "Greg",
"Douglas Gaspar", "Penfore",
"JSST", "JSST",
"AlexDuKaNa", "esthe",
"George",
"lmsupporter", "lmsupporter",
"Phil",
"Charles Blakemore",
"IamAyam", "IamAyam",
"wfpearl", "wfpearl",
"Rob Williams",
"Baekdoosixt", "Baekdoosixt",
"Jonathan Ross", "Jonathan Ross",
"Jack B Nimble", "Jack B Nimble",
@@ -125,127 +110,118 @@
"contrite831", "contrite831",
"Alex", "Alex",
"bh", "bh",
"confiscated Zyra",
"Marlon Daniels", "Marlon Daniels",
"Starkselle", "Starkselle",
"Aaron Bleuer", "Aaron Bleuer",
"LacesOut!", "LacesOut!",
"Graham Colehour", "greebles",
"Adam Shaw",
"Tee Gee",
"Anthony Rizzo",
"tarek helmi",
"M Postkasse", "M Postkasse",
"Tomohiro Baba",
"David Ortega",
"ASLPro3D", "ASLPro3D",
"Jacob Hoehler", "Jacob Hoehler",
"FinalyFree", "FinalyFree",
"Weasyl", "Weasyl",
"Lex Song", "Timmy",
"Johnny",
"Cory Paza", "Cory Paza",
"Tak", "Tak",
"Gonzalo Andre Allendes Lopez", "Gonzalo Andre Allendes Lopez",
"Zach Gonser", "Zach Gonser",
"Big Red", "Big Red",
"Jimmy Ledbetter", "whudunit",
"Luc Job", "Luc Job",
"dl0901dm", "dl0901dm",
"Philip Hempel", "Philip Hempel",
"corde", "corde",
"Nick Walker", "Nick Walker",
"lh qwe",
"Bishoujoker", "Bishoujoker",
"conner", "conner",
"aai", "aai",
"Yaboi", "Briton Heilbrun",
"Tori", "Tori",
"wildnut", "wildnut",
"Princess Bright Eyes", "Princess Bright Eyes",
"Damon Cunliffe",
"CryptoTraderJK",
"Davaitamin",
"AbstractAss", "AbstractAss",
"Felipe dos Santos",
"ViperC", "ViperC",
"jean jahren",
"Aleksander Wujczyk", "Aleksander Wujczyk",
"AM Kuro", "AM Kuro",
"jean jahren", "Markus",
"Ran C",
"tedcor",
"S Sang", "S Sang",
"MagnaInsomnia",
"Akira_HentAI",
"Karl P.", "Karl P.",
"Akira_HentAI",
"MagnaInsomnia",
"Gordon Cole", "Gordon Cole",
"yuxz69", "yuxz69",
"MadSpin", "Douglas Gaspar",
"AlexDuKaNa",
"George",
"andrew.tappan", "andrew.tappan",
"dw", "dw",
"N/A", "N/A",
"The Spawn", "The Spawn",
"Phil",
"graysock", "graysock",
"Greenmoustache", "Greenmoustache",
"zounic", "zounic",
"Gamalonia",
"fancypants", "fancypants",
"Vir",
"Joboshy",
"Digital", "Digital",
"JaxMax", "JaxMax",
"takyamtom", "takyamtom",
"Bohemian Corporal",
"奚明 刘", "奚明 刘",
"Dan",
"Seth Christensen",
"Jwk0205", "Jwk0205",
"Bro Xie", "Bro Xie",
"Draven T", "준희 김",
"yer fey",
"batblue", "batblue",
"carey6409", "carey6409",
"Olive", "Olive",
"太郎 ゲーム", "太郎 ゲーム",
"Some Guy Named Barry", "Some Guy Named Barry",
"jinxedx",
"Aquatic Coffee",
"Max Marklund", "Max Marklund",
"Tomohiro Baba",
"David Ortega",
"AELOX", "AELOX",
"Dankin",
"Nicfit23", "Nicfit23",
"Noora", "Noora",
"ethanfel",
"wamekukyouzin", "wamekukyouzin",
"drum matthieu", "drum matthieu",
"Dogmaster", "Dogmaster",
"Matt Wenzel", "Matt Wenzel",
"Mattssn", "Mattssn",
"Frank Nitty", "Lex Song",
"John Saveas", "John Saveas",
"Focuschannel",
"Christopher Michel", "Christopher Michel",
"Serge Bekenkamp", "Serge Bekenkamp",
"Jimmy Ledbetter",
"LeoZero", "LeoZero",
"Antonio Pontes", "Antonio Pontes",
"ApathyJones", "ApathyJones",
"nahinahi9", "nahinahi9",
"Anthony Faxlandez",
"Dustin Chen", "Dustin Chen",
"dan", "dan",
"Blackfish95", "Yaboi",
"Mouthlessman", "Mouthlessman",
"Steam Steam", "Steam Steam",
"Paul Kroll", "Damon Cunliffe",
"CryptoTraderJK",
"Davaitamin",
"otaku fra", "otaku fra",
"semicolon drainpipe", "Ran C",
"Thesharingbrother", "tedcor",
"Fotek Design", "Fotek Design",
"Bas Imagineer",
"Pat Hen",
"ResidentDeviant",
"Adam Taylor", "Adam Taylor",
"JC",
"Weird_With_A_Beard", "Weird_With_A_Beard",
"Prompt Pirate", "MadSpin",
"Pozadine1", "Pozadine1",
"uwutismxd",
"Qarob", "Qarob",
"AIGooner", "AIGooner",
"inbijiburu", "inbijiburu",
"decoy",
"Luc", "Luc",
"ProtonPrince", "ProtonPrince",
"DiffDuck", "DiffDuck",
@@ -258,53 +234,54 @@
"thesoftwaredruid", "thesoftwaredruid",
"wundershark", "wundershark",
"mr_dinosaur", "mr_dinosaur",
"Tyrswood",
"linnfrey", "linnfrey",
"zenobeus", "Gamalonia",
"Jackthemind", "Vir",
"Stryker",
"Pkrsky", "Pkrsky",
"raf8osz", "Joboshy",
"blikkies", "Bohemian Corporal",
"Dan",
"Josef Lanzl", "Josef Lanzl",
"Seth Christensen",
"Griffin Dahlberg", "Griffin Dahlberg",
"준희 김", "Draven T",
"yer fey",
"Error_Rule34_Not_found", "Error_Rule34_Not_found",
"Gerald Welly", "Gerald Welly",
"Shock Shockor",
"Roslynd", "Roslynd",
"Geolog", "Geolog",
"Goldwaters", "jinxedx",
"Neco28", "Neco28",
"Zude", "Aquatic Coffee",
"Dankin",
"ethanfel",
"Cristian Vazquez", "Cristian Vazquez",
"Kyler", "Frank Nitty",
"Magic Noob", "Magic Noob",
"aRtFuL_DodGeR", "Focuschannel",
"X",
"DougPeterson", "DougPeterson",
"Jeff", "Jeff",
"Bruce", "Bruce",
"CrimsonDX",
"Kevin John Duck", "Kevin John Duck",
"Anthony Faxlandez",
"Kevin Christopher", "Kevin Christopher",
"Ouro Boros", "Ouro Boros",
"DarkSunset", "Blackfish95",
"dd", "dd",
"Billy Gladky", "Paul Kroll",
"Probis", "MiraiKuriyamaSy",
"shrshpp", "semicolon drainpipe",
"Dušan Ryban", "Thesharingbrother",
"ItsGeneralButtNaked", "Bas Imagineer",
"sjon kreutz", "Pat Hen",
"Nimess",
"John Statham", "John Statham",
"Youguang", "ResidentDeviant",
"Nihongasuki", "Nihongasuki",
"Metryman55", "JC",
"andrewzpong", "Prompt Pirate",
"FrxzenSnxw", "uwutismxd",
"BossGame", "decoy",
"Tyrswood",
"Ray Wing", "Ray Wing",
"Ranzitho", "Ranzitho",
"Gus", "Gus",
@@ -316,7 +293,6 @@
"WRL_SPR", "WRL_SPR",
"capn", "capn",
"Joseph", "Joseph",
"lrdchs",
"Mirko Katzula", "Mirko Katzula",
"dan", "dan",
"Piccio08", "Piccio08",
@@ -326,51 +302,135 @@
"Moon Knight", "Moon Knight",
"몽타주", "몽타주",
"Kland", "Kland",
"Hailshem", "zenobeus",
"Jackthemind",
"ryoma", "ryoma",
"John Martin", "Stryker",
"raf8osz",
"ElitaSSJ4",
"blikkies",
"Chris", "Chris",
"Brian M", "Brian M",
"Nerezza", "Nerezza",
"sanborondon", "sanborondon",
"moranqianlong",
"Taylor Funk", "Taylor Funk",
"aezin", "aezin",
"Thought2Form", "Thought2Form",
"jcay015", "jcay015",
"Kevin Picco", "Kevin Picco",
"Erik Lopez", "Erik Lopez",
"Shock Shockor",
"Mateo Curić", "Mateo Curić",
"Haru Yotu", "Goldwaters",
"Zude",
"Eris3D", "Eris3D",
"m", "m",
"Pierce McBride", "Pierce McBride",
"Joshua Gray", "Joshua Gray",
"Kyler",
"Mikko Hemilä", "Mikko Hemilä",
"Matura Arbeit", "aRtFuL_DodGeR",
"Jamie Ogletree", "Jamie Ogletree",
"TBitz33",
"Emil Bernhoff",
"a _", "a _",
"SendingRavens",
"James Coleman", "James Coleman",
"CrimsonDX",
"Martial", "Martial",
"battu", "battu",
"Emil Andersson", "Emil Andersson",
"Chad Idk", "Chad Idk",
"Michael Docherty", "DarkSunset",
"Billy Gladky",
"Yuji Kaneko", "Yuji Kaneko",
"Probis",
"Dušan Ryban",
"ItsGeneralButtNaked",
"Jordan Shaw",
"Rops Alot",
"Sam",
"sjon kreutz",
"Nimess",
"SRDB",
"Ace Ventura",
"g unit",
"Youguang",
"Metryman55",
"andrewzpong",
"FrxzenSnxw",
"BossGame",
"lrdchs",
"momokai",
"Hailshem",
"kudari",
"Naomi Hale Danchi",
"dc7431",
"ken",
"Inversity",
"AIVORY3D",
"epicgamer0020690",
"Joshua Porrata",
"keemun",
"SuBu",
"RedPIXel",
"Kevinj",
"Wind",
"Nexus",
"Ramneek“Guy”Ashok",
"squid_actually",
"Nat_20",
"Edward Weeks",
"kyoumei",
"RadStorm04",
"JohnDoe42054",
"BillyHill",
"emyth",
"chriphost",
"KitKatM",
"socrasteeze",
"ResidentDeviant",
"gzmzmvp",
"Welkor",
"John Martin",
"Richard",
"Andrew",
"Robert Wegemund",
"Littlehuggy",
"moranqianlong",
"Gregory Kozhemiak",
"mrjuan",
"Brian Buie",
"Sadlip",
"Haru Yotu",
"Eric Whitney",
"Joey Callahan",
"Ivan Tadic",
"Mike Simone",
"Morgandel",
"Kyron Mahan",
"Matura Arbeit",
"Noah",
"Jacob McDaniel",
"X",
"Sloan Steddy",
"TBitz33",
"Anonym dkjglfleeoeldldldlkf",
"Temikus",
"Artokun",
"Michael Taylor",
"SendingRavens",
"Derek Baker",
"Michael Anthony Scott",
"Atilla Berke Pekduyar",
"Michael Docherty",
"Nathan",
"Decx _",
"Paul Hartsuyker",
"elitassj", "elitassj",
"Jacob Winter", "Jacob Winter",
"Jordan Shaw", "Distortik",
"Sam",
"Rops Alot",
"SRDB",
"g unit",
"Ace Ventura",
"David", "David",
"Meilo", "Meilo",
"Pen Bouryoung", "Pen Bouryoung",
"四糸凜音",
"shinonomeiro", "shinonomeiro",
"Snille", "Snille",
"MaartenAlbers", "MaartenAlbers",
@@ -378,101 +438,104 @@
"xybrightsummer", "xybrightsummer",
"jreedatchison", "jreedatchison",
"PhilW", "PhilW",
"momokai", "Tree Tagger",
"Janik", "Janik",
"kudari",
"Naomi Hale Danchi",
"dc7431",
"ken",
"Inversity",
"Crocket", "Crocket",
"AIVORY3D",
"epicgamer0020690",
"Joshua Porrata",
"Cruel", "Cruel",
"keemun",
"SuBu",
"RedPIXel",
"MRBlack", "MRBlack",
"Kevinj",
"Wind",
"Nexus",
"Mitchell Robson", "Mitchell Robson",
"Ramneek“Guy”Ashok",
"squid_actually",
"Nat_20",
"Kiyoe", "Kiyoe",
"Edward Weeks",
"kyoumei",
"RadStorm04",
"JohnDoe42054",
"BillyHill",
"humptynutz", "humptynutz",
"emyth",
"michael.isaza", "michael.isaza",
"Kalnei", "Kalnei",
"chriphost", "Whitepinetrader",
"KitKatM", "OrganicArtifact",
"socrasteeze",
"ResidentDeviant",
"Scott", "Scott",
"gzmzmvp", "MudkipMedkitz",
"Welkor", "deanbrian",
"POPPIN",
"Alex Wortman",
"Cody",
"Raku",
"smart.edge5178",
"emadsultan",
"InformedViewz",
"CHKeeho80",
"Bubbafett",
"leaf",
"Menard",
"Skyfire83",
"Adam Rinehart",
"D",
"Pitpe11",
"TheD1rtyD03",
"moonpetal",
"SomeDude",
"g9p0o",
"nanana",
"TheHolySheep",
"Monte Won",
"SpringBootisTrash",
"carsten",
"ikok",
"Buecyb99",
"4IXplr0r3r",
"dfklsjfkljslfjd",
"hayden", "hayden",
"Richard",
"ahoystan", "ahoystan",
"Leland Saunders", "Leland Saunders",
"Andrew", "Wolfe7D1",
"Ink Temptation",
"Bob Barker", "Bob Barker",
"Robert Wegemund", "edk",
"Littlehuggy", "Kalli Core",
"Gregory Kozhemiak",
"mrjuan",
"Aeternyx", "Aeternyx",
"Brian Buie", "elleshar666",
"YOU SINWOO", "YOU SINWOO",
"Sadlip",
"ja s", "ja s",
"Eric Whitney",
"Doug Mason", "Doug Mason",
"Joey Callahan", "Kauffy",
"Ivan Tadic",
"y2Rxy7FdXzWo",
"Jeremy Townsend", "Jeremy Townsend",
"Mike Simone", "EpicElric",
"Sean voets", "Sean voets",
"Owen Gwosdz", "Owen Gwosdz",
"Morgandel", "John J Linehan",
"Elliot E",
"Thomas Wanner", "Thomas Wanner",
"Kyron Mahan",
"Theerat Jiramate", "Theerat Jiramate",
"Noah", "Edward Kennedy",
"Jacob McDaniel", "Justin Blaylock",
"Devil Lude",
"Nick Kage",
"kevin stoddard", "kevin stoddard",
"Sloan Steddy",
"Jack Dole", "Jack Dole",
"Vane Holzer",
"psytrax",
"Ezokewn", "Ezokewn",
"Temikus", "hexxish",
"Artokun", "CptNeo",
"Michael Taylor", "notedfakes",
"Derek Baker",
"Michael Anthony Scott",
"Atilla Berke Pekduyar",
"Maso", "Maso",
"Nathan", "Eric Ketchum",
"Decx _", "NICHOLAS BAXLEY",
"Michael Scott",
"Kevin Wallace", "Kevin Wallace",
"Matheus Couto", "Matheus Couto",
"Paul Hartsuyker", "Saya",
"ChicRic", "ChicRic",
"mercur", "mercur",
"J C", "J C",
"Distortik", "Ed Wang",
"Ryan Presley Ng",
"Wes Sims",
"Donor4115",
"Yves Poezevara", "Yves Poezevara",
"Teriak47", "Teriak47",
"Just me", "Just me",
"Raf Stahelin", "Raf Stahelin",
"Вячеслав Маринин", "Вячеслав Маринин",
"Lyavph",
"Filippo Ferrari",
"Cola Matthew", "Cola Matthew",
"OniNoKen", "OniNoKen",
"Iain Wisely", "Iain Wisely",
@@ -505,117 +568,100 @@
"RevyHiep", "RevyHiep",
"Captain_Swag", "Captain_Swag",
"obkircher", "obkircher",
"Tree Tagger",
"gwyar", "gwyar",
"D", "D",
"edgecase", "edgecase",
"Neoxena", "Neoxena",
"mrmhalo", "mrmhalo",
"dg", "dg",
"Whitepinetrader",
"Maarten Harms", "Maarten Harms",
"OrganicArtifact",
"四糸凜音",
"MudkipMedkitz",
"Israel", "Israel",
"deanbrian",
"POPPIN",
"Muratoraccio", "Muratoraccio",
"SelfishMedic", "SelfishMedic",
"Ginnie", "Ginnie",
"Alex Wortman",
"Cody",
"adderleighn", "adderleighn",
"Raku",
"smart.edge5178",
"emadsultan",
"InformedViewz",
"CHKeeho80",
"Bubbafett",
"leaf",
"Menard",
"Skyfire83",
"Adam Rinehart",
"D",
"Pitpe11",
"TheD1rtyD03",
"EnragedAntelope", "EnragedAntelope",
"moonpetal", "Alan+Cano",
"SomeDude", "FeralOpticsAI",
"g9p0o", "Pavlaki",
"nanana", "generic404",
"TheHolySheep", "Mateusz+Kosela",
"Monte Won", "Doug+Rintoul",
"SpringBootisTrash", "Noor",
"carsten", "Yorunai",
"ikok", "Bula",
"Buecyb99", "quantenmecha",
"4IXplr0r3r", "abattoirblues",
"Jason+Nash",
"BillyBoy84",
"DarkRoast",
"zounik",
"letzte",
"Nasty+Hobbit",
"SgtFluffles",
"lrdchs2",
"Duk3+Rand0m",
"KUJYAKU",
"NathenChoi",
"Thomas+Reck",
"Larses",
"cocona",
"Coeur+de+cochon", "Coeur+de+cochon",
"David Schenck", "David Schenck",
"han b", "han b",
"Nico", "Nico",
"Wolfe7D1",
"Banana Joe", "Banana Joe",
"_ G3n", "_ G3n",
"Donovan Jenkins", "Donovan Jenkins",
"Ink Temptation", "JBsuede",
"edk",
"Michael Eid", "Michael Eid",
"beersandbacon", "beersandbacon",
"Maximilian Pyko", "Maximilian Pyko",
"Invis", "Invis",
"Kalli Core",
"Justin Houston", "Justin Houston",
"Time Valentine",
"james", "james",
"elleshar666",
"OrochiNights", "OrochiNights",
"Michael Zhu", "Michael Zhu",
"ACTUALLY_the_Real_Willem_Dafoe", "ACTUALLY_the_Real_Willem_Dafoe",
"gonzalo", "gonzalo",
"Seraphy", "Seraphy",
"Михал Михалыч",
"雨の心 落", "雨の心 落",
"Matt",
"AllTimeNoobie", "AllTimeNoobie",
"jumpd", "jumpd",
"John C", "John C",
"Kauffy",
"Rim", "Rim",
"Dismem", "Dismem",
"EpicElric", "Frogmilk",
"John J Linehan", "SPJ",
"Xan Dionysus", "Xan Dionysus",
"Nathan lee", "Nathan lee",
"Mewtora", "Mewtora",
"Elliot E",
"Middo", "Middo",
"Forbidden Atelier", "Forbidden Atelier",
"Edward Kennedy", "Bryan Rutkowski",
"Justin Blaylock",
"Adictedtohumping", "Adictedtohumping",
"Devil Lude",
"Nick Kage",
"Towelie", "Towelie",
"Vane Holzer",
"psytrax",
"Cyrus Fett", "Cyrus Fett",
"Jean-françois SEMA", "Jean-françois SEMA",
"Kurt", "Kurt",
"hexxish", "max blo",
"giani kidd", "Xenon Xue",
"CptNeo", "JackJohnnyJim",
"notedfakes", "Edward Ten Eyck",
"Chase Kwon", "Chase Kwon",
"Inyoshu",
"Goober719", "Goober719",
"Eric Ketchum",
"Chad Barnes", "Chad Barnes",
"NICHOLAS BAXLEY",
"Michael Scott",
"James Ming", "James Ming",
"vanditking", "vanditking",
"kripitonga", "kripitonga",
"Rizzi", "Rizzi",
"nimin", "nimin",
"OMAR LUCIANO", "OMAR LUCIANO",
"hannibal",
"Jo+Example", "Jo+Example",
"BrentBertram", "BrentBertram",
"eumelzocker", "eumelzocker",
@@ -623,5 +669,5 @@
"L C", "L C",
"Dude" "Dude"
], ],
"totalCount": 620 "totalCount": 666
} }

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -291,7 +291,15 @@
"blurNsfwContent": "NSFW-Inhalte unscharf stellen", "blurNsfwContent": "NSFW-Inhalte unscharf stellen",
"blurNsfwContentHelp": "Nicht jugendfreie (NSFW) Vorschaubilder unscharf stellen", "blurNsfwContentHelp": "Nicht jugendfreie (NSFW) Vorschaubilder unscharf stellen",
"showOnlySfw": "Nur SFW-Ergebnisse anzeigen", "showOnlySfw": "Nur SFW-Ergebnisse anzeigen",
"showOnlySfwHelp": "Alle NSFW-Inhalte beim Durchsuchen und Suchen herausfiltern" "showOnlySfwHelp": "Alle NSFW-Inhalte beim Durchsuchen und Suchen herausfiltern",
"matureBlurThreshold": "Schwelle für Unschärfe bei jugendgefährdenden Inhalten",
"matureBlurThresholdHelp": "Legen Sie fest, ab welcher Altersfreigabe die Unschärfe beginnt, wenn NSFW-Unschärfe aktiviert ist.",
"matureBlurThresholdOptions": {
"pg13": "PG13 und höher",
"r": "R und höher (Standard)",
"x": "X und höher",
"xxx": "Nur XXX"
}
}, },
"videoSettings": { "videoSettings": {
"autoplayOnHover": "Videos bei Hover automatisch abspielen", "autoplayOnHover": "Videos bei Hover automatisch abspielen",
@@ -315,6 +323,28 @@
"saveFailed": "Übersprungene Pfade konnten nicht gespeichert werden: {message}" "saveFailed": "Übersprungene Pfade konnten nicht gespeichert werden: {message}"
} }
}, },
"downloadSkipBaseModels": {
"label": "Downloads für Basismodelle überspringen",
"help": "Gilt für alle Download-Abläufe. Hier können nur unterstützte Basismodelle ausgewählt werden.",
"searchPlaceholder": "Basismodelle filtern...",
"empty": "Keine Basismodelle entsprechen der aktuellen Suche.",
"summary": {
"none": "Nichts ausgewählt",
"count": "{count} ausgewählt"
},
"actions": {
"edit": "Bearbeiten",
"collapse": "Einklappen",
"clear": "Löschen"
},
"validation": {
"saveFailed": "Ausgeschlossene Basismodelle konnten nicht gespeichert werden: {message}"
}
},
"skipPreviouslyDownloadedModelVersions": {
"label": "Bereits heruntergeladene Modellversionen überspringen",
"help": "Wenn aktiviert, überspringt LoRA Manager den Download einer Modellversion, wenn der Download-Verlaufsdienst diese spezifische Version als bereits heruntergeladen erfasst hat. Gilt für alle Download-Abläufe."
},
"layoutSettings": { "layoutSettings": {
"displayDensity": "Anzeige-Dichte", "displayDensity": "Anzeige-Dichte",
"displayDensityOptions": { "displayDensityOptions": {
@@ -367,8 +397,8 @@
}, },
"extraFolderPaths": { "extraFolderPaths": {
"title": "Zusätzliche Ordnerpfade", "title": "Zusätzliche Ordnerpfade",
"help": "Fügen Sie zusätzliche Modellordner außerhalb der Standardpfade von ComfyUI hinzu. Diese Pfade werden separat gespeichert und zusammen mit den Standardordnern gescannt.", "description": "Zusätzliche Modellstammverzeichnisse, die ausschließlich für LoRA Manager gelten. Laden Sie Modelle von Speicherorten außerhalb der Standardordner von ComfyUI ideal für große Bibliotheken, die ComfyUI sonst verlangsamen würden.",
"description": "Konfigurieren Sie zusätzliche Ordner zum Scannen von Modellen. Diese Pfade sind spezifisch für LoRA Manager und werden mit den Standardpfaden von ComfyUI zusammengeführt.", "restartRequired": "Requires restart to take effect",
"modelTypes": { "modelTypes": {
"lora": "LoRA-Pfade", "lora": "LoRA-Pfade",
"checkpoint": "Checkpoint-Pfade", "checkpoint": "Checkpoint-Pfade",
@@ -376,7 +406,7 @@
"embedding": "Embedding-Pfade" "embedding": "Embedding-Pfade"
}, },
"pathPlaceholder": "/pfad/zu/extra/modellen", "pathPlaceholder": "/pfad/zu/extra/modellen",
"saveSuccess": "Zusätzliche Ordnerpfade aktualisiert.", "saveSuccess": "Zusätzliche Ordnerpfade aktualisiert. Neustart erforderlich, um Änderungen anzuwenden.",
"saveError": "Fehler beim Aktualisieren der zusätzlichen Ordnerpfade: {message}", "saveError": "Fehler beim Aktualisieren der zusätzlichen Ordnerpfade: {message}",
"validation": { "validation": {
"duplicatePath": "Dieser Pfad ist bereits konfiguriert" "duplicatePath": "Dieser Pfad ist bereits konfiguriert"
@@ -575,6 +605,7 @@
"skipMetadataRefresh": "Metadaten-Aktualisierung für ausgewählte Modelle überspringen", "skipMetadataRefresh": "Metadaten-Aktualisierung für ausgewählte Modelle überspringen",
"resumeMetadataRefresh": "Metadaten-Aktualisierung für ausgewählte Modelle fortsetzen", "resumeMetadataRefresh": "Metadaten-Aktualisierung für ausgewählte Modelle fortsetzen",
"deleteAll": "Alle Modelle löschen", "deleteAll": "Alle Modelle löschen",
"downloadMissingLoras": "Fehlende LoRAs herunterladen",
"clear": "Auswahl löschen", "clear": "Auswahl löschen",
"skipMetadataRefreshCount": "Überspringen{count} Modelle", "skipMetadataRefreshCount": "Überspringen{count} Modelle",
"resumeMetadataRefreshCount": "Fortsetzen{count} Modelle", "resumeMetadataRefreshCount": "Fortsetzen{count} Modelle",
@@ -799,7 +830,8 @@
"diffusion_model": "Diffusion Model" "diffusion_model": "Diffusion Model"
}, },
"contextMenu": { "contextMenu": {
"moveToOtherTypeFolder": "In {otherType}-Ordner verschieben" "moveToOtherTypeFolder": "In {otherType}-Ordner verschieben",
"sendToWorkflow": "An Workflow senden"
} }
}, },
"embeddings": { "embeddings": {
@@ -812,8 +844,8 @@
"unpinSidebar": "Sidebar lösen", "unpinSidebar": "Sidebar lösen",
"switchToListView": "Zur Listenansicht wechseln", "switchToListView": "Zur Listenansicht wechseln",
"switchToTreeView": "Zur Baumansicht wechseln", "switchToTreeView": "Zur Baumansicht wechseln",
"recursiveOn": "Unterordner durchsuchen", "recursiveOn": "Unterordner einbeziehen",
"recursiveOff": "Nur aktuellen Ordner durchsuchen", "recursiveOff": "Nur aktueller Ordner",
"recursiveUnavailable": "Rekursive Suche ist nur in der Baumansicht verfügbar", "recursiveUnavailable": "Rekursive Suche ist nur in der Baumansicht verfügbar",
"collapseAllDisabled": "Im Listenmodus nicht verfügbar", "collapseAllDisabled": "Im Listenmodus nicht verfügbar",
"dragDrop": { "dragDrop": {
@@ -983,6 +1015,14 @@
"save": "Basis-Modell aktualisieren", "save": "Basis-Modell aktualisieren",
"cancel": "Abbrechen" "cancel": "Abbrechen"
}, },
"bulkDownloadMissingLoras": {
"title": "Fehlende LoRAs herunterladen",
"message": "{uniqueCount} einzigartige fehlende LoRAs gefunden (von insgesamt {totalCount} in ausgewählten Rezepten).",
"previewTitle": "Zu herunterladende LoRAs:",
"moreItems": "...und {count} weitere",
"note": "Dateien werden mit Standard-Pfad-Vorlagen heruntergeladen. Dies kann je nach Anzahl der LoRAs eine Weile dauern.",
"downloadButton": "{count} LoRA(s) herunterladen"
},
"exampleAccess": { "exampleAccess": {
"title": "Lokale Beispielbilder", "title": "Lokale Beispielbilder",
"message": "Keine lokalen Beispielbilder für dieses Modell gefunden. Ansichtsoptionen:", "message": "Keine lokalen Beispielbilder für dieses Modell gefunden. Ansichtsoptionen:",
@@ -1034,7 +1074,9 @@
"viewOnCivitai": "Auf Civitai anzeigen", "viewOnCivitai": "Auf Civitai anzeigen",
"viewOnCivitaiText": "Auf Civitai anzeigen", "viewOnCivitaiText": "Auf Civitai anzeigen",
"viewCreatorProfile": "Ersteller-Profil anzeigen", "viewCreatorProfile": "Ersteller-Profil anzeigen",
"openFileLocation": "Dateispeicherort öffnen" "openFileLocation": "Dateispeicherort öffnen",
"sendToWorkflow": "An ComfyUI senden",
"sendToWorkflowText": "An ComfyUI senden"
}, },
"openFileLocation": { "openFileLocation": {
"success": "Dateispeicherort erfolgreich geöffnet", "success": "Dateispeicherort erfolgreich geöffnet",
@@ -1042,6 +1084,9 @@
"copied": "Pfad in die Zwischenablage kopiert: {{path}}", "copied": "Pfad in die Zwischenablage kopiert: {{path}}",
"clipboardFallback": "Pfad: {{path}}" "clipboardFallback": "Pfad: {{path}}"
}, },
"sendToWorkflow": {
"noFilePath": "Kann nicht an ComfyUI senden: Kein Dateipfad verfügbar"
},
"metadata": { "metadata": {
"version": "Version", "version": "Version",
"fileName": "Dateiname", "fileName": "Dateiname",
@@ -1299,7 +1344,9 @@
"recipeReplaced": "Rezept im Workflow ersetzt", "recipeReplaced": "Rezept im Workflow ersetzt",
"recipeFailedToSend": "Fehler beim Senden des Rezepts an den Workflow", "recipeFailedToSend": "Fehler beim Senden des Rezepts an den Workflow",
"noMatchingNodes": "Keine kompatiblen Knoten im aktuellen Workflow verfügbar", "noMatchingNodes": "Keine kompatiblen Knoten im aktuellen Workflow verfügbar",
"noTargetNodeSelected": "Kein Zielknoten ausgewählt" "noTargetNodeSelected": "Kein Zielknoten ausgewählt",
"modelUpdated": "Modell im Workflow aktualisiert",
"modelFailed": "Fehler beim Aktualisieren des Modellknotens"
}, },
"nodeSelector": { "nodeSelector": {
"recipe": "Rezept", "recipe": "Rezept",
@@ -1450,6 +1497,7 @@
"pleaseSelectVersion": "Bitte wählen Sie eine Version aus", "pleaseSelectVersion": "Bitte wählen Sie eine Version aus",
"versionExists": "Diese Version existiert bereits in Ihrer Bibliothek", "versionExists": "Diese Version existiert bereits in Ihrer Bibliothek",
"downloadCompleted": "Download erfolgreich abgeschlossen", "downloadCompleted": "Download erfolgreich abgeschlossen",
"downloadSkippedByBaseModel": "Download übersprungen, weil das Basismodell {baseModel} ausgeschlossen ist",
"autoOrganizeSuccess": "Automatische Organisation für {count} {type} erfolgreich abgeschlossen", "autoOrganizeSuccess": "Automatische Organisation für {count} {type} erfolgreich abgeschlossen",
"autoOrganizePartialSuccess": "Automatische Organisation abgeschlossen: {success} verschoben, {failures} fehlgeschlagen von insgesamt {total} Modellen", "autoOrganizePartialSuccess": "Automatische Organisation abgeschlossen: {success} verschoben, {failures} fehlgeschlagen von insgesamt {total} Modellen",
"autoOrganizeFailed": "Automatische Organisation fehlgeschlagen: {error}", "autoOrganizeFailed": "Automatische Organisation fehlgeschlagen: {error}",
@@ -1469,7 +1517,11 @@
"nameUpdated": "Rezeptname erfolgreich aktualisiert", "nameUpdated": "Rezeptname erfolgreich aktualisiert",
"tagsUpdated": "Rezept-Tags erfolgreich aktualisiert", "tagsUpdated": "Rezept-Tags erfolgreich aktualisiert",
"sourceUrlUpdated": "Quell-URL erfolgreich aktualisiert", "sourceUrlUpdated": "Quell-URL erfolgreich aktualisiert",
"promptUpdated": "Prompt erfolgreich aktualisiert",
"negativePromptUpdated": "Negativer Prompt erfolgreich aktualisiert",
"promptEditorHint": "Drücken Sie Enter zum Speichern, Shift+Enter für neue Zeile",
"noRecipeId": "Keine Rezept-ID verfügbar", "noRecipeId": "Keine Rezept-ID verfügbar",
"sendToWorkflowFailed": "Fehler beim Senden des Rezepts an den Workflow: {message}",
"copyFailed": "Fehler beim Kopieren der Rezept-Syntax: {message}", "copyFailed": "Fehler beim Kopieren der Rezept-Syntax: {message}",
"noMissingLoras": "Keine fehlenden LoRAs zum Herunterladen", "noMissingLoras": "Keine fehlenden LoRAs zum Herunterladen",
"missingLorasInfoFailed": "Fehler beim Abrufen der Informationen für fehlende LoRAs", "missingLorasInfoFailed": "Fehler beim Abrufen der Informationen für fehlende LoRAs",
@@ -1507,7 +1559,10 @@
"batchImportNoUrls": "Please enter at least one URL or file path", "batchImportNoUrls": "Please enter at least one URL or file path",
"batchImportNoDirectory": "Please enter a directory path", "batchImportNoDirectory": "Please enter a directory path",
"batchImportBrowseFailed": "Failed to browse directory: {message}", "batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}" "batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "Keine Rezepte ausgewählt",
"noMissingLorasInSelection": "Keine fehlenden LoRAs in ausgewählten Rezepten gefunden",
"noLoraRootConfigured": "Kein LoRA-Stammverzeichnis konfiguriert. Bitte legen Sie ein Standard-LoRA-Stammverzeichnis in den Einstellungen fest."
}, },
"models": { "models": {
"noModelsSelected": "Keine Modelle ausgewählt", "noModelsSelected": "Keine Modelle ausgewählt",

View File

@@ -291,7 +291,15 @@
"blurNsfwContent": "Blur NSFW Content", "blurNsfwContent": "Blur NSFW Content",
"blurNsfwContentHelp": "Blur mature (NSFW) content preview images", "blurNsfwContentHelp": "Blur mature (NSFW) content preview images",
"showOnlySfw": "Show Only SFW Results", "showOnlySfw": "Show Only SFW Results",
"showOnlySfwHelp": "Filter out all NSFW content when browsing and searching" "showOnlySfwHelp": "Filter out all NSFW content when browsing and searching",
"matureBlurThreshold": "Mature Blur Threshold",
"matureBlurThresholdHelp": "Set which rating level starts blur filtering when NSFW blur is enabled.",
"matureBlurThresholdOptions": {
"pg13": "PG13 and above",
"r": "R and above (default)",
"x": "X and above",
"xxx": "XXX only"
}
}, },
"videoSettings": { "videoSettings": {
"autoplayOnHover": "Autoplay Videos on Hover", "autoplayOnHover": "Autoplay Videos on Hover",
@@ -315,6 +323,28 @@
"saveFailed": "Unable to save skip paths: {message}" "saveFailed": "Unable to save skip paths: {message}"
} }
}, },
"downloadSkipBaseModels": {
"label": "Skip downloads for base models",
"help": "When enabled, versions using the selected base models will be skipped.",
"searchPlaceholder": "Filter base models...",
"empty": "No base models match the current search.",
"summary": {
"none": "None selected",
"count": "{count} selected"
},
"actions": {
"edit": "Edit",
"collapse": "Collapse",
"clear": "Clear"
},
"validation": {
"saveFailed": "Unable to save excluded base models: {message}"
}
},
"skipPreviouslyDownloadedModelVersions": {
"label": "Skip previously downloaded model versions",
"help": "When enabled, versions downloaded before will be skipped."
},
"layoutSettings": { "layoutSettings": {
"displayDensity": "Display Density", "displayDensity": "Display Density",
"displayDensityOptions": { "displayDensityOptions": {
@@ -367,8 +397,8 @@
}, },
"extraFolderPaths": { "extraFolderPaths": {
"title": "Extra Folder Paths", "title": "Extra Folder Paths",
"help": "Add additional model folders outside of ComfyUI's standard paths. These paths are stored separately and scanned alongside the default folders.", "description": "Additional model root paths exclusive to LoRA Manager. Load models from locations outside ComfyUI's standard folders—ideal for large libraries that would otherwise slow down ComfyUI.",
"description": "Configure additional folders to scan for models. These paths are specific to LoRA Manager and will be merged with ComfyUI's default paths.", "restartRequired": "Requires restart to take effect",
"modelTypes": { "modelTypes": {
"lora": "LoRA Paths", "lora": "LoRA Paths",
"checkpoint": "Checkpoint Paths", "checkpoint": "Checkpoint Paths",
@@ -376,7 +406,7 @@
"embedding": "Embedding Paths" "embedding": "Embedding Paths"
}, },
"pathPlaceholder": "/path/to/extra/models", "pathPlaceholder": "/path/to/extra/models",
"saveSuccess": "Extra folder paths updated.", "saveSuccess": "Extra folder paths updated. Restart required to apply changes.",
"saveError": "Failed to update extra folder paths: {message}", "saveError": "Failed to update extra folder paths: {message}",
"validation": { "validation": {
"duplicatePath": "This path is already configured" "duplicatePath": "This path is already configured"
@@ -575,6 +605,7 @@
"skipMetadataRefresh": "Skip Metadata Refresh for Selected", "skipMetadataRefresh": "Skip Metadata Refresh for Selected",
"resumeMetadataRefresh": "Resume Metadata Refresh for Selected", "resumeMetadataRefresh": "Resume Metadata Refresh for Selected",
"deleteAll": "Delete Selected Models", "deleteAll": "Delete Selected Models",
"downloadMissingLoras": "Download Missing LoRAs",
"clear": "Clear Selection", "clear": "Clear Selection",
"skipMetadataRefreshCount": "Skip ({count} models)", "skipMetadataRefreshCount": "Skip ({count} models)",
"resumeMetadataRefreshCount": "Resume ({count} models)", "resumeMetadataRefreshCount": "Resume ({count} models)",
@@ -799,7 +830,8 @@
"diffusion_model": "Diffusion Model" "diffusion_model": "Diffusion Model"
}, },
"contextMenu": { "contextMenu": {
"moveToOtherTypeFolder": "Move to {otherType} Folder" "moveToOtherTypeFolder": "Move to {otherType} Folder",
"sendToWorkflow": "Send to Workflow"
} }
}, },
"embeddings": { "embeddings": {
@@ -812,8 +844,8 @@
"unpinSidebar": "Unpin Sidebar", "unpinSidebar": "Unpin Sidebar",
"switchToListView": "Switch to List View", "switchToListView": "Switch to List View",
"switchToTreeView": "Switch to Tree View", "switchToTreeView": "Switch to Tree View",
"recursiveOn": "Search subfolders", "recursiveOn": "Include subfolders",
"recursiveOff": "Search current folder only", "recursiveOff": "Current folder only",
"recursiveUnavailable": "Recursive search is available in tree view only", "recursiveUnavailable": "Recursive search is available in tree view only",
"collapseAllDisabled": "Not available in list view", "collapseAllDisabled": "Not available in list view",
"dragDrop": { "dragDrop": {
@@ -983,6 +1015,14 @@
"save": "Update Base Model", "save": "Update Base Model",
"cancel": "Cancel" "cancel": "Cancel"
}, },
"bulkDownloadMissingLoras": {
"title": "Download Missing LoRAs",
"message": "Found {uniqueCount} unique missing LoRAs (from {totalCount} total across selected recipes).",
"previewTitle": "LoRAs to download:",
"moreItems": "...and {count} more",
"note": "Files will be downloaded using default path templates. This may take a while depending on the number of LoRAs.",
"downloadButton": "Download {count} LoRA(s)"
},
"exampleAccess": { "exampleAccess": {
"title": "Local Example Images", "title": "Local Example Images",
"message": "No local example images found for this model. View options:", "message": "No local example images found for this model. View options:",
@@ -1034,7 +1074,9 @@
"viewOnCivitai": "View on Civitai", "viewOnCivitai": "View on Civitai",
"viewOnCivitaiText": "View on Civitai", "viewOnCivitaiText": "View on Civitai",
"viewCreatorProfile": "View Creator Profile", "viewCreatorProfile": "View Creator Profile",
"openFileLocation": "Open File Location" "openFileLocation": "Open File Location",
"sendToWorkflow": "Send to ComfyUI",
"sendToWorkflowText": "Send to ComfyUI"
}, },
"openFileLocation": { "openFileLocation": {
"success": "File location opened successfully", "success": "File location opened successfully",
@@ -1042,6 +1084,9 @@
"copied": "Path copied to clipboard: {{path}}", "copied": "Path copied to clipboard: {{path}}",
"clipboardFallback": "Path: {{path}}" "clipboardFallback": "Path: {{path}}"
}, },
"sendToWorkflow": {
"noFilePath": "Unable to send to ComfyUI: No file path available"
},
"metadata": { "metadata": {
"version": "Version", "version": "Version",
"fileName": "File Name", "fileName": "File Name",
@@ -1299,7 +1344,9 @@
"recipeReplaced": "Recipe replaced in workflow", "recipeReplaced": "Recipe replaced in workflow",
"recipeFailedToSend": "Failed to send recipe to workflow", "recipeFailedToSend": "Failed to send recipe to workflow",
"noMatchingNodes": "No compatible nodes available in the current workflow", "noMatchingNodes": "No compatible nodes available in the current workflow",
"noTargetNodeSelected": "No target node selected" "noTargetNodeSelected": "No target node selected",
"modelUpdated": "Model updated in workflow",
"modelFailed": "Failed to update model node"
}, },
"nodeSelector": { "nodeSelector": {
"recipe": "Recipe", "recipe": "Recipe",
@@ -1450,6 +1497,7 @@
"pleaseSelectVersion": "Please select a version", "pleaseSelectVersion": "Please select a version",
"versionExists": "This version already exists in your library", "versionExists": "This version already exists in your library",
"downloadCompleted": "Download completed successfully", "downloadCompleted": "Download completed successfully",
"downloadSkippedByBaseModel": "Skipped download because base model {baseModel} is excluded",
"autoOrganizeSuccess": "Auto-organize completed successfully for {count} {type}", "autoOrganizeSuccess": "Auto-organize completed successfully for {count} {type}",
"autoOrganizePartialSuccess": "Auto-organize completed with {success} moved, {failures} failed out of {total} models", "autoOrganizePartialSuccess": "Auto-organize completed with {success} moved, {failures} failed out of {total} models",
"autoOrganizeFailed": "Auto-organize failed: {error}", "autoOrganizeFailed": "Auto-organize failed: {error}",
@@ -1469,7 +1517,11 @@
"nameUpdated": "Recipe name updated successfully", "nameUpdated": "Recipe name updated successfully",
"tagsUpdated": "Recipe tags updated successfully", "tagsUpdated": "Recipe tags updated successfully",
"sourceUrlUpdated": "Source URL updated successfully", "sourceUrlUpdated": "Source URL updated successfully",
"promptUpdated": "Prompt updated successfully",
"negativePromptUpdated": "Negative prompt updated successfully",
"promptEditorHint": "Press Enter to save, Shift+Enter for new line",
"noRecipeId": "No recipe ID available", "noRecipeId": "No recipe ID available",
"sendToWorkflowFailed": "Failed to send recipe to workflow: {message}",
"copyFailed": "Error copying recipe syntax: {message}", "copyFailed": "Error copying recipe syntax: {message}",
"noMissingLoras": "No missing LoRAs to download", "noMissingLoras": "No missing LoRAs to download",
"missingLorasInfoFailed": "Failed to get information for missing LoRAs", "missingLorasInfoFailed": "Failed to get information for missing LoRAs",
@@ -1507,7 +1559,10 @@
"batchImportNoUrls": "Please enter at least one URL or file path", "batchImportNoUrls": "Please enter at least one URL or file path",
"batchImportNoDirectory": "Please enter a directory path", "batchImportNoDirectory": "Please enter a directory path",
"batchImportBrowseFailed": "Failed to browse directory: {message}", "batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}" "batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "No recipes selected",
"noMissingLorasInSelection": "No missing LoRAs found in selected recipes",
"noLoraRootConfigured": "No LoRA root directory configured. Please set a default LoRA root in settings."
}, },
"models": { "models": {
"noModelsSelected": "No models selected", "noModelsSelected": "No models selected",
@@ -1746,4 +1801,4 @@
"retry": "Retry" "retry": "Retry"
} }
} }
} }

View File

@@ -291,7 +291,15 @@
"blurNsfwContent": "Difuminar contenido NSFW", "blurNsfwContent": "Difuminar contenido NSFW",
"blurNsfwContentHelp": "Difuminar imágenes de vista previa de contenido para adultos (NSFW)", "blurNsfwContentHelp": "Difuminar imágenes de vista previa de contenido para adultos (NSFW)",
"showOnlySfw": "Mostrar solo resultados SFW", "showOnlySfw": "Mostrar solo resultados SFW",
"showOnlySfwHelp": "Filtrar todo el contenido NSFW al navegar y buscar" "showOnlySfwHelp": "Filtrar todo el contenido NSFW al navegar y buscar",
"matureBlurThreshold": "Umbral de difuminado para contenido adulto",
"matureBlurThresholdHelp": "Establecer a partir de qué nivel de clasificación comienza el filtrado por difuminado cuando el difuminado NSFW está habilitado.",
"matureBlurThresholdOptions": {
"pg13": "PG13 y superior",
"r": "R y superior (predeterminado)",
"x": "X y superior",
"xxx": "Solo XXX"
}
}, },
"videoSettings": { "videoSettings": {
"autoplayOnHover": "Reproducir videos automáticamente al pasar el ratón", "autoplayOnHover": "Reproducir videos automáticamente al pasar el ratón",
@@ -315,6 +323,28 @@
"saveFailed": "No se pudieron guardar las rutas a omitir: {message}" "saveFailed": "No se pudieron guardar las rutas a omitir: {message}"
} }
}, },
"downloadSkipBaseModels": {
"label": "Omitir descargas para modelos base",
"help": "Se aplica a todos los flujos de descarga. Aquí solo se pueden seleccionar modelos base compatibles.",
"searchPlaceholder": "Filtrar modelos base...",
"empty": "Ningún modelo base coincide con la búsqueda actual.",
"summary": {
"none": "Ninguno seleccionado",
"count": "{count} seleccionados"
},
"actions": {
"edit": "Editar",
"collapse": "Contraer",
"clear": "Limpiar"
},
"validation": {
"saveFailed": "No se pudieron guardar los modelos base excluidos: {message}"
}
},
"skipPreviouslyDownloadedModelVersions": {
"label": "Omitir versiones de modelos previamente descargadas",
"help": "Cuando está habilitado, LoRA Manager omitirá la descarga de una versión de modelo si el servicio de historial de descargas registra esa versión exacta como ya descargada. Aplica a todos los flujos de descarga."
},
"layoutSettings": { "layoutSettings": {
"displayDensity": "Densidad de visualización", "displayDensity": "Densidad de visualización",
"displayDensityOptions": { "displayDensityOptions": {
@@ -367,8 +397,8 @@
}, },
"extraFolderPaths": { "extraFolderPaths": {
"title": "Rutas de carpetas adicionales", "title": "Rutas de carpetas adicionales",
"help": "Agregue carpetas de modelos adicionales fuera de las rutas estándar de ComfyUI. Estas rutas se almacenan por separado y se escanean junto con las carpetas predeterminadas.", "description": "Rutas raíz de modelos adicionales exclusivas para LoRA Manager. Cargue modelos desde ubicaciones fuera de las carpetas estándar de ComfyUI, ideal para bibliotecas grandes que de otro modo ralentizarían ComfyUI.",
"description": "Configure carpetas adicionales para escanear modelos. Estas rutas son específicas de LoRA Manager y se fusionarán con las rutas predeterminadas de ComfyUI.", "restartRequired": "Requires restart to take effect",
"modelTypes": { "modelTypes": {
"lora": "Rutas de LoRA", "lora": "Rutas de LoRA",
"checkpoint": "Rutas de Checkpoint", "checkpoint": "Rutas de Checkpoint",
@@ -376,7 +406,7 @@
"embedding": "Rutas de Embedding" "embedding": "Rutas de Embedding"
}, },
"pathPlaceholder": "/ruta/a/modelos/extra", "pathPlaceholder": "/ruta/a/modelos/extra",
"saveSuccess": "Rutas de carpetas adicionales actualizadas.", "saveSuccess": "Rutas de carpetas adicionales actualizadas. Se requiere reinicio para aplicar los cambios.",
"saveError": "Error al actualizar las rutas de carpetas adicionales: {message}", "saveError": "Error al actualizar las rutas de carpetas adicionales: {message}",
"validation": { "validation": {
"duplicatePath": "Esta ruta ya está configurada" "duplicatePath": "Esta ruta ya está configurada"
@@ -575,6 +605,7 @@
"skipMetadataRefresh": "Omitir actualización de metadatos para seleccionados", "skipMetadataRefresh": "Omitir actualización de metadatos para seleccionados",
"resumeMetadataRefresh": "Reanudar actualización de metadatos para seleccionados", "resumeMetadataRefresh": "Reanudar actualización de metadatos para seleccionados",
"deleteAll": "Eliminar todos los modelos", "deleteAll": "Eliminar todos los modelos",
"downloadMissingLoras": "Descargar LoRAs faltantes",
"clear": "Limpiar selección", "clear": "Limpiar selección",
"skipMetadataRefreshCount": "Omitir{count} modelos", "skipMetadataRefreshCount": "Omitir{count} modelos",
"resumeMetadataRefreshCount": "Reanudar{count} modelos", "resumeMetadataRefreshCount": "Reanudar{count} modelos",
@@ -799,7 +830,8 @@
"diffusion_model": "Diffusion Model" "diffusion_model": "Diffusion Model"
}, },
"contextMenu": { "contextMenu": {
"moveToOtherTypeFolder": "Mover a la carpeta {otherType}" "moveToOtherTypeFolder": "Mover a la carpeta {otherType}",
"sendToWorkflow": "Enviar al flujo de trabajo"
} }
}, },
"embeddings": { "embeddings": {
@@ -812,8 +844,8 @@
"unpinSidebar": "Desfijar barra lateral", "unpinSidebar": "Desfijar barra lateral",
"switchToListView": "Cambiar a vista de lista", "switchToListView": "Cambiar a vista de lista",
"switchToTreeView": "Cambiar a vista de árbol", "switchToTreeView": "Cambiar a vista de árbol",
"recursiveOn": "Buscar en subcarpetas", "recursiveOn": "Incluir subcarpetas",
"recursiveOff": "Buscar solo en la carpeta actual", "recursiveOff": "Solo carpeta actual",
"recursiveUnavailable": "La búsqueda recursiva solo está disponible en la vista en árbol", "recursiveUnavailable": "La búsqueda recursiva solo está disponible en la vista en árbol",
"collapseAllDisabled": "No disponible en vista de lista", "collapseAllDisabled": "No disponible en vista de lista",
"dragDrop": { "dragDrop": {
@@ -983,6 +1015,14 @@
"save": "Actualizar modelo base", "save": "Actualizar modelo base",
"cancel": "Cancelar" "cancel": "Cancelar"
}, },
"bulkDownloadMissingLoras": {
"title": "Descargar LoRAs faltantes",
"message": "Se encontraron {uniqueCount} LoRAs faltantes únicos (de {totalCount} en total entre las recetas seleccionadas).",
"previewTitle": "LoRAs para descargar:",
"moreItems": "...y {count} más",
"note": "Los archivos se descargarán usando las plantillas de ruta predeterminadas. Esto puede tomar un tiempo dependiendo del número de LoRAs.",
"downloadButton": "Descargar {count} LoRA(s)"
},
"exampleAccess": { "exampleAccess": {
"title": "Imágenes de ejemplo locales", "title": "Imágenes de ejemplo locales",
"message": "No se encontraron imágenes de ejemplo locales para este modelo. Opciones de visualización:", "message": "No se encontraron imágenes de ejemplo locales para este modelo. Opciones de visualización:",
@@ -1034,7 +1074,9 @@
"viewOnCivitai": "Ver en Civitai", "viewOnCivitai": "Ver en Civitai",
"viewOnCivitaiText": "Ver en Civitai", "viewOnCivitaiText": "Ver en Civitai",
"viewCreatorProfile": "Ver perfil del creador", "viewCreatorProfile": "Ver perfil del creador",
"openFileLocation": "Abrir ubicación del archivo" "openFileLocation": "Abrir ubicación del archivo",
"sendToWorkflow": "Enviar a ComfyUI",
"sendToWorkflowText": "Enviar a ComfyUI"
}, },
"openFileLocation": { "openFileLocation": {
"success": "Ubicación del archivo abierta exitosamente", "success": "Ubicación del archivo abierta exitosamente",
@@ -1042,6 +1084,9 @@
"copied": "Ruta copiada al portapapeles: {{path}}", "copied": "Ruta copiada al portapapeles: {{path}}",
"clipboardFallback": "Ruta: {{path}}" "clipboardFallback": "Ruta: {{path}}"
}, },
"sendToWorkflow": {
"noFilePath": "No se puede enviar a ComfyUI: no hay ruta de archivo disponible"
},
"metadata": { "metadata": {
"version": "Versión", "version": "Versión",
"fileName": "Nombre de archivo", "fileName": "Nombre de archivo",
@@ -1299,7 +1344,9 @@
"recipeReplaced": "Receta reemplazada en el flujo de trabajo", "recipeReplaced": "Receta reemplazada en el flujo de trabajo",
"recipeFailedToSend": "Error al enviar receta al flujo de trabajo", "recipeFailedToSend": "Error al enviar receta al flujo de trabajo",
"noMatchingNodes": "No hay nodos compatibles disponibles en el flujo de trabajo actual", "noMatchingNodes": "No hay nodos compatibles disponibles en el flujo de trabajo actual",
"noTargetNodeSelected": "No se ha seleccionado ningún nodo de destino" "noTargetNodeSelected": "No se ha seleccionado ningún nodo de destino",
"modelUpdated": "Modelo actualizado en el flujo de trabajo",
"modelFailed": "Error al actualizar nodo de modelo"
}, },
"nodeSelector": { "nodeSelector": {
"recipe": "Receta", "recipe": "Receta",
@@ -1450,6 +1497,7 @@
"pleaseSelectVersion": "Por favor selecciona una versión", "pleaseSelectVersion": "Por favor selecciona una versión",
"versionExists": "Esta versión ya existe en tu biblioteca", "versionExists": "Esta versión ya existe en tu biblioteca",
"downloadCompleted": "Descarga completada exitosamente", "downloadCompleted": "Descarga completada exitosamente",
"downloadSkippedByBaseModel": "Descarga omitida porque el modelo base {baseModel} está excluido",
"autoOrganizeSuccess": "Auto-organización completada exitosamente para {count} {type}", "autoOrganizeSuccess": "Auto-organización completada exitosamente para {count} {type}",
"autoOrganizePartialSuccess": "Auto-organización completada con {success} movidos, {failures} fallidos de un total de {total} modelos", "autoOrganizePartialSuccess": "Auto-organización completada con {success} movidos, {failures} fallidos de un total de {total} modelos",
"autoOrganizeFailed": "Auto-organización fallida: {error}", "autoOrganizeFailed": "Auto-organización fallida: {error}",
@@ -1469,7 +1517,11 @@
"nameUpdated": "Nombre de receta actualizado exitosamente", "nameUpdated": "Nombre de receta actualizado exitosamente",
"tagsUpdated": "Etiquetas de receta actualizadas exitosamente", "tagsUpdated": "Etiquetas de receta actualizadas exitosamente",
"sourceUrlUpdated": "URL de origen actualizada exitosamente", "sourceUrlUpdated": "URL de origen actualizada exitosamente",
"promptUpdated": "Prompt actualizado exitosamente",
"negativePromptUpdated": "Prompt negativo actualizado exitosamente",
"promptEditorHint": "Presiona Enter para guardar, Shift+Enter para nueva línea",
"noRecipeId": "No hay ID de receta disponible", "noRecipeId": "No hay ID de receta disponible",
"sendToWorkflowFailed": "Error al enviar la receta al flujo de trabajo: {message}",
"copyFailed": "Error copiando sintaxis de receta: {message}", "copyFailed": "Error copiando sintaxis de receta: {message}",
"noMissingLoras": "No hay LoRAs faltantes para descargar", "noMissingLoras": "No hay LoRAs faltantes para descargar",
"missingLorasInfoFailed": "Error al obtener información de LoRAs faltantes", "missingLorasInfoFailed": "Error al obtener información de LoRAs faltantes",
@@ -1507,7 +1559,10 @@
"batchImportNoUrls": "Please enter at least one URL or file path", "batchImportNoUrls": "Please enter at least one URL or file path",
"batchImportNoDirectory": "Please enter a directory path", "batchImportNoDirectory": "Please enter a directory path",
"batchImportBrowseFailed": "Failed to browse directory: {message}", "batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}" "batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "No se han seleccionado recetas",
"noMissingLorasInSelection": "No se encontraron LoRAs faltantes en las recetas seleccionadas",
"noLoraRootConfigured": "No se ha configurado el directorio raíz de LoRA. Por favor, establezca un directorio raíz de LoRA predeterminado en la configuración."
}, },
"models": { "models": {
"noModelsSelected": "No hay modelos seleccionados", "noModelsSelected": "No hay modelos seleccionados",

View File

@@ -291,7 +291,15 @@
"blurNsfwContent": "Flouter le contenu NSFW", "blurNsfwContent": "Flouter le contenu NSFW",
"blurNsfwContentHelp": "Flouter les images d'aperçu de contenu pour adultes (NSFW)", "blurNsfwContentHelp": "Flouter les images d'aperçu de contenu pour adultes (NSFW)",
"showOnlySfw": "Afficher uniquement les résultats SFW", "showOnlySfw": "Afficher uniquement les résultats SFW",
"showOnlySfwHelp": "Filtrer tout le contenu NSFW lors de la navigation et de la recherche" "showOnlySfwHelp": "Filtrer tout le contenu NSFW lors de la navigation et de la recherche",
"matureBlurThreshold": "Seuil de floutage pour contenu adulte",
"matureBlurThresholdHelp": "Définir à partir de quel niveau de classification le floutage s'applique lorsque le floutage NSFW est activé.",
"matureBlurThresholdOptions": {
"pg13": "PG13 et plus",
"r": "R et plus (par défaut)",
"x": "X et plus",
"xxx": "XXX uniquement"
}
}, },
"videoSettings": { "videoSettings": {
"autoplayOnHover": "Lecture automatique vidéo au survol", "autoplayOnHover": "Lecture automatique vidéo au survol",
@@ -315,6 +323,28 @@
"saveFailed": "Impossible d'enregistrer les chemins à ignorer : {message}" "saveFailed": "Impossible d'enregistrer les chemins à ignorer : {message}"
} }
}, },
"downloadSkipBaseModels": {
"label": "Ignorer les téléchargements pour certains modèles de base",
"help": "Sapplique à tous les flux de téléchargement. Seuls les modèles de base pris en charge peuvent être sélectionnés ici.",
"searchPlaceholder": "Filtrer les modèles de base...",
"empty": "Aucun modèle de base ne correspond à la recherche actuelle.",
"summary": {
"none": "Aucune sélection",
"count": "{count} sélectionnés"
},
"actions": {
"edit": "Modifier",
"collapse": "Réduire",
"clear": "Effacer"
},
"validation": {
"saveFailed": "Impossible denregistrer les modèles de base exclus : {message}"
}
},
"skipPreviouslyDownloadedModelVersions": {
"label": "Ignorer les versions de modèles précédemment téléchargées",
"help": "Lorsque activé, LoRA Manager ignorera le téléchargement d'une version de modèle si le service d'historique des téléchargements enregistre cette version exacte comme déjà téléchargée. S'applique à tous les flux de téléchargement."
},
"layoutSettings": { "layoutSettings": {
"displayDensity": "Densité d'affichage", "displayDensity": "Densité d'affichage",
"displayDensityOptions": { "displayDensityOptions": {
@@ -367,8 +397,8 @@
}, },
"extraFolderPaths": { "extraFolderPaths": {
"title": "Chemins de dossiers supplémentaires", "title": "Chemins de dossiers supplémentaires",
"help": "Ajoutez des dossiers de modèles supplémentaires en dehors des chemins standard de ComfyUI. Ces chemins sont stockés séparément et analysés aux côtés des dossiers par défaut.", "description": "Chemins racine de modèles supplémentaires exclusifs à LoRA Manager. Chargez des modèles depuis des emplacements en dehors des dossiers standard de ComfyUI, idéal pour les grandes bibliothèques qui ralentiraient autrement ComfyUI.",
"description": "Configurez des dossiers supplémentaires pour l'analyse de modèles. Ces chemins sont spécifiques à LoRA Manager et seront fusionnés avec les chemins par défaut de ComfyUI.", "restartRequired": "Requires restart to take effect",
"modelTypes": { "modelTypes": {
"lora": "Chemins LoRA", "lora": "Chemins LoRA",
"checkpoint": "Chemins Checkpoint", "checkpoint": "Chemins Checkpoint",
@@ -376,7 +406,7 @@
"embedding": "Chemins Embedding" "embedding": "Chemins Embedding"
}, },
"pathPlaceholder": "/chemin/vers/modèles/supplémentaires", "pathPlaceholder": "/chemin/vers/modèles/supplémentaires",
"saveSuccess": "Chemins de dossiers supplémentaires mis à jour.", "saveSuccess": "Chemins de dossiers supplémentaires mis à jour. Redémarrage requis pour appliquer les changements.",
"saveError": "Échec de la mise à jour des chemins de dossiers supplémentaires: {message}", "saveError": "Échec de la mise à jour des chemins de dossiers supplémentaires: {message}",
"validation": { "validation": {
"duplicatePath": "Ce chemin est déjà configuré" "duplicatePath": "Ce chemin est déjà configuré"
@@ -575,6 +605,7 @@
"skipMetadataRefresh": "Ignorer l'actualisation des métadonnées pour la sélection", "skipMetadataRefresh": "Ignorer l'actualisation des métadonnées pour la sélection",
"resumeMetadataRefresh": "Reprendre l'actualisation des métadonnées pour la sélection", "resumeMetadataRefresh": "Reprendre l'actualisation des métadonnées pour la sélection",
"deleteAll": "Supprimer tous les modèles", "deleteAll": "Supprimer tous les modèles",
"downloadMissingLoras": "Télécharger les LoRAs manquants",
"clear": "Effacer la sélection", "clear": "Effacer la sélection",
"skipMetadataRefreshCount": "Ignorer{count} modèles", "skipMetadataRefreshCount": "Ignorer{count} modèles",
"resumeMetadataRefreshCount": "Reprendre{count} modèles", "resumeMetadataRefreshCount": "Reprendre{count} modèles",
@@ -799,7 +830,8 @@
"diffusion_model": "Diffusion Model" "diffusion_model": "Diffusion Model"
}, },
"contextMenu": { "contextMenu": {
"moveToOtherTypeFolder": "Déplacer vers le dossier {otherType}" "moveToOtherTypeFolder": "Déplacer vers le dossier {otherType}",
"sendToWorkflow": "Envoyer vers le workflow"
} }
}, },
"embeddings": { "embeddings": {
@@ -812,8 +844,8 @@
"unpinSidebar": "Désépingler la barre latérale", "unpinSidebar": "Désépingler la barre latérale",
"switchToListView": "Passer en vue liste", "switchToListView": "Passer en vue liste",
"switchToTreeView": "Passer en vue arborescence", "switchToTreeView": "Passer en vue arborescence",
"recursiveOn": "Rechercher dans les sous-dossiers", "recursiveOn": "Inclure les sous-dossiers",
"recursiveOff": "Rechercher uniquement dans le dossier actuel", "recursiveOff": "Dossier actuel uniquement",
"recursiveUnavailable": "La recherche récursive n'est disponible qu'en vue arborescente", "recursiveUnavailable": "La recherche récursive n'est disponible qu'en vue arborescente",
"collapseAllDisabled": "Non disponible en vue liste", "collapseAllDisabled": "Non disponible en vue liste",
"dragDrop": { "dragDrop": {
@@ -983,6 +1015,14 @@
"save": "Mettre à jour le modèle de base", "save": "Mettre à jour le modèle de base",
"cancel": "Annuler" "cancel": "Annuler"
}, },
"bulkDownloadMissingLoras": {
"title": "Télécharger les LoRAs manquants",
"message": "{uniqueCount} LoRAs manquants uniques trouvés (sur un total de {totalCount} dans les recettes sélectionnées).",
"previewTitle": "LoRAs à télécharger :",
"moreItems": "...et {count} de plus",
"note": "Les fichiers seront téléchargés en utilisant les modèles de chemins par défaut. Cela peut prendre un certain temps selon le nombre de LoRAs.",
"downloadButton": "Télécharger {count} LoRA(s)"
},
"exampleAccess": { "exampleAccess": {
"title": "Images d'exemple locales", "title": "Images d'exemple locales",
"message": "Aucune image d'exemple locale trouvée pour ce modèle. Options d'affichage :", "message": "Aucune image d'exemple locale trouvée pour ce modèle. Options d'affichage :",
@@ -1034,7 +1074,9 @@
"viewOnCivitai": "Voir sur Civitai", "viewOnCivitai": "Voir sur Civitai",
"viewOnCivitaiText": "Voir sur Civitai", "viewOnCivitaiText": "Voir sur Civitai",
"viewCreatorProfile": "Voir le profil du créateur", "viewCreatorProfile": "Voir le profil du créateur",
"openFileLocation": "Ouvrir l'emplacement du fichier" "openFileLocation": "Ouvrir l'emplacement du fichier",
"sendToWorkflow": "Envoyer vers ComfyUI",
"sendToWorkflowText": "Envoyer vers ComfyUI"
}, },
"openFileLocation": { "openFileLocation": {
"success": "Emplacement du fichier ouvert avec succès", "success": "Emplacement du fichier ouvert avec succès",
@@ -1042,6 +1084,9 @@
"copied": "Chemin copié dans le presse-papiers: {{path}}", "copied": "Chemin copié dans le presse-papiers: {{path}}",
"clipboardFallback": "Chemin: {{path}}" "clipboardFallback": "Chemin: {{path}}"
}, },
"sendToWorkflow": {
"noFilePath": "Impossible d'envoyer vers ComfyUI : aucun chemin de fichier disponible"
},
"metadata": { "metadata": {
"version": "Version", "version": "Version",
"fileName": "Nom de fichier", "fileName": "Nom de fichier",
@@ -1299,7 +1344,9 @@
"recipeReplaced": "Recipe remplacée dans le workflow", "recipeReplaced": "Recipe remplacée dans le workflow",
"recipeFailedToSend": "Échec de l'envoi de la recipe au workflow", "recipeFailedToSend": "Échec de l'envoi de la recipe au workflow",
"noMatchingNodes": "Aucun nœud compatible disponible dans le workflow actuel", "noMatchingNodes": "Aucun nœud compatible disponible dans le workflow actuel",
"noTargetNodeSelected": "Aucun nœud cible sélectionné" "noTargetNodeSelected": "Aucun nœud cible sélectionné",
"modelUpdated": "Modèle mis à jour dans le workflow",
"modelFailed": "Échec de la mise à jour du nœud modèle"
}, },
"nodeSelector": { "nodeSelector": {
"recipe": "Recipe", "recipe": "Recipe",
@@ -1450,6 +1497,7 @@
"pleaseSelectVersion": "Veuillez sélectionner une version", "pleaseSelectVersion": "Veuillez sélectionner une version",
"versionExists": "Cette version existe déjà dans votre bibliothèque", "versionExists": "Cette version existe déjà dans votre bibliothèque",
"downloadCompleted": "Téléchargement terminé avec succès", "downloadCompleted": "Téléchargement terminé avec succès",
"downloadSkippedByBaseModel": "Téléchargement ignoré, car le modèle de base {baseModel} est exclu",
"autoOrganizeSuccess": "Auto-organisation terminée avec succès pour {count} {type}", "autoOrganizeSuccess": "Auto-organisation terminée avec succès pour {count} {type}",
"autoOrganizePartialSuccess": "Auto-organisation terminée avec {success} déplacés, {failures} échecs sur {total} modèles", "autoOrganizePartialSuccess": "Auto-organisation terminée avec {success} déplacés, {failures} échecs sur {total} modèles",
"autoOrganizeFailed": "Échec de l'auto-organisation : {error}", "autoOrganizeFailed": "Échec de l'auto-organisation : {error}",
@@ -1469,7 +1517,11 @@
"nameUpdated": "Nom de la recipe mis à jour avec succès", "nameUpdated": "Nom de la recipe mis à jour avec succès",
"tagsUpdated": "Tags de la recipe mis à jour avec succès", "tagsUpdated": "Tags de la recipe mis à jour avec succès",
"sourceUrlUpdated": "URL source mise à jour avec succès", "sourceUrlUpdated": "URL source mise à jour avec succès",
"promptUpdated": "Prompt mis à jour avec succès",
"negativePromptUpdated": "Prompt négatif mis à jour avec succès",
"promptEditorHint": "Appuyez sur Entrée pour sauvegarder, Maj+Entrée pour nouvelle ligne",
"noRecipeId": "Aucun ID de recipe disponible", "noRecipeId": "Aucun ID de recipe disponible",
"sendToWorkflowFailed": "Échec de l'envoi de la recette vers le workflow : {message}",
"copyFailed": "Erreur lors de la copie de la syntaxe de la recipe : {message}", "copyFailed": "Erreur lors de la copie de la syntaxe de la recipe : {message}",
"noMissingLoras": "Aucun LoRA manquant à télécharger", "noMissingLoras": "Aucun LoRA manquant à télécharger",
"missingLorasInfoFailed": "Échec de l'obtention des informations pour les LoRAs manquants", "missingLorasInfoFailed": "Échec de l'obtention des informations pour les LoRAs manquants",
@@ -1507,7 +1559,10 @@
"batchImportNoUrls": "Please enter at least one URL or file path", "batchImportNoUrls": "Please enter at least one URL or file path",
"batchImportNoDirectory": "Please enter a directory path", "batchImportNoDirectory": "Please enter a directory path",
"batchImportBrowseFailed": "Failed to browse directory: {message}", "batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}" "batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "Aucune recette sélectionnée",
"noMissingLorasInSelection": "Aucun LoRA manquant trouvé dans les recettes sélectionnées",
"noLoraRootConfigured": "Aucun répertoire racine LoRA configuré. Veuillez définir un répertoire racine LoRA par défaut dans les paramètres."
}, },
"models": { "models": {
"noModelsSelected": "Aucun modèle sélectionné", "noModelsSelected": "Aucun modèle sélectionné",

View File

@@ -291,7 +291,15 @@
"blurNsfwContent": "טשטש תוכן NSFW", "blurNsfwContent": "טשטש תוכן NSFW",
"blurNsfwContentHelp": "טשטש תמונות תצוגה מקדימה של תוכן למבוגרים (NSFW)", "blurNsfwContentHelp": "טשטש תמונות תצוגה מקדימה של תוכן למבוגרים (NSFW)",
"showOnlySfw": "הצג רק תוצאות SFW", "showOnlySfw": "הצג רק תוצאות SFW",
"showOnlySfwHelp": "סנן את כל התוכן ה-NSFW בעת גלישה וחיפוש" "showOnlySfwHelp": "סנן את כל התוכן ה-NSFW בעת גלישה וחיפוש",
"matureBlurThreshold": "סף טשטוש תוכן מבוגרים",
"matureBlurThresholdHelp": "הגדר מאיזו רמת דירוג מתחיל סינון הטשטוש כאשר טשטוש NSFW מופעל.",
"matureBlurThresholdOptions": {
"pg13": "PG13 ומעלה",
"r": "R ומעלה (ברירת מחדל)",
"x": "X ומעלה",
"xxx": "XXX בלבד"
}
}, },
"videoSettings": { "videoSettings": {
"autoplayOnHover": "נגן וידאו אוטומטית בריחוף", "autoplayOnHover": "נגן וידאו אוטומטית בריחוף",
@@ -315,6 +323,28 @@
"saveFailed": "לא ניתן לשמור נתיבי דילוג: {message}" "saveFailed": "לא ניתן לשמור נתיבי דילוג: {message}"
} }
}, },
"downloadSkipBaseModels": {
"label": "דלג על הורדות עבור מודלי בסיס",
"help": "חל על כל תהליכי ההורדה. ניתן לבחור כאן רק מודלי בסיס נתמכים.",
"searchPlaceholder": "סנן מודלי בסיס...",
"empty": "אין מודלי בסיס התואמים לחיפוש הנוכחי.",
"summary": {
"none": "לא נבחר דבר",
"count": "{count} נבחרו"
},
"actions": {
"edit": "עריכה",
"collapse": "כווץ",
"clear": "נקה"
},
"validation": {
"saveFailed": "לא ניתן לשמור את מודלי הבסיס המוחרגים: {message}"
}
},
"skipPreviouslyDownloadedModelVersions": {
"label": "דלג על גרסאות מודלים שהורדו בעבר",
"help": "כאשר מופעל, LoRA Manager ידלג על הורדת גרסת מודל אם שירות היסטוריית ההורדות רושם את הגרסה המדויקת הזו ככבר שהורדה. חל על כל תהליכי ההורדה."
},
"layoutSettings": { "layoutSettings": {
"displayDensity": "צפיפות תצוגה", "displayDensity": "צפיפות תצוגה",
"displayDensityOptions": { "displayDensityOptions": {
@@ -367,8 +397,8 @@
}, },
"extraFolderPaths": { "extraFolderPaths": {
"title": "נתיבי תיקיות נוספים", "title": "נתיבי תיקיות נוספים",
"help": "הוסף תיקיות מודלים נוספות מחוץ לנתיבים הסטנדרטיים של ComfyUI. נתיבים אלה נשמרים בנפרד ונסרקים לצד תיקיות ברירת המחדל.", "description": "נתיבי שורש מודלים נוספים בלעדיים ל-LoRA Manager. טען מודלים ממיקומים מחוץ לתיקיות הסטנדרטיות של ComfyUI - אידיאלי לספריות גדולות שאחרת יאטו את ComfyUI.",
"description": "הגדר תיקיות נוספות לסריקת מודלים. נתיבים אלה ספציפיים ל-LoRA Manager וימוזגו עם נתיבי ברירת המחדל של ComfyUI.", "restartRequired": "Requires restart to take effect",
"modelTypes": { "modelTypes": {
"lora": "נתיבי LoRA", "lora": "נתיבי LoRA",
"checkpoint": "נתיבי Checkpoint", "checkpoint": "נתיבי Checkpoint",
@@ -376,7 +406,7 @@
"embedding": "נתיבי Embedding" "embedding": "נתיבי Embedding"
}, },
"pathPlaceholder": "/נתיב/למודלים/נוספים", "pathPlaceholder": "/נתיב/למודלים/נוספים",
"saveSuccess": "נתיבי תיקיות נוספים עודכנו.", "saveSuccess": "נתיבי תיקיות נוספים עודכנו. נדרשת הפעלה מחדש כדי להחיל את השינויים.",
"saveError": "נכשל בעדכון נתיבי תיקיות נוספים: {message}", "saveError": "נכשל בעדכון נתיבי תיקיות נוספים: {message}",
"validation": { "validation": {
"duplicatePath": "נתיב זה כבר מוגדר" "duplicatePath": "נתיב זה כבר מוגדר"
@@ -575,6 +605,7 @@
"skipMetadataRefresh": "דילוג על רענון מטא-נתונים לנבחרים", "skipMetadataRefresh": "דילוג על רענון מטא-נתונים לנבחרים",
"resumeMetadataRefresh": "המשך רענון מטא-נתונים לנבחרים", "resumeMetadataRefresh": "המשך רענון מטא-נתונים לנבחרים",
"deleteAll": "מחק את כל המודלים", "deleteAll": "מחק את כל המודלים",
"downloadMissingLoras": "הורדת LoRAs חסרים",
"clear": "נקה בחירה", "clear": "נקה בחירה",
"skipMetadataRefreshCount": "דילוג({count} מודלים)", "skipMetadataRefreshCount": "דילוג({count} מודלים)",
"resumeMetadataRefreshCount": "המשך({count} מודלים)", "resumeMetadataRefreshCount": "המשך({count} מודלים)",
@@ -799,7 +830,8 @@
"diffusion_model": "Diffusion Model" "diffusion_model": "Diffusion Model"
}, },
"contextMenu": { "contextMenu": {
"moveToOtherTypeFolder": "העבר לתיקיית {otherType}" "moveToOtherTypeFolder": "העבר לתיקיית {otherType}",
"sendToWorkflow": "שלח ל-workflow"
} }
}, },
"embeddings": { "embeddings": {
@@ -812,8 +844,8 @@
"unpinSidebar": "שחרר סרגל צד", "unpinSidebar": "שחרר סרגל צד",
"switchToListView": "עבור לתצוגת רשימה", "switchToListView": "עבור לתצוגת רשימה",
"switchToTreeView": "תצוגת עץ", "switchToTreeView": "תצוגת עץ",
"recursiveOn": "חיפוש בתיקיות משנה", "recursiveOn": "כלול תיקיות משנה",
"recursiveOff": "חיפוש רק בתיקייה הנוכחית", "recursiveOff": "רק התיקייה הנוכחית",
"recursiveUnavailable": "חיפוש רקורסיבי זמין רק בתצוגת עץ", "recursiveUnavailable": "חיפוש רקורסיבי זמין רק בתצוגת עץ",
"collapseAllDisabled": "לא זמין בתצוגת רשימה", "collapseAllDisabled": "לא זמין בתצוגת רשימה",
"dragDrop": { "dragDrop": {
@@ -983,6 +1015,14 @@
"save": "עדכן מודל בסיס", "save": "עדכן מודל בסיס",
"cancel": "ביטול" "cancel": "ביטול"
}, },
"bulkDownloadMissingLoras": {
"title": "הורדת LoRAs חסרים",
"message": "נמצאו {uniqueCount} LoRAs חסרים ייחודיים (מתוך {totalCount} בסך הכל במתכונים שנבחרו).",
"previewTitle": "LoRAs להורדה:",
"moreItems": "...ועוד {count}",
"note": "הקבצים יורדו באמצעות תבניות נתיב ברירת מחדל. זה עשוי לקחת זמן בהתאם למספר ה-LoRAs.",
"downloadButton": "הורד {count} LoRA(s)"
},
"exampleAccess": { "exampleAccess": {
"title": "תמונות דוגמה מקומיות", "title": "תמונות דוגמה מקומיות",
"message": "לא נמצאו תמונות דוגמה מקומיות למודל זה. אפשרויות צפייה:", "message": "לא נמצאו תמונות דוגמה מקומיות למודל זה. אפשרויות צפייה:",
@@ -1034,7 +1074,9 @@
"viewOnCivitai": "הצג ב-Civitai", "viewOnCivitai": "הצג ב-Civitai",
"viewOnCivitaiText": "הצג ב-Civitai", "viewOnCivitaiText": "הצג ב-Civitai",
"viewCreatorProfile": "הצג פרופיל יוצר", "viewCreatorProfile": "הצג פרופיל יוצר",
"openFileLocation": "פתח מיקום קובץ" "openFileLocation": "פתח מיקום קובץ",
"sendToWorkflow": "שלח ל-ComfyUI",
"sendToWorkflowText": "שלח ל-ComfyUI"
}, },
"openFileLocation": { "openFileLocation": {
"success": "מיקום הקובץ נפתח בהצלחה", "success": "מיקום הקובץ נפתח בהצלחה",
@@ -1042,6 +1084,9 @@
"copied": "הנתיב הועתק ללוח העריכה: {{path}}", "copied": "הנתיב הועתק ללוח העריכה: {{path}}",
"clipboardFallback": "נתיב: {{path}}" "clipboardFallback": "נתיב: {{path}}"
}, },
"sendToWorkflow": {
"noFilePath": "לא ניתן לשלוח ל-ComfyUI: אין נתיב קובץ זמין"
},
"metadata": { "metadata": {
"version": "גרסה", "version": "גרסה",
"fileName": "שם קובץ", "fileName": "שם קובץ",
@@ -1299,7 +1344,9 @@
"recipeReplaced": "מתכון הוחלף ב-workflow", "recipeReplaced": "מתכון הוחלף ב-workflow",
"recipeFailedToSend": "שליחת מתכון ל-workflow נכשלה", "recipeFailedToSend": "שליחת מתכון ל-workflow נכשלה",
"noMatchingNodes": "אין צמתים תואמים זמינים ב-workflow הנוכחי", "noMatchingNodes": "אין צמתים תואמים זמינים ב-workflow הנוכחי",
"noTargetNodeSelected": "לא נבחר צומת יעד" "noTargetNodeSelected": "לא נבחר צומת יעד",
"modelUpdated": "מודל עודכן ב-workflow",
"modelFailed": "עדכון צומת המודל נכשל"
}, },
"nodeSelector": { "nodeSelector": {
"recipe": "מתכון", "recipe": "מתכון",
@@ -1450,6 +1497,7 @@
"pleaseSelectVersion": "אנא בחר גרסה", "pleaseSelectVersion": "אנא בחר גרסה",
"versionExists": "גרסה זו כבר קיימת בספרייה שלך", "versionExists": "גרסה זו כבר קיימת בספרייה שלך",
"downloadCompleted": "ההורדה הושלמה בהצלחה", "downloadCompleted": "ההורדה הושלמה בהצלחה",
"downloadSkippedByBaseModel": "ההורדה דולגה כי מודל הבסיס {baseModel} מוחרג",
"autoOrganizeSuccess": "הארגון האוטומטי הושלם בהצלחה עבור {count} {type}", "autoOrganizeSuccess": "הארגון האוטומטי הושלם בהצלחה עבור {count} {type}",
"autoOrganizePartialSuccess": "הארגון האוטומטי הושלם עם {success} שהועברו, {failures} שנכשלו מתוך {total} מודלים", "autoOrganizePartialSuccess": "הארגון האוטומטי הושלם עם {success} שהועברו, {failures} שנכשלו מתוך {total} מודלים",
"autoOrganizeFailed": "הארגון האוטומטי נכשל: {error}", "autoOrganizeFailed": "הארגון האוטומטי נכשל: {error}",
@@ -1469,7 +1517,11 @@
"nameUpdated": "שם המתכון עודכן בהצלחה", "nameUpdated": "שם המתכון עודכן בהצלחה",
"tagsUpdated": "תגיות המתכון עודכנו בהצלחה", "tagsUpdated": "תגיות המתכון עודכנו בהצלחה",
"sourceUrlUpdated": "כתובת ה-URL המקורית עודכנה בהצלחה", "sourceUrlUpdated": "כתובת ה-URL המקורית עודכנה בהצלחה",
"promptUpdated": "הפרומפט עודכן בהצלחה",
"negativePromptUpdated": "הפרומפט השלילי עודכן בהצלחה",
"promptEditorHint": "לחץ Enter לשמירה, Shift+Enter לשורה חדשה",
"noRecipeId": "אין מזהה מתכון זמין", "noRecipeId": "אין מזהה מתכון זמין",
"sendToWorkflowFailed": "נכשל שליחת המתכון ל-workflow: {message}",
"copyFailed": "שגיאה בהעתקת תחביר המתכון: {message}", "copyFailed": "שגיאה בהעתקת תחביר המתכון: {message}",
"noMissingLoras": "אין LoRAs חסרים להורדה", "noMissingLoras": "אין LoRAs חסרים להורדה",
"missingLorasInfoFailed": "קבלת מידע עבור LoRAs חסרים נכשלה", "missingLorasInfoFailed": "קבלת מידע עבור LoRAs חסרים נכשלה",
@@ -1507,7 +1559,10 @@
"batchImportNoUrls": "Please enter at least one URL or file path", "batchImportNoUrls": "Please enter at least one URL or file path",
"batchImportNoDirectory": "Please enter a directory path", "batchImportNoDirectory": "Please enter a directory path",
"batchImportBrowseFailed": "Failed to browse directory: {message}", "batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}" "batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "לא נבחרו מתכונים",
"noMissingLorasInSelection": "לא נמצאו LoRAs חסרים במתכונים שנבחרו",
"noLoraRootConfigured": "תיקיית השורש של LoRA לא מוגדרת. אנא הגדר תיקיית שורש LoRA ברירת מחדל בהגדרות."
}, },
"models": { "models": {
"noModelsSelected": "לא נבחרו מודלים", "noModelsSelected": "לא נבחרו מודלים",

View File

@@ -291,7 +291,15 @@
"blurNsfwContent": "NSFWコンテンツをぼかす", "blurNsfwContent": "NSFWコンテンツをぼかす",
"blurNsfwContentHelp": "成人向けNSFWコンテンツのプレビュー画像をぼかします", "blurNsfwContentHelp": "成人向けNSFWコンテンツのプレビュー画像をぼかします",
"showOnlySfw": "SFWコンテンツのみ表示", "showOnlySfw": "SFWコンテンツのみ表示",
"showOnlySfwHelp": "閲覧と検索時にすべてのNSFWコンテンツを除外します" "showOnlySfwHelp": "閲覧と検索時にすべてのNSFWコンテンツを除外します",
"matureBlurThreshold": "成人コンテンツぼかし閾値",
"matureBlurThresholdHelp": "NSFWぼかしが有効な場合、どのレーティングレベルからぼかしフィルタリングを開始するかを設定します。",
"matureBlurThresholdOptions": {
"pg13": "PG13 以上",
"r": "R 以上(デフォルト)",
"x": "X 以上",
"xxx": "XXX のみ"
}
}, },
"videoSettings": { "videoSettings": {
"autoplayOnHover": "ホバー時に動画を自動再生", "autoplayOnHover": "ホバー時に動画を自動再生",
@@ -315,6 +323,28 @@
"saveFailed": "スキップパスの保存に失敗しました:{message}" "saveFailed": "スキップパスの保存に失敗しました:{message}"
} }
}, },
"downloadSkipBaseModels": {
"label": "ベースモデルのダウンロードをスキップ",
"help": "すべてのダウンロードフローに適用されます。ここでは対応しているベースモデルのみ選択できます。",
"searchPlaceholder": "ベースモデルを絞り込む...",
"empty": "現在の検索に一致するベースモデルはありません。",
"summary": {
"none": "未選択",
"count": "{count} 件を選択"
},
"actions": {
"edit": "編集",
"collapse": "折りたたむ",
"clear": "クリア"
},
"validation": {
"saveFailed": "除外するベースモデルを保存できませんでした: {message}"
}
},
"skipPreviouslyDownloadedModelVersions": {
"label": "以前にダウンロードしたモデルバージョンをスキップ",
"help": "有効にすると、ダウンロード履歴サービスがそのバージョンが既にダウンロード済みと記録している場合、LoRA Managerはそのモデルバージョンのダウンロードをスキップします。すべてのダウンロードフローに適用されます。"
},
"layoutSettings": { "layoutSettings": {
"displayDensity": "表示密度", "displayDensity": "表示密度",
"displayDensityOptions": { "displayDensityOptions": {
@@ -367,8 +397,8 @@
}, },
"extraFolderPaths": { "extraFolderPaths": {
"title": "追加フォルダーパス", "title": "追加フォルダーパス",
"help": "ComfyUIの標準パスの外部に追加のモデルフォルダを追加します。これらのパスは別々に保存され、デフォルトのフォルダと一緒にスキャンされます。", "description": "LoRA Manager専用の追加モデルルートパス。ComfyUIの標準フォルダー外の場所からモデルを読み込みます。ComfyUIの動作を低下させる可能性のある大規模ライブラリに最適です。",
"description": "モデルをスキャンするための追加フォルダを設定します。これらのパスはLoRA Manager固有であり、ComfyUIのデフォルトパスとマージされます。", "restartRequired": "Requires restart to take effect",
"modelTypes": { "modelTypes": {
"lora": "LoRAパス", "lora": "LoRAパス",
"checkpoint": "Checkpointパス", "checkpoint": "Checkpointパス",
@@ -376,7 +406,7 @@
"embedding": "Embeddingパス" "embedding": "Embeddingパス"
}, },
"pathPlaceholder": "/追加モデルへのパス", "pathPlaceholder": "/追加モデルへのパス",
"saveSuccess": "追加フォルダーパスを更新しました。", "saveSuccess": "追加フォルダーパスを更新しました。変更を適用するには再起動が必要です。",
"saveError": "追加フォルダーパスの更新に失敗しました: {message}", "saveError": "追加フォルダーパスの更新に失敗しました: {message}",
"validation": { "validation": {
"duplicatePath": "このパスはすでに設定されています" "duplicatePath": "このパスはすでに設定されています"
@@ -575,6 +605,7 @@
"skipMetadataRefresh": "選択したモデルのメタデータ更新をスキップ", "skipMetadataRefresh": "選択したモデルのメタデータ更新をスキップ",
"resumeMetadataRefresh": "選択したモデルのメタデータ更新を再開", "resumeMetadataRefresh": "選択したモデルのメタデータ更新を再開",
"deleteAll": "すべてのモデルを削除", "deleteAll": "すべてのモデルを削除",
"downloadMissingLoras": "不足している LoRA をダウンロード",
"clear": "選択をクリア", "clear": "選択をクリア",
"skipMetadataRefreshCount": "スキップ({count}モデル)", "skipMetadataRefreshCount": "スキップ({count}モデル)",
"resumeMetadataRefreshCount": "再開({count}モデル)", "resumeMetadataRefreshCount": "再開({count}モデル)",
@@ -799,7 +830,8 @@
"diffusion_model": "Diffusion Model" "diffusion_model": "Diffusion Model"
}, },
"contextMenu": { "contextMenu": {
"moveToOtherTypeFolder": "{otherType} フォルダに移動" "moveToOtherTypeFolder": "{otherType} フォルダに移動",
"sendToWorkflow": "ワークフローに送信"
} }
}, },
"embeddings": { "embeddings": {
@@ -812,8 +844,8 @@
"unpinSidebar": "サイドバーの固定を解除", "unpinSidebar": "サイドバーの固定を解除",
"switchToListView": "リストビューに切り替え", "switchToListView": "リストビューに切り替え",
"switchToTreeView": "ツリー表示に切り替え", "switchToTreeView": "ツリー表示に切り替え",
"recursiveOn": "サブフォルダーを検索", "recursiveOn": "サブフォルダーを含める",
"recursiveOff": "現在のフォルダーのみを検索", "recursiveOff": "現在のフォルダーのみ",
"recursiveUnavailable": "再帰検索はツリービューでのみ利用できます", "recursiveUnavailable": "再帰検索はツリービューでのみ利用できます",
"collapseAllDisabled": "リストビューでは利用できません", "collapseAllDisabled": "リストビューでは利用できません",
"dragDrop": { "dragDrop": {
@@ -983,6 +1015,14 @@
"save": "ベースモデルを更新", "save": "ベースモデルを更新",
"cancel": "キャンセル" "cancel": "キャンセル"
}, },
"bulkDownloadMissingLoras": {
"title": "不足している LoRA をダウンロード",
"message": "選択したレシピから合計 {totalCount} 個中 {uniqueCount} 個のユニークな不足している LoRA が見つかりました。",
"previewTitle": "ダウンロードする LoRA:",
"moreItems": "...あと {count} 個",
"note": "ファイルはデフォルトのパステンプレートを使用してダウンロードされます。LoRA の数によっては時間がかかる場合があります。",
"downloadButton": "{count} 個の LoRA をダウンロード"
},
"exampleAccess": { "exampleAccess": {
"title": "ローカル例画像", "title": "ローカル例画像",
"message": "このモデルのローカル例画像が見つかりませんでした。表示オプション:", "message": "このモデルのローカル例画像が見つかりませんでした。表示オプション:",
@@ -1034,7 +1074,9 @@
"viewOnCivitai": "Civitaiで表示", "viewOnCivitai": "Civitaiで表示",
"viewOnCivitaiText": "Civitaiで表示", "viewOnCivitaiText": "Civitaiで表示",
"viewCreatorProfile": "作成者プロフィールを表示", "viewCreatorProfile": "作成者プロフィールを表示",
"openFileLocation": "ファイルの場所を開く" "openFileLocation": "ファイルの場所を開く",
"sendToWorkflow": "ComfyUI に送信",
"sendToWorkflowText": "ComfyUI に送信"
}, },
"openFileLocation": { "openFileLocation": {
"success": "ファイルの場所を正常に開きました", "success": "ファイルの場所を正常に開きました",
@@ -1042,6 +1084,9 @@
"copied": "パスをクリップボードにコピーしました: {{path}}", "copied": "パスをクリップボードにコピーしました: {{path}}",
"clipboardFallback": "パス: {{path}}" "clipboardFallback": "パス: {{path}}"
}, },
"sendToWorkflow": {
"noFilePath": "ComfyUI に送信できません:ファイルパスがありません"
},
"metadata": { "metadata": {
"version": "バージョン", "version": "バージョン",
"fileName": "ファイル名", "fileName": "ファイル名",
@@ -1299,7 +1344,9 @@
"recipeReplaced": "レシピがワークフローで置換されました", "recipeReplaced": "レシピがワークフローで置換されました",
"recipeFailedToSend": "レシピをワークフローに送信できませんでした", "recipeFailedToSend": "レシピをワークフローに送信できませんでした",
"noMatchingNodes": "現在のワークフローには互換性のあるノードがありません", "noMatchingNodes": "現在のワークフローには互換性のあるノードがありません",
"noTargetNodeSelected": "ターゲットノードが選択されていません" "noTargetNodeSelected": "ターゲットノードが選択されていません",
"modelUpdated": "モデルがワークフローで更新されました",
"modelFailed": "モデルノードの更新に失敗しました"
}, },
"nodeSelector": { "nodeSelector": {
"recipe": "レシピ", "recipe": "レシピ",
@@ -1450,6 +1497,7 @@
"pleaseSelectVersion": "バージョンを選択してください", "pleaseSelectVersion": "バージョンを選択してください",
"versionExists": "このバージョンは既にライブラリに存在します", "versionExists": "このバージョンは既にライブラリに存在します",
"downloadCompleted": "ダウンロードが正常に完了しました", "downloadCompleted": "ダウンロードが正常に完了しました",
"downloadSkippedByBaseModel": "ベースモデル {baseModel} が除外されているため、ダウンロードをスキップしました",
"autoOrganizeSuccess": "{count} {type} の自動整理が正常に完了しました", "autoOrganizeSuccess": "{count} {type} の自動整理が正常に完了しました",
"autoOrganizePartialSuccess": "自動整理が完了しました:{total} モデル中 {success} 移動、{failures} 失敗", "autoOrganizePartialSuccess": "自動整理が完了しました:{total} モデル中 {success} 移動、{failures} 失敗",
"autoOrganizeFailed": "自動整理に失敗しました:{error}", "autoOrganizeFailed": "自動整理に失敗しました:{error}",
@@ -1469,7 +1517,11 @@
"nameUpdated": "レシピ名が正常に更新されました", "nameUpdated": "レシピ名が正常に更新されました",
"tagsUpdated": "レシピタグが正常に更新されました", "tagsUpdated": "レシピタグが正常に更新されました",
"sourceUrlUpdated": "ソースURLが正常に更新されました", "sourceUrlUpdated": "ソースURLが正常に更新されました",
"promptUpdated": "プロンプトが正常に更新されました",
"negativePromptUpdated": "ネガティブプロンプトが正常に更新されました",
"promptEditorHint": "Enterキーで保存、Shift+Enterで改行",
"noRecipeId": "レシピIDが利用できません", "noRecipeId": "レシピIDが利用できません",
"sendToWorkflowFailed": "ワークフローへのレシピ送信に失敗しました:{message}",
"copyFailed": "レシピ構文のコピーエラー:{message}", "copyFailed": "レシピ構文のコピーエラー:{message}",
"noMissingLoras": "ダウンロードする不足LoRAがありません", "noMissingLoras": "ダウンロードする不足LoRAがありません",
"missingLorasInfoFailed": "不足LoRAの情報取得に失敗しました", "missingLorasInfoFailed": "不足LoRAの情報取得に失敗しました",
@@ -1507,7 +1559,10 @@
"batchImportNoUrls": "Please enter at least one URL or file path", "batchImportNoUrls": "Please enter at least one URL or file path",
"batchImportNoDirectory": "Please enter a directory path", "batchImportNoDirectory": "Please enter a directory path",
"batchImportBrowseFailed": "Failed to browse directory: {message}", "batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}" "batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "レシピが選択されていません",
"noMissingLorasInSelection": "選択したレシピに不足している LoRA が見つかりませんでした",
"noLoraRootConfigured": "LoRA ルートディレクトリが設定されていません。設定でデフォルトの LoRA ルートを設定してください。"
}, },
"models": { "models": {
"noModelsSelected": "モデルが選択されていません", "noModelsSelected": "モデルが選択されていません",

View File

@@ -291,7 +291,15 @@
"blurNsfwContent": "NSFW 콘텐츠 블러 처리", "blurNsfwContent": "NSFW 콘텐츠 블러 처리",
"blurNsfwContentHelp": "성인(NSFW) 콘텐츠 미리보기 이미지를 블러 처리합니다", "blurNsfwContentHelp": "성인(NSFW) 콘텐츠 미리보기 이미지를 블러 처리합니다",
"showOnlySfw": "SFW 결과만 표시", "showOnlySfw": "SFW 결과만 표시",
"showOnlySfwHelp": "탐색 및 검색 시 모든 NSFW 콘텐츠를 필터링합니다" "showOnlySfwHelp": "탐색 및 검색 시 모든 NSFW 콘텐츠를 필터링합니다",
"matureBlurThreshold": "성인 콘텐츠 블러 임계값",
"matureBlurThresholdHelp": "NSFW 블러가 활성화될 때 어떤 등급 레벨부터 블러 필터링을 시작할지 설정합니다.",
"matureBlurThresholdOptions": {
"pg13": "PG13 이상",
"r": "R 이상(기본값)",
"x": "X 이상",
"xxx": "XXX만"
}
}, },
"videoSettings": { "videoSettings": {
"autoplayOnHover": "호버 시 비디오 자동 재생", "autoplayOnHover": "호버 시 비디오 자동 재생",
@@ -315,6 +323,28 @@
"saveFailed": "건너뛰기 경로를 저장할 수 없습니다: {message}" "saveFailed": "건너뛰기 경로를 저장할 수 없습니다: {message}"
} }
}, },
"downloadSkipBaseModels": {
"label": "기본 모델 다운로드 건너뛰기",
"help": "모든 다운로드 흐름에 적용됩니다. 여기서는 지원되는 기본 모델만 선택할 수 있습니다.",
"searchPlaceholder": "기본 모델 필터링...",
"empty": "현재 검색과 일치하는 기본 모델이 없습니다.",
"summary": {
"none": "선택 없음",
"count": "{count}개 선택됨"
},
"actions": {
"edit": "편집",
"collapse": "접기",
"clear": "지우기"
},
"validation": {
"saveFailed": "제외된 기본 모델을 저장할 수 없습니다: {message}"
}
},
"skipPreviouslyDownloadedModelVersions": {
"label": "이전에 다운로드한 모델 버전 건너뛰기",
"help": "활성화하면 다운로드 기록 서비스가 해당 버전이 이미 다운로드되었음을 기록한 경우 LoRA Manager는 해당 모델 버전 다운로드를 건너뜁니다. 모든 다운로드 플로우에 적용됩니다."
},
"layoutSettings": { "layoutSettings": {
"displayDensity": "표시 밀도", "displayDensity": "표시 밀도",
"displayDensityOptions": { "displayDensityOptions": {
@@ -367,8 +397,8 @@
}, },
"extraFolderPaths": { "extraFolderPaths": {
"title": "추가 폴다 경로", "title": "추가 폴다 경로",
"help": "ComfyUI의 표준 경로 외부에 추가 모델 폴드를 추가하세요. 이러한 경로는 별도로 저장되며 기본 폴와 함께 스캔됩니다.", "description": "LoRA Manager 전용 추가 모델 루트 경로입니다. ComfyUI의 표준 폴더 외부 위치에서 모델을 로드하여 대규모 라이브러리로 인한 성능 저하를 방지합니다.",
"description": "모델을 스캔하기 위한 추가 폴를 설정하세요. 이러한 경로는 LoRA Manager 특유의 것이며 ComfyUI의 기본 경로와 병합됩니다.", "restartRequired": "Requires restart to take effect",
"modelTypes": { "modelTypes": {
"lora": "LoRA 경로", "lora": "LoRA 경로",
"checkpoint": "Checkpoint 경로", "checkpoint": "Checkpoint 경로",
@@ -376,7 +406,7 @@
"embedding": "Embedding 경로" "embedding": "Embedding 경로"
}, },
"pathPlaceholder": "/추가/모델/경로", "pathPlaceholder": "/추가/모델/경로",
"saveSuccess": "추가 폴다 경로가 업데이트되었습니다.", "saveSuccess": "추가 폴다 경로가 업데이트되었습니다. 변경 사항을 적용하려면 재시작이 필요합니다.",
"saveError": "추가 폴다 경로 업데이트 실패: {message}", "saveError": "추가 폴다 경로 업데이트 실패: {message}",
"validation": { "validation": {
"duplicatePath": "이 경로는 이미 구성되어 있습니다" "duplicatePath": "이 경로는 이미 구성되어 있습니다"
@@ -575,6 +605,7 @@
"skipMetadataRefresh": "선택한 모델의 메타데이터 새로고침 건너뛰기", "skipMetadataRefresh": "선택한 모델의 메타데이터 새로고침 건너뛰기",
"resumeMetadataRefresh": "선택한 모델의 메타데이터 새로고침 재개", "resumeMetadataRefresh": "선택한 모델의 메타데이터 새로고침 재개",
"deleteAll": "모든 모델 삭제", "deleteAll": "모든 모델 삭제",
"downloadMissingLoras": "누락된 LoRA 다운로드",
"clear": "선택 지우기", "clear": "선택 지우기",
"skipMetadataRefreshCount": "건너뛰기({count}개 모델)", "skipMetadataRefreshCount": "건너뛰기({count}개 모델)",
"resumeMetadataRefreshCount": "재개({count}개 모델)", "resumeMetadataRefreshCount": "재개({count}개 모델)",
@@ -799,7 +830,8 @@
"diffusion_model": "Diffusion Model" "diffusion_model": "Diffusion Model"
}, },
"contextMenu": { "contextMenu": {
"moveToOtherTypeFolder": "{otherType} 폴더로 이동" "moveToOtherTypeFolder": "{otherType} 폴더로 이동",
"sendToWorkflow": "워크플로우로 전송"
} }
}, },
"embeddings": { "embeddings": {
@@ -812,8 +844,8 @@
"unpinSidebar": "사이드바 고정 해제", "unpinSidebar": "사이드바 고정 해제",
"switchToListView": "목록 보기로 전환", "switchToListView": "목록 보기로 전환",
"switchToTreeView": "트리 보기로 전환", "switchToTreeView": "트리 보기로 전환",
"recursiveOn": "하위 폴더 검색", "recursiveOn": "하위 폴더 포함",
"recursiveOff": "현재 폴더만 검색", "recursiveOff": "현재 폴더만",
"recursiveUnavailable": "재귀 검색은 트리 보기에서만 사용할 수 있습니다", "recursiveUnavailable": "재귀 검색은 트리 보기에서만 사용할 수 있습니다",
"collapseAllDisabled": "목록 보기에서는 사용할 수 없습니다", "collapseAllDisabled": "목록 보기에서는 사용할 수 없습니다",
"dragDrop": { "dragDrop": {
@@ -983,6 +1015,14 @@
"save": "베이스 모델 업데이트", "save": "베이스 모델 업데이트",
"cancel": "취소" "cancel": "취소"
}, },
"bulkDownloadMissingLoras": {
"title": "누락된 LoRA 다운로드",
"message": "선택한 레시피에서 총 {totalCount}개 중 {uniqueCount}개의 고유한 누락된 LoRA를 찾았습니다.",
"previewTitle": "다운로드할 LoRA:",
"moreItems": "...그리고 {count}개 더",
"note": "파일은 기본 경로 템플릿을 사용하여 다운로드됩니다. LoRA의 수에 따라 다소 시간이 걸릴 수 있습니다.",
"downloadButton": "{count}개 LoRA 다운로드"
},
"exampleAccess": { "exampleAccess": {
"title": "로컬 예시 이미지", "title": "로컬 예시 이미지",
"message": "이 모델의 로컬 예시 이미지를 찾을 수 없습니다. 보기 옵션:", "message": "이 모델의 로컬 예시 이미지를 찾을 수 없습니다. 보기 옵션:",
@@ -1034,7 +1074,9 @@
"viewOnCivitai": "Civitai에서 보기", "viewOnCivitai": "Civitai에서 보기",
"viewOnCivitaiText": "Civitai에서 보기", "viewOnCivitaiText": "Civitai에서 보기",
"viewCreatorProfile": "제작자 프로필 보기", "viewCreatorProfile": "제작자 프로필 보기",
"openFileLocation": "파일 위치 열기" "openFileLocation": "파일 위치 열기",
"sendToWorkflow": "ComfyUI로 보내기",
"sendToWorkflowText": "ComfyUI로 보내기"
}, },
"openFileLocation": { "openFileLocation": {
"success": "파일 위치가 성공적으로 열렸습니다", "success": "파일 위치가 성공적으로 열렸습니다",
@@ -1042,6 +1084,9 @@
"copied": "경로가 클립보드에 복사되었습니다: {{path}}", "copied": "경로가 클립보드에 복사되었습니다: {{path}}",
"clipboardFallback": "경로: {{path}}" "clipboardFallback": "경로: {{path}}"
}, },
"sendToWorkflow": {
"noFilePath": "ComfyUI로 보낼 수 없습니다: 파일 경로가 없습니다"
},
"metadata": { "metadata": {
"version": "버전", "version": "버전",
"fileName": "파일명", "fileName": "파일명",
@@ -1299,7 +1344,9 @@
"recipeReplaced": "레시피가 워크플로에서 교체되었습니다", "recipeReplaced": "레시피가 워크플로에서 교체되었습니다",
"recipeFailedToSend": "레시피를 워크플로로 전송하지 못했습니다", "recipeFailedToSend": "레시피를 워크플로로 전송하지 못했습니다",
"noMatchingNodes": "현재 워크플로에서 호환되는 노드가 없습니다", "noMatchingNodes": "현재 워크플로에서 호환되는 노드가 없습니다",
"noTargetNodeSelected": "대상 노드가 선택되지 않았습니다" "noTargetNodeSelected": "대상 노드가 선택되지 않았습니다",
"modelUpdated": "모델이 워크플로에서 업데이트되었습니다",
"modelFailed": "모델 노드 업데이트 실패"
}, },
"nodeSelector": { "nodeSelector": {
"recipe": "레시피", "recipe": "레시피",
@@ -1450,6 +1497,7 @@
"pleaseSelectVersion": "버전을 선택해주세요", "pleaseSelectVersion": "버전을 선택해주세요",
"versionExists": "이 버전은 이미 라이브러리에 있습니다", "versionExists": "이 버전은 이미 라이브러리에 있습니다",
"downloadCompleted": "다운로드가 성공적으로 완료되었습니다", "downloadCompleted": "다운로드가 성공적으로 완료되었습니다",
"downloadSkippedByBaseModel": "기본 모델 {baseModel}이(가) 제외되어 다운로드를 건너뛰었습니다",
"autoOrganizeSuccess": "{count}개의 {type}에 대해 자동 정리가 성공적으로 완료되었습니다", "autoOrganizeSuccess": "{count}개의 {type}에 대해 자동 정리가 성공적으로 완료되었습니다",
"autoOrganizePartialSuccess": "자동 정리 완료: 전체 {total}개 중 {success}개 이동, {failures}개 실패", "autoOrganizePartialSuccess": "자동 정리 완료: 전체 {total}개 중 {success}개 이동, {failures}개 실패",
"autoOrganizeFailed": "자동 정리 실패: {error}", "autoOrganizeFailed": "자동 정리 실패: {error}",
@@ -1469,7 +1517,11 @@
"nameUpdated": "레시피 이름이 성공적으로 업데이트되었습니다", "nameUpdated": "레시피 이름이 성공적으로 업데이트되었습니다",
"tagsUpdated": "레시피 태그가 성공적으로 업데이트되었습니다", "tagsUpdated": "레시피 태그가 성공적으로 업데이트되었습니다",
"sourceUrlUpdated": "소스 URL이 성공적으로 업데이트되었습니다", "sourceUrlUpdated": "소스 URL이 성공적으로 업데이트되었습니다",
"promptUpdated": "프롬프트가 성공적으로 업데이트되었습니다",
"negativePromptUpdated": "네거티브 프롬프트가 성공적으로 업데이트되었습니다",
"promptEditorHint": "Enter 키를 눌러 저장, Shift+Enter로 새 줄",
"noRecipeId": "사용 가능한 레시피 ID가 없습니다", "noRecipeId": "사용 가능한 레시피 ID가 없습니다",
"sendToWorkflowFailed": "워크플로우에 레시피 보내기 실패: {message}",
"copyFailed": "레시피 문법 복사 오류: {message}", "copyFailed": "레시피 문법 복사 오류: {message}",
"noMissingLoras": "다운로드할 누락된 LoRA가 없습니다", "noMissingLoras": "다운로드할 누락된 LoRA가 없습니다",
"missingLorasInfoFailed": "누락된 LoRA 정보를 가져오는데 실패했습니다", "missingLorasInfoFailed": "누락된 LoRA 정보를 가져오는데 실패했습니다",
@@ -1507,7 +1559,10 @@
"batchImportNoUrls": "Please enter at least one URL or file path", "batchImportNoUrls": "Please enter at least one URL or file path",
"batchImportNoDirectory": "Please enter a directory path", "batchImportNoDirectory": "Please enter a directory path",
"batchImportBrowseFailed": "Failed to browse directory: {message}", "batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}" "batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "선택한 레시피가 없습니다",
"noMissingLorasInSelection": "선택한 레시피에서 누락된 LoRA를 찾을 수 없습니다",
"noLoraRootConfigured": "LoRA 루트 디렉토리가 구성되지 않았습니다. 설정에서 기본 LoRA 루트를 설정하세요."
}, },
"models": { "models": {
"noModelsSelected": "선택된 모델이 없습니다", "noModelsSelected": "선택된 모델이 없습니다",

View File

@@ -291,7 +291,15 @@
"blurNsfwContent": "Размывать NSFW контент", "blurNsfwContent": "Размывать NSFW контент",
"blurNsfwContentHelp": "Размывать превью изображений контента для взрослых (NSFW)", "blurNsfwContentHelp": "Размывать превью изображений контента для взрослых (NSFW)",
"showOnlySfw": "Показывать только SFW результаты", "showOnlySfw": "Показывать только SFW результаты",
"showOnlySfwHelp": "Фильтровать весь NSFW контент при просмотре и поиске" "showOnlySfwHelp": "Фильтровать весь NSFW контент при просмотре и поиске",
"matureBlurThreshold": "Порог размытия взрослого контента",
"matureBlurThresholdHelp": "Установить, с какого уровня рейтинга начинается размытие при включенном размытии NSFW.",
"matureBlurThresholdOptions": {
"pg13": "PG13 и выше",
"r": "R и выше (по умолчанию)",
"x": "X и выше",
"xxx": "Только XXX"
}
}, },
"videoSettings": { "videoSettings": {
"autoplayOnHover": "Автовоспроизведение видео при наведении", "autoplayOnHover": "Автовоспроизведение видео при наведении",
@@ -315,6 +323,28 @@
"saveFailed": "Не удалось сохранить пути для пропуска: {message}" "saveFailed": "Не удалось сохранить пути для пропуска: {message}"
} }
}, },
"downloadSkipBaseModels": {
"label": "Пропускать загрузки для базовых моделей",
"help": "Применяется ко всем сценариям загрузки. Здесь можно выбрать только поддерживаемые базовые модели.",
"searchPlaceholder": "Фильтровать базовые модели...",
"empty": "Нет базовых моделей, соответствующих текущему поиску.",
"summary": {
"none": "Ничего не выбрано",
"count": "Выбрано: {count}"
},
"actions": {
"edit": "Изменить",
"collapse": "Свернуть",
"clear": "Очистить"
},
"validation": {
"saveFailed": "Не удалось сохранить исключённые базовые модели: {message}"
}
},
"skipPreviouslyDownloadedModelVersions": {
"label": "Пропускать ранее загруженные версии моделей",
"help": "Если включено, LoRA Manager будет пропускать загрузку версии модели, если сервис истории загрузок записал, что эта конкретная версия уже загружена. Применяется ко всем потокам загрузки."
},
"layoutSettings": { "layoutSettings": {
"displayDensity": "Плотность отображения", "displayDensity": "Плотность отображения",
"displayDensityOptions": { "displayDensityOptions": {
@@ -367,8 +397,8 @@
}, },
"extraFolderPaths": { "extraFolderPaths": {
"title": "Дополнительные пути к папкам", "title": "Дополнительные пути к папкам",
"help": "Добавьте дополнительные папки моделей за пределами стандартных путей ComfyUI. Эти пути хранятся отдельно и сканируются вместе с папками по умолчанию.", "description": "Дополнительные корневые пути моделей, эксклюзивные для LoRA Manager. Загружайте модели из расположений за пределами стандартных папок ComfyUI — идеально подходит для больших библиотек, которые иначе замедлили бы ComfyUI.",
"description": "Настройте дополнительные папки для сканирования моделей. Эти пути специфичны для LoRA Manager и будут объединены с путями по умолчанию ComfyUI.", "restartRequired": "Requires restart to take effect",
"modelTypes": { "modelTypes": {
"lora": "Пути LoRA", "lora": "Пути LoRA",
"checkpoint": "Пути Checkpoint", "checkpoint": "Пути Checkpoint",
@@ -376,7 +406,7 @@
"embedding": "Пути Embedding" "embedding": "Пути Embedding"
}, },
"pathPlaceholder": "/путь/к/дополнительным/моделям", "pathPlaceholder": "/путь/к/дополнительным/моделям",
"saveSuccess": "Дополнительные пути к папкам обновлены.", "saveSuccess": "Дополнительные пути к папкам обновлены. Требуется перезапуск для применения изменений.",
"saveError": "Не удалось обновить дополнительные пути к папкам: {message}", "saveError": "Не удалось обновить дополнительные пути к папкам: {message}",
"validation": { "validation": {
"duplicatePath": "Этот путь уже настроен" "duplicatePath": "Этот путь уже настроен"
@@ -575,6 +605,7 @@
"skipMetadataRefresh": "Пропустить обновление метаданных для выбранных", "skipMetadataRefresh": "Пропустить обновление метаданных для выбранных",
"resumeMetadataRefresh": "Возобновить обновление метаданных для выбранных", "resumeMetadataRefresh": "Возобновить обновление метаданных для выбранных",
"deleteAll": "Удалить все модели", "deleteAll": "Удалить все модели",
"downloadMissingLoras": "Скачать отсутствующие LoRAs",
"clear": "Очистить выбор", "clear": "Очистить выбор",
"skipMetadataRefreshCount": "Пропустить({count} моделей)", "skipMetadataRefreshCount": "Пропустить({count} моделей)",
"resumeMetadataRefreshCount": "Возобновить({count} моделей)", "resumeMetadataRefreshCount": "Возобновить({count} моделей)",
@@ -799,7 +830,8 @@
"diffusion_model": "Diffusion Model" "diffusion_model": "Diffusion Model"
}, },
"contextMenu": { "contextMenu": {
"moveToOtherTypeFolder": "Переместить в папку {otherType}" "moveToOtherTypeFolder": "Переместить в папку {otherType}",
"sendToWorkflow": "Отправить в workflow"
} }
}, },
"embeddings": { "embeddings": {
@@ -812,8 +844,8 @@
"unpinSidebar": "Открепить боковую панель", "unpinSidebar": "Открепить боковую панель",
"switchToListView": "Переключить на вид списка", "switchToListView": "Переключить на вид списка",
"switchToTreeView": "Переключить на древовидный вид", "switchToTreeView": "Переключить на древовидный вид",
"recursiveOn": "Искать во вложенных папках", "recursiveOn": "Включать вложенные папки",
"recursiveOff": "Искать только в текущей папке", "recursiveOff": "Только текущая папка",
"recursiveUnavailable": "Рекурсивный поиск доступен только в режиме дерева", "recursiveUnavailable": "Рекурсивный поиск доступен только в режиме дерева",
"collapseAllDisabled": "Недоступно в виде списка", "collapseAllDisabled": "Недоступно в виде списка",
"dragDrop": { "dragDrop": {
@@ -983,6 +1015,14 @@
"save": "Обновить базовую модель", "save": "Обновить базовую модель",
"cancel": "Отмена" "cancel": "Отмена"
}, },
"bulkDownloadMissingLoras": {
"title": "Скачать отсутствующие LoRAs",
"message": "Найдено {uniqueCount} уникальных отсутствующих LoRAs (из {totalCount} всего в выбранных рецептах).",
"previewTitle": "LoRAs для скачивания:",
"moreItems": "...и еще {count}",
"note": "Файлы будут скачаны с использованием шаблонов путей по умолчанию. Это может занять некоторое время в зависимости от количества LoRAs.",
"downloadButton": "Скачать {count} LoRA(s)"
},
"exampleAccess": { "exampleAccess": {
"title": "Локальные примеры изображений", "title": "Локальные примеры изображений",
"message": "Локальные примеры изображений для этой модели не найдены. Варианты просмотра:", "message": "Локальные примеры изображений для этой модели не найдены. Варианты просмотра:",
@@ -1034,7 +1074,9 @@
"viewOnCivitai": "Посмотреть на Civitai", "viewOnCivitai": "Посмотреть на Civitai",
"viewOnCivitaiText": "Посмотреть на Civitai", "viewOnCivitaiText": "Посмотреть на Civitai",
"viewCreatorProfile": "Посмотреть профиль создателя", "viewCreatorProfile": "Посмотреть профиль создателя",
"openFileLocation": "Открыть расположение файла" "openFileLocation": "Открыть расположение файла",
"sendToWorkflow": "Отправить в ComfyUI",
"sendToWorkflowText": "Отправить в ComfyUI"
}, },
"openFileLocation": { "openFileLocation": {
"success": "Расположение файла успешно открыто", "success": "Расположение файла успешно открыто",
@@ -1042,6 +1084,9 @@
"copied": "Путь скопирован в буфер обмена: {{path}}", "copied": "Путь скопирован в буфер обмена: {{path}}",
"clipboardFallback": "Путь: {{path}}" "clipboardFallback": "Путь: {{path}}"
}, },
"sendToWorkflow": {
"noFilePath": "Невозможно отправить в ComfyUI: путь к файлу недоступен"
},
"metadata": { "metadata": {
"version": "Версия", "version": "Версия",
"fileName": "Имя файла", "fileName": "Имя файла",
@@ -1299,7 +1344,9 @@
"recipeReplaced": "Рецепт заменён в workflow", "recipeReplaced": "Рецепт заменён в workflow",
"recipeFailedToSend": "Не удалось отправить рецепт в workflow", "recipeFailedToSend": "Не удалось отправить рецепт в workflow",
"noMatchingNodes": "В текущем workflow нет совместимых узлов", "noMatchingNodes": "В текущем workflow нет совместимых узлов",
"noTargetNodeSelected": "Целевой узел не выбран" "noTargetNodeSelected": "Целевой узел не выбран",
"modelUpdated": "Модель обновлена в workflow",
"modelFailed": "Не удалось обновить узел модели"
}, },
"nodeSelector": { "nodeSelector": {
"recipe": "Рецепт", "recipe": "Рецепт",
@@ -1450,6 +1497,7 @@
"pleaseSelectVersion": "Пожалуйста, выберите версию", "pleaseSelectVersion": "Пожалуйста, выберите версию",
"versionExists": "Эта версия уже существует в вашей библиотеке", "versionExists": "Эта версия уже существует в вашей библиотеке",
"downloadCompleted": "Загрузка успешно завершена", "downloadCompleted": "Загрузка успешно завершена",
"downloadSkippedByBaseModel": "Загрузка пропущена, потому что базовая модель {baseModel} исключена",
"autoOrganizeSuccess": "Автоматическая организация успешно завершена для {count} {type}", "autoOrganizeSuccess": "Автоматическая организация успешно завершена для {count} {type}",
"autoOrganizePartialSuccess": "Автоматическая организация завершена: перемещено {success}, не удалось {failures} из {total} моделей", "autoOrganizePartialSuccess": "Автоматическая организация завершена: перемещено {success}, не удалось {failures} из {total} моделей",
"autoOrganizeFailed": "Ошибка автоматической организации: {error}", "autoOrganizeFailed": "Ошибка автоматической организации: {error}",
@@ -1469,7 +1517,11 @@
"nameUpdated": "Название рецепта успешно обновлено", "nameUpdated": "Название рецепта успешно обновлено",
"tagsUpdated": "Теги рецепта успешно обновлены", "tagsUpdated": "Теги рецепта успешно обновлены",
"sourceUrlUpdated": "Исходный URL успешно обновлен", "sourceUrlUpdated": "Исходный URL успешно обновлен",
"promptUpdated": "Промпт успешно обновлён",
"negativePromptUpdated": "Негативный промпт успешно обновлён",
"promptEditorHint": "Нажмите Enter для сохранения, Shift+Enter для новой строки",
"noRecipeId": "ID рецепта недоступен", "noRecipeId": "ID рецепта недоступен",
"sendToWorkflowFailed": "Не удалось отправить рецепт в рабочий процесс: {message}",
"copyFailed": "Ошибка копирования синтаксиса рецепта: {message}", "copyFailed": "Ошибка копирования синтаксиса рецепта: {message}",
"noMissingLoras": "Нет отсутствующих LoRAs для загрузки", "noMissingLoras": "Нет отсутствующих LoRAs для загрузки",
"missingLorasInfoFailed": "Не удалось получить информацию для отсутствующих LoRAs", "missingLorasInfoFailed": "Не удалось получить информацию для отсутствующих LoRAs",
@@ -1507,7 +1559,10 @@
"batchImportNoUrls": "Please enter at least one URL or file path", "batchImportNoUrls": "Please enter at least one URL or file path",
"batchImportNoDirectory": "Please enter a directory path", "batchImportNoDirectory": "Please enter a directory path",
"batchImportBrowseFailed": "Failed to browse directory: {message}", "batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}" "batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "Рецепты не выбраны",
"noMissingLorasInSelection": "В выбранных рецептах не найдены отсутствующие LoRAs",
"noLoraRootConfigured": "Корневой каталог LoRA не настроен. Пожалуйста, установите корневой каталог LoRA по умолчанию в настройках."
}, },
"models": { "models": {
"noModelsSelected": "Модели не выбраны", "noModelsSelected": "Модели не выбраны",

View File

@@ -291,7 +291,15 @@
"blurNsfwContent": "模糊 NSFW 内容", "blurNsfwContent": "模糊 NSFW 内容",
"blurNsfwContentHelp": "模糊成熟NSFW内容预览图片", "blurNsfwContentHelp": "模糊成熟NSFW内容预览图片",
"showOnlySfw": "仅显示 SFW 结果", "showOnlySfw": "仅显示 SFW 结果",
"showOnlySfwHelp": "浏览和搜索时过滤所有 NSFW 内容" "showOnlySfwHelp": "浏览和搜索时过滤所有 NSFW 内容",
"matureBlurThreshold": "成人内容模糊阈值",
"matureBlurThresholdHelp": "设置当启用 NSFW 模糊时,从哪个评级级别开始模糊过滤。",
"matureBlurThresholdOptions": {
"pg13": "PG13 及以上",
"r": "R 及以上(默认)",
"x": "X 及以上",
"xxx": "仅 XXX"
}
}, },
"videoSettings": { "videoSettings": {
"autoplayOnHover": "悬停时自动播放视频", "autoplayOnHover": "悬停时自动播放视频",
@@ -315,6 +323,28 @@
"saveFailed": "无法保存跳过路径:{message}" "saveFailed": "无法保存跳过路径:{message}"
} }
}, },
"downloadSkipBaseModels": {
"label": "跳过这些基础模型的下载",
"help": "适用于所有下载流程。这里只能选择受支持的基础模型。",
"searchPlaceholder": "筛选基础模型...",
"empty": "没有与当前搜索匹配的基础模型。",
"summary": {
"none": "未选择",
"count": "已选择 {count} 项"
},
"actions": {
"edit": "编辑",
"collapse": "收起",
"clear": "清空"
},
"validation": {
"saveFailed": "无法保存已排除的基础模型:{message}"
}
},
"skipPreviouslyDownloadedModelVersions": {
"label": "跳过已下载的模型版本",
"help": "启用后如果下载历史服务记录显示该版本已下载LoRA Manager 将跳过下载该模型版本。适用于所有下载流程。"
},
"layoutSettings": { "layoutSettings": {
"displayDensity": "显示密度", "displayDensity": "显示密度",
"displayDensityOptions": { "displayDensityOptions": {
@@ -367,8 +397,8 @@
}, },
"extraFolderPaths": { "extraFolderPaths": {
"title": "额外文件夹路径", "title": "额外文件夹路径",
"help": "在 ComfyUI 标准路径之外添加额外的模型文件夹。这些路径单独存储,并与默认文件夹一起扫描。", "description": "LoRA Manager 专属的额外模型根目录。从 ComfyUI 标准文件夹之外的位置加载模型,特别适合管理大型模型库,避免影响 ComfyUI 性能。",
"description": "配置额外的文件夹以扫描模型。这些路径是 LoRA Manager 特有的,将与 ComfyUI 的默认路径合并。", "restartRequired": "需要重启才能生效",
"modelTypes": { "modelTypes": {
"lora": "LoRA 路径", "lora": "LoRA 路径",
"checkpoint": "Checkpoint 路径", "checkpoint": "Checkpoint 路径",
@@ -376,7 +406,7 @@
"embedding": "Embedding 路径" "embedding": "Embedding 路径"
}, },
"pathPlaceholder": "/额外/模型/路径", "pathPlaceholder": "/额外/模型/路径",
"saveSuccess": "额外文件夹路径已更新。", "saveSuccess": "额外文件夹路径已更新,需要重启才能生效。",
"saveError": "更新额外文件夹路径失败:{message}", "saveError": "更新额外文件夹路径失败:{message}",
"validation": { "validation": {
"duplicatePath": "此路径已配置" "duplicatePath": "此路径已配置"
@@ -575,6 +605,7 @@
"skipMetadataRefresh": "跳过所选模型的元数据刷新", "skipMetadataRefresh": "跳过所选模型的元数据刷新",
"resumeMetadataRefresh": "恢复所选模型的元数据刷新", "resumeMetadataRefresh": "恢复所选模型的元数据刷新",
"deleteAll": "删除选中模型", "deleteAll": "删除选中模型",
"downloadMissingLoras": "下载缺失的 LoRAs",
"clear": "清除选择", "clear": "清除选择",
"skipMetadataRefreshCount": "跳过({count} 个模型)", "skipMetadataRefreshCount": "跳过({count} 个模型)",
"resumeMetadataRefreshCount": "恢复({count} 个模型)", "resumeMetadataRefreshCount": "恢复({count} 个模型)",
@@ -799,7 +830,8 @@
"diffusion_model": "Diffusion Model" "diffusion_model": "Diffusion Model"
}, },
"contextMenu": { "contextMenu": {
"moveToOtherTypeFolder": "移动到 {otherType} 文件夹" "moveToOtherTypeFolder": "移动到 {otherType} 文件夹",
"sendToWorkflow": "发送到工作流"
} }
}, },
"embeddings": { "embeddings": {
@@ -812,8 +844,8 @@
"unpinSidebar": "取消固定侧边栏", "unpinSidebar": "取消固定侧边栏",
"switchToListView": "切换到列表视图", "switchToListView": "切换到列表视图",
"switchToTreeView": "切换到树状视图", "switchToTreeView": "切换到树状视图",
"recursiveOn": "搜索子文件夹", "recursiveOn": "包含子文件夹",
"recursiveOff": "仅搜索当前文件夹", "recursiveOff": "仅当前文件夹",
"recursiveUnavailable": "仅在树形视图中可使用递归搜索", "recursiveUnavailable": "仅在树形视图中可使用递归搜索",
"collapseAllDisabled": "列表视图下不可用", "collapseAllDisabled": "列表视图下不可用",
"dragDrop": { "dragDrop": {
@@ -983,6 +1015,14 @@
"save": "更新基础模型", "save": "更新基础模型",
"cancel": "取消" "cancel": "取消"
}, },
"bulkDownloadMissingLoras": {
"title": "下载缺失的 LoRAs",
"message": "发现 {uniqueCount} 个独特的缺失 LoRAs从选定配方中的 {totalCount} 个总数)。",
"previewTitle": "要下载的 LoRAs",
"moreItems": "...还有 {count} 个",
"note": "文件将使用默认路径模板下载。根据 LoRAs 的数量,这可能需要一些时间。",
"downloadButton": "下载 {count} 个 LoRA(s)"
},
"exampleAccess": { "exampleAccess": {
"title": "本地示例图片", "title": "本地示例图片",
"message": "未找到此模型的本地示例图片。可选操作:", "message": "未找到此模型的本地示例图片。可选操作:",
@@ -1034,7 +1074,9 @@
"viewOnCivitai": "在 Civitai 查看", "viewOnCivitai": "在 Civitai 查看",
"viewOnCivitaiText": "在 Civitai 查看", "viewOnCivitaiText": "在 Civitai 查看",
"viewCreatorProfile": "查看创作者主页", "viewCreatorProfile": "查看创作者主页",
"openFileLocation": "打开文件位置" "openFileLocation": "打开文件位置",
"sendToWorkflow": "发送到 ComfyUI",
"sendToWorkflowText": "发送到 ComfyUI"
}, },
"openFileLocation": { "openFileLocation": {
"success": "文件位置已成功打开", "success": "文件位置已成功打开",
@@ -1042,6 +1084,9 @@
"copied": "路径已复制到剪贴板:{{path}}", "copied": "路径已复制到剪贴板:{{path}}",
"clipboardFallback": "路径:{{path}}" "clipboardFallback": "路径:{{path}}"
}, },
"sendToWorkflow": {
"noFilePath": "无法发送到 ComfyUI没有可用的文件路径"
},
"metadata": { "metadata": {
"version": "版本", "version": "版本",
"fileName": "文件名", "fileName": "文件名",
@@ -1299,7 +1344,9 @@
"recipeReplaced": "配方已替换到工作流", "recipeReplaced": "配方已替换到工作流",
"recipeFailedToSend": "发送配方到工作流失败", "recipeFailedToSend": "发送配方到工作流失败",
"noMatchingNodes": "当前工作流中没有兼容的节点", "noMatchingNodes": "当前工作流中没有兼容的节点",
"noTargetNodeSelected": "未选择目标节点" "noTargetNodeSelected": "未选择目标节点",
"modelUpdated": "模型已更新到工作流",
"modelFailed": "更新模型节点失败"
}, },
"nodeSelector": { "nodeSelector": {
"recipe": "配方", "recipe": "配方",
@@ -1450,6 +1497,7 @@
"pleaseSelectVersion": "请选择版本", "pleaseSelectVersion": "请选择版本",
"versionExists": "该版本已存在于你的库中", "versionExists": "该版本已存在于你的库中",
"downloadCompleted": "下载成功完成", "downloadCompleted": "下载成功完成",
"downloadSkippedByBaseModel": "由于基础模型 {baseModel} 已被排除,已跳过下载",
"autoOrganizeSuccess": "自动整理已成功完成,共 {count} 个 {type}", "autoOrganizeSuccess": "自动整理已成功完成,共 {count} 个 {type}",
"autoOrganizePartialSuccess": "自动整理完成:已移动 {success} 个,{failures} 个失败,共 {total} 个模型", "autoOrganizePartialSuccess": "自动整理完成:已移动 {success} 个,{failures} 个失败,共 {total} 个模型",
"autoOrganizeFailed": "自动整理失败:{error}", "autoOrganizeFailed": "自动整理失败:{error}",
@@ -1469,7 +1517,11 @@
"nameUpdated": "配方名称更新成功", "nameUpdated": "配方名称更新成功",
"tagsUpdated": "配方标签更新成功", "tagsUpdated": "配方标签更新成功",
"sourceUrlUpdated": "来源 URL 更新成功", "sourceUrlUpdated": "来源 URL 更新成功",
"promptUpdated": "提示词更新成功",
"negativePromptUpdated": "负面提示词更新成功",
"promptEditorHint": "按 Enter 保存Shift+Enter 换行",
"noRecipeId": "无配方 ID", "noRecipeId": "无配方 ID",
"sendToWorkflowFailed": "发送配方到工作流失败:{message}",
"copyFailed": "复制配方语法出错:{message}", "copyFailed": "复制配方语法出错:{message}",
"noMissingLoras": "没有缺失的 LoRA 可下载", "noMissingLoras": "没有缺失的 LoRA 可下载",
"missingLorasInfoFailed": "获取缺失 LoRA 信息失败", "missingLorasInfoFailed": "获取缺失 LoRA 信息失败",
@@ -1507,7 +1559,10 @@
"batchImportNoUrls": "请输入至少一个 URL 或文件路径", "batchImportNoUrls": "请输入至少一个 URL 或文件路径",
"batchImportNoDirectory": "请输入目录路径", "batchImportNoDirectory": "请输入目录路径",
"batchImportBrowseFailed": "浏览目录失败:{message}", "batchImportBrowseFailed": "浏览目录失败:{message}",
"batchImportDirectorySelected": "已选择目录:{path}" "batchImportDirectorySelected": "已选择目录:{path}",
"noRecipesSelected": "未选择任何配方",
"noMissingLorasInSelection": "在选定的配方中未找到缺失的 LoRAs",
"noLoraRootConfigured": "未配置 LoRA 根目录。请在设置中设置默认的 LoRA 根目录。"
}, },
"models": { "models": {
"noModelsSelected": "未选中模型", "noModelsSelected": "未选中模型",

View File

@@ -291,7 +291,15 @@
"blurNsfwContent": "模糊 NSFW 內容", "blurNsfwContent": "模糊 NSFW 內容",
"blurNsfwContentHelp": "模糊成熟NSFW內容預覽圖片", "blurNsfwContentHelp": "模糊成熟NSFW內容預覽圖片",
"showOnlySfw": "僅顯示 SFW 結果", "showOnlySfw": "僅顯示 SFW 結果",
"showOnlySfwHelp": "瀏覽和搜尋時過濾所有 NSFW 內容" "showOnlySfwHelp": "瀏覽和搜尋時過濾所有 NSFW 內容",
"matureBlurThreshold": "成人內容模糊閾值",
"matureBlurThresholdHelp": "設定當啟用 NSFW 模糊時,從哪個評級級別開始模糊過濾。",
"matureBlurThresholdOptions": {
"pg13": "PG13 及以上",
"r": "R 及以上(預設)",
"x": "X 及以上",
"xxx": "僅 XXX"
}
}, },
"videoSettings": { "videoSettings": {
"autoplayOnHover": "滑鼠懸停自動播放影片", "autoplayOnHover": "滑鼠懸停自動播放影片",
@@ -315,6 +323,28 @@
"saveFailed": "無法儲存跳過路徑:{message}" "saveFailed": "無法儲存跳過路徑:{message}"
} }
}, },
"downloadSkipBaseModels": {
"label": "跳過這些基礎模型的下載",
"help": "適用於所有下載流程。這裡只能選擇受支援的基礎模型。",
"searchPlaceholder": "篩選基礎模型...",
"empty": "沒有符合目前搜尋條件的基礎模型。",
"summary": {
"none": "未選擇",
"count": "已選擇 {count} 項"
},
"actions": {
"edit": "編輯",
"collapse": "收起",
"clear": "清空"
},
"validation": {
"saveFailed": "無法儲存已排除的基礎模型:{message}"
}
},
"skipPreviouslyDownloadedModelVersions": {
"label": "跳過已下載的模型版本",
"help": "啟用後如果下載歷史服務記錄顯示該版本已下載LoRA Manager 將跳過下載該模型版本。適用於所有下載流程。"
},
"layoutSettings": { "layoutSettings": {
"displayDensity": "顯示密度", "displayDensity": "顯示密度",
"displayDensityOptions": { "displayDensityOptions": {
@@ -367,8 +397,8 @@
}, },
"extraFolderPaths": { "extraFolderPaths": {
"title": "額外資料夾路徑", "title": "額外資料夾路徑",
"help": "在 ComfyUI 標準路徑之外新增額外的模型資料夾。這些路徑單獨儲存,並與預設資料夾一起掃描。", "description": "LoRA Manager 專屬的額外模型根目錄。從 ComfyUI 標準資料夾之外的位置載入模型,特別適合管理大型模型庫,避免影響 ComfyUI 效能。",
"description": "設定額外的資料夾以掃描模型。這些路徑是 LoRA Manager 特有的,將與 ComfyUI 的預設路徑合併。", "restartRequired": "Requires restart to take effect",
"modelTypes": { "modelTypes": {
"lora": "LoRA 路徑", "lora": "LoRA 路徑",
"checkpoint": "Checkpoint 路徑", "checkpoint": "Checkpoint 路徑",
@@ -376,7 +406,7 @@
"embedding": "Embedding 路徑" "embedding": "Embedding 路徑"
}, },
"pathPlaceholder": "/額外/模型/路徑", "pathPlaceholder": "/額外/模型/路徑",
"saveSuccess": "額外資料夾路徑已更新。", "saveSuccess": "額外資料夾路徑已更新,需要重啟才能生效。",
"saveError": "更新額外資料夾路徑失敗:{message}", "saveError": "更新額外資料夾路徑失敗:{message}",
"validation": { "validation": {
"duplicatePath": "此路徑已設定" "duplicatePath": "此路徑已設定"
@@ -575,6 +605,7 @@
"skipMetadataRefresh": "跳過所選模型的元數據更新", "skipMetadataRefresh": "跳過所選模型的元數據更新",
"resumeMetadataRefresh": "恢復所選模型的元數據更新", "resumeMetadataRefresh": "恢復所選模型的元數據更新",
"deleteAll": "刪除全部模型", "deleteAll": "刪除全部模型",
"downloadMissingLoras": "下載缺失的 LoRAs",
"clear": "清除選取", "clear": "清除選取",
"skipMetadataRefreshCount": "跳過({count} 個模型)", "skipMetadataRefreshCount": "跳過({count} 個模型)",
"resumeMetadataRefreshCount": "恢復({count} 個模型)", "resumeMetadataRefreshCount": "恢復({count} 個模型)",
@@ -799,7 +830,8 @@
"diffusion_model": "Diffusion Model" "diffusion_model": "Diffusion Model"
}, },
"contextMenu": { "contextMenu": {
"moveToOtherTypeFolder": "移動到 {otherType} 資料夾" "moveToOtherTypeFolder": "移動到 {otherType} 資料夾",
"sendToWorkflow": "傳送到工作流"
} }
}, },
"embeddings": { "embeddings": {
@@ -812,8 +844,8 @@
"unpinSidebar": "取消固定側邊欄", "unpinSidebar": "取消固定側邊欄",
"switchToListView": "切換至列表檢視", "switchToListView": "切換至列表檢視",
"switchToTreeView": "切換到樹狀檢視", "switchToTreeView": "切換到樹狀檢視",
"recursiveOn": "搜尋子資料夾", "recursiveOn": "包含子資料夾",
"recursiveOff": "僅搜尋目前資料夾", "recursiveOff": "僅目前資料夾",
"recursiveUnavailable": "遞迴搜尋僅能在樹狀檢視中使用", "recursiveUnavailable": "遞迴搜尋僅能在樹狀檢視中使用",
"collapseAllDisabled": "列表檢視下不可用", "collapseAllDisabled": "列表檢視下不可用",
"dragDrop": { "dragDrop": {
@@ -983,6 +1015,14 @@
"save": "更新基礎模型", "save": "更新基礎模型",
"cancel": "取消" "cancel": "取消"
}, },
"bulkDownloadMissingLoras": {
"title": "下載缺失的 LoRAs",
"message": "發現 {uniqueCount} 個獨特的缺失 LoRAs從選取食譜中的 {totalCount} 個總數)。",
"previewTitle": "要下載的 LoRAs",
"moreItems": "...還有 {count} 個",
"note": "檔案將使用預設路徑模板下載。根據 LoRAs 的數量,這可能需要一些時間。",
"downloadButton": "下載 {count} 個 LoRA(s)"
},
"exampleAccess": { "exampleAccess": {
"title": "本機範例圖片", "title": "本機範例圖片",
"message": "此模型未找到本機範例圖片。可選擇:", "message": "此模型未找到本機範例圖片。可選擇:",
@@ -1034,7 +1074,9 @@
"viewOnCivitai": "在 Civitai 查看", "viewOnCivitai": "在 Civitai 查看",
"viewOnCivitaiText": "在 Civitai 查看", "viewOnCivitaiText": "在 Civitai 查看",
"viewCreatorProfile": "查看創作者個人檔案", "viewCreatorProfile": "查看創作者個人檔案",
"openFileLocation": "開啟檔案位置" "openFileLocation": "開啟檔案位置",
"sendToWorkflow": "傳送到 ComfyUI",
"sendToWorkflowText": "傳送到 ComfyUI"
}, },
"openFileLocation": { "openFileLocation": {
"success": "檔案位置已成功開啟", "success": "檔案位置已成功開啟",
@@ -1042,6 +1084,9 @@
"copied": "路徑已複製到剪貼簿:{{path}}", "copied": "路徑已複製到剪貼簿:{{path}}",
"clipboardFallback": "路徑:{{path}}" "clipboardFallback": "路徑:{{path}}"
}, },
"sendToWorkflow": {
"noFilePath": "無法傳送到 ComfyUI沒有可用的檔案路徑"
},
"metadata": { "metadata": {
"version": "版本", "version": "版本",
"fileName": "檔案名稱", "fileName": "檔案名稱",
@@ -1299,7 +1344,9 @@
"recipeReplaced": "配方已取代於工作流", "recipeReplaced": "配方已取代於工作流",
"recipeFailedToSend": "傳送配方到工作流失敗", "recipeFailedToSend": "傳送配方到工作流失敗",
"noMatchingNodes": "目前工作流程中沒有相容的節點", "noMatchingNodes": "目前工作流程中沒有相容的節點",
"noTargetNodeSelected": "未選擇目標節點" "noTargetNodeSelected": "未選擇目標節點",
"modelUpdated": "模型已更新到工作流",
"modelFailed": "更新模型節點失敗"
}, },
"nodeSelector": { "nodeSelector": {
"recipe": "配方", "recipe": "配方",
@@ -1450,6 +1497,7 @@
"pleaseSelectVersion": "請選擇一個版本", "pleaseSelectVersion": "請選擇一個版本",
"versionExists": "此版本已存在於您的庫中", "versionExists": "此版本已存在於您的庫中",
"downloadCompleted": "下載成功完成", "downloadCompleted": "下載成功完成",
"downloadSkippedByBaseModel": "由於基礎模型 {baseModel} 已被排除,已跳過下載",
"autoOrganizeSuccess": "自動整理已成功完成,共 {count} 個 {type} 已整理", "autoOrganizeSuccess": "自動整理已成功完成,共 {count} 個 {type} 已整理",
"autoOrganizePartialSuccess": "自動整理完成:已移動 {success} 個,{failures} 個失敗,共 {total} 個模型", "autoOrganizePartialSuccess": "自動整理完成:已移動 {success} 個,{failures} 個失敗,共 {total} 個模型",
"autoOrganizeFailed": "自動整理失敗:{error}", "autoOrganizeFailed": "自動整理失敗:{error}",
@@ -1469,7 +1517,11 @@
"nameUpdated": "配方名稱已更新", "nameUpdated": "配方名稱已更新",
"tagsUpdated": "配方標籤已更新", "tagsUpdated": "配方標籤已更新",
"sourceUrlUpdated": "來源網址已更新", "sourceUrlUpdated": "來源網址已更新",
"promptUpdated": "提示詞更新成功",
"negativePromptUpdated": "負面提示詞更新成功",
"promptEditorHint": "按 Enter 儲存Shift+Enter 換行",
"noRecipeId": "無配方 ID", "noRecipeId": "無配方 ID",
"sendToWorkflowFailed": "傳送配方到工作流失敗:{message}",
"copyFailed": "複製配方語法錯誤:{message}", "copyFailed": "複製配方語法錯誤:{message}",
"noMissingLoras": "無缺少的 LoRA 可下載", "noMissingLoras": "無缺少的 LoRA 可下載",
"missingLorasInfoFailed": "取得缺少 LoRA 資訊失敗", "missingLorasInfoFailed": "取得缺少 LoRA 資訊失敗",
@@ -1507,7 +1559,10 @@
"batchImportNoUrls": "請輸入至少一個 URL 或檔案路徑", "batchImportNoUrls": "請輸入至少一個 URL 或檔案路徑",
"batchImportNoDirectory": "請輸入目錄路徑", "batchImportNoDirectory": "請輸入目錄路徑",
"batchImportBrowseFailed": "瀏覽目錄失敗:{message}", "batchImportBrowseFailed": "瀏覽目錄失敗:{message}",
"batchImportDirectorySelected": "已選擇目錄:{path}" "batchImportDirectorySelected": "已選擇目錄:{path}",
"noRecipesSelected": "未選取任何食譜",
"noMissingLorasInSelection": "在選取的食譜中未找到缺失的 LoRAs",
"noLoraRootConfigured": "未配置 LoRA 根目錄。請在設定中設定預設的 LoRA 根目錄。"
}, },
"models": { "models": {
"noModelsSelected": "未選擇模型", "noModelsSelected": "未選擇模型",

View File

@@ -25,6 +25,31 @@ standalone_mode = (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _resolve_valid_default_root(
current: str, primary_paths: List[str], name: str
) -> str:
"""Return a valid default root from the current primary path set."""
valid_paths = [path for path in primary_paths if isinstance(path, str) and path.strip()]
if not valid_paths:
return ""
if current in valid_paths:
return current
if current:
logger.info(
"Repaired stale %s from '%s' to '%s'",
name,
current,
valid_paths[0],
)
else:
logger.info("Auto-setting %s to '%s'", name, valid_paths[0])
return valid_paths[0]
def _normalize_folder_paths_for_comparison( def _normalize_folder_paths_for_comparison(
folder_paths: Mapping[str, Iterable[str]], folder_paths: Mapping[str, Iterable[str]],
) -> Dict[str, Set[str]]: ) -> Dict[str, Set[str]]:
@@ -197,25 +222,23 @@ class Config:
"Failed to rename legacy 'default' library: %s", rename_error "Failed to rename legacy 'default' library: %s", rename_error
) )
default_lora_root = comfy_library.get("default_lora_root", "") default_lora_root = _resolve_valid_default_root(
if not default_lora_root and len(self.loras_roots) == 1: comfy_library.get("default_lora_root", ""),
default_lora_root = self.loras_roots[0] list(self.loras_roots or []),
"default_lora_root",
)
default_checkpoint_root = comfy_library.get("default_checkpoint_root", "") default_checkpoint_root = _resolve_valid_default_root(
if ( comfy_library.get("default_checkpoint_root", ""),
not default_checkpoint_root list(self.checkpoints_roots or []),
and self.checkpoints_roots "default_checkpoint_root",
and len(self.checkpoints_roots) == 1 )
):
default_checkpoint_root = self.checkpoints_roots[0]
default_embedding_root = comfy_library.get("default_embedding_root", "") default_embedding_root = _resolve_valid_default_root(
if ( comfy_library.get("default_embedding_root", ""),
not default_embedding_root list(self.embeddings_roots or []),
and self.embeddings_roots "default_embedding_root",
and len(self.embeddings_roots) == 1 )
):
default_embedding_root = self.embeddings_roots[0]
metadata = dict(comfy_library.get("metadata", {})) metadata = dict(comfy_library.get("metadata", {}))
metadata.setdefault("display_name", "ComfyUI") metadata.setdefault("display_name", "ComfyUI")
@@ -705,6 +728,122 @@ class Config:
return unique_paths return unique_paths
@staticmethod
def _normalize_path_for_comparison(
path: str, *, resolve_realpath: bool = False
) -> str:
"""Normalize a path for equality checks across platforms."""
candidate = os.path.realpath(path) if resolve_realpath else path
return os.path.normcase(os.path.normpath(candidate)).replace(os.sep, "/")
def _filter_overlapping_extra_lora_paths(
self,
primary_paths: Iterable[str],
extra_paths: Iterable[str],
) -> List[str]:
"""Drop extra LoRA paths that resolve to the same physical location as primary roots."""
primary_map = {
self._normalize_path_for_comparison(path, resolve_realpath=True): path
for path in primary_paths
if isinstance(path, str) and path.strip() and os.path.exists(path)
}
primary_symlink_map = self._collect_first_level_symlink_targets(primary_paths)
filtered: List[str] = []
for original_path in extra_paths:
if not isinstance(original_path, str):
continue
stripped = original_path.strip()
if not stripped:
continue
if not os.path.exists(stripped):
continue
real_path = self._normalize_path_for_comparison(
stripped,
resolve_realpath=True,
)
normalized_path = os.path.normpath(stripped).replace(os.sep, "/")
primary_path = primary_map.get(real_path)
if primary_path:
# Config loading should stay tolerant of existing invalid state and warn.
logger.warning(
"Detected the same LoRA folder in both ComfyUI model paths and "
"LoRA Manager Extra Folder Paths. This can cause duplicate items or "
"other unexpected behavior, and it usually means the path setup is "
"not doing what you intended. LoRA Manager will keep the ComfyUI "
"path and ignore this Extra Folder Paths entry: '%s'. Please review "
"your path settings and remove the duplicate entry.",
normalized_path,
)
continue
symlink_path = primary_symlink_map.get(real_path)
if symlink_path:
# Config loading should stay tolerant of existing invalid state and warn.
logger.warning(
"Detected the same LoRA folder in both ComfyUI model paths and "
"LoRA Manager Extra Folder Paths. This can cause duplicate items or "
"other unexpected behavior, and it usually means the path setup is "
"not doing what you intended. LoRA Manager will keep the ComfyUI "
"path and ignore this Extra Folder Paths entry: '%s'. Please review "
"your path settings and remove the duplicate entry.",
normalized_path,
)
continue
filtered.append(stripped)
return filtered
def _collect_first_level_symlink_targets(
self, roots: Iterable[str]
) -> Dict[str, str]:
"""Return real-path -> link-path mappings for first-level symlinks under the given roots."""
targets: Dict[str, str] = {}
for root in roots:
if not isinstance(root, str):
continue
stripped_root = root.strip()
if not stripped_root or not os.path.isdir(stripped_root):
continue
try:
with os.scandir(stripped_root) as iterator:
for entry in iterator:
try:
if not self._entry_is_symlink(entry):
continue
target_path = os.path.realpath(entry.path)
if not os.path.isdir(target_path):
continue
normalized_target = self._normalize_path_for_comparison(
target_path,
resolve_realpath=True,
)
normalized_link = os.path.normpath(entry.path).replace(
os.sep, "/"
)
targets.setdefault(normalized_target, normalized_link)
except Exception as inner_exc:
logger.debug(
"Error collecting LoRA symlink target for %s: %s",
entry.path,
inner_exc,
)
except Exception as exc:
logger.debug(
"Error scanning first-level LoRA symlinks in %s: %s",
stripped_root,
exc,
)
return targets
def _prepare_checkpoint_paths( def _prepare_checkpoint_paths(
self, checkpoint_paths: Iterable[str], unet_paths: Iterable[str] self, checkpoint_paths: Iterable[str], unet_paths: Iterable[str]
) -> Tuple[List[str], List[str], List[str]]: ) -> Tuple[List[str], List[str], List[str]]:
@@ -796,7 +935,11 @@ class Config:
extra_unet_paths = extra_paths.get("unet", []) or [] extra_unet_paths = extra_paths.get("unet", []) or []
extra_embedding_paths = extra_paths.get("embeddings", []) or [] extra_embedding_paths = extra_paths.get("embeddings", []) or []
self.extra_loras_roots = self._prepare_lora_paths(extra_lora_paths) filtered_extra_lora_paths = self._filter_overlapping_extra_lora_paths(
self.loras_roots,
extra_lora_paths,
)
self.extra_loras_roots = self._prepare_lora_paths(filtered_extra_lora_paths)
( (
_, _,
self.extra_checkpoints_roots, self.extra_checkpoints_roots,

View File

@@ -4,15 +4,21 @@ from typing import Awaitable, Callable, Dict, List
from aiohttp import web from aiohttp import web
# Use wildcard for CivitAI to support their CDN subdomains (e.g., image-b2.civitai.com)
# Security note: This is acceptable because:
# 1. CSP img-src only controls image/video loading, not script execution
# 2. All *.civitai.com subdomains are controlled by Civitai
# 3. Explicit domain list would require constant updates as Civitai adds CDN nodes
REMOTE_MEDIA_SOURCES = ( REMOTE_MEDIA_SOURCES = (
"https://image.civitai.com", "https://*.civitai.com",
"https://img.genur.art", "https://img.genur.art",
) )
@web.middleware @web.middleware
async def relax_csp_for_remote_media( async def relax_csp_for_remote_media(
request: web.Request, handler: Callable[[web.Request], Awaitable[web.StreamResponse]] request: web.Request,
handler: Callable[[web.Request], Awaitable[web.StreamResponse]],
) -> web.StreamResponse: ) -> web.StreamResponse:
"""Allow LoRA Manager media previews to load from trusted remote domains. """Allow LoRA Manager media previews to load from trusted remote domains.
@@ -43,7 +49,9 @@ async def relax_csp_for_remote_media(
directive_order.append(name) directive_order.append(name)
directives[name] = values directives[name] = values
def merge_sources(name: str, sources: List[str], defaults: List[str] | None = None) -> None: def merge_sources(
name: str, sources: List[str], defaults: List[str] | None = None
) -> None:
existing = directives.get(name, list(defaults or [])) existing = directives.get(name, list(defaults or []))
for source in sources: for source in sources:

View File

@@ -8,6 +8,7 @@ and tracks the cycle progress which persists across workflow save/load.
import logging import logging
import os import os
from ..utils.utils import get_lora_info from ..utils.utils import get_lora_info
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -54,6 +55,9 @@ class LoraCyclerLM:
current_index = cycler_config.get("current_index", 1) # 1-based current_index = cycler_config.get("current_index", 1) # 1-based
model_strength = float(cycler_config.get("model_strength", 1.0)) model_strength = float(cycler_config.get("model_strength", 1.0))
clip_strength = float(cycler_config.get("clip_strength", 1.0)) clip_strength = float(cycler_config.get("clip_strength", 1.0))
use_same_clip_strength = cycler_config.get("use_same_clip_strength", True)
use_preset_strength = cycler_config.get("use_preset_strength", False)
preset_strength_scale = float(cycler_config.get("preset_strength_scale", 1.0))
sort_by = "filename" sort_by = "filename"
# Include "no lora" option # Include "no lora" option
@@ -131,6 +135,39 @@ class LoraCyclerLM:
else: else:
# Normalize path separators # Normalize path separators
lora_path = lora_path.replace("/", os.sep) lora_path = lora_path.replace("/", os.sep)
if use_preset_strength:
lora_metadata = await lora_service.get_lora_metadata_by_filename(
current_lora["file_name"]
)
if lora_metadata:
recommended_strength = (
lora_service.get_recommended_strength_from_lora_data(
lora_metadata
)
)
if recommended_strength is not None:
model_strength = round(
recommended_strength * preset_strength_scale, 2
)
if use_same_clip_strength:
clip_strength = model_strength
else:
recommended_clip_strength = (
lora_service.get_recommended_clip_strength_from_lora_data(
lora_metadata
)
)
if recommended_clip_strength is not None:
clip_strength = round(
recommended_clip_strength * preset_strength_scale, 2
)
elif use_same_clip_strength:
clip_strength = model_strength
elif use_same_clip_strength:
clip_strength = model_strength
lora_stack = [(lora_path, model_strength, clip_strength)] lora_stack = [(lora_path, model_strength, clip_strength)]
# Calculate next index (wrap to 1 if at end) # Calculate next index (wrap to 1 if at end)

View File

@@ -1,22 +1,138 @@
import importlib
import logging import logging
import re import re
import comfy.utils # type: ignore
import comfy.sd # type: ignore import comfy.sd # type: ignore
import comfy.utils # type: ignore
from ..utils.utils import get_lora_info_absolute from ..utils.utils import get_lora_info_absolute
from .utils import FlexibleOptionalInputType, any_type, extract_lora_name, get_loras_list, nunchaku_load_lora from .utils import (
FlexibleOptionalInputType,
any_type,
detect_nunchaku_model_kind,
extract_lora_name,
get_loras_list,
nunchaku_load_lora,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _get_nunchaku_load_qwen_loras():
try:
module = importlib.import_module(".nunchaku_qwen", __package__)
except ImportError as exc:
raise RuntimeError(
"Qwen-Image LoRA loading requires the ComfyUI runtime with its torch dependency available."
) from exc
return module.nunchaku_load_qwen_loras
def _collect_stack_entries(lora_stack):
entries = []
if not lora_stack:
return entries
for lora_path, model_strength, clip_strength in lora_stack:
lora_name = extract_lora_name(lora_path)
absolute_lora_path, trigger_words = get_lora_info_absolute(lora_name)
entries.append({
"name": lora_name,
"absolute_path": absolute_lora_path,
"input_path": lora_path,
"model_strength": float(model_strength),
"clip_strength": float(clip_strength),
"trigger_words": trigger_words,
})
return entries
def _collect_widget_entries(kwargs):
entries = []
for lora in get_loras_list(kwargs):
if not lora.get("active", False):
continue
lora_name = lora["name"]
model_strength = float(lora["strength"])
clip_strength = float(lora.get("clipStrength", model_strength))
lora_path, trigger_words = get_lora_info_absolute(lora_name)
entries.append({
"name": lora_name,
"absolute_path": lora_path,
"input_path": lora_path,
"model_strength": model_strength,
"clip_strength": clip_strength,
"trigger_words": trigger_words,
})
return entries
def _format_loaded_loras(loaded_loras):
formatted_loras = []
for item in loaded_loras:
if item["include_clip_strength"]:
formatted_loras.append(
f"<lora:{item['name']}:{item['model_strength']}:{item['clip_strength']}>"
)
else:
formatted_loras.append(f"<lora:{item['name']}:{item['model_strength']}>")
return " ".join(formatted_loras)
def _apply_entries(model, clip, lora_entries, nunchaku_model_kind):
loaded_loras = []
all_trigger_words = []
if nunchaku_model_kind == "qwen_image":
nunchaku_load_qwen_loras = _get_nunchaku_load_qwen_loras()
qwen_lora_configs = []
for entry in lora_entries:
qwen_lora_configs.append((entry["absolute_path"], entry["model_strength"]))
loaded_loras.append({
"name": entry["name"],
"model_strength": entry["model_strength"],
"clip_strength": entry["model_strength"],
"include_clip_strength": False,
})
all_trigger_words.extend(entry["trigger_words"])
if qwen_lora_configs:
model = nunchaku_load_qwen_loras(model, qwen_lora_configs)
return model, clip, loaded_loras, all_trigger_words
for entry in lora_entries:
if nunchaku_model_kind == "flux":
model = nunchaku_load_lora(model, entry["input_path"], entry["model_strength"])
else:
lora = comfy.utils.load_torch_file(entry["absolute_path"], safe_load=True)
model, clip = comfy.sd.load_lora_for_models(
model,
clip,
lora,
entry["model_strength"],
entry["clip_strength"],
)
include_clip_strength = nunchaku_model_kind is None and abs(entry["model_strength"] - entry["clip_strength"]) > 0.001
loaded_loras.append({
"name": entry["name"],
"model_strength": entry["model_strength"],
"clip_strength": entry["clip_strength"],
"include_clip_strength": include_clip_strength,
})
all_trigger_words.extend(entry["trigger_words"])
return model, clip, loaded_loras, all_trigger_words
class LoraLoaderLM: class LoraLoaderLM:
NAME = "Lora Loader (LoraManager)" NAME = "Lora Loader (LoraManager)"
CATEGORY = "Lora Manager/loaders" CATEGORY = "Lora Manager/loaders"
@classmethod @classmethod
def INPUT_TYPES(cls): def INPUT_TYPES(cls):
return { return {
"required": { "required": {
"model": ("MODEL",), "model": ("MODEL",),
# "clip": ("CLIP",),
"text": ("AUTOCOMPLETE_TEXT_LORAS", { "text": ("AUTOCOMPLETE_TEXT_LORAS", {
"placeholder": "Search LoRAs to add...", "placeholder": "Search LoRAs to add...",
"tooltip": "Format: <lora:lora_name:strength> separated by spaces or punctuation", "tooltip": "Format: <lora:lora_name:strength> separated by spaces or punctuation",
@@ -28,114 +144,30 @@ class LoraLoaderLM:
RETURN_TYPES = ("MODEL", "CLIP", "STRING", "STRING") RETURN_TYPES = ("MODEL", "CLIP", "STRING", "STRING")
RETURN_NAMES = ("MODEL", "CLIP", "trigger_words", "loaded_loras") RETURN_NAMES = ("MODEL", "CLIP", "trigger_words", "loaded_loras")
FUNCTION = "load_loras" FUNCTION = "load_loras"
def load_loras(self, model, text, **kwargs): def load_loras(self, model, text, **kwargs):
"""Loads multiple LoRAs based on the kwargs input and lora_stack.""" """Loads multiple LoRAs based on the kwargs input and lora_stack."""
loaded_loras = [] del text
all_trigger_words = [] clip = kwargs.get("clip", None)
lora_entries = _collect_stack_entries(kwargs.get("lora_stack", None))
clip = kwargs.get('clip', None) lora_entries.extend(_collect_widget_entries(kwargs))
lora_stack = kwargs.get('lora_stack', None)
# Check if model is a Nunchaku Flux model - simplified approach
is_nunchaku_model = False
try:
model_wrapper = model.model.diffusion_model
# Check if model is a Nunchaku Flux model using only class name
if model_wrapper.__class__.__name__ == "ComfyFluxWrapper":
is_nunchaku_model = True
logger.info("Detected Nunchaku Flux model")
except (AttributeError, TypeError):
# Not a model with the expected structure
pass
# First process lora_stack if available
if lora_stack:
for lora_path, model_strength, clip_strength in lora_stack:
# Extract lora name and convert to absolute path
# lora_stack stores relative paths, but load_torch_file needs absolute paths
lora_name = extract_lora_name(lora_path)
absolute_lora_path, trigger_words = get_lora_info_absolute(lora_name)
# Apply the LoRA using the appropriate loader
if is_nunchaku_model:
# Use our custom function for Flux models
model = nunchaku_load_lora(model, lora_path, model_strength)
# clip remains unchanged for Nunchaku models
else:
# Use lower-level API to load LoRA directly without folder_paths validation
lora = comfy.utils.load_torch_file(absolute_lora_path, safe_load=True)
model, clip = comfy.sd.load_lora_for_models(model, clip, lora, model_strength, clip_strength)
all_trigger_words.extend(trigger_words)
# Add clip strength to output if different from model strength (except for Nunchaku models)
if not is_nunchaku_model and abs(model_strength - clip_strength) > 0.001:
loaded_loras.append(f"{lora_name}: {model_strength},{clip_strength}")
else:
loaded_loras.append(f"{lora_name}: {model_strength}")
# Then process loras from kwargs with support for both old and new formats
loras_list = get_loras_list(kwargs)
for lora in loras_list:
if not lora.get('active', False):
continue
lora_name = lora['name']
model_strength = float(lora['strength'])
# Get clip strength - use model strength as default if not specified
clip_strength = float(lora.get('clipStrength', model_strength))
# Get lora path and trigger words
lora_path, trigger_words = get_lora_info_absolute(lora_name)
# Apply the LoRA using the appropriate loader
if is_nunchaku_model:
# For Nunchaku models, use our custom function
model = nunchaku_load_lora(model, lora_path, model_strength)
# clip remains unchanged
else:
# Use lower-level API to load LoRA directly without folder_paths validation
lora = comfy.utils.load_torch_file(lora_path, safe_load=True)
model, clip = comfy.sd.load_lora_for_models(model, clip, lora, model_strength, clip_strength)
# Include clip strength in output if different from model strength and not a Nunchaku model
if not is_nunchaku_model and abs(model_strength - clip_strength) > 0.001:
loaded_loras.append(f"{lora_name}: {model_strength},{clip_strength}")
else:
loaded_loras.append(f"{lora_name}: {model_strength}")
# Add trigger words to collection
all_trigger_words.extend(trigger_words)
# use ',, ' to separate trigger words for group mode
trigger_words_text = ",, ".join(all_trigger_words) if all_trigger_words else ""
# Format loaded_loras with support for both formats
formatted_loras = []
for item in loaded_loras:
parts = item.split(":")
lora_name = parts[0]
strength_parts = parts[1].strip().split(",")
if len(strength_parts) > 1:
# Different model and clip strengths
model_str = strength_parts[0].strip()
clip_str = strength_parts[1].strip()
formatted_loras.append(f"<lora:{lora_name}:{model_str}:{clip_str}>")
else:
# Same strength for both
model_str = strength_parts[0].strip()
formatted_loras.append(f"<lora:{lora_name}:{model_str}>")
formatted_loras_text = " ".join(formatted_loras)
nunchaku_model_kind = detect_nunchaku_model_kind(model)
if nunchaku_model_kind == "flux":
logger.info("Detected Nunchaku Flux model")
elif nunchaku_model_kind == "qwen_image":
logger.info("Detected Nunchaku Qwen-Image model")
model, clip, loaded_loras, all_trigger_words = _apply_entries(model, clip, lora_entries, nunchaku_model_kind)
trigger_words_text = ",, ".join(all_trigger_words) if all_trigger_words else ""
formatted_loras_text = _format_loaded_loras(loaded_loras)
return (model, clip, trigger_words_text, formatted_loras_text) return (model, clip, trigger_words_text, formatted_loras_text)
class LoraTextLoaderLM: class LoraTextLoaderLM:
NAME = "LoRA Text Loader (LoraManager)" NAME = "LoRA Text Loader (LoraManager)"
CATEGORY = "Lora Manager/loaders" CATEGORY = "Lora Manager/loaders"
@classmethod @classmethod
def INPUT_TYPES(cls): def INPUT_TYPES(cls):
return { return {
@@ -143,131 +175,55 @@ class LoraTextLoaderLM:
"model": ("MODEL",), "model": ("MODEL",),
"lora_syntax": ("STRING", { "lora_syntax": ("STRING", {
"forceInput": True, "forceInput": True,
"tooltip": "Format: <lora:lora_name:strength> separated by spaces or punctuation" "tooltip": "Format: <lora:lora_name:strength> separated by spaces or punctuation",
}), }),
}, },
"optional": { "optional": {
"clip": ("CLIP",), "clip": ("CLIP",),
"lora_stack": ("LORA_STACK",), "lora_stack": ("LORA_STACK",),
} },
} }
RETURN_TYPES = ("MODEL", "CLIP", "STRING", "STRING") RETURN_TYPES = ("MODEL", "CLIP", "STRING", "STRING")
RETURN_NAMES = ("MODEL", "CLIP", "trigger_words", "loaded_loras") RETURN_NAMES = ("MODEL", "CLIP", "trigger_words", "loaded_loras")
FUNCTION = "load_loras_from_text" FUNCTION = "load_loras_from_text"
def parse_lora_syntax(self, text): def parse_lora_syntax(self, text):
"""Parse LoRA syntax from text input.""" """Parse LoRA syntax from text input."""
# Pattern to match <lora:name:strength> or <lora:name:model_strength:clip_strength> pattern = r"<lora:([^:>]+):([^:>]+)(?::([^:>]+))?>"
pattern = r'<lora:([^:>]+):([^:>]+)(?::([^:>]+))?>'
matches = re.findall(pattern, text, re.IGNORECASE) matches = re.findall(pattern, text, re.IGNORECASE)
loras = [] loras = []
for match in matches: for match in matches:
lora_name = match[0]
model_strength = float(match[1]) model_strength = float(match[1])
clip_strength = float(match[2]) if match[2] else model_strength
loras.append({ loras.append({
'name': lora_name, "name": match[0],
'model_strength': model_strength, "model_strength": model_strength,
'clip_strength': clip_strength "clip_strength": float(match[2]) if match[2] else model_strength,
}) })
return loras return loras
def load_loras_from_text(self, model, lora_syntax, clip=None, lora_stack=None): def load_loras_from_text(self, model, lora_syntax, clip=None, lora_stack=None):
"""Load LoRAs based on text syntax input.""" """Load LoRAs based on text syntax input."""
loaded_loras = [] lora_entries = _collect_stack_entries(lora_stack)
all_trigger_words = [] for lora in self.parse_lora_syntax(lora_syntax):
lora_path, trigger_words = get_lora_info_absolute(lora["name"])
# Check if model is a Nunchaku Flux model - simplified approach lora_entries.append({
is_nunchaku_model = False "name": lora["name"],
"absolute_path": lora_path,
try: "input_path": lora_path,
model_wrapper = model.model.diffusion_model "model_strength": lora["model_strength"],
# Check if model is a Nunchaku Flux model using only class name "clip_strength": lora["clip_strength"],
if model_wrapper.__class__.__name__ == "ComfyFluxWrapper": "trigger_words": trigger_words,
is_nunchaku_model = True })
logger.info("Detected Nunchaku Flux model")
except (AttributeError, TypeError): nunchaku_model_kind = detect_nunchaku_model_kind(model)
# Not a model with the expected structure if nunchaku_model_kind == "flux":
pass logger.info("Detected Nunchaku Flux model")
elif nunchaku_model_kind == "qwen_image":
# First process lora_stack if available logger.info("Detected Nunchaku Qwen-Image model")
if lora_stack:
for lora_path, model_strength, clip_strength in lora_stack: model, clip, loaded_loras, all_trigger_words = _apply_entries(model, clip, lora_entries, nunchaku_model_kind)
# Extract lora name and convert to absolute path
# lora_stack stores relative paths, but load_torch_file needs absolute paths
lora_name = extract_lora_name(lora_path)
absolute_lora_path, trigger_words = get_lora_info_absolute(lora_name)
# Apply the LoRA using the appropriate loader
if is_nunchaku_model:
# Use our custom function for Flux models
model = nunchaku_load_lora(model, lora_path, model_strength)
# clip remains unchanged for Nunchaku models
else:
# Use lower-level API to load LoRA directly without folder_paths validation
lora = comfy.utils.load_torch_file(absolute_lora_path, safe_load=True)
model, clip = comfy.sd.load_lora_for_models(model, clip, lora, model_strength, clip_strength)
all_trigger_words.extend(trigger_words)
# Add clip strength to output if different from model strength (except for Nunchaku models)
if not is_nunchaku_model and abs(model_strength - clip_strength) > 0.001:
loaded_loras.append(f"{lora_name}: {model_strength},{clip_strength}")
else:
loaded_loras.append(f"{lora_name}: {model_strength}")
# Parse and process LoRAs from text syntax
parsed_loras = self.parse_lora_syntax(lora_syntax)
for lora in parsed_loras:
lora_name = lora['name']
model_strength = lora['model_strength']
clip_strength = lora['clip_strength']
# Get lora path and trigger words
lora_path, trigger_words = get_lora_info_absolute(lora_name)
# Apply the LoRA using the appropriate loader
if is_nunchaku_model:
# For Nunchaku models, use our custom function
model = nunchaku_load_lora(model, lora_path, model_strength)
# clip remains unchanged
else:
# Use lower-level API to load LoRA directly without folder_paths validation
lora = comfy.utils.load_torch_file(lora_path, safe_load=True)
model, clip = comfy.sd.load_lora_for_models(model, clip, lora, model_strength, clip_strength)
# Include clip strength in output if different from model strength and not a Nunchaku model
if not is_nunchaku_model and abs(model_strength - clip_strength) > 0.001:
loaded_loras.append(f"{lora_name}: {model_strength},{clip_strength}")
else:
loaded_loras.append(f"{lora_name}: {model_strength}")
# Add trigger words to collection
all_trigger_words.extend(trigger_words)
# use ',, ' to separate trigger words for group mode
trigger_words_text = ",, ".join(all_trigger_words) if all_trigger_words else "" trigger_words_text = ",, ".join(all_trigger_words) if all_trigger_words else ""
formatted_loras_text = _format_loaded_loras(loaded_loras)
# Format loaded_loras with support for both formats return (model, clip, trigger_words_text, formatted_loras_text)
formatted_loras = []
for item in loaded_loras:
parts = item.split(":")
lora_name = parts[0].strip()
strength_parts = parts[1].strip().split(",")
if len(strength_parts) > 1:
# Different model and clip strengths
model_str = strength_parts[0].strip()
clip_str = strength_parts[1].strip()
formatted_loras.append(f"<lora:{lora_name}:{model_str}:{clip_str}>")
else:
# Same strength for both
model_str = strength_parts[0].strip()
formatted_loras.append(f"<lora:{lora_name}:{model_str}>")
formatted_loras_text = " ".join(formatted_loras)
return (model, clip, trigger_words_text, formatted_loras_text)

View File

@@ -0,0 +1,26 @@
class LoraStackCombinerLM:
NAME = "Lora Stack Combiner (LoraManager)"
CATEGORY = "Lora Manager/stackers"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"lora_stack_a": ("LORA_STACK",),
"lora_stack_b": ("LORA_STACK",),
},
}
RETURN_TYPES = ("LORA_STACK",)
RETURN_NAMES = ("LORA_STACK",)
FUNCTION = "combine_stacks"
def combine_stacks(self, lora_stack_a, lora_stack_b):
combined_stack = []
if lora_stack_a:
combined_stack.extend(lora_stack_a)
if lora_stack_b:
combined_stack.extend(lora_stack_b)
return (combined_stack,)

570
py/nodes/nunchaku_qwen.py Normal file
View File

@@ -0,0 +1,570 @@
from __future__ import annotations
"""Qwen-Image LoRA support for Nunchaku models.
Portions of the LoRA mapping/application logic in this file are adapted from
ComfyUI-QwenImageLoraLoader by GitHub user ussoewwin:
https://github.com/ussoewwin/ComfyUI-QwenImageLoraLoader
The upstream project is licensed under Apache License 2.0.
"""
import copy
import logging
import os
import re
from collections import defaultdict
from pathlib import Path
from typing import Dict, List, Optional, Tuple, Union
import comfy.utils # type: ignore
import folder_paths # type: ignore
import torch
import torch.nn as nn
from safetensors import safe_open
from nunchaku.lora.flux.nunchaku_converter import (
pack_lowrank_weight,
unpack_lowrank_weight,
)
logger = logging.getLogger(__name__)
KEY_MAPPING = [
(re.compile(r"^(layers)[._](\d+)[._]attention[._]to[._]([qkv])$"), r"\1.\2.attention.to_qkv", "qkv", lambda m: m.group(3).upper()),
(re.compile(r"^(layers)[._](\d+)[._]feed_forward[._](w1|w3)$"), r"\1.\2.feed_forward.net.0.proj", "glu", lambda m: m.group(3)),
(re.compile(r"^(layers)[._](\d+)[._]feed_forward[._]w2$"), r"\1.\2.feed_forward.net.2", "regular", None),
(re.compile(r"^(layers)[._](\d+)[._](.*)$"), r"\1.\2.\3", "regular", None),
(re.compile(r"^(transformer_blocks)[._](\d+)[._]attn[._]to[._]([qkv])$"), r"\1.\2.attn.to_qkv", "qkv", lambda m: m.group(3).upper()),
(re.compile(r"^(transformer_blocks)[._](\d+)[._]attn[._](q|k|v)[._]proj$"), r"\1.\2.attn.to_qkv", "qkv", lambda m: m.group(3).upper()),
(re.compile(r"^(transformer_blocks)[._](\d+)[._]attn[._]add[._](q|k|v)[._]proj$"), r"\1.\2.attn.add_qkv_proj", "add_qkv", lambda m: m.group(3).upper()),
(re.compile(r"^(transformer_blocks)[._](\d+)[._]out[._]proj[._]context$"), r"\1.\2.attn.to_add_out", "regular", None),
(re.compile(r"^(transformer_blocks)[._](\d+)[._]out[._]proj$"), r"\1.\2.attn.to_out.0", "regular", None),
(re.compile(r"^(transformer_blocks)[._](\d+)[._]attn[._]to[._]out$"), r"\1.\2.attn.to_out.0", "regular", None),
(re.compile(r"^(single_transformer_blocks)[._](\d+)[._]attn[._]to[._]([qkv])$"), r"\1.\2.attn.to_qkv", "qkv", lambda m: m.group(3).upper()),
(re.compile(r"^(single_transformer_blocks)[._](\d+)[._]attn[._]to[._]out$"), r"\1.\2.attn.to_out", "regular", None),
(re.compile(r"^(transformer_blocks)[._](\d+)[._]ff[._]net[._]0(?:[._]proj)?$"), r"\1.\2.mlp_fc1", "regular", None),
(re.compile(r"^(transformer_blocks)[._](\d+)[._]ff[._]net[._]2$"), r"\1.\2.mlp_fc2", "regular", None),
(re.compile(r"^(transformer_blocks)[._](\d+)[._]ff_context[._]net[._]0(?:[._]proj)?$"), r"\1.\2.mlp_context_fc1", "regular", None),
(re.compile(r"^(transformer_blocks)[._](\d+)[._]ff_context[._]net[._]2$"), r"\1.\2.mlp_context_fc2", "regular", None),
(re.compile(r"^(transformer_blocks)[._](\d+)[._](img_mlp)[._](net)[._](0)[._](proj)$"), r"\1.\2.\3.\4.\5.\6", "regular", None),
(re.compile(r"^(transformer_blocks)[._](\d+)[._](img_mlp)[._](net)[._](2)$"), r"\1.\2.\3.\4.\5", "regular", None),
(re.compile(r"^(transformer_blocks)[._](\d+)[._](txt_mlp)[._](net)[._](0)[._](proj)$"), r"\1.\2.\3.\4.\5.\6", "regular", None),
(re.compile(r"^(transformer_blocks)[._](\d+)[._](txt_mlp)[._](net)[._](2)$"), r"\1.\2.\3.\4.\5", "regular", None),
(re.compile(r"^(transformer_blocks)[._](\d+)[._](img_mod)[._](1)$"), r"\1.\2.\3.\4", "regular", None),
(re.compile(r"^(transformer_blocks)[._](\d+)[._](txt_mod)[._](1)$"), r"\1.\2.\3.\4", "regular", None),
(re.compile(r"^(single_transformer_blocks)[._](\d+)[._]proj[._]out$"), r"\1.\2.proj_out", "single_proj_out", None),
(re.compile(r"^(single_transformer_blocks)[._](\d+)[._]proj[._]mlp$"), r"\1.\2.mlp_fc1", "regular", None),
(re.compile(r"^(single_transformer_blocks)[._](\d+)[._]norm[._]linear$"), r"\1.\2.norm.linear", "regular", None),
(re.compile(r"^(transformer_blocks)[._](\d+)[._]norm1[._]linear$"), r"\1.\2.norm1.linear", "regular", None),
(re.compile(r"^(transformer_blocks)[._](\d+)[._]norm1_context[._]linear$"), r"\1.\2.norm1_context.linear", "regular", None),
(re.compile(r"^(img_in)$"), r"\1", "regular", None),
(re.compile(r"^(txt_in)$"), r"\1", "regular", None),
(re.compile(r"^(proj_out)$"), r"\1", "regular", None),
(re.compile(r"^(norm_out)[._](linear)$"), r"\1.\2", "regular", None),
(re.compile(r"^(time_text_embed)[._](timestep_embedder)[._](linear_1)$"), r"\1.\2.\3", "regular", None),
(re.compile(r"^(time_text_embed)[._](timestep_embedder)[._](linear_2)$"), r"\1.\2.\3", "regular", None),
]
_RE_LORA_SUFFIX = re.compile(r"\.(?P<tag>lora(?:[._](?:A|B|down|up)))(?:\.[^.]+)*\.weight$")
_RE_ALPHA_SUFFIX = re.compile(r"\.(?:alpha|lora_alpha)(?:\.[^.]+)*$")
def _rename_layer_underscore_layer_name(old_name: str) -> str:
rules = [
(r"_(\d+)_attn_to_out_(\d+)", r".\1.attn.to_out.\2"),
(r"_(\d+)_img_mlp_net_(\d+)_proj", r".\1.img_mlp.net.\2.proj"),
(r"_(\d+)_txt_mlp_net_(\d+)_proj", r".\1.txt_mlp.net.\2.proj"),
(r"_(\d+)_img_mlp_net_(\d+)", r".\1.img_mlp.net.\2"),
(r"_(\d+)_txt_mlp_net_(\d+)", r".\1.txt_mlp.net.\2"),
(r"_(\d+)_img_mod_(\d+)", r".\1.img_mod.\2"),
(r"_(\d+)_txt_mod_(\d+)", r".\1.txt_mod.\2"),
(r"_(\d+)_attn_", r".\1.attn."),
]
new_name = old_name
for pattern, replacement in rules:
new_name = re.sub(pattern, replacement, new_name)
return new_name
def _is_indexable_module(module):
return isinstance(module, (nn.ModuleList, nn.Sequential, list, tuple))
def _get_module_by_name(model: nn.Module, name: str) -> Optional[nn.Module]:
if not name:
return model
module = model
for part in name.split("."):
if not part:
continue
if hasattr(module, part):
module = getattr(module, part)
elif part.isdigit() and _is_indexable_module(module):
try:
module = module[int(part)]
except (IndexError, TypeError):
return None
else:
return None
return module
def _resolve_module_name(model: nn.Module, name: str) -> Tuple[str, Optional[nn.Module]]:
module = _get_module_by_name(model, name)
if module is not None:
return name, module
replacements = [
(".attn.to_out.0", ".attn.to_out"),
(".attention.to_qkv", ".attention.qkv"),
(".attention.to_out.0", ".attention.out"),
(".feed_forward.net.0.proj", ".feed_forward.w13"),
(".feed_forward.net.2", ".feed_forward.w2"),
(".ff.net.0.proj", ".mlp_fc1"),
(".ff.net.2", ".mlp_fc2"),
(".ff_context.net.0.proj", ".mlp_context_fc1"),
(".ff_context.net.2", ".mlp_context_fc2"),
]
for src, dst in replacements:
if src in name:
alt = name.replace(src, dst)
module = _get_module_by_name(model, alt)
if module is not None:
return alt, module
return name, None
def _classify_and_map_key(key: str) -> Optional[Tuple[str, str, Optional[str], str]]:
normalized = key
if normalized.startswith("transformer."):
normalized = normalized[len("transformer."):]
if normalized.startswith("diffusion_model."):
normalized = normalized[len("diffusion_model."):]
if normalized.startswith("lora_unet_"):
normalized = _rename_layer_underscore_layer_name(normalized[len("lora_unet_"):])
match = _RE_LORA_SUFFIX.search(normalized)
if match:
tag = match.group("tag")
base = normalized[:match.start()]
ab = "A" if ("lora_A" in tag or tag.endswith(".A") or "down" in tag) else "B"
else:
match = _RE_ALPHA_SUFFIX.search(normalized)
if not match:
return None
base = normalized[:match.start()]
ab = "alpha"
for pattern, template, group, comp_fn in KEY_MAPPING:
key_match = pattern.match(base)
if key_match:
return group, key_match.expand(template), comp_fn(key_match) if comp_fn else None, ab
return None
def _detect_lora_format(lora_state_dict: Dict[str, torch.Tensor]) -> bool:
standard_patterns = (
".lora_up.",
".lora_down.",
".lora_A.",
".lora_B.",
".lora.up.",
".lora.down.",
".lora.A.",
".lora.B.",
)
return any(pattern in key for key in lora_state_dict for pattern in standard_patterns)
def _load_lora_state_dict(path_or_dict: Union[str, Path, Dict[str, torch.Tensor]]) -> Dict[str, torch.Tensor]:
if isinstance(path_or_dict, dict):
return path_or_dict
path = Path(path_or_dict)
if path.suffix == ".safetensors":
state_dict: Dict[str, torch.Tensor] = {}
with safe_open(path, framework="pt", device="cpu") as handle:
for key in handle.keys():
state_dict[key] = handle.get_tensor(key)
return state_dict
return comfy.utils.load_torch_file(str(path), safe_load=True)
def _fuse_glu_lora(glu_weights: Dict[str, torch.Tensor]) -> Tuple[Optional[torch.Tensor], Optional[torch.Tensor], Optional[torch.Tensor]]:
if "w1_A" not in glu_weights or "w3_A" not in glu_weights:
return None, None, None
a_w1, b_w1 = glu_weights["w1_A"], glu_weights["w1_B"]
a_w3, b_w3 = glu_weights["w3_A"], glu_weights["w3_B"]
if a_w1.shape[1] != a_w3.shape[1]:
return None, None, None
a_fused = torch.cat([a_w1, a_w3], dim=0)
out1, out3 = b_w1.shape[0], b_w3.shape[0]
rank1, rank3 = b_w1.shape[1], b_w3.shape[1]
b_fused = torch.zeros(out1 + out3, rank1 + rank3, dtype=b_w1.dtype, device=b_w1.device)
b_fused[:out1, :rank1] = b_w1
b_fused[out1:, rank1:] = b_w3
return a_fused, b_fused, glu_weights.get("w1_alpha")
def _fuse_qkv_lora(qkv_weights: Dict[str, torch.Tensor], model: Optional[nn.Module] = None, base_key: Optional[str] = None) -> Tuple[Optional[torch.Tensor], Optional[torch.Tensor], Optional[torch.Tensor]]:
required_keys = ["Q_A", "Q_B", "K_A", "K_B", "V_A", "V_B"]
if not all(key in qkv_weights for key in required_keys):
return None, None, None
a_q, a_k, a_v = qkv_weights["Q_A"], qkv_weights["K_A"], qkv_weights["V_A"]
b_q, b_k, b_v = qkv_weights["Q_B"], qkv_weights["K_B"], qkv_weights["V_B"]
if not (a_q.shape == a_k.shape == a_v.shape):
return None, None, None
if not (b_q.shape[1] == b_k.shape[1] == b_v.shape[1]):
return None, None, None
out_features = None
if model is not None and base_key is not None:
_, module = _resolve_module_name(model, base_key)
out_features = getattr(module, "out_features", None) if module is not None else None
alpha_fused = None
alpha_q = qkv_weights.get("Q_alpha")
alpha_k = qkv_weights.get("K_alpha")
alpha_v = qkv_weights.get("V_alpha")
if alpha_q is not None and alpha_k is not None and alpha_v is not None and alpha_q.item() == alpha_k.item() == alpha_v.item():
alpha_fused = alpha_q
a_fused = torch.cat([a_q, a_k, a_v], dim=0)
rank = b_q.shape[1]
out_q, out_k, out_v = b_q.shape[0], b_k.shape[0], b_v.shape[0]
total_out = out_features if out_features is not None else out_q + out_k + out_v
b_fused = torch.zeros(total_out, 3 * rank, dtype=b_q.dtype, device=b_q.device)
b_fused[:out_q, :rank] = b_q
b_fused[out_q:out_q + out_k, rank:2 * rank] = b_k
b_fused[out_q + out_k:out_q + out_k + out_v, 2 * rank:] = b_v
return a_fused, b_fused, alpha_fused
def _handle_proj_out_split(lora_dict: Dict[str, Dict[str, torch.Tensor]], base_key: str, model: nn.Module) -> Tuple[Dict[str, Tuple[torch.Tensor, torch.Tensor, Optional[torch.Tensor]]], List[str]]:
result: Dict[str, Tuple[torch.Tensor, torch.Tensor, Optional[torch.Tensor]]] = {}
consumed: List[str] = []
match = re.search(r"single_transformer_blocks\.(\d+)", base_key)
if not match or base_key not in lora_dict:
return result, consumed
block_idx = match.group(1)
block = _get_module_by_name(model, f"single_transformer_blocks.{block_idx}")
if block is None:
return result, consumed
a_full = lora_dict[base_key].get("A")
b_full = lora_dict[base_key].get("B")
alpha = lora_dict[base_key].get("alpha")
attn_to_out = getattr(getattr(block, "attn", None), "to_out", None)
mlp_fc2 = getattr(block, "mlp_fc2", None)
if a_full is None or b_full is None or attn_to_out is None or mlp_fc2 is None:
return result, consumed
attn_in = getattr(attn_to_out, "in_features", None)
mlp_in = getattr(mlp_fc2, "in_features", None)
if attn_in is None or mlp_in is None or a_full.shape[1] != attn_in + mlp_in:
return result, consumed
result[f"single_transformer_blocks.{block_idx}.attn.to_out"] = (a_full[:, :attn_in], b_full.clone(), alpha)
result[f"single_transformer_blocks.{block_idx}.mlp_fc2"] = (a_full[:, attn_in:], b_full.clone(), alpha)
consumed.append(base_key)
return result, consumed
def _apply_lora_to_module(module: nn.Module, a_tensor: torch.Tensor, b_tensor: torch.Tensor, module_name: str, model: nn.Module) -> None:
if not hasattr(module, "in_features") or not hasattr(module, "out_features"):
raise ValueError(f"{module_name}: unsupported module without in/out features")
if a_tensor.shape[1] != module.in_features or b_tensor.shape[0] != module.out_features:
raise ValueError(f"{module_name}: LoRA shape mismatch")
if module.__class__.__name__ == "AWQW4A16Linear" and hasattr(module, "qweight"):
if not hasattr(module, "_lora_original_forward"):
module._lora_original_forward = module.forward
if not hasattr(module, "_nunchaku_lora_bundle"):
module._nunchaku_lora_bundle = []
module._nunchaku_lora_bundle.append((a_tensor, b_tensor))
def _awq_lora_forward(x, *args, **kwargs):
out = module._lora_original_forward(x, *args, **kwargs)
x_flat = x.reshape(-1, module.in_features)
for local_a, local_b in module._nunchaku_lora_bundle:
local_a = local_a.to(device=out.device, dtype=out.dtype)
local_b = local_b.to(device=out.device, dtype=out.dtype)
lora_term = (x_flat @ local_a.transpose(0, 1)) @ local_b.transpose(0, 1)
try:
out = out + lora_term.reshape(out.shape)
except Exception:
pass
return out
module.forward = _awq_lora_forward
if not hasattr(model, "_lora_slots"):
model._lora_slots = {}
model._lora_slots[module_name] = {"type": "awq_w4a16"}
return
if hasattr(module, "proj_down") and hasattr(module, "proj_up"):
proj_down = unpack_lowrank_weight(module.proj_down.data, down=True)
proj_up = unpack_lowrank_weight(module.proj_up.data, down=False)
base_rank = proj_down.shape[0] if proj_down.shape[1] == module.in_features else proj_down.shape[1]
if proj_down.shape[1] == module.in_features:
updated_down = torch.cat([proj_down, a_tensor], dim=0)
axis_down = 0
else:
updated_down = torch.cat([proj_down, a_tensor.T], dim=1)
axis_down = 1
updated_up = torch.cat([proj_up, b_tensor], dim=1)
module.proj_down.data = pack_lowrank_weight(updated_down, down=True)
module.proj_up.data = pack_lowrank_weight(updated_up, down=False)
module.rank = base_rank + a_tensor.shape[0]
if not hasattr(model, "_lora_slots"):
model._lora_slots = {}
model._lora_slots[module_name] = {
"type": "nunchaku",
"base_rank": base_rank,
"axis_down": axis_down,
}
return
if isinstance(module, nn.Linear):
if not hasattr(model, "_lora_slots"):
model._lora_slots = {}
if module_name not in model._lora_slots:
model._lora_slots[module_name] = {
"type": "linear",
"original_weight": module.weight.detach().cpu().clone(),
}
module.weight.data.add_((b_tensor @ a_tensor).to(dtype=module.weight.dtype, device=module.weight.device))
return
raise ValueError(f"{module_name}: unsupported module type {type(module)}")
def reset_lora_v2(model: nn.Module) -> None:
slots = getattr(model, "_lora_slots", None)
if not slots:
return
for name, info in list(slots.items()):
module = _get_module_by_name(model, name)
if module is None:
continue
module_type = info.get("type", "nunchaku")
if module_type == "nunchaku":
base_rank = info["base_rank"]
proj_down = unpack_lowrank_weight(module.proj_down.data, down=True)
proj_up = unpack_lowrank_weight(module.proj_up.data, down=False)
if info.get("axis_down", 0) == 0:
proj_down = proj_down[:base_rank, :].clone()
else:
proj_down = proj_down[:, :base_rank].clone()
proj_up = proj_up[:, :base_rank].clone()
module.proj_down.data = pack_lowrank_weight(proj_down, down=True)
module.proj_up.data = pack_lowrank_weight(proj_up, down=False)
module.rank = base_rank
elif module_type == "linear" and "original_weight" in info:
module.weight.data.copy_(info["original_weight"].to(device=module.weight.device, dtype=module.weight.dtype))
elif module_type == "awq_w4a16":
if hasattr(module, "_lora_original_forward"):
module.forward = module._lora_original_forward
for attr in ("_lora_original_forward", "_nunchaku_lora_bundle"):
if hasattr(module, attr):
delattr(module, attr)
model._lora_slots = {}
def compose_loras_v2(model: nn.Module, lora_configs: List[Tuple[Union[str, Path, Dict[str, torch.Tensor]], float]], apply_awq_mod: bool = True) -> bool:
del apply_awq_mod # retained for interface compatibility
reset_lora_v2(model)
aggregated_weights: Dict[str, List[Dict[str, object]]] = defaultdict(list)
saw_supported_format = False
unresolved_targets = 0
for index, (path_or_dict, strength) in enumerate(lora_configs):
if abs(strength) < 1e-5:
continue
lora_name = str(path_or_dict) if not isinstance(path_or_dict, dict) else f"lora_{index}"
lora_state_dict = _load_lora_state_dict(path_or_dict)
if not lora_state_dict or not _detect_lora_format(lora_state_dict):
logger.warning("Skipping unsupported Qwen LoRA: %s", lora_name)
continue
saw_supported_format = True
grouped_weights: Dict[str, Dict[str, torch.Tensor]] = defaultdict(dict)
for key, value in lora_state_dict.items():
parsed = _classify_and_map_key(key)
if parsed is None:
continue
group, base_key, component, ab = parsed
if component and ab:
grouped_weights[base_key][f"{component}_{ab}"] = value
else:
grouped_weights[base_key][ab] = value
processed_groups: Dict[str, Tuple[torch.Tensor, torch.Tensor, Optional[torch.Tensor]]] = {}
handled: set[str] = set()
for base_key, weights in grouped_weights.items():
if base_key in handled:
continue
a_tensor = b_tensor = alpha = None
if "qkv" in base_key or "add_qkv_proj" in base_key:
a_tensor, b_tensor, alpha = _fuse_qkv_lora(weights, model=model, base_key=base_key)
elif "w1_A" in weights or "w3_A" in weights:
a_tensor, b_tensor, alpha = _fuse_glu_lora(weights)
elif ".proj_out" in base_key and "single_transformer_blocks" in base_key:
split_map, consumed = _handle_proj_out_split(grouped_weights, base_key, model)
processed_groups.update(split_map)
handled.update(consumed)
continue
else:
a_tensor, b_tensor, alpha = weights.get("A"), weights.get("B"), weights.get("alpha")
if a_tensor is not None and b_tensor is not None:
processed_groups[base_key] = (a_tensor, b_tensor, alpha)
for module_name, (a_tensor, b_tensor, alpha) in processed_groups.items():
aggregated_weights[module_name].append({
"A": a_tensor,
"B": b_tensor,
"alpha": alpha,
"strength": strength,
})
for module_name, weight_list in aggregated_weights.items():
resolved_name, module = _resolve_module_name(model, module_name)
if module is None:
logger.warning("Skipping unresolved Qwen LoRA target: %s", module_name)
unresolved_targets += 1
continue
all_a = []
all_b_scaled = []
for item in weight_list:
a_tensor = item["A"]
b_tensor = item["B"]
alpha = item["alpha"]
strength = float(item["strength"])
rank = a_tensor.shape[0]
scale = strength * ((alpha / rank) if alpha is not None else 1.0)
if module.__class__.__name__ == "AWQW4A16Linear" and hasattr(module, "qweight"):
target_dtype = torch.float16
target_device = module.qweight.device
elif hasattr(module, "proj_down"):
target_dtype = module.proj_down.dtype
target_device = module.proj_down.device
elif hasattr(module, "weight"):
target_dtype = module.weight.dtype
target_device = module.weight.device
else:
target_dtype = torch.float16
target_device = "cuda" if torch.cuda.is_available() else "cpu"
all_a.append(a_tensor.to(dtype=target_dtype, device=target_device))
all_b_scaled.append((b_tensor * scale).to(dtype=target_dtype, device=target_device))
if not all_a:
continue
_apply_lora_to_module(module, torch.cat(all_a, dim=0), torch.cat(all_b_scaled, dim=1), resolved_name, model)
slot_count = len(getattr(model, "_lora_slots", {}) or {})
logger.info(
"Qwen LoRA composition finished: requested=%d supported=%s applied_targets=%d unresolved=%d",
len(lora_configs),
saw_supported_format,
slot_count,
unresolved_targets,
)
return saw_supported_format
class ComfyQwenImageWrapperLM(nn.Module):
def __init__(self, model: nn.Module, config=None, apply_awq_mod: bool = True):
super().__init__()
self.model = model
self.config = {} if config is None else config
self.dtype = next(model.parameters()).dtype
self.loras: List[Tuple[Union[str, Path, Dict[str, torch.Tensor]], float]] = []
self._applied_loras: Optional[List[Tuple[Union[str, Path, Dict[str, torch.Tensor]], float]]] = None
self.apply_awq_mod = apply_awq_mod
def __getattr__(self, name):
try:
inner = object.__getattribute__(self, "_modules").get("model")
except (AttributeError, KeyError):
inner = None
if inner is None:
raise AttributeError(f"{type(self).__name__!s} has no attribute {name}")
if name == "model":
return inner
return getattr(inner, name)
def process_img(self, *args, **kwargs):
return self.model.process_img(*args, **kwargs)
def _ensure_composed(self):
if self._applied_loras != self.loras or (not self.loras and getattr(self.model, "_lora_slots", None)):
is_supported_format = compose_loras_v2(self.model, self.loras, apply_awq_mod=self.apply_awq_mod)
self._applied_loras = self.loras.copy()
has_slots = bool(getattr(self.model, "_lora_slots", None))
if self.loras and is_supported_format and not has_slots:
logger.warning("Qwen LoRA compose produced 0 target modules. Resetting and retrying once.")
reset_lora_v2(self.model)
compose_loras_v2(self.model, self.loras, apply_awq_mod=self.apply_awq_mod)
has_slots = bool(getattr(self.model, "_lora_slots", None))
logger.info("Qwen LoRA retry result: applied_targets=%d", len(getattr(self.model, "_lora_slots", {}) or {}))
offload_manager = getattr(self.model, "offload_manager", None)
if offload_manager is not None:
offload_settings = {
"num_blocks_on_gpu": getattr(offload_manager, "num_blocks_on_gpu", 1),
"use_pin_memory": getattr(offload_manager, "use_pin_memory", False),
}
logger.info(
"Rebuilding Qwen offload manager after LoRA compose: num_blocks_on_gpu=%s use_pin_memory=%s",
offload_settings["num_blocks_on_gpu"],
offload_settings["use_pin_memory"],
)
self.model.set_offload(False)
self.model.set_offload(True, **offload_settings)
def forward(self, *args, **kwargs):
self._ensure_composed()
return self.model(*args, **kwargs)
def _get_qwen_wrapper_and_transformer(model):
model_wrapper = model.model.diffusion_model
if hasattr(model_wrapper, "model") and hasattr(model_wrapper, "loras"):
transformer = model_wrapper.model
if transformer.__class__.__name__.endswith("NunchakuQwenImageTransformer2DModel"):
return model_wrapper, transformer
if model_wrapper.__class__.__name__.endswith("NunchakuQwenImageTransformer2DModel"):
wrapped_model = ComfyQwenImageWrapperLM(model_wrapper, getattr(model_wrapper, "config", {}))
model.model.diffusion_model = wrapped_model
return wrapped_model, wrapped_model.model
raise TypeError(f"This LoRA loader only works with Nunchaku Qwen Image models, but got {type(model_wrapper).__name__}.")
def nunchaku_load_qwen_loras(model, lora_configs: List[Tuple[str, float]], apply_awq_mod: bool = True):
model_wrapper, transformer = _get_qwen_wrapper_and_transformer(model)
model_wrapper.apply_awq_mod = apply_awq_mod
saved_config = None
if hasattr(model, "model") and hasattr(model.model, "model_config"):
saved_config = model.model.model_config
model.model.model_config = None
model_wrapper.model = None
try:
ret_model = copy.deepcopy(model)
finally:
if saved_config is not None:
model.model.model_config = saved_config
model_wrapper.model = transformer
ret_model_wrapper = ret_model.model.diffusion_model
if saved_config is not None:
ret_model.model.model_config = saved_config
ret_model_wrapper.model = transformer
ret_model_wrapper.apply_awq_mod = apply_awq_mod
ret_model_wrapper.loras = list(getattr(model_wrapper, "loras", []))
for lora_name, lora_strength in lora_configs:
lora_path = lora_name if os.path.isfile(lora_name) else folder_paths.get_full_path("loras", lora_name)
if not lora_path or not os.path.isfile(lora_path):
logger.warning("Skipping Qwen LoRA '%s' because it could not be found", lora_name)
continue
ret_model_wrapper.loras.append((lora_path, lora_strength))
return ret_model

View File

@@ -72,6 +72,13 @@ class SaveImageLM:
"tooltip": "Embeds the complete workflow data into the image metadata. Only works with PNG and WebP formats.", "tooltip": "Embeds the complete workflow data into the image metadata. Only works with PNG and WebP formats.",
}, },
), ),
"save_with_metadata": (
"BOOLEAN",
{
"default": True,
"tooltip": "When enabled, embeds generation parameters into the saved image metadata. Disable to skip writing generation metadata.",
},
),
"add_counter_to_filename": ( "add_counter_to_filename": (
"BOOLEAN", "BOOLEAN",
{ {
@@ -350,6 +357,7 @@ class SaveImageLM:
lossless_webp=True, lossless_webp=True,
quality=100, quality=100,
embed_workflow=False, embed_workflow=False,
save_with_metadata=True,
add_counter_to_filename=True, add_counter_to_filename=True,
): ):
"""Save images with metadata""" """Save images with metadata"""
@@ -421,7 +429,7 @@ class SaveImageLM:
try: try:
if file_format == "png": if file_format == "png":
assert pnginfo is not None assert pnginfo is not None
if metadata: if save_with_metadata and metadata:
pnginfo.add_text("parameters", metadata) pnginfo.add_text("parameters", metadata)
if embed_workflow and extra_pnginfo is not None: if embed_workflow and extra_pnginfo is not None:
workflow_json = json.dumps(extra_pnginfo["workflow"]) workflow_json = json.dumps(extra_pnginfo["workflow"])
@@ -430,7 +438,7 @@ class SaveImageLM:
img.save(file_path, format="PNG", **save_kwargs) img.save(file_path, format="PNG", **save_kwargs)
elif file_format == "jpeg": elif file_format == "jpeg":
# For JPEG, use piexif # For JPEG, use piexif
if metadata: if save_with_metadata and metadata:
try: try:
exif_dict = { exif_dict = {
"Exif": { "Exif": {
@@ -448,7 +456,7 @@ class SaveImageLM:
# For WebP, use piexif for metadata # For WebP, use piexif for metadata
exif_dict = {} exif_dict = {}
if metadata: if save_with_metadata and metadata:
exif_dict["Exif"] = { exif_dict["Exif"] = {
piexif.ExifIFD.UserComment: b"UNICODE\0" piexif.ExifIFD.UserComment: b"UNICODE\0"
+ metadata.encode("utf-16be") + metadata.encode("utf-16be")
@@ -489,6 +497,7 @@ class SaveImageLM:
lossless_webp=True, lossless_webp=True,
quality=100, quality=100,
embed_workflow=False, embed_workflow=False,
save_with_metadata=True,
add_counter_to_filename=True, add_counter_to_filename=True,
): ):
"""Process and save image with metadata""" """Process and save image with metadata"""
@@ -516,7 +525,11 @@ class SaveImageLM:
lossless_webp, lossless_webp,
quality, quality,
embed_workflow, embed_workflow,
save_with_metadata,
add_counter_to_filename, add_counter_to_filename,
) )
return (images,) return {
"result": (images,),
"ui": {"images": results},
}

View File

@@ -158,3 +158,24 @@ def nunchaku_load_lora(model, lora_name, lora_strength):
ret_model.model.model_config.unet_config["in_channels"] = new_in_channels ret_model.model.model_config.unet_config["in_channels"] = new_in_channels
return ret_model return ret_model
def detect_nunchaku_model_kind(model):
"""Return the supported Nunchaku model kind for a Comfy model, if any."""
try:
model_wrapper = model.model.diffusion_model
except (AttributeError, TypeError):
return None
wrapper_name = model_wrapper.__class__.__name__
if wrapper_name == "ComfyFluxWrapper":
return "flux"
inner_model = getattr(model_wrapper, "model", None)
inner_name = inner_model.__class__.__name__ if inner_model is not None else ""
if wrapper_name.endswith("NunchakuQwenImageTransformer2DModel"):
return "qwen_image"
if inner_name.endswith("NunchakuQwenImageTransformer2DModel"):
return "qwen_image"
return None

View File

@@ -42,6 +42,7 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
"height", "height",
"Model", "Model",
"Model hash", "Model hash",
"modelVersionIds",
) )
return any(key in payload for key in civitai_image_fields) return any(key in payload for key in civitai_image_fields)
@@ -429,6 +430,65 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
result["loras"].append(lora_entry) result["loras"].append(lora_entry)
# Process modelVersionIds from Civitai image API
# These are model version IDs returned at root level when meta doesn't contain resources
if "modelVersionIds" in metadata and isinstance(
metadata["modelVersionIds"], list
):
for version_id in metadata["modelVersionIds"]:
version_id_str = str(version_id)
# Skip if we've already added this LoRA by version ID
if version_id_str in added_loras:
continue
# Initialize lora entry with version ID
lora_entry = {
"id": version_id,
"modelId": 0,
"name": "Unknown LoRA",
"version": "",
"type": "lora",
"weight": 1.0,
"existsLocally": False,
"thumbnailUrl": "/loras_static/images/no-preview.png",
"baseModel": "",
"size": 0,
"downloadUrl": "",
"isDeleted": False,
}
# Fetch model info from Civitai
if metadata_provider and version_id_str:
try:
civitai_info = (
await metadata_provider.get_model_version_info(
version_id_str
)
)
populated_entry = await self.populate_lora_from_civitai(
lora_entry,
civitai_info,
recipe_scanner,
base_model_counts,
)
if populated_entry is None:
continue # Skip invalid LoRA types
lora_entry = populated_entry
except Exception as e:
logger.error(
f"Error fetching Civitai info for model version {version_id}: {e}"
)
# Track this LoRA for deduplication
if version_id_str:
added_loras[version_id_str] = len(result["loras"])
result["loras"].append(lora_entry)
# If we found LoRA hashes in the metadata but haven't already # If we found LoRA hashes in the metadata but haven't already
# populated entries for them, fall back to creating LoRAs from # populated entries for them, fall back to creating LoRAs from
# the hashes section. Some Civitai image responses only include # the hashes section. Some Civitai image responses only include

View File

@@ -0,0 +1,141 @@
"""Handlers for base model related endpoints."""
from __future__ import annotations
import logging
from typing import Any, Awaitable, Callable, Dict
from aiohttp import web
from ...services.civitai_base_model_service import get_civitai_base_model_service
logger = logging.getLogger(__name__)
class BaseModelHandlerSet:
"""Collection of handlers for base model operations."""
def __init__(
self,
base_model_service_factory: Callable[[], Any] = get_civitai_base_model_service,
) -> None:
self._base_model_service_factory = base_model_service_factory
def to_route_mapping(
self,
) -> Dict[str, Callable[[web.Request], Awaitable[web.StreamResponse]]]:
"""Return mapping of route names to handler methods."""
return {
"get_base_models": self.get_base_models,
"refresh_base_models": self.refresh_base_models,
"get_base_model_categories": self.get_base_model_categories,
"get_base_model_cache_status": self.get_base_model_cache_status,
}
async def get_base_models(self, request: web.Request) -> web.Response:
"""Get merged base models (hardcoded + remote from Civitai).
Query Parameters:
refresh: If 'true', force refresh from API
Returns:
JSON response with:
- models: List of base model names
- source: 'cache', 'api', or 'fallback'
- last_updated: ISO timestamp
- counts: hardcoded_count, remote_count, merged_count
"""
try:
service = await self._base_model_service_factory()
# Check for refresh parameter
force_refresh = request.query.get("refresh", "").lower() == "true"
result = await service.get_base_models(force_refresh=force_refresh)
return web.json_response(
{
"success": True,
"data": result,
}
)
except Exception as e:
logger.error(f"Error in get_base_models: {e}")
return web.json_response(
{"success": False, "error": str(e)},
status=500,
)
async def refresh_base_models(self, request: web.Request) -> web.Response:
"""Force refresh base models from Civitai API.
Returns:
JSON response with refreshed data
"""
try:
service = await self._base_model_service_factory()
result = await service.refresh_cache()
return web.json_response(
{
"success": True,
"data": result,
"message": "Base models cache refreshed successfully",
}
)
except Exception as e:
logger.error(f"Error in refresh_base_models: {e}")
return web.json_response(
{"success": False, "error": str(e)},
status=500,
)
async def get_base_model_categories(self, request: web.Request) -> web.Response:
"""Get categorized base models.
Returns:
JSON response with categorized models
"""
try:
service = await self._base_model_service_factory()
categories = service.get_model_categories()
return web.json_response(
{
"success": True,
"data": categories,
}
)
except Exception as e:
logger.error(f"Error in get_base_model_categories: {e}")
return web.json_response(
{"success": False, "error": str(e)},
status=500,
)
async def get_base_model_cache_status(self, request: web.Request) -> web.Response:
"""Get cache status for base models.
Returns:
JSON response with cache status
"""
try:
service = await self._base_model_service_factory()
status = service.get_cache_status()
return web.json_response(
{
"success": True,
"data": status,
}
)
except Exception as e:
logger.error(f"Error in get_base_model_cache_status: {e}")
return web.json_response(
{"success": False, "error": str(e)},
status=500,
)

View File

@@ -40,6 +40,7 @@ from ...utils.civitai_utils import rewrite_preview_url
from ...utils.example_images_paths import is_valid_example_images_root from ...utils.example_images_paths import is_valid_example_images_root
from ...utils.lora_metadata import extract_trained_words from ...utils.lora_metadata import extract_trained_words
from ...utils.usage_stats import UsageStats from ...utils.usage_stats import UsageStats
from .base_model_handlers import BaseModelHandlerSet
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -750,6 +751,7 @@ class ServiceRegistryAdapter:
get_lora_scanner: Callable[[], Awaitable] get_lora_scanner: Callable[[], Awaitable]
get_checkpoint_scanner: Callable[[], Awaitable] get_checkpoint_scanner: Callable[[], Awaitable]
get_embedding_scanner: Callable[[], Awaitable] get_embedding_scanner: Callable[[], Awaitable]
get_downloaded_version_history_service: Callable[[], Awaitable]
class ModelLibraryHandler: class ModelLibraryHandler:
@@ -763,6 +765,41 @@ class ModelLibraryHandler:
self._service_registry = service_registry self._service_registry = service_registry
self._metadata_provider_factory = metadata_provider_factory self._metadata_provider_factory = metadata_provider_factory
@staticmethod
def _normalize_model_type(model_type: str | None) -> str | None:
if not isinstance(model_type, str):
return None
normalized = model_type.strip().lower()
if normalized in {"lora", "locon", "dora"}:
return "lora"
if normalized == "checkpoint":
return "checkpoint"
if normalized in {"embedding", "textualinversion"}:
return "embedding"
return None
async def _get_scanner_for_type(self, model_type: str | None):
normalized_type = self._normalize_model_type(model_type)
if normalized_type == "lora":
return normalized_type, await self._service_registry.get_lora_scanner()
if normalized_type == "checkpoint":
return normalized_type, await self._service_registry.get_checkpoint_scanner()
if normalized_type == "embedding":
return normalized_type, await self._service_registry.get_embedding_scanner()
return None, None
async def _get_download_history_service(self):
return await self._service_registry.get_downloaded_version_history_service()
@staticmethod
def _with_downloaded_flag(versions: list[dict]) -> list[dict]:
enriched: list[dict] = []
for version in versions:
entry = dict(version)
entry.setdefault("hasBeenDownloaded", True)
enriched.append(entry)
return enriched
async def check_model_exists(self, request: web.Request) -> web.Response: async def check_model_exists(self, request: web.Request) -> web.Response:
try: try:
model_id_str = request.query.get("modelId") model_id_str = request.query.get("modelId")
@@ -818,11 +855,30 @@ class ModelLibraryHandler:
exists = True exists = True
model_type = "embedding" model_type = "embedding"
history_service = await self._get_download_history_service()
has_been_downloaded = False
history_type = model_type
if history_type:
has_been_downloaded = await history_service.has_been_downloaded(
history_type,
model_version_id,
)
else:
for candidate_type in ("lora", "checkpoint", "embedding"):
if await history_service.has_been_downloaded(
candidate_type,
model_version_id,
):
has_been_downloaded = True
history_type = candidate_type
break
return web.json_response( return web.json_response(
{ {
"success": True, "success": True,
"exists": exists, "exists": exists,
"modelType": model_type if exists else None, "modelType": model_type if exists else history_type,
"hasBeenDownloaded": has_been_downloaded,
} }
) )
@@ -840,23 +896,166 @@ class ModelLibraryHandler:
model_type = None model_type = None
versions = [] versions = []
downloaded_version_ids = []
history_service = await self._get_download_history_service()
if lora_versions: if lora_versions:
model_type = "lora" model_type = "lora"
versions = lora_versions versions = self._with_downloaded_flag(lora_versions)
downloaded_version_ids = await history_service.get_downloaded_version_ids(
model_type,
model_id,
)
elif checkpoint_versions: elif checkpoint_versions:
model_type = "checkpoint" model_type = "checkpoint"
versions = checkpoint_versions versions = self._with_downloaded_flag(checkpoint_versions)
downloaded_version_ids = await history_service.get_downloaded_version_ids(
model_type,
model_id,
)
elif embedding_versions: elif embedding_versions:
model_type = "embedding" model_type = "embedding"
versions = embedding_versions versions = self._with_downloaded_flag(embedding_versions)
downloaded_version_ids = await history_service.get_downloaded_version_ids(
model_type,
model_id,
)
else:
for candidate_type in ("lora", "checkpoint", "embedding"):
candidate_downloaded_version_ids = (
await history_service.get_downloaded_version_ids(
candidate_type,
model_id,
)
)
if candidate_downloaded_version_ids:
model_type = candidate_type
downloaded_version_ids = candidate_downloaded_version_ids
break
return web.json_response( return web.json_response(
{"success": True, "modelType": model_type, "versions": versions} {
"success": True,
"modelType": model_type,
"versions": versions,
"downloadedVersionIds": downloaded_version_ids,
}
) )
except Exception as exc: # pragma: no cover - defensive logging except Exception as exc: # pragma: no cover - defensive logging
logger.error("Failed to check model existence: %s", exc, exc_info=True) logger.error("Failed to check model existence: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500) return web.json_response({"success": False, "error": str(exc)}, status=500)
async def get_model_version_download_status(
self, request: web.Request
) -> web.Response:
try:
model_type, _ = await self._get_scanner_for_type(request.query.get("modelType"))
if not model_type:
return web.json_response(
{"success": False, "error": "Parameter modelType is required"},
status=400,
)
model_version_id_str = request.query.get("modelVersionId")
if not model_version_id_str:
return web.json_response(
{"success": False, "error": "Missing required parameter: modelVersionId"},
status=400,
)
try:
model_version_id = int(model_version_id_str)
except ValueError:
return web.json_response(
{"success": False, "error": "Parameter modelVersionId must be an integer"},
status=400,
)
history_service = await self._get_download_history_service()
return web.json_response(
{
"success": True,
"modelType": model_type,
"modelVersionId": model_version_id,
"hasBeenDownloaded": await history_service.has_been_downloaded(
model_type,
model_version_id,
),
}
)
except Exception as exc: # pragma: no cover - defensive logging
logger.error(
"Failed to get model version download status: %s",
exc,
exc_info=True,
)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def set_model_version_download_status(
self, request: web.Request
) -> web.Response:
try:
if request.method == "GET":
data = request.query
else:
data = await request.json()
model_type, _ = await self._get_scanner_for_type(data.get("modelType"))
if not model_type:
return web.json_response(
{"success": False, "error": "Parameter modelType is required"},
status=400,
)
try:
model_version_id = int(data.get("modelVersionId"))
except (TypeError, ValueError):
return web.json_response(
{"success": False, "error": "Parameter modelVersionId must be an integer"},
status=400,
)
downloaded = data.get("downloaded")
if isinstance(downloaded, str):
normalized_downloaded = downloaded.strip().lower()
if normalized_downloaded in {"true", "1"}:
downloaded = True
elif normalized_downloaded in {"false", "0"}:
downloaded = False
if not isinstance(downloaded, bool):
return web.json_response(
{"success": False, "error": "Parameter downloaded must be a boolean"},
status=400,
)
history_service = await self._get_download_history_service()
if downloaded:
model_id = data.get("modelId")
file_path = data.get("filePath")
await history_service.mark_downloaded(
model_type,
model_version_id,
model_id=model_id,
source="manual",
file_path=file_path if isinstance(file_path, str) else None,
)
else:
await history_service.mark_not_downloaded(model_type, model_version_id)
return web.json_response(
{
"success": True,
"modelType": model_type,
"modelVersionId": model_version_id,
"hasBeenDownloaded": downloaded,
}
)
except Exception as exc: # pragma: no cover - defensive logging
logger.error(
"Failed to set model version download status: %s",
exc,
exc_info=True,
)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def get_model_versions_status(self, request: web.Request) -> web.Response: async def get_model_versions_status(self, request: web.Request) -> web.Response:
try: try:
model_id_str = request.query.get("modelId") model_id_str = request.query.get("modelId")
@@ -895,18 +1094,8 @@ class ModelLibraryHandler:
model_name = response.get("name", "") model_name = response.get("name", "")
model_type = response.get("type", "").lower() model_type = response.get("type", "").lower()
scanner = None normalized_type, scanner = await self._get_scanner_for_type(model_type)
normalized_type = None if not normalized_type:
if model_type in {"lora", "locon", "dora"}:
scanner = await self._service_registry.get_lora_scanner()
normalized_type = "lora"
elif model_type == "checkpoint":
scanner = await self._service_registry.get_checkpoint_scanner()
normalized_type = "checkpoint"
elif model_type == "textualinversion":
scanner = await self._service_registry.get_embedding_scanner()
normalized_type = "embedding"
else:
return web.json_response( return web.json_response(
{ {
"success": False, "success": False,
@@ -924,8 +1113,14 @@ class ModelLibraryHandler:
status=503, status=503,
) )
history_service = await self._get_download_history_service()
local_versions = await scanner.get_model_versions_by_id(model_id) local_versions = await scanner.get_model_versions_by_id(model_id)
local_version_ids = {version["versionId"] for version in local_versions} local_version_ids = {version["versionId"] for version in local_versions}
downloaded_version_ids = await history_service.get_downloaded_version_ids(
normalized_type,
model_id,
)
downloaded_version_id_set = set(downloaded_version_ids)
enriched_versions = [] enriched_versions = []
for version in versions: for version in versions:
@@ -938,6 +1133,7 @@ class ModelLibraryHandler:
if version.get("images") if version.get("images")
else None, else None,
"inLibrary": version_id in local_version_ids, "inLibrary": version_id in local_version_ids,
"hasBeenDownloaded": version_id in downloaded_version_id_set,
} }
) )
@@ -1006,6 +1202,33 @@ class ModelLibraryHandler:
} }
versions: list[dict] = [] versions: list[dict] = []
history_service = await self._get_download_history_service()
model_ids: list[int] = []
for model in models:
try:
model_ids.append(int(model.get("id")))
except (TypeError, ValueError):
continue
lora_downloaded = await history_service.get_downloaded_version_ids_bulk(
"lora",
model_ids,
)
checkpoint_downloaded = await history_service.get_downloaded_version_ids_bulk(
"checkpoint",
model_ids,
)
embedding_downloaded = await history_service.get_downloaded_version_ids_bulk(
"embedding",
model_ids,
)
downloaded_version_map: Dict[str, Dict[int, set[int]]] = {
"lora": lora_downloaded,
"locon": lora_downloaded,
"dora": lora_downloaded,
"checkpoint": checkpoint_downloaded,
"textualinversion": embedding_downloaded,
}
for model in models: for model in models:
if not isinstance(model, dict): if not isinstance(model, dict):
continue continue
@@ -1060,6 +1283,8 @@ class ModelLibraryHandler:
in_library = await scanner.check_model_version_exists( in_library = await scanner.check_model_version_exists(
version_id_int version_id_int
) )
downloaded_versions = downloaded_version_map.get(model_type, {})
downloaded_version_ids = downloaded_versions.get(model_id_int, set())
versions.append( versions.append(
{ {
@@ -1072,6 +1297,7 @@ class ModelLibraryHandler:
"baseModel": version.get("baseModel"), "baseModel": version.get("baseModel"),
"thumbnailUrl": thumbnail_url, "thumbnailUrl": thumbnail_url,
"inLibrary": in_library, "inLibrary": in_library,
"hasBeenDownloaded": version_id_int in downloaded_version_ids,
} }
) )
@@ -1618,6 +1844,7 @@ class MiscHandlerSet:
custom_words: CustomWordsHandler, custom_words: CustomWordsHandler,
supporters: SupportersHandler, supporters: SupportersHandler,
example_workflows: ExampleWorkflowsHandler, example_workflows: ExampleWorkflowsHandler,
base_model: BaseModelHandlerSet,
) -> None: ) -> None:
self.health = health self.health = health
self.settings = settings self.settings = settings
@@ -1632,6 +1859,7 @@ class MiscHandlerSet:
self.custom_words = custom_words self.custom_words = custom_words
self.supporters = supporters self.supporters = supporters
self.example_workflows = example_workflows self.example_workflows = example_workflows
self.base_model = base_model
def to_route_mapping( def to_route_mapping(
self, self,
@@ -1652,6 +1880,8 @@ class MiscHandlerSet:
"update_node_widget": self.node_registry.update_node_widget, "update_node_widget": self.node_registry.update_node_widget,
"get_registry": self.node_registry.get_registry, "get_registry": self.node_registry.get_registry,
"check_model_exists": self.model_library.check_model_exists, "check_model_exists": self.model_library.check_model_exists,
"get_model_version_download_status": self.model_library.get_model_version_download_status,
"set_model_version_download_status": self.model_library.set_model_version_download_status,
"get_civitai_user_models": self.model_library.get_civitai_user_models, "get_civitai_user_models": self.model_library.get_civitai_user_models,
"download_metadata_archive": self.metadata_archive.download_metadata_archive, "download_metadata_archive": self.metadata_archive.download_metadata_archive,
"remove_metadata_archive": self.metadata_archive.remove_metadata_archive, "remove_metadata_archive": self.metadata_archive.remove_metadata_archive,
@@ -1663,6 +1893,11 @@ class MiscHandlerSet:
"get_supporters": self.supporters.get_supporters, "get_supporters": self.supporters.get_supporters,
"get_example_workflows": self.example_workflows.get_example_workflows, "get_example_workflows": self.example_workflows.get_example_workflows,
"get_example_workflow": self.example_workflows.get_example_workflow, "get_example_workflow": self.example_workflows.get_example_workflow,
# Base model handlers
"get_base_models": self.base_model.get_base_models,
"refresh_base_models": self.base_model.refresh_base_models,
"get_base_model_categories": self.base_model.get_base_model_categories,
"get_base_model_cache_status": self.base_model.get_base_model_cache_status,
} }
@@ -1671,4 +1906,5 @@ def build_service_registry_adapter() -> ServiceRegistryAdapter:
get_lora_scanner=ServiceRegistry.get_lora_scanner, get_lora_scanner=ServiceRegistry.get_lora_scanner,
get_checkpoint_scanner=ServiceRegistry.get_checkpoint_scanner, get_checkpoint_scanner=ServiceRegistry.get_checkpoint_scanner,
get_embedding_scanner=ServiceRegistry.get_embedding_scanner, get_embedding_scanner=ServiceRegistry.get_embedding_scanner,
get_downloaded_version_history_service=ServiceRegistry.get_downloaded_version_history_service,
) )

View File

@@ -81,6 +81,7 @@ class RecipeHandlerSet:
"bulk_delete": self.management.bulk_delete, "bulk_delete": self.management.bulk_delete,
"save_recipe_from_widget": self.management.save_recipe_from_widget, "save_recipe_from_widget": self.management.save_recipe_from_widget,
"get_recipes_for_lora": self.query.get_recipes_for_lora, "get_recipes_for_lora": self.query.get_recipes_for_lora,
"get_recipes_for_checkpoint": self.query.get_recipes_for_checkpoint,
"scan_recipes": self.query.scan_recipes, "scan_recipes": self.query.scan_recipes,
"move_recipe": self.management.move_recipe, "move_recipe": self.management.move_recipe,
"repair_recipes": self.management.repair_recipes, "repair_recipes": self.management.repair_recipes,
@@ -218,6 +219,7 @@ class RecipeListingHandler:
filters["tags"] = tag_filters filters["tags"] = tag_filters
lora_hash = request.query.get("lora_hash") lora_hash = request.query.get("lora_hash")
checkpoint_hash = request.query.get("checkpoint_hash")
result = await recipe_scanner.get_paginated_data( result = await recipe_scanner.get_paginated_data(
page=page, page=page,
@@ -227,6 +229,7 @@ class RecipeListingHandler:
filters=filters, filters=filters,
search_options=search_options, search_options=search_options,
lora_hash=lora_hash, lora_hash=lora_hash,
checkpoint_hash=checkpoint_hash,
folder=folder, folder=folder,
recursive=recursive, recursive=recursive,
) )
@@ -423,6 +426,28 @@ class RecipeQueryHandler:
self._logger.error("Error getting recipes for Lora: %s", exc) self._logger.error("Error getting recipes for Lora: %s", exc)
return web.json_response({"success": False, "error": str(exc)}, status=500) return web.json_response({"success": False, "error": str(exc)}, status=500)
async def get_recipes_for_checkpoint(self, request: web.Request) -> web.Response:
try:
await self._ensure_dependencies_ready()
recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None:
raise RuntimeError("Recipe scanner unavailable")
checkpoint_hash = request.query.get("hash")
if not checkpoint_hash:
return web.json_response(
{"success": False, "error": "Checkpoint hash is required"},
status=400,
)
matching_recipes = await recipe_scanner.get_recipes_for_checkpoint(
checkpoint_hash
)
return web.json_response({"success": True, "recipes": matching_recipes})
except Exception as exc:
self._logger.error("Error getting recipes for checkpoint: %s", exc)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def scan_recipes(self, request: web.Request) -> web.Response: async def scan_recipes(self, request: web.Request) -> web.Response:
try: try:
await self._ensure_dependencies_ready() await self._ensure_dependencies_ready()

View File

@@ -37,6 +37,21 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("POST", "/api/lm/update-node-widget", "update_node_widget"), RouteDefinition("POST", "/api/lm/update-node-widget", "update_node_widget"),
RouteDefinition("GET", "/api/lm/get-registry", "get_registry"), RouteDefinition("GET", "/api/lm/get-registry", "get_registry"),
RouteDefinition("GET", "/api/lm/check-model-exists", "check_model_exists"), RouteDefinition("GET", "/api/lm/check-model-exists", "check_model_exists"),
RouteDefinition(
"GET",
"/api/lm/model-version-download-status",
"get_model_version_download_status",
),
RouteDefinition(
"POST",
"/api/lm/model-version-download-status",
"set_model_version_download_status",
),
RouteDefinition(
"GET",
"/api/lm/set-model-version-download-status",
"set_model_version_download_status",
),
RouteDefinition("GET", "/api/lm/civitai/user-models", "get_civitai_user_models"), RouteDefinition("GET", "/api/lm/civitai/user-models", "get_civitai_user_models"),
RouteDefinition( RouteDefinition(
"POST", "/api/lm/download-metadata-archive", "download_metadata_archive" "POST", "/api/lm/download-metadata-archive", "download_metadata_archive"
@@ -56,6 +71,15 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition( RouteDefinition(
"GET", "/api/lm/example-workflows/{filename}", "get_example_workflow" "GET", "/api/lm/example-workflows/{filename}", "get_example_workflow"
), ),
# Base model management routes
RouteDefinition("GET", "/api/lm/base-models", "get_base_models"),
RouteDefinition("POST", "/api/lm/base-models/refresh", "refresh_base_models"),
RouteDefinition(
"GET", "/api/lm/base-models/categories", "get_base_model_categories"
),
RouteDefinition(
"GET", "/api/lm/base-models/cache-status", "get_base_model_cache_status"
),
) )

View File

@@ -35,6 +35,7 @@ from .handlers.misc_handlers import (
UsageStatsHandler, UsageStatsHandler,
build_service_registry_adapter, build_service_registry_adapter,
) )
from .handlers.base_model_handlers import BaseModelHandlerSet
from .misc_route_registrar import MiscRouteRegistrar from .misc_route_registrar import MiscRouteRegistrar
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -128,6 +129,7 @@ class MiscRoutes:
custom_words = CustomWordsHandler() custom_words = CustomWordsHandler()
supporters = SupportersHandler() supporters = SupportersHandler()
example_workflows = ExampleWorkflowsHandler() example_workflows = ExampleWorkflowsHandler()
base_model = BaseModelHandlerSet()
return self._handler_set_factory( return self._handler_set_factory(
health=health, health=health,
@@ -143,6 +145,7 @@ class MiscRoutes:
custom_words=custom_words, custom_words=custom_words,
supporters=supporters, supporters=supporters,
example_workflows=example_workflows, example_workflows=example_workflows,
base_model=base_model,
) )

View File

@@ -51,6 +51,9 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
"POST", "/api/lm/recipes/save-from-widget", "save_recipe_from_widget" "POST", "/api/lm/recipes/save-from-widget", "save_recipe_from_widget"
), ),
RouteDefinition("GET", "/api/lm/recipes/for-lora", "get_recipes_for_lora"), RouteDefinition("GET", "/api/lm/recipes/for-lora", "get_recipes_for_lora"),
RouteDefinition(
"GET", "/api/lm/recipes/for-checkpoint", "get_recipes_for_checkpoint"
),
RouteDefinition("GET", "/api/lm/recipes/scan", "scan_recipes"), RouteDefinition("GET", "/api/lm/recipes/scan", "scan_recipes"),
RouteDefinition("POST", "/api/lm/recipes/repair", "repair_recipes"), RouteDefinition("POST", "/api/lm/recipes/repair", "repair_recipes"),
RouteDefinition("POST", "/api/lm/recipes/cancel-repair", "cancel_repair"), RouteDefinition("POST", "/api/lm/recipes/cancel-repair", "cancel_repair"),

View File

@@ -0,0 +1,430 @@
from __future__ import annotations
import asyncio
import json
import logging
import re
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Set, Tuple
from ..utils.constants import SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS
from .downloader import get_downloader
logger = logging.getLogger(__name__)
class CivitaiBaseModelService:
"""Service for fetching and managing Civitai base models.
This service provides:
- Fetching base models from Civitai API
- Caching with TTL (7 days default)
- Merging hardcoded and remote base models
- Generating abbreviations for new/unknown models
"""
_instance: Optional[CivitaiBaseModelService] = None
_lock = asyncio.Lock()
# Default TTL for cache in seconds (7 days)
DEFAULT_CACHE_TTL = 7 * 24 * 60 * 60
# Civitai API endpoint for enums
CIVITAI_ENUMS_URL = "https://civitai.com/api/v1/enums"
@classmethod
async def get_instance(cls) -> CivitaiBaseModelService:
"""Get singleton instance of the service."""
async with cls._lock:
if cls._instance is None:
cls._instance = cls()
return cls._instance
def __init__(self):
"""Initialize the service."""
if hasattr(self, "_initialized"):
return
self._initialized = True
# Cache storage
self._cache: Optional[Dict[str, Any]] = None
self._cache_timestamp: Optional[datetime] = None
self._cache_ttl = self.DEFAULT_CACHE_TTL
# Hardcoded models for fallback
self._hardcoded_models = set(SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS)
logger.info("CivitaiBaseModelService initialized")
async def get_base_models(self, force_refresh: bool = False) -> Dict[str, Any]:
"""Get merged base models (hardcoded + remote).
Args:
force_refresh: If True, fetch from API regardless of cache state.
Returns:
Dictionary containing:
- models: List of merged base model names
- source: 'cache', 'api', or 'fallback'
- last_updated: ISO timestamp of last successful API fetch
- hardcoded_count: Number of hardcoded models
- remote_count: Number of remote models
- merged_count: Total unique models
"""
# Check if cache is valid
if not force_refresh and self._is_cache_valid():
logger.debug("Returning cached base models")
return self._build_response("cache")
# Try to fetch from API
try:
remote_models = await self._fetch_from_civitai()
if remote_models:
self._update_cache(remote_models)
return self._build_response("api")
except Exception as e:
logger.error(f"Failed to fetch base models from Civitai: {e}")
# Fallback to hardcoded models
return self._build_response("fallback")
async def refresh_cache(self) -> Dict[str, Any]:
"""Force refresh the cache from Civitai API.
Returns:
Response dict same as get_base_models()
"""
return await self.get_base_models(force_refresh=True)
def get_cache_status(self) -> Dict[str, Any]:
"""Get current cache status.
Returns:
Dictionary containing:
- has_cache: Whether cache exists
- last_updated: ISO timestamp or None
- is_expired: Whether cache is expired
- ttl_seconds: TTL in seconds
- age_seconds: Age of cache in seconds (if exists)
"""
if self._cache is None or self._cache_timestamp is None:
return {
"has_cache": False,
"last_updated": None,
"is_expired": True,
"ttl_seconds": self._cache_ttl,
"age_seconds": None,
}
age = (datetime.now(timezone.utc) - self._cache_timestamp).total_seconds()
return {
"has_cache": True,
"last_updated": self._cache_timestamp.isoformat(),
"is_expired": age > self._cache_ttl,
"ttl_seconds": self._cache_ttl,
"age_seconds": int(age),
}
def generate_abbreviation(self, model_name: str) -> str:
"""Generate abbreviation for a base model name.
Algorithm:
1. Extract version patterns (e.g., "2.5" from "Wan Video 2.5")
2. Extract main acronym (e.g., "SD" from "SD 1.5")
3. Handle special cases (Flux, Wan, etc.)
4. Fallback to first letters of words (max 4 chars)
Args:
model_name: Full base model name
Returns:
Generated abbreviation (max 4 characters)
"""
if not model_name or not isinstance(model_name, str):
return "OTH"
name = model_name.strip()
if not name:
return "OTH"
# Check if it's already in hardcoded abbreviations
# This is a simplified check - in practice you'd have a mapping
lower_name = name.lower()
# Special cases
special_cases = {
"sd 1.4": "SD1",
"sd 1.5": "SD1",
"sd 1.5 lcm": "SD1",
"sd 1.5 hyper": "SD1",
"sd 2.0": "SD2",
"sd 2.1": "SD2",
"sd 3": "SD3",
"sd 3.5": "SD3",
"sd 3.5 medium": "SD3",
"sd 3.5 large": "SD3",
"sd 3.5 large turbo": "SD3",
"sdxl 1.0": "XL",
"sdxl lightning": "XL",
"sdxl hyper": "XL",
"flux.1 d": "F1D",
"flux.1 s": "F1S",
"flux.1 krea": "F1KR",
"flux.1 kontext": "F1KX",
"flux.2 d": "F2D",
"flux.2 klein 9b": "FK9",
"flux.2 klein 9b-base": "FK9B",
"flux.2 klein 4b": "FK4",
"flux.2 klein 4b-base": "FK4B",
"auraflow": "AF",
"chroma": "CHR",
"pixart a": "PXA",
"pixart e": "PXE",
"hunyuan 1": "HY",
"hunyuan video": "HYV",
"lumina": "L",
"kolors": "KLR",
"noobai": "NAI",
"illustrious": "IL",
"pony": "PONY",
"pony v7": "PNY7",
"hidream": "HID",
"qwen": "QWEN",
"zimageturbo": "ZIT",
"zimagebase": "ZIB",
"anima": "ANI",
"svd": "SVD",
"ltxv": "LTXV",
"ltxv2": "LTV2",
"ltxv 2.3": "LTX",
"cogvideox": "CVX",
"mochi": "MCHI",
"wan video": "WAN",
"wan video 1.3b t2v": "WAN",
"wan video 14b t2v": "WAN",
"wan video 14b i2v 480p": "WAN",
"wan video 14b i2v 720p": "WAN",
"wan video 2.2 ti2v-5b": "WAN",
"wan video 2.2 t2v-a14b": "WAN",
"wan video 2.2 i2v-a14b": "WAN",
"wan video 2.5 t2v": "WAN",
"wan video 2.5 i2v": "WAN",
}
if lower_name in special_cases:
return special_cases[lower_name]
# Try to extract acronym from version pattern
# e.g., "Model Name 2.5" -> "MN25"
version_match = re.search(r"(\d+(?:\.\d+)?)", name)
version = version_match.group(1) if version_match else ""
# Remove version and common words
words = re.sub(r"\d+(?:\.\d+)?", "", name)
words = re.sub(
r"\b(model|video|diffusion|checkpoint|textualinversion)\b",
"",
words,
flags=re.I,
)
words = words.strip()
# Get first letters of remaining words
tokens = re.findall(r"[A-Za-z]+", words)
if tokens:
# Build abbreviation from first letters
abbrev = "".join(token[0].upper() for token in tokens)
# Add version if present
if version:
# Clean version (remove dots for abbreviation)
version_clean = version.replace(".", "")
abbrev = abbrev[: 4 - len(version_clean)] + version_clean
return abbrev[:4]
# Final fallback: just take first 4 alphanumeric chars
alphanumeric = re.sub(r"[^A-Za-z0-9]", "", name)
if alphanumeric:
return alphanumeric[:4].upper()
return "OTH"
async def _fetch_from_civitai(self) -> Optional[Set[str]]:
"""Fetch base models from Civitai API.
Returns:
Set of base model names, or None if failed
"""
try:
downloader = await get_downloader()
success, result = await downloader.make_request(
"GET",
self.CIVITAI_ENUMS_URL,
use_auth=False, # enums endpoint doesn't require auth
)
if not success:
logger.warning(f"Failed to fetch enums from Civitai: {result}")
return None
if isinstance(result, str):
data = json.loads(result)
else:
data = result
# Extract base models from response
base_models = set()
# Use ActiveBaseModel if available (recommended active models)
if "ActiveBaseModel" in data:
base_models.update(data["ActiveBaseModel"])
logger.info(f"Fetched {len(base_models)} models from ActiveBaseModel")
# Fallback to full BaseModel list
elif "BaseModel" in data:
base_models.update(data["BaseModel"])
logger.info(f"Fetched {len(base_models)} models from BaseModel")
else:
logger.warning("No base model data found in Civitai response")
return None
return base_models
except Exception as e:
logger.error(f"Error fetching from Civitai: {e}")
return None
def _update_cache(self, remote_models: Set[str]) -> None:
"""Update internal cache with fetched models.
Args:
remote_models: Set of base model names from API
"""
self._cache = {
"remote_models": sorted(remote_models),
"hardcoded_models": sorted(self._hardcoded_models),
}
self._cache_timestamp = datetime.now(timezone.utc)
logger.info(f"Cache updated with {len(remote_models)} remote models")
def _is_cache_valid(self) -> bool:
"""Check if current cache is valid (not expired).
Returns:
True if cache exists and is not expired
"""
if self._cache is None or self._cache_timestamp is None:
return False
age = (datetime.now(timezone.utc) - self._cache_timestamp).total_seconds()
return age <= self._cache_ttl
def _build_response(self, source: str) -> Dict[str, Any]:
"""Build response dictionary.
Args:
source: 'cache', 'api', or 'fallback'
Returns:
Response dictionary
"""
if source == "fallback" or self._cache is None:
# Use only hardcoded models
merged = sorted(self._hardcoded_models)
return {
"models": merged,
"source": source,
"last_updated": None,
"hardcoded_count": len(self._hardcoded_models),
"remote_count": 0,
"merged_count": len(merged),
}
# Merge hardcoded and remote models
remote_set = set(self._cache.get("remote_models", []))
merged = sorted(self._hardcoded_models | remote_set)
return {
"models": merged,
"source": source,
"last_updated": self._cache_timestamp.isoformat()
if self._cache_timestamp
else None,
"hardcoded_count": len(self._hardcoded_models),
"remote_count": len(remote_set),
"merged_count": len(merged),
}
def get_model_categories(self) -> Dict[str, List[str]]:
"""Get categorized base models.
Returns:
Dictionary mapping category names to lists of model names
"""
# Define category patterns
categories = {
"Stable Diffusion 1.x": ["SD 1.4", "SD 1.5", "SD 1.5 LCM", "SD 1.5 Hyper"],
"Stable Diffusion 2.x": ["SD 2.0", "SD 2.1"],
"Stable Diffusion 3.x": [
"SD 3",
"SD 3.5",
"SD 3.5 Medium",
"SD 3.5 Large",
"SD 3.5 Large Turbo",
],
"SDXL": ["SDXL 1.0", "SDXL Lightning", "SDXL Hyper"],
"Flux Models": [
"Flux.1 D",
"Flux.1 S",
"Flux.1 Krea",
"Flux.1 Kontext",
"Flux.2 D",
"Flux.2 Klein 9B",
"Flux.2 Klein 9B-base",
"Flux.2 Klein 4B",
"Flux.2 Klein 4B-base",
],
"Video Models": [
"SVD",
"LTXV",
"LTXV2",
"LTXV 2.3",
"CogVideoX",
"Mochi",
"Hunyuan Video",
"Wan Video",
"Wan Video 1.3B t2v",
"Wan Video 14B t2v",
"Wan Video 14B i2v 480p",
"Wan Video 14B i2v 720p",
"Wan Video 2.2 TI2V-5B",
"Wan Video 2.2 T2V-A14B",
"Wan Video 2.2 I2V-A14B",
"Wan Video 2.5 T2V",
"Wan Video 2.5 I2V",
],
"Other Models": [
"Illustrious",
"Pony",
"Pony V7",
"HiDream",
"Qwen",
"AuraFlow",
"Chroma",
"ZImageTurbo",
"ZImageBase",
"PixArt a",
"PixArt E",
"Hunyuan 1",
"Lumina",
"Kolors",
"NoobAI",
"Anima",
],
}
return categories
# Convenience function for getting the singleton instance
async def get_civitai_base_model_service() -> CivitaiBaseModelService:
"""Get the singleton instance of CivitaiBaseModelService."""
return await CivitaiBaseModelService.get_instance()

View File

@@ -13,10 +13,11 @@ from ..utils.models import LoraMetadata, CheckpointMetadata, EmbeddingMetadata
from ..utils.constants import ( from ..utils.constants import (
CARD_PREVIEW_WIDTH, CARD_PREVIEW_WIDTH,
DIFFUSION_MODEL_BASE_MODELS, DIFFUSION_MODEL_BASE_MODELS,
SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS,
VALID_LORA_TYPES, VALID_LORA_TYPES,
) )
from ..utils.civitai_utils import rewrite_preview_url from ..utils.civitai_utils import rewrite_preview_url
from ..utils.preview_selection import select_preview_media from ..utils.preview_selection import resolve_mature_threshold, select_preview_media
from ..utils.utils import sanitize_folder_name from ..utils.utils import sanitize_folder_name
from ..utils.exif_utils import ExifUtils from ..utils.exif_utils import ExifUtils
from ..utils.metadata_manager import MetadataManager from ..utils.metadata_manager import MetadataManager
@@ -63,6 +64,19 @@ class DownloadManager:
"""Get the checkpoint scanner from registry""" """Get the checkpoint scanner from registry"""
return await ServiceRegistry.get_checkpoint_scanner() return await ServiceRegistry.get_checkpoint_scanner()
async def _has_been_downloaded(self, model_type: str, model_version_id: int) -> bool:
try:
history_service = await ServiceRegistry.get_downloaded_version_history_service()
return await history_service.has_been_downloaded(model_type, model_version_id)
except Exception as exc:
logger.debug(
"Failed to read download history for %s version %s: %s",
model_type,
model_version_id,
exc,
)
return False
async def download_from_civitai( async def download_from_civitai(
self, self,
model_id: int = None, model_id: int = None,
@@ -228,7 +242,9 @@ class DownloadManager:
# Update status based on result # Update status based on result
if task_id in self._active_downloads: if task_id in self._active_downloads:
self._active_downloads[task_id]["status"] = ( self._active_downloads[task_id]["status"] = (
"completed" if result["success"] else "failed" result.get("status", "completed")
if result["success"]
else "failed"
) )
if not result["success"]: if not result["success"]:
self._active_downloads[task_id]["error"] = result.get( self._active_downloads[task_id]["error"] = result.get(
@@ -352,10 +368,105 @@ class DownloadManager:
"error": f'Model type "{model_type_from_info}" is not supported for download', "error": f'Model type "{model_type_from_info}" is not supported for download',
} }
resolved_version_id = model_version_id
raw_version_id = version_info.get("id")
if resolved_version_id is None and raw_version_id is not None:
try:
resolved_version_id = int(raw_version_id)
except (TypeError, ValueError):
resolved_version_id = None
if (
get_settings_manager().get_skip_previously_downloaded_model_versions()
and resolved_version_id is not None
and await self._has_been_downloaded(model_type, resolved_version_id)
):
file_name = ""
files = version_info.get("files")
if isinstance(files, list):
primary_file = next(
(
file_info
for file_info in files
if isinstance(file_info, dict) and file_info.get("primary")
),
None,
)
selected_file = primary_file
if selected_file is None:
selected_file = next(
(file_info for file_info in files if isinstance(file_info, dict)),
None,
)
if isinstance(selected_file, dict):
raw_file_name = selected_file.get("name", "")
if isinstance(raw_file_name, str):
file_name = raw_file_name.strip()
message = (
f"Skipped download for '{file_name or version_info.get('name') or f'model_version:{resolved_version_id}'}' "
f"because version {resolved_version_id} was already downloaded before"
)
logger.info(message)
return {
"success": True,
"skipped": True,
"status": "skipped",
"reason": "previously_downloaded_version",
"message": message,
"model_version_id": resolved_version_id,
"file_name": file_name,
"download_id": download_id,
}
excluded_base_models = get_settings_manager().get_download_skip_base_models()
base_model_value = version_info.get("baseModel", "")
if (
isinstance(base_model_value, str)
and base_model_value in SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS
and base_model_value in excluded_base_models
):
file_name = ""
files = version_info.get("files")
if isinstance(files, list):
primary_file = next(
(
file_info
for file_info in files
if isinstance(file_info, dict) and file_info.get("primary")
),
None,
)
selected_file = primary_file
if selected_file is None:
selected_file = next(
(file_info for file_info in files if isinstance(file_info, dict)),
None,
)
if isinstance(selected_file, dict):
raw_file_name = selected_file.get("name", "")
if isinstance(raw_file_name, str):
file_name = raw_file_name.strip()
message = (
f"Skipped download for '{file_name or version_info.get('name') or f'model_version:{model_version_id or model_id}'}' "
f"because base model '{base_model_value}' is excluded in settings"
)
logger.info(message)
return {
"success": True,
"skipped": True,
"status": "skipped",
"reason": "base_model_excluded",
"message": message,
"base_model": base_model_value,
"file_name": file_name,
"download_id": download_id,
}
# Check if this checkpoint should be treated as a diffusion model based on baseModel # Check if this checkpoint should be treated as a diffusion model based on baseModel
is_diffusion_model = False is_diffusion_model = False
if model_type == "checkpoint": if model_type == "checkpoint":
base_model_value = version_info.get("baseModel", "")
if base_model_value in DIFFUSION_MODEL_BASE_MODELS: if base_model_value in DIFFUSION_MODEL_BASE_MODELS:
is_diffusion_model = True is_diffusion_model = True
logger.info( logger.info(
@@ -593,6 +704,13 @@ class DownloadManager:
or version_info.get("modelId") or version_info.get("modelId")
or (version_info.get("model") or {}).get("id") or (version_info.get("model") or {}).get("id")
) )
await self._record_downloaded_version_history(
model_type,
resolved_model_id,
version_info,
model_version_id,
save_path,
)
await self._sync_downloaded_version( await self._sync_downloaded_version(
model_type, model_type,
resolved_model_id, resolved_model_id,
@@ -622,6 +740,55 @@ class DownloadManager:
} }
return {"success": False, "error": str(e)} return {"success": False, "error": str(e)}
async def _record_downloaded_version_history(
self,
model_type: str,
model_id_value,
version_info: Dict,
fallback_version_id=None,
file_path: str | None = None,
) -> None:
try:
history_service = await ServiceRegistry.get_downloaded_version_history_service()
except Exception as exc:
logger.debug(
"Skipping download history sync; failed to acquire history service: %s",
exc,
)
return
if history_service is None:
return
resolved_model_id = model_id_value
if resolved_model_id is None:
resolved_model_id = version_info.get("modelId")
if resolved_model_id is None:
model_info = version_info.get("model")
if isinstance(model_info, dict):
resolved_model_id = model_info.get("id")
version_id = version_info.get("id")
if version_id is None:
version_id = fallback_version_id
try:
await history_service.mark_downloaded(
model_type,
int(version_id),
model_id=int(resolved_model_id) if resolved_model_id is not None else None,
source="download",
file_path=file_path,
)
except (TypeError, ValueError):
logger.debug(
"Skipping download history sync; invalid identifiers model=%s version=%s",
resolved_model_id,
version_id,
)
except Exception as exc:
logger.debug("Failed to sync download history for %s: %s", model_type, exc)
async def _sync_downloaded_version( async def _sync_downloaded_version(
self, self,
model_type: str, model_type: str,
@@ -846,9 +1013,13 @@ class DownloadManager:
blur_mature_content = bool( blur_mature_content = bool(
settings_manager.get("blur_mature_content", True) settings_manager.get("blur_mature_content", True)
) )
mature_threshold = resolve_mature_threshold(
{"mature_blur_level": settings_manager.get("mature_blur_level", "R")}
)
selected_image, nsfw_level = select_preview_media( selected_image, nsfw_level = select_preview_media(
images, images,
blur_mature_content=blur_mature_content, blur_mature_content=blur_mature_content,
mature_threshold=mature_threshold,
) )
preview_url = selected_image.get("url") if selected_image else None preview_url = selected_image.get("url") if selected_image else None

View File

@@ -0,0 +1,313 @@
from __future__ import annotations
import asyncio
import logging
import os
import sqlite3
import time
from typing import Iterable, Mapping, Optional, Sequence
from ..utils.cache_paths import get_cache_base_dir
from .settings_manager import get_settings_manager
logger = logging.getLogger(__name__)
def _normalize_model_type(model_type: str | None) -> Optional[str]:
if not isinstance(model_type, str):
return None
normalized = model_type.strip().lower()
if normalized in {"lora", "locon", "dora"}:
return "lora"
if normalized == "checkpoint":
return "checkpoint"
if normalized in {"embedding", "textualinversion"}:
return "embedding"
return None
def _normalize_int(value) -> Optional[int]:
try:
if value is None:
return None
return int(value)
except (TypeError, ValueError):
return None
def _resolve_database_path() -> str:
base_dir = get_cache_base_dir(create=True)
history_dir = os.path.join(base_dir, "download_history")
os.makedirs(history_dir, exist_ok=True)
return os.path.join(history_dir, "downloaded_versions.sqlite")
class DownloadedVersionHistoryService:
_SCHEMA = """
CREATE TABLE IF NOT EXISTS downloaded_model_versions (
model_type TEXT NOT NULL,
version_id INTEGER NOT NULL,
model_id INTEGER,
first_seen_at REAL NOT NULL,
last_seen_at REAL NOT NULL,
source TEXT NOT NULL,
last_file_path TEXT,
last_library_name TEXT,
is_deleted_override INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (model_type, version_id)
);
CREATE INDEX IF NOT EXISTS idx_downloaded_model_versions_model
ON downloaded_model_versions(model_type, model_id);
"""
def __init__(self, db_path: str | None = None, *, settings_manager=None) -> None:
self._db_path = db_path or _resolve_database_path()
self._settings = settings_manager or get_settings_manager()
self._lock = asyncio.Lock()
self._schema_initialized = False
self._ensure_directory()
self._initialize_schema()
def _ensure_directory(self) -> None:
directory = os.path.dirname(self._db_path)
if directory:
os.makedirs(directory, exist_ok=True)
def _connect(self) -> sqlite3.Connection:
conn = sqlite3.connect(self._db_path, check_same_thread=False)
conn.row_factory = sqlite3.Row
return conn
def _initialize_schema(self) -> None:
if self._schema_initialized:
return
with self._connect() as conn:
conn.executescript(self._SCHEMA)
conn.commit()
self._schema_initialized = True
def get_database_path(self) -> str:
return self._db_path
def _get_active_library_name(self) -> str | None:
try:
value = self._settings.get_active_library_name()
except Exception:
return None
return value or None
async def mark_downloaded(
self,
model_type: str,
version_id: int,
*,
model_id: int | None = None,
source: str = "manual",
file_path: str | None = None,
library_name: str | None = None,
) -> None:
normalized_type = _normalize_model_type(model_type)
normalized_version_id = _normalize_int(version_id)
normalized_model_id = _normalize_int(model_id)
if normalized_type is None or normalized_version_id is None:
return
active_library_name = library_name or self._get_active_library_name()
timestamp = time.time()
async with self._lock:
with self._connect() as conn:
conn.execute(
"""
INSERT INTO downloaded_model_versions (
model_type, version_id, model_id, first_seen_at, last_seen_at,
source, last_file_path, last_library_name, is_deleted_override
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)
ON CONFLICT(model_type, version_id) DO UPDATE SET
model_id = COALESCE(excluded.model_id, downloaded_model_versions.model_id),
last_seen_at = excluded.last_seen_at,
source = excluded.source,
last_file_path = COALESCE(excluded.last_file_path, downloaded_model_versions.last_file_path),
last_library_name = COALESCE(excluded.last_library_name, downloaded_model_versions.last_library_name),
is_deleted_override = 0
""",
(
normalized_type,
normalized_version_id,
normalized_model_id,
timestamp,
timestamp,
source,
file_path,
active_library_name,
),
)
conn.commit()
async def mark_downloaded_bulk(
self,
model_type: str,
records: Sequence[Mapping[str, object]],
*,
source: str = "scan",
library_name: str | None = None,
) -> None:
normalized_type = _normalize_model_type(model_type)
if normalized_type is None or not records:
return
timestamp = time.time()
active_library_name = library_name or self._get_active_library_name()
payload: list[tuple[object, ...]] = []
for record in records:
version_id = _normalize_int(record.get("version_id"))
if version_id is None:
continue
payload.append(
(
normalized_type,
version_id,
_normalize_int(record.get("model_id")),
timestamp,
timestamp,
source,
record.get("file_path"),
active_library_name,
)
)
if not payload:
return
async with self._lock:
with self._connect() as conn:
conn.executemany(
"""
INSERT INTO downloaded_model_versions (
model_type, version_id, model_id, first_seen_at, last_seen_at,
source, last_file_path, last_library_name, is_deleted_override
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)
ON CONFLICT(model_type, version_id) DO UPDATE SET
model_id = COALESCE(excluded.model_id, downloaded_model_versions.model_id),
last_seen_at = excluded.last_seen_at,
source = excluded.source,
last_file_path = COALESCE(excluded.last_file_path, downloaded_model_versions.last_file_path),
last_library_name = COALESCE(excluded.last_library_name, downloaded_model_versions.last_library_name),
is_deleted_override = 0
""",
payload,
)
conn.commit()
async def mark_not_downloaded(self, model_type: str, version_id: int) -> None:
normalized_type = _normalize_model_type(model_type)
normalized_version_id = _normalize_int(version_id)
if normalized_type is None or normalized_version_id is None:
return
timestamp = time.time()
async with self._lock:
with self._connect() as conn:
conn.execute(
"""
INSERT INTO downloaded_model_versions (
model_type, version_id, model_id, first_seen_at, last_seen_at,
source, last_file_path, last_library_name, is_deleted_override
) VALUES (?, ?, NULL, ?, ?, 'manual', NULL, ?, 1)
ON CONFLICT(model_type, version_id) DO UPDATE SET
last_seen_at = excluded.last_seen_at,
source = excluded.source,
last_library_name = COALESCE(excluded.last_library_name, downloaded_model_versions.last_library_name),
is_deleted_override = 1
""",
(
normalized_type,
normalized_version_id,
timestamp,
timestamp,
self._get_active_library_name(),
),
)
conn.commit()
async def has_been_downloaded(self, model_type: str, version_id: int) -> bool:
normalized_type = _normalize_model_type(model_type)
normalized_version_id = _normalize_int(version_id)
if normalized_type is None or normalized_version_id is None:
return False
async with self._lock:
with self._connect() as conn:
row = conn.execute(
"""
SELECT is_deleted_override
FROM downloaded_model_versions
WHERE model_type = ? AND version_id = ?
""",
(normalized_type, normalized_version_id),
).fetchone()
return bool(row) and not bool(row["is_deleted_override"])
async def get_downloaded_version_ids(
self, model_type: str, model_id: int
) -> list[int]:
normalized_type = _normalize_model_type(model_type)
normalized_model_id = _normalize_int(model_id)
if normalized_type is None or normalized_model_id is None:
return []
async with self._lock:
with self._connect() as conn:
rows = conn.execute(
"""
SELECT version_id
FROM downloaded_model_versions
WHERE model_type = ? AND model_id = ? AND is_deleted_override = 0
ORDER BY version_id ASC
""",
(normalized_type, normalized_model_id),
).fetchall()
return [int(row["version_id"]) for row in rows]
async def get_downloaded_version_ids_bulk(
self, model_type: str, model_ids: Iterable[int]
) -> dict[int, set[int]]:
normalized_type = _normalize_model_type(model_type)
if normalized_type is None:
return {}
normalized_model_ids = sorted(
{
value
for value in (_normalize_int(model_id) for model_id in model_ids)
if value is not None
}
)
if not normalized_model_ids:
return {}
placeholders = ", ".join(["?"] * len(normalized_model_ids))
params: list[object] = [normalized_type, *normalized_model_ids]
async with self._lock:
with self._connect() as conn:
rows = conn.execute(
f"""
SELECT model_id, version_id
FROM downloaded_model_versions
WHERE model_type = ?
AND model_id IN ({placeholders})
AND is_deleted_override = 0
""",
params,
).fetchall()
result: dict[int, set[int]] = {}
for row in rows:
model_id = _normalize_int(row["model_id"])
version_id = _normalize_int(row["version_id"])
if model_id is None or version_id is None:
continue
result.setdefault(model_id, set()).add(version_id)
return result

View File

@@ -1,5 +1,6 @@
import os
import logging import logging
import json
import os
from typing import Dict, List, Optional from typing import Dict, List, Optional
from .base_model_service import BaseModelService from .base_model_service import BaseModelService
@@ -278,6 +279,42 @@ class LoraService(BaseModelService):
return None return None
@staticmethod
def get_recommended_strength_from_lora_data(lora_data: Dict) -> Optional[float]:
"""Parse usage_tips JSON and extract recommended model strength."""
try:
usage_tips = lora_data.get("usage_tips", "")
if not usage_tips:
return None
tips_data = json.loads(usage_tips)
return tips_data.get("strength")
except (json.JSONDecodeError, TypeError, AttributeError):
return None
@staticmethod
def get_recommended_clip_strength_from_lora_data(
lora_data: Dict,
) -> Optional[float]:
"""Parse usage_tips JSON and extract recommended clip strength."""
try:
usage_tips = lora_data.get("usage_tips", "")
if not usage_tips:
return None
tips_data = json.loads(usage_tips)
return tips_data.get("clipStrength")
except (json.JSONDecodeError, TypeError, AttributeError):
return None
async def get_lora_metadata_by_filename(self, filename: str) -> Optional[Dict]:
"""Return cached raw metadata for a LoRA matching the given filename."""
cache = await self.scanner.get_cached_data(force_refresh=False)
for lora in cache.raw_data if cache else []:
if lora.get("file_name") == filename:
return lora
return None
def find_duplicate_hashes(self) -> Dict: def find_duplicate_hashes(self) -> Dict:
"""Find LoRAs with duplicate SHA256 hashes""" """Find LoRAs with duplicate SHA256 hashes"""
return self.scanner._hash_index.get_duplicate_hashes() return self.scanner._hash_index.get_duplicate_hashes()
@@ -328,34 +365,10 @@ class LoraService(BaseModelService):
List of LoRA dicts with randomized strengths List of LoRA dicts with randomized strengths
""" """
import random import random
import json
# Use a local Random instance to avoid affecting global random state # Use a local Random instance to avoid affecting global random state
# This ensures each execution with a different seed produces different results # This ensures each execution with a different seed produces different results
rng = random.Random(seed) rng = random.Random(seed)
def get_recommended_strength(lora_data: Dict) -> Optional[float]:
"""Parse usage_tips JSON and extract recommended strength"""
try:
usage_tips = lora_data.get("usage_tips", "")
if not usage_tips:
return None
tips_data = json.loads(usage_tips)
return tips_data.get("strength")
except (json.JSONDecodeError, TypeError, AttributeError):
return None
def get_recommended_clip_strength(lora_data: Dict) -> Optional[float]:
"""Parse usage_tips JSON and extract recommended clip strength"""
try:
usage_tips = lora_data.get("usage_tips", "")
if not usage_tips:
return None
tips_data = json.loads(usage_tips)
return tips_data.get("clipStrength")
except (json.JSONDecodeError, TypeError, AttributeError):
return None
if locked_loras is None: if locked_loras is None:
locked_loras = [] locked_loras = []
@@ -403,7 +416,9 @@ class LoraService(BaseModelService):
result_loras = [] result_loras = []
for lora in selected: for lora in selected:
if use_recommended_strength: if use_recommended_strength:
recommended_strength = get_recommended_strength(lora) recommended_strength = self.get_recommended_strength_from_lora_data(
lora
)
if recommended_strength is not None: if recommended_strength is not None:
scale = rng.uniform( scale = rng.uniform(
recommended_strength_scale_min, recommended_strength_scale_max recommended_strength_scale_min, recommended_strength_scale_max
@@ -421,7 +436,9 @@ class LoraService(BaseModelService):
if use_same_clip_strength: if use_same_clip_strength:
clip_str = model_str clip_str = model_str
elif use_recommended_strength: elif use_recommended_strength:
recommended_clip_strength = get_recommended_clip_strength(lora) recommended_clip_strength = (
self.get_recommended_clip_strength_from_lora_data(lora)
)
if recommended_clip_strength is not None: if recommended_clip_strength is not None:
scale = rng.uniform( scale = rng.uniform(
recommended_strength_scale_min, recommended_strength_scale_max recommended_strength_scale_min, recommended_strength_scale_max

View File

@@ -411,6 +411,7 @@ class ModelScanner:
if scan_result: if scan_result:
await self._apply_scan_result(scan_result) await self._apply_scan_result(scan_result)
await self._save_persistent_cache(scan_result) await self._save_persistent_cache(scan_result)
await self._sync_download_history(scan_result.raw_data, source='scan')
# Send final progress update # Send final progress update
await ws_manager.broadcast_init_progress({ await ws_manager.broadcast_init_progress({
@@ -516,6 +517,7 @@ class ModelScanner:
) )
await self._apply_scan_result(scan_result) await self._apply_scan_result(scan_result)
await self._sync_download_history(adjusted_raw_data, source='scan')
await ws_manager.broadcast_init_progress({ await ws_manager.broadcast_init_progress({
'stage': 'loading_cache', 'stage': 'loading_cache',
@@ -576,6 +578,7 @@ class ModelScanner:
excluded_models=list(self._excluded_models) excluded_models=list(self._excluded_models)
) )
await self._save_persistent_cache(snapshot) await self._save_persistent_cache(snapshot)
await self._sync_download_history(snapshot.raw_data, source='scan')
def _count_model_files(self) -> int: def _count_model_files(self) -> int:
"""Count all model files with supported extensions in all roots """Count all model files with supported extensions in all roots
@@ -704,6 +707,7 @@ class ModelScanner:
scan_result = await self._gather_model_data() scan_result = await self._gather_model_data()
await self._apply_scan_result(scan_result) await self._apply_scan_result(scan_result)
await self._save_persistent_cache(scan_result) await self._save_persistent_cache(scan_result)
await self._sync_download_history(scan_result.raw_data, source='scan')
logger.info( logger.info(
f"{self.model_type.capitalize()} Scanner: Cache initialization completed in {time.time() - start_time:.2f} seconds, " f"{self.model_type.capitalize()} Scanner: Cache initialization completed in {time.time() - start_time:.2f} seconds, "
@@ -732,18 +736,23 @@ class ModelScanner:
# Get current cached file paths # Get current cached file paths
cached_paths = {item['file_path'] for item in self._cache.raw_data} cached_paths = {item['file_path'] for item in self._cache.raw_data}
path_to_item = {item['file_path']: item for item in self._cache.raw_data} path_to_item = {item['file_path']: item for item in self._cache.raw_data}
cached_real_paths = {}
for cached_path in cached_paths:
try:
cached_real_paths.setdefault(os.path.realpath(cached_path), cached_path)
except Exception:
continue
# Track found files and new files # Track found files and new files
found_paths = set() found_paths = set()
new_files = [] new_files = []
visited_real_paths = set()
discovered_real_files = set()
# Scan all model roots # Scan all model roots
for root_path in self.get_model_roots(): for root_path in self.get_model_roots():
if not os.path.exists(root_path): if not os.path.exists(root_path):
continue continue
# Track visited real paths to avoid symlink loops
visited_real_paths = set()
# Recursively scan directory # Recursively scan directory
for root, _, files in os.walk(root_path, followlinks=True): for root, _, files in os.walk(root_path, followlinks=True):
@@ -757,12 +766,18 @@ class ModelScanner:
if ext in self.file_extensions: if ext in self.file_extensions:
# Construct paths exactly as they would be in cache # Construct paths exactly as they would be in cache
file_path = os.path.join(root, file).replace(os.sep, '/') file_path = os.path.join(root, file).replace(os.sep, '/')
real_file_path = os.path.realpath(os.path.join(root, file))
# Check if this file is already in cache # Check if this file is already in cache
if file_path in cached_paths: if file_path in cached_paths:
found_paths.add(file_path) found_paths.add(file_path)
continue continue
cached_real_match = cached_real_paths.get(real_file_path)
if cached_real_match:
found_paths.add(cached_real_match)
continue
if file_path in self._excluded_models: if file_path in self._excluded_models:
continue continue
@@ -778,6 +793,10 @@ class ModelScanner:
if matched: if matched:
continue continue
if real_file_path in discovered_real_files:
continue
discovered_real_files.add(real_file_path)
# This is a new file to process # This is a new file to process
new_files.append(file_path) new_files.append(file_path)
@@ -1086,6 +1105,49 @@ class ModelScanner:
await self._cache.resort() await self._cache.resort()
async def _sync_download_history(
self,
raw_data: List[Mapping[str, Any]],
*,
source: str,
) -> None:
records: List[Dict[str, Any]] = []
for item in raw_data or []:
if not isinstance(item, Mapping):
continue
civitai = item.get('civitai')
if not isinstance(civitai, Mapping):
continue
version_id = civitai.get('id')
if version_id in (None, ''):
continue
records.append(
{
'version_id': version_id,
'model_id': civitai.get('modelId'),
'file_path': item.get('file_path'),
}
)
if not records:
return
try:
history_service = await ServiceRegistry.get_downloaded_version_history_service()
await history_service.mark_downloaded_bulk(
self.model_type,
records,
source=source,
)
except Exception as exc:
logger.debug(
"%s Scanner: Failed to sync download history: %s",
self.model_type.capitalize(),
exc,
)
async def _gather_model_data( async def _gather_model_data(
self, self,
*, *,
@@ -1099,6 +1161,8 @@ class ModelScanner:
tags_count: Dict[str, int] = {} tags_count: Dict[str, int] = {}
excluded_models: List[str] = [] excluded_models: List[str] = []
processed_files = 0 processed_files = 0
processed_real_files: Set[str] = set()
visited_real_dirs: Set[str] = set()
async def handle_progress() -> None: async def handle_progress() -> None:
if progress_callback is None: if progress_callback is None:
@@ -1115,9 +1179,10 @@ class ModelScanner:
try: try:
real_path = os.path.realpath(current_path) real_path = os.path.realpath(current_path)
if real_path in visited_paths: if real_path in visited_paths or real_path in visited_real_dirs:
return return
visited_paths.add(real_path) visited_paths.add(real_path)
visited_real_dirs.add(real_path)
with os.scandir(current_path) as iterator: with os.scandir(current_path) as iterator:
entries = list(iterator) entries = list(iterator)
@@ -1130,6 +1195,11 @@ class ModelScanner:
continue continue
file_path = entry.path.replace(os.sep, "/") file_path = entry.path.replace(os.sep, "/")
real_file_path = os.path.realpath(entry.path)
if real_file_path in processed_real_files:
continue
processed_real_files.add(real_file_path)
result = await self._process_model_file( result = await self._process_model_file(
file_path, file_path,
root_path, root_path,

View File

@@ -13,7 +13,7 @@ from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence
from .errors import RateLimitError, ResourceNotFoundError from .errors import RateLimitError, ResourceNotFoundError
from .settings_manager import get_settings_manager from .settings_manager import get_settings_manager
from ..utils.civitai_utils import rewrite_preview_url from ..utils.civitai_utils import rewrite_preview_url
from ..utils.preview_selection import select_preview_media from ..utils.preview_selection import resolve_mature_threshold, select_preview_media
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -1252,14 +1252,23 @@ class ModelUpdateService:
return None return None
blur_mature_content = True blur_mature_content = True
mature_threshold = resolve_mature_threshold({"mature_blur_level": "R"})
settings = getattr(self, "_settings", None) settings = getattr(self, "_settings", None)
if settings is not None and hasattr(settings, "get"): if settings is not None and hasattr(settings, "get"):
try: try:
blur_mature_content = bool(settings.get("blur_mature_content", True)) blur_mature_content = bool(settings.get("blur_mature_content", True))
mature_threshold = resolve_mature_threshold(
{"mature_blur_level": settings.get("mature_blur_level", "R")}
)
except Exception: # pragma: no cover - defensive guard except Exception: # pragma: no cover - defensive guard
blur_mature_content = True blur_mature_content = True
mature_threshold = resolve_mature_threshold({"mature_blur_level": "R"})
selected, _ = select_preview_media(candidates, blur_mature_content=blur_mature_content) selected, _ = select_preview_media(
candidates,
blur_mature_content=blur_mature_content,
mature_threshold=mature_threshold,
)
if not selected: if not selected:
return None return None

View File

@@ -9,7 +9,7 @@ from urllib.parse import urlparse
from ..utils.constants import CARD_PREVIEW_WIDTH, PREVIEW_EXTENSIONS from ..utils.constants import CARD_PREVIEW_WIDTH, PREVIEW_EXTENSIONS
from ..utils.civitai_utils import rewrite_preview_url from ..utils.civitai_utils import rewrite_preview_url
from ..utils.preview_selection import select_preview_media from ..utils.preview_selection import resolve_mature_threshold, select_preview_media
from .settings_manager import get_settings_manager from .settings_manager import get_settings_manager
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -49,9 +49,13 @@ class PreviewAssetService:
blur_mature_content = bool( blur_mature_content = bool(
settings_manager.get("blur_mature_content", True) settings_manager.get("blur_mature_content", True)
) )
mature_threshold = resolve_mature_threshold(
{"mature_blur_level": settings_manager.get("mature_blur_level", "R")}
)
first_preview, nsfw_level = select_preview_media( first_preview, nsfw_level = select_preview_media(
images, images,
blur_mature_content=blur_mature_content, blur_mature_content=blur_mature_content,
mature_threshold=mature_threshold,
) )
if not first_preview: if not first_preview:
@@ -216,4 +220,3 @@ class PreviewAssetService:
if "webm" in content_type: if "webm" in content_type:
return ".webm" return ".webm"
return ".mp4" return ".mp4"

View File

@@ -1615,6 +1615,9 @@ class RecipeScanner:
) -> Optional[Dict[str, Any]]: ) -> Optional[Dict[str, Any]]:
"""Coerce legacy or malformed checkpoint entries into a dict.""" """Coerce legacy or malformed checkpoint entries into a dict."""
if checkpoint_raw is None:
return None
if isinstance(checkpoint_raw, dict): if isinstance(checkpoint_raw, dict):
return dict(checkpoint_raw) return dict(checkpoint_raw)
@@ -1632,9 +1635,6 @@ class RecipeScanner:
"file_name": file_name, "file_name": file_name,
} }
logger.warning(
"Unexpected checkpoint payload type %s", type(checkpoint_raw).__name__
)
return None return None
def _enrich_checkpoint_entry(self, checkpoint: Dict[str, Any]) -> Dict[str, Any]: def _enrich_checkpoint_entry(self, checkpoint: Dict[str, Any]) -> Dict[str, Any]:
@@ -1790,6 +1790,7 @@ class RecipeScanner:
filters: dict = None, filters: dict = None,
search_options: dict = None, search_options: dict = None,
lora_hash: str = None, lora_hash: str = None,
checkpoint_hash: str = None,
bypass_filters: bool = True, bypass_filters: bool = True,
folder: str | None = None, folder: str | None = None,
recursive: bool = True, recursive: bool = True,
@@ -1804,7 +1805,8 @@ class RecipeScanner:
filters: Dictionary of filters to apply filters: Dictionary of filters to apply
search_options: Dictionary of search options to apply search_options: Dictionary of search options to apply
lora_hash: Optional SHA256 hash of a LoRA to filter recipes by lora_hash: Optional SHA256 hash of a LoRA to filter recipes by
bypass_filters: If True, ignore other filters when a lora_hash is provided checkpoint_hash: Optional SHA256 hash of a checkpoint to filter recipes by
bypass_filters: If True, ignore other filters when a hash filter is provided
folder: Optional folder filter relative to recipes directory folder: Optional folder filter relative to recipes directory
recursive: Whether to include recipes in subfolders of the selected folder recursive: Whether to include recipes in subfolders of the selected folder
""" """
@@ -1852,9 +1854,23 @@ class RecipeScanner:
# Skip other filters if bypass_filters is True # Skip other filters if bypass_filters is True
pass pass
# Otherwise continue with normal filtering after applying LoRA hash filter # Otherwise continue with normal filtering after applying LoRA hash filter
elif checkpoint_hash:
normalized_checkpoint_hash = checkpoint_hash.lower()
filtered_data = [
item
for item in filtered_data
if isinstance(item.get("checkpoint"), dict)
and (item["checkpoint"].get("hash", "") or "").lower()
== normalized_checkpoint_hash
]
# Skip further filtering if we're only filtering by LoRA hash with bypass enabled if bypass_filters:
if not (lora_hash and bypass_filters): pass
has_hash_filter = bool(lora_hash or checkpoint_hash)
# Skip further filtering if we're only filtering by model hash with bypass enabled
if not (has_hash_filter and bypass_filters):
# Apply folder filter before other criteria # Apply folder filter before other criteria
if folder is not None: if folder is not None:
normalized_folder = folder.strip("/") normalized_folder = folder.strip("/")
@@ -2334,6 +2350,38 @@ class RecipeScanner:
return matching_recipes return matching_recipes
async def get_recipes_for_checkpoint(
self, checkpoint_hash: str
) -> List[Dict[str, Any]]:
"""Return recipes that reference a given checkpoint hash."""
if not checkpoint_hash:
return []
normalized_hash = checkpoint_hash.lower()
cache = await self.get_cached_data()
matching_recipes: List[Dict[str, Any]] = []
for recipe in cache.raw_data:
checkpoint = self._normalize_checkpoint_entry(recipe.get("checkpoint"))
if not checkpoint:
continue
enriched_checkpoint = self._enrich_checkpoint_entry(dict(checkpoint))
if (enriched_checkpoint.get("hash") or "").lower() != normalized_hash:
continue
recipe_copy = {**recipe}
recipe_copy["checkpoint"] = enriched_checkpoint
recipe_copy["loras"] = [
self._enrich_lora_entry(dict(entry))
for entry in recipe.get("loras", [])
]
recipe_copy["file_url"] = self._format_file_url(recipe.get("file_path"))
matching_recipes.append(recipe_copy)
return matching_recipes
async def get_recipe_syntax_tokens(self, recipe_id: str) -> List[str]: async def get_recipe_syntax_tokens(self, recipe_id: str) -> List[str]:
"""Build LoRA syntax tokens for a recipe.""" """Build LoRA syntax tokens for a recipe."""

View File

@@ -143,6 +143,12 @@ class RecipeAnalysisService:
): ):
metadata = metadata["meta"] metadata = metadata["meta"]
# Include modelVersionIds from root level if available
# Civitai API returns modelVersionIds at root level, not in meta
model_version_ids = image_info.get("modelVersionIds")
if model_version_ids and isinstance(metadata, dict):
metadata["modelVersionIds"] = model_version_ids
# Validate that metadata contains meaningful recipe fields # Validate that metadata contains meaningful recipe fields
# If not, treat as None to trigger EXIF extraction from downloaded image # If not, treat as None to trigger EXIF extraction from downloaded image
if isinstance(metadata, dict) and not self._has_recipe_fields(metadata): if isinstance(metadata, dict) and not self._has_recipe_fields(metadata):

View File

@@ -173,11 +173,23 @@ class RecipePersistenceService:
async def update_recipe(self, *, recipe_scanner, recipe_id: str, updates: dict[str, Any]) -> PersistenceResult: async def update_recipe(self, *, recipe_scanner, recipe_id: str, updates: dict[str, Any]) -> PersistenceResult:
"""Update persisted metadata for a recipe.""" """Update persisted metadata for a recipe."""
if not any(key in updates for key in ("title", "tags", "source_path", "preview_nsfw_level", "favorite")): allowed_fields = (
"title",
"tags",
"source_path",
"preview_nsfw_level",
"favorite",
"gen_params",
)
if not any(key in updates for key in allowed_fields):
raise RecipeValidationError( raise RecipeValidationError(
"At least one field to update must be provided (title or tags or source_path or preview_nsfw_level or favorite)" "At least one field to update must be provided (title or tags or source_path or preview_nsfw_level or favorite or gen_params)"
) )
if "gen_params" in updates and not isinstance(updates["gen_params"], dict):
raise RecipeValidationError("gen_params must be an object")
success = await recipe_scanner.update_recipe_metadata(recipe_id, updates) success = await recipe_scanner.update_recipe_metadata(recipe_id, updates)
if not success: if not success:
raise RecipeNotFoundError("Recipe not found or update failed") raise RecipeNotFoundError("Recipe not found or update failed")

View File

@@ -167,6 +167,28 @@ class ServiceRegistry:
logger.debug(f"Created and registered {service_name}") logger.debug(f"Created and registered {service_name}")
return service return service
@classmethod
async def get_downloaded_version_history_service(cls):
"""Get or create the downloaded-version history service."""
service_name = "downloaded_version_history_service"
if service_name in cls._services:
return cls._services[service_name]
async with cls._get_lock(service_name):
if service_name in cls._services:
return cls._services[service_name]
from .downloaded_version_history_service import (
DownloadedVersionHistoryService,
)
service = DownloadedVersionHistoryService()
cls._services[service_name] = service
logger.debug(f"Created and registered {service_name}")
return service
@classmethod @classmethod
async def get_civarchive_client(cls): async def get_civarchive_client(cls):
"""Get or create CivArchive client instance""" """Get or create CivArchive client instance"""
@@ -255,4 +277,4 @@ class ServiceRegistry:
"""Clear all registered services - mainly for testing""" """Clear all registered services - mainly for testing"""
cls._services.clear() cls._services.clear()
cls._locks.clear() cls._locks.clear()
logger.info("Cleared all registered services") logger.info("Cleared all registered services")

View File

@@ -7,12 +7,31 @@ import logging
from pathlib import Path from pathlib import Path
from datetime import datetime, timezone from datetime import datetime, timezone
from threading import Lock from threading import Lock
from typing import Any, Awaitable, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple from typing import (
Any,
Awaitable,
Dict,
Iterable,
List,
Mapping,
Optional,
Sequence,
Tuple,
)
from platformdirs import user_config_dir from platformdirs import user_config_dir
from ..utils.constants import DEFAULT_HASH_CHUNK_SIZE_MB, DEFAULT_PRIORITY_TAG_CONFIG from ..utils.constants import (
from ..utils.settings_paths import APP_NAME, ensure_settings_file, get_legacy_settings_path DEFAULT_HASH_CHUNK_SIZE_MB,
DEFAULT_PRIORITY_TAG_CONFIG,
SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS,
)
from ..utils.preview_selection import VALID_MATURE_BLUR_LEVELS
from ..utils.settings_paths import (
APP_NAME,
ensure_settings_file,
get_legacy_settings_path,
)
from ..utils.tag_priorities import ( from ..utils.tag_priorities import (
PriorityTagEntry, PriorityTagEntry,
collect_canonical_tags, collect_canonical_tags,
@@ -59,6 +78,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
"optimize_example_images": True, "optimize_example_images": True,
"auto_download_example_images": False, "auto_download_example_images": False,
"blur_mature_content": True, "blur_mature_content": True,
"mature_blur_level": "R",
"autoplay_on_hover": False, "autoplay_on_hover": False,
"display_density": "default", "display_density": "default",
"card_info_display": "always", "card_info_display": "always",
@@ -71,6 +91,8 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
"update_flag_strategy": "same_base", "update_flag_strategy": "same_base",
"auto_organize_exclusions": [], "auto_organize_exclusions": [],
"metadata_refresh_skip_paths": [], "metadata_refresh_skip_paths": [],
"skip_previously_downloaded_model_versions": False,
"download_skip_base_models": [],
} }
@@ -87,7 +109,9 @@ class SettingsManager:
self._template_payload_cache_loaded = False self._template_payload_cache_loaded = False
self._original_disk_payload: Optional[Dict[str, Any]] = None self._original_disk_payload: Optional[Dict[str, Any]] = None
self._preserve_disk_template = False self._preserve_disk_template = False
self._template_path = Path(__file__).resolve().parents[2] / "settings.json.example" self._template_path = (
Path(__file__).resolve().parents[2] / "settings.json.example"
)
self.settings = self._load_settings() self.settings = self._load_settings()
self._migrate_setting_keys() self._migrate_setting_keys()
self._ensure_default_settings() self._ensure_default_settings()
@@ -113,7 +137,7 @@ class SettingsManager:
"""Load settings from file""" """Load settings from file"""
if os.path.exists(self.settings_file): if os.path.exists(self.settings_file):
try: try:
with open(self.settings_file, 'r', encoding='utf-8') as f: with open(self.settings_file, "r", encoding="utf-8") as f:
data = json.load(f) data = json.load(f)
if isinstance(data, dict): if isinstance(data, dict):
self._original_disk_payload = copy.deepcopy(data) self._original_disk_payload = copy.deepcopy(data)
@@ -191,7 +215,9 @@ class SettingsManager:
return None return None
if not isinstance(data, dict): if not isinstance(data, dict):
logger.debug("settings.json.example is not a JSON object; ignoring template") logger.debug(
"settings.json.example is not a JSON object; ignoring template"
)
return None return None
self._template_payload_cache = copy.deepcopy(data) self._template_payload_cache = copy.deepcopy(data)
@@ -267,13 +293,42 @@ class SettingsManager:
normalized_skip_paths = self.normalize_metadata_refresh_skip_paths( normalized_skip_paths = self.normalize_metadata_refresh_skip_paths(
self.settings.get("metadata_refresh_skip_paths") self.settings.get("metadata_refresh_skip_paths")
) )
if normalized_skip_paths != self.settings.get("metadata_refresh_skip_paths"): if normalized_skip_paths != self.settings.get(
"metadata_refresh_skip_paths"
):
self.settings["metadata_refresh_skip_paths"] = normalized_skip_paths self.settings["metadata_refresh_skip_paths"] = normalized_skip_paths
updated_existing = True updated_existing = True
else: else:
self.settings["metadata_refresh_skip_paths"] = [] self.settings["metadata_refresh_skip_paths"] = []
inserted_defaults = True inserted_defaults = True
if "download_skip_base_models" in self.settings:
normalized_skip_base_models = self.normalize_download_skip_base_models(
self.settings.get("download_skip_base_models")
)
if normalized_skip_base_models != self.settings.get(
"download_skip_base_models"
):
self.settings["download_skip_base_models"] = normalized_skip_base_models
updated_existing = True
else:
self.settings["download_skip_base_models"] = []
inserted_defaults = True
if "skip_previously_downloaded_model_versions" not in self.settings:
self.settings["skip_previously_downloaded_model_versions"] = False
inserted_defaults = True
had_mature_level = "mature_blur_level" in self.settings
raw_mature_level = self.settings.get("mature_blur_level")
normalized_mature_level = self.normalize_mature_blur_level(raw_mature_level)
if normalized_mature_level != raw_mature_level:
self.settings["mature_blur_level"] = normalized_mature_level
if had_mature_level:
updated_existing = True
else:
inserted_defaults = True
for key, value in defaults.items(): for key, value in defaults.items():
if key == "priority_tags": if key == "priority_tags":
continue continue
@@ -298,19 +353,19 @@ class SettingsManager:
raw_top_level_paths = self.settings.get("folder_paths", {}) raw_top_level_paths = self.settings.get("folder_paths", {})
normalized_top_level_paths: Dict[str, List[str]] = {} normalized_top_level_paths: Dict[str, List[str]] = {}
if isinstance(raw_top_level_paths, Mapping): if isinstance(raw_top_level_paths, Mapping):
normalized_top_level_paths = self._normalize_folder_paths(raw_top_level_paths) normalized_top_level_paths = self._normalize_folder_paths(
raw_top_level_paths
)
if normalized_top_level_paths != raw_top_level_paths: if normalized_top_level_paths != raw_top_level_paths:
self.settings["folder_paths"] = copy.deepcopy(normalized_top_level_paths) self.settings["folder_paths"] = copy.deepcopy(
normalized_top_level_paths
)
top_level_has_paths = self._has_configured_paths(normalized_top_level_paths) top_level_has_paths = self._has_configured_paths(normalized_top_level_paths)
needs_library_bootstrap = not isinstance(libraries, dict) or not libraries needs_library_bootstrap = not isinstance(libraries, dict) or not libraries
if ( if not needs_library_bootstrap and top_level_has_paths and len(libraries) == 1:
not needs_library_bootstrap
and top_level_has_paths
and len(libraries) == 1
):
only_library_payload = next(iter(libraries.values())) only_library_payload = next(iter(libraries.values()))
if isinstance(only_library_payload, Mapping): if isinstance(only_library_payload, Mapping):
folder_payload = only_library_payload.get("folder_paths") folder_payload = only_library_payload.get("folder_paths")
@@ -322,7 +377,9 @@ class SettingsManager:
library_payload = self._build_library_payload( library_payload = self._build_library_payload(
folder_paths=normalized_top_level_paths, folder_paths=normalized_top_level_paths,
default_lora_root=self.settings.get("default_lora_root", ""), default_lora_root=self.settings.get("default_lora_root", ""),
default_checkpoint_root=self.settings.get("default_checkpoint_root", ""), default_checkpoint_root=self.settings.get(
"default_checkpoint_root", ""
),
default_unet_root=self.settings.get("default_unet_root", ""), default_unet_root=self.settings.get("default_unet_root", ""),
default_embedding_root=self.settings.get("default_embedding_root", ""), default_embedding_root=self.settings.get("default_embedding_root", ""),
) )
@@ -344,7 +401,11 @@ class SettingsManager:
if target_name: if target_name:
candidate_payload = libraries.get(target_name) candidate_payload = libraries.get(target_name)
if isinstance(candidate_payload, Mapping) and not self._has_configured_paths(candidate_payload.get("folder_paths")): if isinstance(
candidate_payload, Mapping
) and not self._has_configured_paths(
candidate_payload.get("folder_paths")
):
seed_library_name = target_name seed_library_name = target_name
sanitized_libraries: Dict[str, Dict[str, Any]] = {} sanitized_libraries: Dict[str, Dict[str, Any]] = {}
@@ -403,11 +464,17 @@ class SettingsManager:
active_library = libraries.get(active_name, {}) active_library = libraries.get(active_name, {})
folder_paths = copy.deepcopy(active_library.get("folder_paths", {})) folder_paths = copy.deepcopy(active_library.get("folder_paths", {}))
self.settings["folder_paths"] = folder_paths self.settings["folder_paths"] = folder_paths
self.settings["extra_folder_paths"] = copy.deepcopy(active_library.get("extra_folder_paths", {})) self.settings["extra_folder_paths"] = copy.deepcopy(
active_library.get("extra_folder_paths", {})
)
self.settings["default_lora_root"] = active_library.get("default_lora_root", "") self.settings["default_lora_root"] = active_library.get("default_lora_root", "")
self.settings["default_checkpoint_root"] = active_library.get("default_checkpoint_root", "") self.settings["default_checkpoint_root"] = active_library.get(
"default_checkpoint_root", ""
)
self.settings["default_unet_root"] = active_library.get("default_unet_root", "") self.settings["default_unet_root"] = active_library.get("default_unet_root", "")
self.settings["default_embedding_root"] = active_library.get("default_embedding_root", "") self.settings["default_embedding_root"] = active_library.get(
"default_embedding_root", ""
)
if save: if save:
self._save_settings() self._save_settings()
@@ -436,7 +503,9 @@ class SettingsManager:
payload.setdefault("folder_paths", {}) payload.setdefault("folder_paths", {})
if extra_folder_paths is not None: if extra_folder_paths is not None:
payload["extra_folder_paths"] = self._normalize_folder_paths(extra_folder_paths) payload["extra_folder_paths"] = self._normalize_folder_paths(
extra_folder_paths
)
else: else:
payload.setdefault("extra_folder_paths", {}) payload.setdefault("extra_folder_paths", {})
@@ -545,7 +614,9 @@ class SettingsManager:
} }
overlap = existing.intersection(new_paths.keys()) overlap = existing.intersection(new_paths.keys())
if overlap: if overlap:
collisions = ", ".join(sorted(new_paths[value] for value in overlap)) collisions = ", ".join(
sorted(new_paths[value] for value in overlap)
)
raise ValueError( raise ValueError(
f"Folder path(s) {collisions} already assigned to library '{other_name}'" f"Folder path(s) {collisions} already assigned to library '{other_name}'"
) )
@@ -580,19 +651,31 @@ class SettingsManager:
library["extra_folder_paths"] = normalized_extra_paths library["extra_folder_paths"] = normalized_extra_paths
changed = True changed = True
if default_lora_root is not None and library.get("default_lora_root") != default_lora_root: if (
default_lora_root is not None
and library.get("default_lora_root") != default_lora_root
):
library["default_lora_root"] = default_lora_root library["default_lora_root"] = default_lora_root
changed = True changed = True
if default_checkpoint_root is not None and library.get("default_checkpoint_root") != default_checkpoint_root: if (
default_checkpoint_root is not None
and library.get("default_checkpoint_root") != default_checkpoint_root
):
library["default_checkpoint_root"] = default_checkpoint_root library["default_checkpoint_root"] = default_checkpoint_root
changed = True changed = True
if default_unet_root is not None and library.get("default_unet_root") != default_unet_root: if (
default_unet_root is not None
and library.get("default_unet_root") != default_unet_root
):
library["default_unet_root"] = default_unet_root library["default_unet_root"] = default_unet_root
changed = True changed = True
if default_embedding_root is not None and library.get("default_embedding_root") != default_embedding_root: if (
default_embedding_root is not None
and library.get("default_embedding_root") != default_embedding_root
):
library["default_embedding_root"] = default_embedding_root library["default_embedding_root"] = default_embedding_root
changed = True changed = True
@@ -605,15 +688,16 @@ class SettingsManager:
def _migrate_setting_keys(self) -> None: def _migrate_setting_keys(self) -> None:
"""Migrate legacy camelCase setting keys to snake_case""" """Migrate legacy camelCase setting keys to snake_case"""
key_migrations = { key_migrations = {
'optimizeExampleImages': 'optimize_example_images', "optimizeExampleImages": "optimize_example_images",
'autoDownloadExampleImages': 'auto_download_example_images', "autoDownloadExampleImages": "auto_download_example_images",
'blurMatureContent': 'blur_mature_content', "blurMatureContent": "blur_mature_content",
'autoplayOnHover': 'autoplay_on_hover', "matureBlurLevel": "mature_blur_level",
'displayDensity': 'display_density', "autoplayOnHover": "autoplay_on_hover",
'cardInfoDisplay': 'card_info_display', "displayDensity": "display_density",
'includeTriggerWords': 'include_trigger_words', "cardInfoDisplay": "card_info_display",
'compactMode': 'compact_mode', "includeTriggerWords": "include_trigger_words",
'modelCardFooterAction': 'model_card_footer_action', "compactMode": "compact_mode",
"modelCardFooterAction": "model_card_footer_action",
} }
updated = False updated = False
@@ -630,65 +714,77 @@ class SettingsManager:
def _migrate_download_path_template(self): def _migrate_download_path_template(self):
"""Migrate old download_path_template to new download_path_templates""" """Migrate old download_path_template to new download_path_templates"""
old_template = self.settings.get('download_path_template') old_template = self.settings.get("download_path_template")
templates = self.settings.get('download_path_templates') templates = self.settings.get("download_path_templates")
# If old template exists and new templates don't exist, migrate # If old template exists and new templates don't exist, migrate
if old_template is not None and not templates: if old_template is not None and not templates:
logger.info("Migrating download_path_template to download_path_templates") logger.info("Migrating download_path_template to download_path_templates")
self.settings['download_path_templates'] = { self.settings["download_path_templates"] = {
'lora': old_template, "lora": old_template,
'checkpoint': old_template, "checkpoint": old_template,
'embedding': old_template "embedding": old_template,
} }
# Remove old setting # Remove old setting
del self.settings['download_path_template'] del self.settings["download_path_template"]
self._save_settings() self._save_settings()
logger.info("Migration completed") logger.info("Migration completed")
def _auto_set_default_roots(self): def _auto_set_default_roots(self):
"""Auto set default root paths when the current default is unset or not among the options. """Ensure default root paths always point at a current valid root.
For single-path cases, always use that path. Empty or stale defaults are repaired to the first configured root.
For multi-path cases, only set if current default is empty or invalid. Skips auto-setting when the settings file matches the template
(user hasn't customized yet).
""" """
folder_paths = self.settings.get('folder_paths', {}) # Skip auto-setting if the user hasn't customized settings yet (template preserved)
if self._preserve_disk_template:
return
folder_paths = self.settings.get("folder_paths", {})
updated = False updated = False
# loras
loras = folder_paths.get('loras', []) def _check_and_auto_set(key: str, setting_key: str) -> bool:
if isinstance(loras, list) and len(loras) == 1: """Repair default roots when empty or no longer present."""
current_lora_root = self.settings.get('default_lora_root') current = self.settings.get(setting_key, "")
if current_lora_root not in loras: candidates = folder_paths.get(key, [])
self.settings['default_lora_root'] = loras[0] if not isinstance(candidates, list) or not candidates:
updated = True return False
# checkpoints
checkpoints = folder_paths.get('checkpoints', []) # Filter valid path strings
if isinstance(checkpoints, list) and len(checkpoints) == 1: valid_paths = [p for p in candidates if isinstance(p, str) and p.strip()]
current_checkpoint_root = self.settings.get('default_checkpoint_root') if not valid_paths:
if current_checkpoint_root not in checkpoints: return False
self.settings['default_checkpoint_root'] = checkpoints[0]
updated = True if current in valid_paths:
# unet (diffusion models) - auto-set if empty or invalid return False
unet_paths = folder_paths.get('unet', [])
if isinstance(unet_paths, list) and len(unet_paths) >= 1: self.settings[setting_key] = valid_paths[0]
current_unet_root = self.settings.get('default_unet_root') if current:
# Set to first path if current is empty or not in the valid paths logger.info(
if not current_unet_root or current_unet_root not in unet_paths: "Repaired stale %s from '%s' to '%s'",
self.settings['default_unet_root'] = unet_paths[0] setting_key,
updated = True current,
# embeddings valid_paths[0],
embeddings = folder_paths.get('embeddings', []) )
if isinstance(embeddings, list) and len(embeddings) == 1: else:
current_embedding_root = self.settings.get('default_embedding_root') logger.info("Auto-set %s to '%s'", setting_key, valid_paths[0])
if current_embedding_root not in embeddings: return True
self.settings['default_embedding_root'] = embeddings[0]
updated = True # Process all model types
updated = _check_and_auto_set("loras", "default_lora_root") or updated
updated = (
_check_and_auto_set("checkpoints", "default_checkpoint_root") or updated
)
updated = _check_and_auto_set("unet", "default_unet_root") or updated
updated = _check_and_auto_set("embeddings", "default_embedding_root") or updated
if updated: if updated:
self._update_active_library_entry( self._update_active_library_entry(
default_lora_root=self.settings.get('default_lora_root'), default_lora_root=self.settings.get("default_lora_root"),
default_checkpoint_root=self.settings.get('default_checkpoint_root'), default_checkpoint_root=self.settings.get("default_checkpoint_root"),
default_unet_root=self.settings.get('default_unet_root'), default_unet_root=self.settings.get("default_unet_root"),
default_embedding_root=self.settings.get('default_embedding_root'), default_embedding_root=self.settings.get("default_embedding_root"),
) )
if self._bootstrap_reason == "missing": if self._bootstrap_reason == "missing":
self._needs_initial_save = True self._needs_initial_save = True
@@ -697,11 +793,11 @@ class SettingsManager:
def _check_environment_variables(self) -> None: def _check_environment_variables(self) -> None:
"""Check for environment variables and update settings if needed""" """Check for environment variables and update settings if needed"""
env_api_key = os.environ.get('CIVITAI_API_KEY') env_api_key = os.environ.get("CIVITAI_API_KEY")
if env_api_key: # Check if the environment variable exists and is not empty if env_api_key: # Check if the environment variable exists and is not empty
logger.info("Found CIVITAI_API_KEY environment variable") logger.info("Found CIVITAI_API_KEY environment variable")
# Always use the environment variable if it exists # Always use the environment variable if it exists
self.settings['civitai_api_key'] = env_api_key self.settings["civitai_api_key"] = env_api_key
self._save_settings() self._save_settings()
def _default_settings_actions(self) -> List[Dict[str, Any]]: def _default_settings_actions(self) -> List[Dict[str, Any]]:
@@ -766,7 +862,9 @@ class SettingsManager:
disk_value = self._original_disk_payload.get(key) disk_value = self._original_disk_payload.get(key)
default_value = defaults.get(key) default_value = defaults.get(key)
# Compare using JSON serialization for complex objects # Compare using JSON serialization for complex objects
if json.dumps(disk_value, sort_keys=True, default=str) == json.dumps(default_value, sort_keys=True, default=str): if json.dumps(disk_value, sort_keys=True, default=str) == json.dumps(
default_value, sort_keys=True, default=str
):
default_value_keys.add(key) default_value_keys.add(key)
# Only cleanup if there are "many" default keys (indicating a bloated file) # Only cleanup if there are "many" default keys (indicating a bloated file)
@@ -774,7 +872,7 @@ class SettingsManager:
if len(default_value_keys) >= DEFAULT_KEYS_CLEANUP_THRESHOLD: if len(default_value_keys) >= DEFAULT_KEYS_CLEANUP_THRESHOLD:
logger.info( logger.info(
"Cleaning up %d default value(s) from settings.json to keep it minimal", "Cleaning up %d default value(s) from settings.json to keep it minimal",
len(default_value_keys) len(default_value_keys),
) )
self._save_settings() self._save_settings()
# Update original payload to match what we just saved # Update original payload to match what we just saved
@@ -784,8 +882,8 @@ class SettingsManager:
if not self._standalone_mode: if not self._standalone_mode:
return return
folder_paths = self.settings.get('folder_paths', {}) or {} folder_paths = self.settings.get("folder_paths", {}) or {}
monitored_keys = ('loras', 'checkpoints', 'embeddings') monitored_keys = ("loras", "checkpoints", "embeddings")
has_valid_paths = False has_valid_paths = False
for key in monitored_keys: for key in monitored_keys:
@@ -796,7 +894,10 @@ class SettingsManager:
iterator = list(raw_paths) iterator = list(raw_paths)
except TypeError: except TypeError:
continue continue
if any(isinstance(path, str) and path and os.path.exists(path) for path in iterator): if any(
isinstance(path, str) and path and os.path.exists(path)
for path in iterator
):
has_valid_paths = True has_valid_paths = True
break break
@@ -827,13 +928,13 @@ class SettingsManager:
def _get_default_settings(self) -> Dict[str, Any]: def _get_default_settings(self) -> Dict[str, Any]:
"""Return default settings""" """Return default settings"""
defaults = copy.deepcopy(DEFAULT_SETTINGS) defaults = copy.deepcopy(DEFAULT_SETTINGS)
defaults['base_model_path_mappings'] = {} defaults["base_model_path_mappings"] = {}
defaults['download_path_templates'] = {} defaults["download_path_templates"] = {}
defaults['priority_tags'] = DEFAULT_PRIORITY_TAG_CONFIG.copy() defaults["priority_tags"] = DEFAULT_PRIORITY_TAG_CONFIG.copy()
defaults.setdefault('folder_paths', {}) defaults.setdefault("folder_paths", {})
defaults.setdefault('extra_folder_paths', {}) defaults.setdefault("extra_folder_paths", {})
defaults['auto_organize_exclusions'] = [] defaults["auto_organize_exclusions"] = []
defaults['metadata_refresh_skip_paths'] = [] defaults["metadata_refresh_skip_paths"] = []
library_name = defaults.get("active_library") or "default" library_name = defaults.get("active_library") or "default"
default_library = self._build_library_payload( default_library = self._build_library_payload(
@@ -843,8 +944,8 @@ class SettingsManager:
default_checkpoint_root=defaults.get("default_checkpoint_root"), default_checkpoint_root=defaults.get("default_checkpoint_root"),
default_embedding_root=defaults.get("default_embedding_root"), default_embedding_root=defaults.get("default_embedding_root"),
) )
defaults['libraries'] = {library_name: default_library} defaults["libraries"] = {library_name: default_library}
defaults['active_library'] = library_name defaults["active_library"] = library_name
return defaults return defaults
def _normalize_priority_tag_config(self, value: Any) -> Dict[str, str]: def _normalize_priority_tag_config(self, value: Any) -> Dict[str, str]:
@@ -860,6 +961,13 @@ class SettingsManager:
return normalized return normalized
def normalize_mature_blur_level(self, value: Any) -> str:
if isinstance(value, str):
normalized = value.strip().upper()
if normalized in VALID_MATURE_BLUR_LEVELS:
return normalized
return "R"
def normalize_auto_organize_exclusions(self, value: Any) -> List[str]: def normalize_auto_organize_exclusions(self, value: Any) -> List[str]:
if value is None: if value is None:
return [] return []
@@ -868,7 +976,9 @@ class SettingsManager:
candidates: Iterable[str] = ( candidates: Iterable[str] = (
value.replace("\n", ",").replace(";", ",").split(",") value.replace("\n", ",").replace(";", ",").split(",")
) )
elif isinstance(value, Sequence) and not isinstance(value, (bytes, bytearray, str)): elif isinstance(value, Sequence) and not isinstance(
value, (bytes, bytearray, str)
):
candidates = value candidates = value
else: else:
return [] return []
@@ -914,7 +1024,9 @@ class SettingsManager:
candidates: Iterable[str] = ( candidates: Iterable[str] = (
value.replace("\n", ",").replace(";", ",").split(",") value.replace("\n", ",").replace(";", ",").split(",")
) )
elif isinstance(value, Sequence) and not isinstance(value, (bytes, bytearray, str)): elif isinstance(value, Sequence) and not isinstance(
value, (bytes, bytearray, str)
):
candidates = value candidates = value
else: else:
return [] return []
@@ -944,6 +1056,56 @@ class SettingsManager:
self._save_settings() self._save_settings()
return skip_paths return skip_paths
def normalize_download_skip_base_models(self, value: Any) -> List[str]:
if value is None:
return []
if isinstance(value, str):
candidates: Iterable[str] = (
value.replace("\n", ",").replace(";", ",").split(",")
)
elif isinstance(value, Sequence) and not isinstance(
value, (bytes, bytearray, str)
):
candidates = value
else:
return []
base_models: List[str] = []
seen = set()
for raw in candidates:
if not isinstance(raw, str):
continue
token = raw.strip()
if not token or token not in SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS:
continue
if token in seen:
continue
seen.add(token)
base_models.append(token)
return base_models
def get_download_skip_base_models(self) -> List[str]:
base_models = self.normalize_download_skip_base_models(
self.settings.get("download_skip_base_models")
)
if base_models != self.settings.get("download_skip_base_models"):
self.settings["download_skip_base_models"] = base_models
self._save_settings()
return base_models
def get_skip_previously_downloaded_model_versions(self) -> bool:
value = self.settings.get("skip_previously_downloaded_model_versions", False)
if isinstance(value, bool):
return value
normalized = False
if isinstance(value, str):
normalized = value.strip().lower() in {"1", "true", "yes", "on"}
self.settings["skip_previously_downloaded_model_versions"] = normalized
self._save_settings()
return normalized
def get_extra_folder_paths(self) -> Dict[str, List[str]]: def get_extra_folder_paths(self) -> Dict[str, List[str]]:
"""Get extra folder paths for the active library. """Get extra folder paths for the active library.
@@ -967,6 +1129,74 @@ class SettingsManager:
active_name = self.get_active_library_name() active_name = self.get_active_library_name()
self._validate_folder_paths(active_name, extra_folder_paths) self._validate_folder_paths(active_name, extra_folder_paths)
active_library = self.get_active_library()
active_folder_paths = active_library.get("folder_paths", {})
active_lora_paths = active_folder_paths.get("loras", []) or []
requested_extra_lora_paths = extra_folder_paths.get("loras", []) or []
primary_real_paths = set()
for path in active_lora_paths:
if not isinstance(path, str):
continue
stripped = path.strip()
if not stripped:
continue
normalized = os.path.normcase(os.path.normpath(stripped))
if os.path.exists(stripped):
normalized = os.path.normcase(
os.path.normpath(os.path.realpath(stripped))
)
primary_real_paths.add(normalized)
primary_symlink_targets = set()
for path in active_lora_paths:
if not isinstance(path, str):
continue
stripped = path.strip()
if not stripped or not os.path.isdir(stripped):
continue
try:
with os.scandir(stripped) as iterator:
for entry in iterator:
try:
if not entry.is_symlink():
continue
target_path = os.path.realpath(entry.path)
if not os.path.isdir(target_path):
continue
primary_symlink_targets.add(
os.path.normcase(os.path.normpath(target_path))
)
except Exception:
continue
except Exception:
continue
overlapping_paths = []
for path in requested_extra_lora_paths:
if not isinstance(path, str):
continue
stripped = path.strip()
if not stripped:
continue
normalized = os.path.normcase(os.path.normpath(stripped))
if os.path.exists(stripped):
normalized = os.path.normcase(
os.path.normpath(os.path.realpath(stripped))
)
if (
normalized in primary_real_paths
or normalized in primary_symlink_targets
):
overlapping_paths.append(stripped)
if overlapping_paths:
collisions = ", ".join(sorted(set(overlapping_paths)))
# Settings writes should reject new conflicting configuration instead of tolerating it.
raise ValueError(
f"Extra LoRA path(s) {collisions} overlap with the active library's primary LoRA roots"
)
normalized_paths = self._normalize_folder_paths(extra_folder_paths) normalized_paths = self._normalize_folder_paths(extra_folder_paths)
self.settings["extra_folder_paths"] = normalized_paths self.settings["extra_folder_paths"] = normalized_paths
self._update_active_library_entry(extra_folder_paths=normalized_paths) self._update_active_library_entry(extra_folder_paths=normalized_paths)
@@ -1012,24 +1242,28 @@ class SettingsManager:
value = self.normalize_auto_organize_exclusions(value) value = self.normalize_auto_organize_exclusions(value)
elif key == "metadata_refresh_skip_paths": elif key == "metadata_refresh_skip_paths":
value = self.normalize_metadata_refresh_skip_paths(value) value = self.normalize_metadata_refresh_skip_paths(value)
elif key == "download_skip_base_models":
value = self.normalize_download_skip_base_models(value)
elif key == "mature_blur_level":
value = self.normalize_mature_blur_level(value)
self.settings[key] = value self.settings[key] = value
portable_switch_pending = False portable_switch_pending = False
if key == "use_portable_settings" and isinstance(value, bool): if key == "use_portable_settings" and isinstance(value, bool):
portable_switch_pending = True portable_switch_pending = True
self._prepare_portable_switch(value) self._prepare_portable_switch(value)
if key == 'folder_paths' and isinstance(value, Mapping): if key == "folder_paths" and isinstance(value, Mapping):
self._update_active_library_entry(folder_paths=value) # type: ignore[arg-type] self._update_active_library_entry(folder_paths=value) # type: ignore[arg-type]
elif key == 'extra_folder_paths' and isinstance(value, Mapping): elif key == "extra_folder_paths" and isinstance(value, Mapping):
self._update_active_library_entry(extra_folder_paths=value) # type: ignore[arg-type] self._update_active_library_entry(extra_folder_paths=value) # type: ignore[arg-type]
elif key == 'default_lora_root': elif key == "default_lora_root":
self._update_active_library_entry(default_lora_root=str(value)) self._update_active_library_entry(default_lora_root=str(value))
elif key == 'default_checkpoint_root': elif key == "default_checkpoint_root":
self._update_active_library_entry(default_checkpoint_root=str(value)) self._update_active_library_entry(default_checkpoint_root=str(value))
elif key == 'default_unet_root': elif key == "default_unet_root":
self._update_active_library_entry(default_unet_root=str(value)) self._update_active_library_entry(default_unet_root=str(value))
elif key == 'default_embedding_root': elif key == "default_embedding_root":
self._update_active_library_entry(default_embedding_root=str(value)) self._update_active_library_entry(default_embedding_root=str(value))
elif key == 'model_name_display': elif key == "model_name_display":
self._notify_model_name_display_change(value) self._notify_model_name_display_change(value)
self._save_settings() self._save_settings()
if portable_switch_pending: if portable_switch_pending:
@@ -1105,10 +1339,9 @@ class SettingsManager:
source_cache_dir = os.path.join(source_dir, "model_cache") source_cache_dir = os.path.join(source_dir, "model_cache")
target_cache_dir = os.path.join(target_dir, "model_cache") target_cache_dir = os.path.join(target_dir, "model_cache")
if ( if os.path.isdir(source_cache_dir) and os.path.abspath(
os.path.isdir(source_cache_dir) source_cache_dir
and os.path.abspath(source_cache_dir) != os.path.abspath(target_cache_dir) ) != os.path.abspath(target_cache_dir):
):
try: try:
shutil.copytree( shutil.copytree(
source_cache_dir, source_cache_dir,
@@ -1126,10 +1359,9 @@ class SettingsManager:
source_cache_file = os.path.join(source_dir, "model_cache.sqlite") source_cache_file = os.path.join(source_dir, "model_cache.sqlite")
target_cache_file = os.path.join(target_dir, "model_cache.sqlite") target_cache_file = os.path.join(target_dir, "model_cache.sqlite")
if ( if os.path.isfile(source_cache_file) and os.path.abspath(
os.path.isfile(source_cache_file) source_cache_file
and os.path.abspath(source_cache_file) != os.path.abspath(target_cache_file) ) != os.path.abspath(target_cache_file):
):
try: try:
shutil.copy2(source_cache_file, target_cache_file) shutil.copy2(source_cache_file, target_cache_file)
except Exception as exc: except Exception as exc:
@@ -1155,7 +1387,9 @@ class SettingsManager:
try: try:
os.makedirs(config_dir, exist_ok=True) os.makedirs(config_dir, exist_ok=True)
except Exception as exc: except Exception as exc:
logger.warning("Failed to create user config directory %s: %s", config_dir, exc) logger.warning(
"Failed to create user config directory %s: %s", config_dir, exc
)
return config_dir return config_dir
@@ -1215,7 +1449,9 @@ class SettingsManager:
try: try:
asyncio.run(coroutine) asyncio.run(coroutine)
except RuntimeError: except RuntimeError:
logger.debug("Skipping name display update due to missing event loop") logger.debug(
"Skipping name display update due to missing event loop"
)
continue continue
if loop is not None and target_loop is loop: if loop is not None and target_loop is loop:
@@ -1238,7 +1474,7 @@ class SettingsManager:
"""Save settings to file""" """Save settings to file"""
try: try:
payload = self._serialize_settings_for_disk() payload = self._serialize_settings_for_disk()
with open(self.settings_file, 'w', encoding='utf-8') as f: with open(self.settings_file, "w", encoding="utf-8") as f:
json.dump(payload, f, indent=2) json.dump(payload, f, indent=2)
except Exception as e: except Exception as e:
logger.error(f"Error saving settings: {e}") logger.error(f"Error saving settings: {e}")
@@ -1279,7 +1515,9 @@ class SettingsManager:
minimal[key] = copy.deepcopy(value) minimal[key] = copy.deepcopy(value)
# Complex objects need deep comparison # Complex objects need deep comparison
elif isinstance(value, (dict, list)) and default_value is not None: elif isinstance(value, (dict, list)) and default_value is not None:
if json.dumps(value, sort_keys=True, default=str) != json.dumps(default_value, sort_keys=True, default=str): if json.dumps(value, sort_keys=True, default=str) != json.dumps(
default_value, sort_keys=True, default=str
):
minimal[key] = copy.deepcopy(value) minimal[key] = copy.deepcopy(value)
# Simple values use direct comparison # Simple values use direct comparison
elif value != default_value: elif value != default_value:
@@ -1356,9 +1594,15 @@ class SettingsManager:
existing = libraries.get(name, {}) existing = libraries.get(name, {})
payload = self._build_library_payload( payload = self._build_library_payload(
folder_paths=folder_paths if folder_paths is not None else existing.get("folder_paths"), folder_paths=folder_paths
extra_folder_paths=extra_folder_paths if extra_folder_paths is not None else existing.get("extra_folder_paths"), if folder_paths is not None
default_lora_root=default_lora_root if default_lora_root is not None else existing.get("default_lora_root"), else existing.get("folder_paths"),
extra_folder_paths=extra_folder_paths
if extra_folder_paths is not None
else existing.get("extra_folder_paths"),
default_lora_root=default_lora_root
if default_lora_root is not None
else existing.get("default_lora_root"),
default_checkpoint_root=( default_checkpoint_root=(
default_checkpoint_root default_checkpoint_root
if default_checkpoint_root is not None if default_checkpoint_root is not None
@@ -1518,7 +1762,9 @@ class SettingsManager:
if service and hasattr(service, "on_library_changed"): if service and hasattr(service, "on_library_changed"):
try: try:
service.on_library_changed() service.on_library_changed()
except Exception as service_exc: # pragma: no cover - defensive logging except (
Exception
) as service_exc: # pragma: no cover - defensive logging
logger.debug( logger.debug(
"Service %s failed to handle library change: %s", "Service %s failed to handle library change: %s",
service_name, service_name,
@@ -1529,15 +1775,15 @@ class SettingsManager:
def get_download_path_template(self, model_type: str) -> str: def get_download_path_template(self, model_type: str) -> str:
"""Get download path template for specific model type """Get download path template for specific model type
Args: Args:
model_type: The type of model ('lora', 'checkpoint', 'embedding') model_type: The type of model ('lora', 'checkpoint', 'embedding')
Returns: Returns:
Template string for the model type, defaults to '{base_model}/{first_tag}' Template string for the model type, defaults to '{base_model}/{first_tag}'
""" """
templates = self.settings.get('download_path_templates', {}) templates = self.settings.get("download_path_templates", {})
# Handle edge case where templates might be stored as JSON string # Handle edge case where templates might be stored as JSON string
if isinstance(templates, str): if isinstance(templates, str):
try: try:
@@ -1545,36 +1791,40 @@ class SettingsManager:
parsed_templates = json.loads(templates) parsed_templates = json.loads(templates)
if isinstance(parsed_templates, dict): if isinstance(parsed_templates, dict):
# Update settings with parsed dictionary # Update settings with parsed dictionary
self.settings['download_path_templates'] = parsed_templates self.settings["download_path_templates"] = parsed_templates
self._save_settings() self._save_settings()
templates = parsed_templates templates = parsed_templates
logger.info("Successfully parsed download_path_templates from JSON string") logger.info(
"Successfully parsed download_path_templates from JSON string"
)
else: else:
raise ValueError("Parsed JSON is not a dictionary") raise ValueError("Parsed JSON is not a dictionary")
except (json.JSONDecodeError, ValueError) as e: except (json.JSONDecodeError, ValueError) as e:
# If parsing fails, set default values # If parsing fails, set default values
logger.warning(f"Failed to parse download_path_templates JSON string: {e}. Setting default values.") logger.warning(
default_template = '{base_model}/{first_tag}' f"Failed to parse download_path_templates JSON string: {e}. Setting default values."
)
default_template = "{base_model}/{first_tag}"
templates = { templates = {
'lora': default_template, "lora": default_template,
'checkpoint': default_template, "checkpoint": default_template,
'embedding': default_template "embedding": default_template,
} }
self.settings['download_path_templates'] = templates self.settings["download_path_templates"] = templates
self._save_settings() self._save_settings()
# Ensure templates is a dictionary # Ensure templates is a dictionary
if not isinstance(templates, dict): if not isinstance(templates, dict):
default_template = '{base_model}/{first_tag}' default_template = "{base_model}/{first_tag}"
templates = { templates = {
'lora': default_template, "lora": default_template,
'checkpoint': default_template, "checkpoint": default_template,
'embedding': default_template "embedding": default_template,
} }
self.settings['download_path_templates'] = templates self.settings["download_path_templates"] = templates
self._save_settings() self._save_settings()
return templates.get(model_type, '{base_model}/{first_tag}') return templates.get(model_type, "{base_model}/{first_tag}")
_SETTINGS_MANAGER: Optional["SettingsManager"] = None _SETTINGS_MANAGER: Optional["SettingsManager"] = None

View File

@@ -22,7 +22,9 @@ def _normalize_commercial_values(value: Any) -> Sequence[str]:
def _split_aggregate(value_str: str) -> list[str]: def _split_aggregate(value_str: str) -> list[str]:
stripped = value_str.strip() stripped = value_str.strip()
looks_aggregate = "," in stripped or (stripped.startswith("{") and stripped.endswith("}")) looks_aggregate = "," in stripped or (
stripped.startswith("{") and stripped.endswith("}")
)
if not looks_aggregate: if not looks_aggregate:
return [value_str] return [value_str]
@@ -141,14 +143,18 @@ def build_license_flags(payload: Mapping[str, Any] | None) -> int:
return flags return flags
def resolve_license_info(model_data: Mapping[str, Any] | None) -> tuple[Dict[str, Any], int]: def resolve_license_info(
model_data: Mapping[str, Any] | None,
) -> tuple[Dict[str, Any], int]:
"""Return normalized license payload and its encoded bitset.""" """Return normalized license payload and its encoded bitset."""
payload = resolve_license_payload(model_data) payload = resolve_license_payload(model_data)
return payload, build_license_flags(payload) return payload, build_license_flags(payload)
def rewrite_preview_url(source_url: str | None, media_type: str | None = None) -> tuple[str | None, bool]: def rewrite_preview_url(
source_url: str | None, media_type: str | None = None
) -> tuple[str | None, bool]:
"""Rewrite Civitai preview URLs to use optimized renditions. """Rewrite Civitai preview URLs to use optimized renditions.
Args: Args:
@@ -168,7 +174,12 @@ def rewrite_preview_url(source_url: str | None, media_type: str | None = None) -
except ValueError: except ValueError:
return source_url, False return source_url, False
if parsed.netloc.lower() != "image.civitai.com": hostname = parsed.hostname
if hostname is None:
return source_url, False
hostname = hostname.lower()
if hostname == "civitai.com" or not hostname.endswith(".civitai.com"):
return source_url, False return source_url, False
replacement = "/width=450,optimized=true" replacement = "/width=450,optimized=true"

View File

@@ -110,6 +110,71 @@ DIFFUSION_MODEL_BASE_MODELS = frozenset(
"Wan Video 2.2 T2V-A14B", "Wan Video 2.2 T2V-A14B",
"Wan Video 2.5 T2V", "Wan Video 2.5 T2V",
"Wan Video 2.5 I2V", "Wan Video 2.5 I2V",
"CogVideoX",
"Mochi",
"Qwen", "Qwen",
] ]
) )
# Supported baseModel values for download exclusion settings.
# Keep this aligned with static/js/utils/constants.js, excluding the generic "Other" value.
SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS = frozenset(
[
"SD 1.4",
"SD 1.5",
"SD 1.5 LCM",
"SD 1.5 Hyper",
"SD 2.0",
"SD 2.1",
"SD 3",
"SD 3.5",
"SD 3.5 Medium",
"SD 3.5 Large",
"SD 3.5 Large Turbo",
"SDXL 1.0",
"SDXL Lightning",
"SDXL Hyper",
"Flux.1 D",
"Flux.1 S",
"Flux.1 Krea",
"Flux.1 Kontext",
"Flux.2 D",
"Flux.2 Klein 9B",
"Flux.2 Klein 9B-base",
"Flux.2 Klein 4B",
"Flux.2 Klein 4B-base",
"AuraFlow",
"Chroma",
"PixArt a",
"PixArt E",
"Hunyuan 1",
"Lumina",
"Kolors",
"NoobAI",
"Illustrious",
"Pony",
"Pony V7",
"HiDream",
"Qwen",
"ZImageTurbo",
"ZImageBase",
"SVD",
"LTXV",
"LTXV2",
"LTXV 2.3",
"CogVideoX",
"Mochi",
"Wan Video",
"Wan Video 1.3B t2v",
"Wan Video 14B t2v",
"Wan Video 14B i2v 480p",
"Wan Video 14B i2v 720p",
"Wan Video 2.2 TI2V-5B",
"Wan Video 2.2 T2V-A14B",
"Wan Video 2.2 I2V-A14B",
"Wan Video 2.5 T2V",
"Wan Video 2.5 I2V",
"Hunyuan Video",
"Anima",
]
)

View File

@@ -2,11 +2,12 @@
from __future__ import annotations from __future__ import annotations
from typing import Mapping, Optional, Sequence, Tuple from typing import Any, Mapping, Optional, Sequence, Tuple
from .constants import NSFW_LEVELS from .constants import NSFW_LEVELS
PreviewMedia = Mapping[str, object] PreviewMedia = Mapping[str, object]
VALID_MATURE_BLUR_LEVELS = ("PG13", "R", "X", "XXX")
def _extract_nsfw_level(entry: Mapping[str, object]) -> int: def _extract_nsfw_level(entry: Mapping[str, object]) -> int:
@@ -19,17 +20,36 @@ def _extract_nsfw_level(entry: Mapping[str, object]) -> int:
return 0 return 0
def resolve_mature_threshold(settings: Mapping[str, Any] | None) -> int:
"""Resolve the configured mature blur threshold from settings.
Allowed values are ``PG13``, ``R``, ``X``, and ``XXX``. Any invalid or
missing value falls back to ``R``.
"""
if not isinstance(settings, Mapping):
return NSFW_LEVELS.get("R", 4)
raw_level = settings.get("mature_blur_level", "R")
normalized = str(raw_level).strip().upper()
if normalized not in VALID_MATURE_BLUR_LEVELS:
normalized = "R"
return NSFW_LEVELS.get(normalized, NSFW_LEVELS.get("R", 4))
def select_preview_media( def select_preview_media(
images: Sequence[Mapping[str, object]] | None, images: Sequence[Mapping[str, object]] | None,
*, *,
blur_mature_content: bool, blur_mature_content: bool,
mature_threshold: int | None = None,
) -> Tuple[Optional[PreviewMedia], int]: ) -> Tuple[Optional[PreviewMedia], int]:
"""Select the most appropriate preview media entry. """Select the most appropriate preview media entry.
When ``blur_mature_content`` is enabled we first try to return the first media When ``blur_mature_content`` is enabled we first try to return the first media
item with an ``nsfwLevel`` lower than :pydata:`NSFW_LEVELS["R"]`. If none are item with an ``nsfwLevel`` lower than the configured mature threshold
available we return the media entry with the lowest NSFW level. When the (defaults to :pydata:`NSFW_LEVELS["R"]`). If none are available we return
setting is disabled we simply return the first entry. the media entry with the lowest NSFW level. When the setting is disabled we
simply return the first entry.
""" """
if not images: if not images:
@@ -45,7 +65,9 @@ def select_preview_media(
if not blur_mature_content: if not blur_mature_content:
return selected, selected_level return selected, selected_level
safe_threshold = NSFW_LEVELS.get("R", 4) safe_threshold = (
mature_threshold if isinstance(mature_threshold, int) else NSFW_LEVELS.get("R", 4)
)
for candidate in candidates: for candidate in candidates:
level = _extract_nsfw_level(candidate) level = _extract_nsfw_level(candidate)
if level < safe_threshold: if level < safe_threshold:
@@ -60,4 +82,4 @@ def select_preview_media(
return selected, selected_level return selected, selected_level
__all__ = ["select_preview_media"] __all__ = ["resolve_mature_threshold", "select_preview_media", "VALID_MATURE_BLUR_LEVELS"]

View File

@@ -1,7 +1,7 @@
[project] [project]
name = "comfyui-lora-manager" name = "comfyui-lora-manager"
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!" description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
version = "1.0.0" version = "1.0.2"
license = {file = "LICENSE"} license = {file = "LICENSE"}
dependencies = [ dependencies = [
"aiohttp", "aiohttp",

View File

@@ -835,7 +835,8 @@
} }
[data-theme="dark"] .creator-info, [data-theme="dark"] .creator-info,
[data-theme="dark"] .civitai-view { [data-theme="dark"] .civitai-view,
[data-theme="dark"] .modal-send-btn {
background: rgba(255, 255, 255, 0.03); background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--lora-border); border: 1px solid var(--lora-border);
} }
@@ -875,7 +876,8 @@
/* Add hover effect for creator info */ /* Add hover effect for creator info */
.creator-info:hover, .creator-info:hover,
.civitai-view:hover { .civitai-view:hover,
.modal-send-btn:hover {
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1); background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
border-color: var(--lora-accent); border-color: var(--lora-accent);
transform: translateY(-1px); transform: translateY(-1px);
@@ -910,3 +912,42 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
/* Send to ComfyUI Button */
.modal-send-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: rgba(0, 0, 0, 0.03);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: var(--border-radius-sm);
color: var(--text-color);
cursor: pointer;
font-weight: 500;
font-size: 0.9em;
transition: all 0.2s;
}
[data-theme="dark"] .modal-send-btn {
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--lora-border);
}
.modal-send-btn:hover {
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
border-color: var(--lora-accent);
transform: translateY(-1px);
}
.modal-send-btn:active {
transform: translateY(0);
}
.modal-send-btn i {
font-size: 14px;
}
.modal-send-btn span {
white-space: nowrap;
}

View File

@@ -151,7 +151,8 @@ body.modal-open {
[data-theme="dark"] .changelog-section, [data-theme="dark"] .changelog-section,
[data-theme="dark"] .update-info, [data-theme="dark"] .update-info,
[data-theme="dark"] .info-item, [data-theme="dark"] .info-item,
[data-theme="dark"] .path-preview { [data-theme="dark"] .path-preview,
[data-theme="dark"] #bulkDownloadMissingLorasModal .bulk-download-loras-preview {
background: rgba(255, 255, 255, 0.03); background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--lora-border); border: 1px solid var(--lora-border);
} }
@@ -349,3 +350,87 @@ button:disabled,
margin-top: var(--space-1); margin-top: var(--space-1);
text-align: center; text-align: center;
} }
/* Bulk Download Missing LoRAs Modal */
#bulkDownloadMissingLorasModal .modal-body {
padding: var(--space-3);
}
#bulkDownloadMissingLorasModal .confirmation-message {
color: var(--text-color);
margin-bottom: var(--space-3);
font-size: 1em;
line-height: 1.5;
}
#bulkDownloadMissingLorasModal .bulk-download-loras-preview {
background: rgba(0, 0, 0, 0.03);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: var(--border-radius-sm);
padding: var(--space-3);
margin-bottom: var(--space-3);
}
#bulkDownloadMissingLorasModal .preview-title {
font-weight: 600;
margin-bottom: var(--space-2);
color: var(--text-color);
font-size: 0.95em;
}
#bulkDownloadMissingLorasModal .bulk-download-loras-list {
list-style: none;
padding: 0;
margin: 0;
}
#bulkDownloadMissingLorasModal .bulk-download-loras-list li {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-1) 0;
border-bottom: 1px solid var(--border-color);
font-size: 0.9em;
}
#bulkDownloadMissingLorasModal .bulk-download-loras-list li:last-child {
border-bottom: none;
}
#bulkDownloadMissingLorasModal .bulk-download-loras-list li.more-items {
font-style: italic;
opacity: 0.7;
text-align: center;
justify-content: center;
padding: var(--space-2) 0;
}
#bulkDownloadMissingLorasModal .lora-name {
font-weight: 500;
color: var(--text-color);
flex: 1;
}
#bulkDownloadMissingLorasModal .lora-version {
font-size: 0.85em;
opacity: 0.7;
margin-left: var(--space-1);
color: var(--text-muted);
}
#bulkDownloadMissingLorasModal .confirmation-note {
display: flex;
align-items: flex-start;
gap: var(--space-2);
padding: var(--space-2);
background: rgba(59, 130, 246, 0.1);
border-radius: var(--border-radius-sm);
font-size: 0.9em;
color: var(--text-color);
}
#bulkDownloadMissingLorasModal .confirmation-note i {
color: var(--lora-accent);
margin-top: 2px;
flex-shrink: 0;
}

View File

@@ -430,6 +430,88 @@
box-sizing: border-box; box-sizing: border-box;
} }
.base-model-skip-toggle {
min-width: 220px;
justify-content: space-between;
gap: 10px;
}
.base-model-skip-toggle-label {
opacity: 0.75;
white-space: nowrap;
}
.base-model-skip-panel {
margin-top: var(--space-2);
padding: 12px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
background-color: var(--lora-surface);
}
.base-model-skip-toolbar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.base-model-skip-search {
flex: 1;
min-width: 0;
padding: 8px 10px;
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);
background-color: var(--settings-bg);
color: var(--text-color);
}
.base-model-skip-search:focus {
border-color: var(--lora-accent);
outline: none;
box-shadow: 0 0 0 2px rgba(var(--lora-accent-rgb, 79, 70, 229), 0.1);
}
.base-model-skip-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 8px;
max-height: 220px;
overflow-y: auto;
}
.base-model-skip-option {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
background-color: var(--settings-bg);
cursor: pointer;
transition: border-color 0.15s ease, background-color 0.15s ease;
}
.base-model-skip-option:hover {
border-color: var(--lora-accent);
background-color: rgba(var(--lora-accent-rgb, 79, 70, 229), 0.05);
}
.base-model-skip-option input {
margin: 0;
}
.base-model-skip-option span {
font-size: 0.9em;
line-height: 1.25;
}
.base-model-skip-empty {
padding: 8px 0 0;
font-size: 0.9em;
opacity: 0.75;
}
.priority-tags-input:focus { .priority-tags-input:focus {
border-color: var(--lora-accent); border-color: var(--lora-accent);
outline: none; outline: none;

View File

@@ -424,6 +424,7 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: 8px;
} }
.param-header label { .param-header label {
@@ -431,7 +432,14 @@
color: var(--text-color); color: var(--text-color);
} }
.copy-btn { .param-actions {
display: flex;
align-items: center;
gap: 4px;
}
.copy-btn,
.edit-btn {
background: none; background: none;
border: none; border: none;
color: var(--text-color); color: var(--text-color);
@@ -442,7 +450,8 @@
transition: all 0.2s; transition: all 0.2s;
} }
.copy-btn:hover { .copy-btn:hover,
.edit-btn:hover {
opacity: 1; opacity: 1;
background: var(--lora-surface); background: var(--lora-surface);
} }
@@ -461,6 +470,48 @@
word-break: break-word; word-break: break-word;
} }
.param-content.hide {
display: none;
}
.param-content.is-placeholder {
color: color-mix(in oklch, var(--text-color), transparent 35%);
font-style: italic;
}
.param-editor {
display: none;
flex-direction: column;
gap: 10px;
}
.param-editor.active {
display: flex;
}
.param-textarea {
width: 100%;
max-width: 100%;
min-height: 140px;
resize: vertical;
background: var(--bg-color);
border: 1px solid var(--lora-border);
border-radius: var(--border-radius-xs);
padding: 10px 12px;
font-size: 0.9em;
line-height: 1.5;
color: var(--text-color);
font-family: inherit;
box-sizing: border-box;
overflow-x: hidden;
}
.param-editor-hint {
font-size: 0.78em;
line-height: 1.4;
color: color-mix(in oklch, var(--text-color), transparent 35%);
}
/* Other Parameters */ /* Other Parameters */
.other-params { .other-params {
display: flex; display: flex;
@@ -565,6 +616,26 @@
color: var(--lora-accent); color: var(--lora-accent);
} }
.send-recipe-btn {
background: none;
border: none;
color: var(--text-color);
opacity: 0.7;
cursor: pointer;
padding: 4px 8px;
border-radius: var(--border-radius-xs);
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.send-recipe-btn:hover {
opacity: 1;
background: var(--lora-surface);
color: var(--lora-accent);
}
#recipeLorasCount { #recipeLorasCount {
font-size: 0.9em; font-size: 0.9em;
color: var(--text-color); color: var(--text-color);

View File

@@ -0,0 +1,164 @@
/**
* API client for Civitai base model management
* Handles fetching and refreshing base models from Civitai API
*/
import { showToast } from '../utils/uiHelpers.js';
const BASE_MODEL_ENDPOINTS = {
getModels: '/api/lm/base-models',
refresh: '/api/lm/base-models/refresh',
categories: '/api/lm/base-models/categories',
cacheStatus: '/api/lm/base-models/cache-status',
};
/**
* Civitai Base Model API Client
*/
export class CivitaiBaseModelApi {
constructor() {
this.cache = null;
this.cacheTimestamp = null;
}
/**
* Get base models (with caching)
* @param {boolean} forceRefresh - Force refresh from API
* @returns {Promise<Object>} Response with models, source, and counts
*/
async getBaseModels(forceRefresh = false) {
try {
const url = new URL(BASE_MODEL_ENDPOINTS.getModels, window.location.origin);
if (forceRefresh) {
url.searchParams.append('refresh', 'true');
}
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch base models: ${response.statusText}`);
}
const data = await response.json();
if (data.success) {
this.cache = data.data;
this.cacheTimestamp = Date.now();
return data.data;
} else {
throw new Error(data.error || 'Failed to fetch base models');
}
} catch (error) {
console.error('Error fetching base models:', error);
showToast('Failed to fetch base models', { message: error.message }, 'error');
throw error;
}
}
/**
* Force refresh base models from Civitai API
* @returns {Promise<Object>} Refreshed data
*/
async refreshBaseModels() {
try {
const response = await fetch(BASE_MODEL_ENDPOINTS.refresh, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
if (!response.ok) {
throw new Error(`Failed to refresh base models: ${response.statusText}`);
}
const data = await response.json();
if (data.success) {
this.cache = data.data;
this.cacheTimestamp = Date.now();
showToast('Base models refreshed successfully', {}, 'success');
return data.data;
} else {
throw new Error(data.error || 'Failed to refresh base models');
}
} catch (error) {
console.error('Error refreshing base models:', error);
showToast('Failed to refresh base models', { message: error.message }, 'error');
throw error;
}
}
/**
* Get base model categories
* @returns {Promise<Object>} Categories with model lists
*/
async getCategories() {
try {
const response = await fetch(BASE_MODEL_ENDPOINTS.categories);
if (!response.ok) {
throw new Error(`Failed to fetch categories: ${response.statusText}`);
}
const data = await response.json();
if (data.success) {
return data.data;
} else {
throw new Error(data.error || 'Failed to fetch categories');
}
} catch (error) {
console.error('Error fetching categories:', error);
throw error;
}
}
/**
* Get cache status
* @returns {Promise<Object>} Cache status information
*/
async getCacheStatus() {
try {
const response = await fetch(BASE_MODEL_ENDPOINTS.cacheStatus);
if (!response.ok) {
throw new Error(`Failed to fetch cache status: ${response.statusText}`);
}
const data = await response.json();
if (data.success) {
return data.data;
} else {
throw new Error(data.error || 'Failed to fetch cache status');
}
} catch (error) {
console.error('Error fetching cache status:', error);
throw error;
}
}
/**
* Get cached models (if available)
* @returns {Object|null} Cached data or null
*/
getCachedModels() {
return this.cache;
}
/**
* Check if cache is available
* @returns {boolean}
*/
hasCache() {
return this.cache !== null;
}
/**
* Get cache age in milliseconds
* @returns {number|null} Age in ms or null if no cache
*/
getCacheAge() {
if (!this.cacheTimestamp) return null;
return Date.now() - this.cacheTimestamp;
}
}
// Export singleton instance
export const civitaiBaseModelApi = new CivitaiBaseModelApi();

View File

@@ -83,6 +83,9 @@ export async function fetchRecipesPage(page = 1, pageSize = 100) {
if (pageState.customFilter?.active && pageState.customFilter?.loraHash) { if (pageState.customFilter?.active && pageState.customFilter?.loraHash) {
params.append('lora_hash', pageState.customFilter.loraHash); params.append('lora_hash', pageState.customFilter.loraHash);
params.append('bypass_filters', 'true'); params.append('bypass_filters', 'true');
} else if (pageState.customFilter?.active && pageState.customFilter?.checkpointHash) {
params.append('checkpoint_hash', pageState.customFilter.checkpointHash);
params.append('bypass_filters', 'true');
} else { } else {
// Normal filtering logic // Normal filtering logic

View File

@@ -2,6 +2,8 @@ import { BaseContextMenu } from './BaseContextMenu.js';
import { state } from '../../state/index.js'; import { state } from '../../state/index.js';
import { bulkManager } from '../../managers/BulkManager.js'; import { bulkManager } from '../../managers/BulkManager.js';
import { updateElementText, translate } from '../../utils/i18nHelpers.js'; import { updateElementText, translate } from '../../utils/i18nHelpers.js';
import { bulkMissingLoraDownloadManager } from '../../managers/BulkMissingLoraDownloadManager.js';
import { showToast } from '../../utils/uiHelpers.js';
export class BulkContextMenu extends BaseContextMenu { export class BulkContextMenu extends BaseContextMenu {
constructor() { constructor() {
@@ -37,6 +39,7 @@ export class BulkContextMenu extends BaseContextMenu {
const moveAllItem = this.menu.querySelector('[data-action="move-all"]'); const moveAllItem = this.menu.querySelector('[data-action="move-all"]');
const autoOrganizeItem = this.menu.querySelector('[data-action="auto-organize"]'); const autoOrganizeItem = this.menu.querySelector('[data-action="auto-organize"]');
const deleteAllItem = this.menu.querySelector('[data-action="delete-all"]'); const deleteAllItem = this.menu.querySelector('[data-action="delete-all"]');
const downloadMissingLorasItem = this.menu.querySelector('[data-action="download-missing-loras"]');
if (sendToWorkflowAppendItem) { if (sendToWorkflowAppendItem) {
sendToWorkflowAppendItem.style.display = config.sendToWorkflow ? 'flex' : 'none'; sendToWorkflowAppendItem.style.display = config.sendToWorkflow ? 'flex' : 'none';
@@ -71,6 +74,10 @@ export class BulkContextMenu extends BaseContextMenu {
if (setContentRatingItem) { if (setContentRatingItem) {
setContentRatingItem.style.display = config.setContentRating ? 'flex' : 'none'; setContentRatingItem.style.display = config.setContentRating ? 'flex' : 'none';
} }
if (downloadMissingLorasItem) {
// Only show for recipes page
downloadMissingLorasItem.style.display = currentModelType === 'recipes' ? 'flex' : 'none';
}
const skipMetadataRefreshItem = this.menu.querySelector('[data-action="skip-metadata-refresh"]'); const skipMetadataRefreshItem = this.menu.querySelector('[data-action="skip-metadata-refresh"]');
const resumeMetadataRefreshItem = this.menu.querySelector('[data-action="resume-metadata-refresh"]'); const resumeMetadataRefreshItem = this.menu.querySelector('[data-action="resume-metadata-refresh"]');
@@ -178,6 +185,9 @@ export class BulkContextMenu extends BaseContextMenu {
case 'delete-all': case 'delete-all':
bulkManager.showBulkDeleteModal(); bulkManager.showBulkDeleteModal();
break; break;
case 'download-missing-loras':
this.handleDownloadMissingLoras();
break;
case 'clear': case 'clear':
bulkManager.clearSelection(); bulkManager.clearSelection();
break; break;
@@ -185,4 +195,39 @@ export class BulkContextMenu extends BaseContextMenu {
console.warn(`Unknown bulk action: ${action}`); console.warn(`Unknown bulk action: ${action}`);
} }
} }
/**
* Handle downloading missing LoRAs for selected recipes
*/
async handleDownloadMissingLoras() {
if (state.selectedModels.size === 0) {
return;
}
// Get selected recipes from the virtual scroller
const selectedRecipes = [];
state.selectedModels.forEach(filePath => {
const card = document.querySelector(`.model-card[data-filepath="${CSS.escape(filePath)}"]`);
if (card && card.recipeData) {
selectedRecipes.push(card.recipeData);
}
});
if (selectedRecipes.length === 0) {
// Try to get recipes from virtual scroller state
const items = state.virtualScroller?.items || [];
items.forEach(recipe => {
if (recipe.file_path && state.selectedModels.has(recipe.file_path)) {
selectedRecipes.push(recipe);
}
});
}
if (selectedRecipes.length === 0) {
showToast('toast.recipes.noRecipesSelected', {}, 'warning');
return;
}
await bulkMissingLoraDownloadManager.downloadMissingLoras(selectedRecipes);
}
} }

View File

@@ -4,6 +4,8 @@ import { getModelApiClient, resetAndReload } from '../../api/modelApiFactory.js'
import { showDeleteModal, showExcludeModal } from '../../utils/modalUtils.js'; import { showDeleteModal, showExcludeModal } from '../../utils/modalUtils.js';
import { moveManager } from '../../managers/MoveManager.js'; import { moveManager } from '../../managers/MoveManager.js';
import { i18n } from '../../i18n/index.js'; import { i18n } from '../../i18n/index.js';
import { sendModelPathToWorkflow } from '../../utils/uiHelpers.js';
import { MODEL_TYPES } from '../../api/apiConfig.js';
export class CheckpointContextMenu extends BaseContextMenu { export class CheckpointContextMenu extends BaseContextMenu {
constructor() { constructor() {
@@ -60,6 +62,10 @@ export class CheckpointContextMenu extends BaseContextMenu {
this.currentCard.querySelector('.fa-copy').click(); this.currentCard.querySelector('.fa-copy').click();
} }
break; break;
case 'sendworkflow':
// Send checkpoint to workflow (always replace mode)
this.sendCheckpointToWorkflow();
break;
case 'refresh-metadata': case 'refresh-metadata':
// Refresh metadata from CivitAI // Refresh metadata from CivitAI
apiClient.refreshSingleModelMetadata(this.currentCard.dataset.filepath); apiClient.refreshSingleModelMetadata(this.currentCard.dataset.filepath);
@@ -79,6 +85,52 @@ export class CheckpointContextMenu extends BaseContextMenu {
break; break;
} }
} }
async sendCheckpointToWorkflow() {
const modelPath = this.currentCard.dataset.filepath;
if (!modelPath) {
return;
}
const subtype = (this.currentCard.dataset.sub_type || 'checkpoint').toLowerCase();
const isDiffusionModel = subtype === 'diffusion_model';
const widgetName = isDiffusionModel ? 'unet_name' : 'ckpt_name';
const actionTypeText = i18n.t(
isDiffusionModel ? 'uiHelpers.nodeSelector.diffusionModel' : 'uiHelpers.nodeSelector.checkpoint',
{},
isDiffusionModel ? 'Diffusion Model' : 'Checkpoint'
);
const successMessage = i18n.t(
'uiHelpers.workflow.modelUpdated',
{},
'Model updated in workflow'
);
const failureMessage = i18n.t(
'uiHelpers.workflow.modelFailed',
{},
'Failed to update model node'
);
const missingNodesMessage = i18n.t(
'uiHelpers.workflow.noMatchingNodes',
{},
'No compatible nodes available in the current workflow'
);
const missingTargetMessage = i18n.t(
'uiHelpers.workflow.noTargetNodeSelected',
{},
'No target node selected'
);
await sendModelPathToWorkflow(modelPath, {
widgetName,
collectionType: MODEL_TYPES.CHECKPOINT,
actionTypeText,
successMessage,
failureMessage,
missingNodesMessage,
missingTargetMessage,
});
}
} }
// Mix in shared methods // Mix in shared methods

View File

@@ -6,7 +6,7 @@ import { modalManager } from '../managers/ModalManager.js';
import { getCurrentPageState } from '../state/index.js'; import { getCurrentPageState } from '../state/index.js';
import { state } from '../state/index.js'; import { state } from '../state/index.js';
import { bulkManager } from '../managers/BulkManager.js'; import { bulkManager } from '../managers/BulkManager.js';
import { NSFW_LEVELS, getBaseModelAbbreviation } from '../utils/constants.js'; import { NSFW_LEVELS, getBaseModelAbbreviation, getMatureBlurThreshold } from '../utils/constants.js';
class RecipeCard { class RecipeCard {
constructor(recipe, clickHandler) { constructor(recipe, clickHandler) {
@@ -74,7 +74,8 @@ class RecipeCard {
// NSFW blur logic - similar to LoraCard // NSFW blur logic - similar to LoraCard
const nsfwLevel = this.recipe.preview_nsfw_level !== undefined ? this.recipe.preview_nsfw_level : 0; const nsfwLevel = this.recipe.preview_nsfw_level !== undefined ? this.recipe.preview_nsfw_level : 0;
const shouldBlur = state.settings.blur_mature_content && nsfwLevel > NSFW_LEVELS.PG13; const matureBlurThreshold = getMatureBlurThreshold(state.settings);
const shouldBlur = state.settings.blur_mature_content && nsfwLevel >= matureBlurThreshold;
if (shouldBlur) { if (shouldBlur) {
card.classList.add('nsfw-content'); card.classList.add('nsfw-content');

View File

@@ -1,5 +1,5 @@
// Recipe Modal Component // Recipe Modal Component
import { showToast, copyToClipboard, sendModelPathToWorkflow, openCivitaiByMetadata } from '../utils/uiHelpers.js'; import { showToast, copyToClipboard, sendLoraToWorkflow, sendModelPathToWorkflow, openCivitaiByMetadata } from '../utils/uiHelpers.js';
import { translate } from '../utils/i18nHelpers.js'; import { translate } from '../utils/i18nHelpers.js';
import { state } from '../state/index.js'; import { state } from '../state/index.js';
import { setSessionItem, removeSessionItem } from '../utils/storageHelpers.js'; import { setSessionItem, removeSessionItem } from '../utils/storageHelpers.js';
@@ -9,11 +9,13 @@ import { MODEL_TYPES } from '../api/apiConfig.js';
class RecipeModal { class RecipeModal {
constructor() { constructor() {
this.promptEditorState = {};
this.init(); this.init();
} }
init() { init() {
this.setupCopyButtons(); this.setupCopyButtons();
this.setupPromptEditors();
// Set up tooltip positioning handlers after DOM is ready // Set up tooltip positioning handlers after DOM is ready
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
this.setupTooltipPositioning(); this.setupTooltipPositioning();
@@ -87,6 +89,7 @@ class RecipeModal {
showRecipeDetails(recipe) { showRecipeDetails(recipe) {
// Store the full recipe for editing // Store the full recipe for editing
this.currentRecipe = recipe; this.currentRecipe = recipe;
this.resetPromptEditors();
// Set modal title with edit icon // Set modal title with edit icon
const modalTitle = document.getElementById('recipeModalTitle'); const modalTitle = document.getElementById('recipeModalTitle');
@@ -300,20 +303,19 @@ class RecipeModal {
const promptElement = document.getElementById('recipePrompt'); const promptElement = document.getElementById('recipePrompt');
const negativePromptElement = document.getElementById('recipeNegativePrompt'); const negativePromptElement = document.getElementById('recipeNegativePrompt');
const otherParamsElement = document.getElementById('recipeOtherParams'); const otherParamsElement = document.getElementById('recipeOtherParams');
const promptInput = document.getElementById('recipePromptInput');
const negativePromptInput = document.getElementById('recipeNegativePromptInput');
if (recipe.gen_params) { if (recipe.gen_params) {
// Set prompt this.renderPromptContent(promptElement, recipe.gen_params.prompt, 'No prompt information available');
if (promptElement && recipe.gen_params.prompt) { this.renderPromptContent(negativePromptElement, recipe.gen_params.negative_prompt, 'No negative prompt information available');
promptElement.textContent = recipe.gen_params.prompt;
} else if (promptElement) { if (promptInput) {
promptElement.textContent = 'No prompt information available'; promptInput.value = recipe.gen_params.prompt || '';
} }
// Set negative prompt if (negativePromptInput) {
if (negativePromptElement && recipe.gen_params.negative_prompt) { negativePromptInput.value = recipe.gen_params.negative_prompt || '';
negativePromptElement.textContent = recipe.gen_params.negative_prompt;
} else if (negativePromptElement) {
negativePromptElement.textContent = 'No negative prompt information available';
} }
// Set other parameters // Set other parameters
@@ -343,8 +345,10 @@ class RecipeModal {
} }
} else { } else {
// No generation parameters available // No generation parameters available
if (promptElement) promptElement.textContent = 'No prompt information available'; this.renderPromptContent(promptElement, '', 'No prompt information available');
if (negativePromptElement) promptElement.textContent = 'No negative prompt information available'; this.renderPromptContent(negativePromptElement, '', 'No negative prompt information available');
if (promptInput) promptInput.value = '';
if (negativePromptInput) negativePromptInput.value = '';
if (otherParamsElement) otherParamsElement.innerHTML = '<div class="no-params">No parameters available</div>'; if (otherParamsElement) otherParamsElement.innerHTML = '<div class="no-params">No parameters available</div>';
} }
@@ -711,16 +715,202 @@ class RecipeModal {
} }
} }
setupPromptEditors() {
const promptConfigs = [
{
editButtonId: 'editPromptBtn',
contentId: 'recipePrompt',
editorId: 'recipePromptEditor',
inputId: 'recipePromptInput',
field: 'prompt',
placeholder: 'No prompt information available',
successKey: 'toast.recipes.promptUpdated',
successFallback: 'Prompt updated successfully',
},
{
editButtonId: 'editNegativePromptBtn',
contentId: 'recipeNegativePrompt',
editorId: 'recipeNegativePromptEditor',
inputId: 'recipeNegativePromptInput',
field: 'negative_prompt',
placeholder: 'No negative prompt information available',
successKey: 'toast.recipes.negativePromptUpdated',
successFallback: 'Negative prompt updated successfully',
}
];
promptConfigs.forEach((config) => {
const editButton = document.getElementById(config.editButtonId);
const input = document.getElementById(config.inputId);
if (editButton) {
editButton.addEventListener('click', () => this.showPromptEditor(config));
}
if (input) {
input.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
this.cancelPromptEdit(config);
return;
}
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
event.stopPropagation();
this.promptEditorState[config.field] = {
...(this.promptEditorState[config.field] || {}),
skipBlurSave: true,
};
this.savePromptEdit(config);
}
});
input.addEventListener('blur', () => {
const promptState = this.promptEditorState[config.field] || {};
if (promptState.skipBlurSave) {
this.promptEditorState[config.field] = {
...promptState,
skipBlurSave: false,
};
return;
}
this.savePromptEdit(config);
});
}
});
}
renderPromptContent(element, value, placeholder) {
if (!element) {
return;
}
const text = value || '';
if (text) {
element.textContent = text;
element.classList.remove('is-placeholder');
} else {
element.textContent = placeholder;
element.classList.add('is-placeholder');
}
}
resetPromptEditors() {
this.hidePromptEditor({ contentId: 'recipePrompt', editorId: 'recipePromptEditor' });
this.hidePromptEditor({ contentId: 'recipeNegativePrompt', editorId: 'recipeNegativePromptEditor' });
}
showPromptEditor(config) {
const content = document.getElementById(config.contentId);
const editor = document.getElementById(config.editorId);
const input = document.getElementById(config.inputId);
if (!content || !editor || !input) {
return;
}
const currentValue = this.currentRecipe?.gen_params?.[config.field] || '';
input.value = currentValue;
this.promptEditorState[config.field] = {
initialValue: currentValue,
skipBlurSave: false,
isSaving: false,
};
content.classList.add('hide');
editor.classList.add('active');
input.focus();
input.setSelectionRange(input.value.length, input.value.length);
}
async savePromptEdit(config) {
const content = document.getElementById(config.contentId);
const editor = document.getElementById(config.editorId);
const input = document.getElementById(config.inputId);
if (!content || !editor || !input || !this.currentRecipe) {
return;
}
const promptState = this.promptEditorState[config.field] || {};
if (promptState.isSaving) {
return;
}
const currentGenParams = this.currentRecipe.gen_params || {};
const nextValue = input.value.trim() === '' ? '' : input.value;
const currentValue = currentGenParams[config.field] || '';
if (nextValue === currentValue) {
this.hidePromptEditor(config);
return;
}
const nextGenParams = {
...currentGenParams,
[config.field]: nextValue,
};
try {
this.promptEditorState[config.field] = {
...promptState,
isSaving: true,
};
await updateRecipeMetadata(this.filePath, { gen_params: nextGenParams });
this.currentRecipe.gen_params = nextGenParams;
this.renderPromptContent(content, nextValue, config.placeholder);
showToast(config.successKey, {}, 'success', config.successFallback);
} catch (error) {
this.renderPromptContent(content, currentValue, config.placeholder);
input.value = currentValue;
} finally {
this.hidePromptEditor(config);
}
}
cancelPromptEdit(config) {
const input = document.getElementById(config.inputId);
if (input) {
const initialValue = this.promptEditorState[config.field]?.initialValue;
input.value = initialValue ?? (this.currentRecipe?.gen_params?.[config.field] || '');
}
this.hidePromptEditor(config);
}
hidePromptEditor(config) {
const content = document.getElementById(config.contentId);
const editor = document.getElementById(config.editorId);
if (content) {
content.classList.remove('hide');
}
if (editor) {
editor.classList.remove('active');
}
delete this.promptEditorState[config.field];
}
// Setup source URL handlers // Setup source URL handlers
setupSourceUrlHandlers() { setupSourceUrlHandlers() {
const sourceUrlContainer = document.querySelector('.source-url-container'); const sourceUrlContainer = document.querySelector('.source-url-container');
const sourceUrlEditor = document.querySelector('.source-url-editor'); const sourceUrlEditor = document.querySelector('.source-url-editor');
if (!sourceUrlContainer || !sourceUrlEditor) {
return;
}
const sourceUrlText = sourceUrlContainer.querySelector('.source-url-text'); const sourceUrlText = sourceUrlContainer.querySelector('.source-url-text');
const sourceUrlEditBtn = sourceUrlContainer.querySelector('.source-url-edit-btn'); const sourceUrlEditBtn = sourceUrlContainer.querySelector('.source-url-edit-btn');
const sourceUrlCancelBtn = sourceUrlEditor.querySelector('.source-url-cancel-btn'); const sourceUrlCancelBtn = sourceUrlEditor.querySelector('.source-url-cancel-btn');
const sourceUrlSaveBtn = sourceUrlEditor.querySelector('.source-url-save-btn'); const sourceUrlSaveBtn = sourceUrlEditor.querySelector('.source-url-save-btn');
const sourceUrlInput = sourceUrlEditor.querySelector('.source-url-input'); const sourceUrlInput = sourceUrlEditor.querySelector('.source-url-input');
if (!sourceUrlText || !sourceUrlEditBtn || !sourceUrlCancelBtn || !sourceUrlSaveBtn || !sourceUrlInput) {
return;
}
// Show editor on edit button click // Show editor on edit button click
sourceUrlEditBtn.addEventListener('click', () => { sourceUrlEditBtn.addEventListener('click', () => {
sourceUrlContainer.classList.add('hide'); sourceUrlContainer.classList.add('hide');
@@ -778,17 +968,18 @@ class RecipeModal {
const copyPromptBtn = document.getElementById('copyPromptBtn'); const copyPromptBtn = document.getElementById('copyPromptBtn');
const copyNegativePromptBtn = document.getElementById('copyNegativePromptBtn'); const copyNegativePromptBtn = document.getElementById('copyNegativePromptBtn');
const copyRecipeSyntaxBtn = document.getElementById('copyRecipeSyntaxBtn'); const copyRecipeSyntaxBtn = document.getElementById('copyRecipeSyntaxBtn');
const sendRecipeBtn = document.getElementById('sendRecipeBtn');
if (copyPromptBtn) { if (copyPromptBtn) {
copyPromptBtn.addEventListener('click', () => { copyPromptBtn.addEventListener('click', () => {
const promptText = document.getElementById('recipePrompt').textContent; const promptText = this.currentRecipe?.gen_params?.prompt || '';
this.copyToClipboard(promptText, 'Prompt copied to clipboard'); this.copyToClipboard(promptText, 'Prompt copied to clipboard');
}); });
} }
if (copyNegativePromptBtn) { if (copyNegativePromptBtn) {
copyNegativePromptBtn.addEventListener('click', () => { copyNegativePromptBtn.addEventListener('click', () => {
const negativePromptText = document.getElementById('recipeNegativePrompt').textContent; const negativePromptText = this.currentRecipe?.gen_params?.negative_prompt || '';
this.copyToClipboard(negativePromptText, 'Negative prompt copied to clipboard'); this.copyToClipboard(negativePromptText, 'Negative prompt copied to clipboard');
}); });
} }
@@ -799,6 +990,13 @@ class RecipeModal {
this.fetchAndCopyRecipeSyntax(); this.fetchAndCopyRecipeSyntax();
}); });
} }
if (sendRecipeBtn) {
sendRecipeBtn.addEventListener('click', () => {
// Send recipe to ComfyUI workflow
this.sendRecipeToWorkflow();
});
}
} }
// Fetch recipe syntax from backend and copy to clipboard // Fetch recipe syntax from backend and copy to clipboard
@@ -835,6 +1033,35 @@ class RecipeModal {
copyToClipboard(text, successMessage); copyToClipboard(text, successMessage);
} }
// Send recipe to ComfyUI workflow
async sendRecipeToWorkflow() {
if (!this.recipeId) {
showToast('toast.recipes.noRecipeId', {}, 'error');
return;
}
try {
// Fetch recipe syntax from backend
const response = await fetch(`/api/lm/recipe/${this.recipeId}/syntax`);
if (!response.ok) {
throw new Error(`Failed to get recipe syntax: ${response.statusText}`);
}
const data = await response.json();
if (data.success && data.syntax) {
// Send the recipe syntax to ComfyUI workflow
await sendLoraToWorkflow(data.syntax, false, 'recipe');
} else {
throw new Error(data.error || 'No syntax returned from server');
}
} catch (error) {
console.error('Error sending recipe to workflow:', error);
showToast('toast.recipes.sendToWorkflowFailed', { message: error.message }, 'error');
}
}
// Add new method to handle downloading missing LoRAs // Add new method to handle downloading missing LoRAs
async showDownloadMissingLorasModal() { async showDownloadMissingLorasModal() {
console.log("currentRecipe", this.currentRecipe); console.log("currentRecipe", this.currentRecipe);
@@ -1188,14 +1415,14 @@ class RecipeModal {
isDiffusionModel ? 'Diffusion Model' : 'Checkpoint' isDiffusionModel ? 'Diffusion Model' : 'Checkpoint'
); );
const successMessage = translate( const successMessage = translate(
isDiffusionModel ? 'uiHelpers.workflow.diffusionModelUpdated' : 'uiHelpers.workflow.checkpointUpdated', 'uiHelpers.workflow.modelUpdated',
{}, {},
isDiffusionModel ? 'Diffusion model updated in workflow' : 'Checkpoint updated in workflow' 'Model updated in workflow'
); );
const failureMessage = translate( const failureMessage = translate(
isDiffusionModel ? 'uiHelpers.workflow.diffusionModelFailed' : 'uiHelpers.workflow.checkpointFailed', 'uiHelpers.workflow.modelFailed',
{}, {},
isDiffusionModel ? 'Failed to update diffusion model node' : 'Failed to update checkpoint node' 'Failed to update model node'
); );
const missingNodesMessage = translate( const missingNodesMessage = translate(
'uiHelpers.workflow.noMatchingNodes', 'uiHelpers.workflow.noMatchingNodes',
@@ -1259,7 +1486,7 @@ class RecipeModal {
const versionId = checkpoint.id || checkpoint.modelVersionId; const versionId = checkpoint.id || checkpoint.modelVersionId;
const modelName = checkpoint.name || checkpoint.modelName || checkpoint.file_name; const modelName = checkpoint.name || checkpoint.modelName || checkpoint.file_name;
if (modelId || modelName) { if (modelId || versionId || modelName) {
openCivitaiByMetadata(modelId, versionId, modelName); openCivitaiByMetadata(modelId, versionId, modelName);
return; return;
} }
@@ -1299,7 +1526,6 @@ class RecipeModal {
// New method to navigate to the LoRAs page // New method to navigate to the LoRAs page
navigateToLorasPage(specificLoraIndex = null) { navigateToLorasPage(specificLoraIndex = null) {
debugger;
// Close the current modal // Close the current modal
modalManager.closeModal('recipeModal'); modalManager.closeModal('recipeModal');
@@ -1318,7 +1544,7 @@ class RecipeModal {
const versionId = lora.id || lora.modelVersionId; const versionId = lora.id || lora.modelVersionId;
const modelName = lora.modelName || lora.name || lora.file_name; const modelName = lora.modelName || lora.name || lora.file_name;
if (modelId || modelName) { if (modelId || versionId || modelName) {
openCivitaiByMetadata(modelId, versionId, modelName); openCivitaiByMetadata(modelId, versionId, modelName);
return; return;
} }

View File

@@ -4,7 +4,7 @@ import { showModelModal } from './ModelModal.js';
import { toggleShowcase } from './showcase/ShowcaseView.js'; import { toggleShowcase } from './showcase/ShowcaseView.js';
import { bulkManager } from '../../managers/BulkManager.js'; import { bulkManager } from '../../managers/BulkManager.js';
import { modalManager } from '../../managers/ModalManager.js'; import { modalManager } from '../../managers/ModalManager.js';
import { NSFW_LEVELS, getBaseModelAbbreviation, getSubTypeAbbreviation, MODEL_SUBTYPE_DISPLAY_NAMES } from '../../utils/constants.js'; import { NSFW_LEVELS, getBaseModelAbbreviation, getSubTypeAbbreviation, getMatureBlurThreshold, MODEL_SUBTYPE_DISPLAY_NAMES } from '../../utils/constants.js';
import { MODEL_TYPES } from '../../api/apiConfig.js'; import { MODEL_TYPES } from '../../api/apiConfig.js';
import { getModelApiClient } from '../../api/modelApiFactory.js'; import { getModelApiClient } from '../../api/modelApiFactory.js';
import { showDeleteModal } from '../../utils/modalUtils.js'; import { showDeleteModal } from '../../utils/modalUtils.js';
@@ -185,14 +185,14 @@ function handleSendToWorkflow(card, replaceMode, modelType) {
isDiffusionModel ? 'Diffusion Model' : 'Checkpoint' isDiffusionModel ? 'Diffusion Model' : 'Checkpoint'
); );
const successMessage = translate( const successMessage = translate(
isDiffusionModel ? 'uiHelpers.workflow.diffusionModelUpdated' : 'uiHelpers.workflow.checkpointUpdated', 'uiHelpers.workflow.modelUpdated',
{}, {},
isDiffusionModel ? 'Diffusion model updated in workflow' : 'Checkpoint updated in workflow' 'Model updated in workflow'
); );
const failureMessage = translate( const failureMessage = translate(
isDiffusionModel ? 'uiHelpers.workflow.diffusionModelFailed' : 'uiHelpers.workflow.checkpointFailed', 'uiHelpers.workflow.modelFailed',
{}, {},
isDiffusionModel ? 'Failed to update diffusion model node' : 'Failed to update checkpoint node' 'Failed to update model node'
); );
const missingNodesMessage = translate( const missingNodesMessage = translate(
'uiHelpers.workflow.noMatchingNodes', 'uiHelpers.workflow.noMatchingNodes',
@@ -478,7 +478,8 @@ export function createModelCard(model, modelType) {
card.dataset.nsfwLevel = nsfwLevel; card.dataset.nsfwLevel = nsfwLevel;
// Determine if the preview should be blurred based on NSFW level and user settings // Determine if the preview should be blurred based on NSFW level and user settings
const shouldBlur = state.settings.blur_mature_content && nsfwLevel > NSFW_LEVELS.PG13; const matureBlurThreshold = getMatureBlurThreshold(state.settings);
const shouldBlur = state.settings.blur_mature_content && nsfwLevel >= matureBlurThreshold;
if (shouldBlur) { if (shouldBlur) {
card.classList.add('nsfw-content'); card.classList.add('nsfw-content');
} }

View File

@@ -1,5 +1,6 @@
import { showToast, openCivitai } from '../../utils/uiHelpers.js'; import { showToast, openCivitai, sendLoraToWorkflow, sendModelPathToWorkflow, buildLoraSyntax } from '../../utils/uiHelpers.js';
import { modalManager } from '../../managers/ModalManager.js'; import { modalManager } from '../../managers/ModalManager.js';
import { MODEL_TYPES } from '../../api/apiConfig.js';
import { import {
toggleShowcase, toggleShowcase,
setupShowcaseScroll, setupShowcaseScroll,
@@ -18,7 +19,7 @@ import { renderCompactTags, setupTagTooltip, formatFileSize, escapeAttribute, es
import { renderTriggerWords, setupTriggerWordsEditMode } from './TriggerWords.js'; import { renderTriggerWords, setupTriggerWordsEditMode } from './TriggerWords.js';
import { parsePresets, renderPresetTags } from './PresetTags.js'; import { parsePresets, renderPresetTags } from './PresetTags.js';
import { initVersionsTab } from './ModelVersionsTab.js'; import { initVersionsTab } from './ModelVersionsTab.js';
import { loadRecipesForLora } from './RecipeTab.js'; import { loadRecipesForModel } from './RecipeTab.js';
import { translate } from '../../utils/i18nHelpers.js'; import { translate } from '../../utils/i18nHelpers.js';
import { state } from '../../state/index.js'; import { state } from '../../state/index.js';
@@ -294,6 +295,17 @@ export async function showModelModal(model, modelType) {
].join('\n') ].join('\n')
: ''; : '';
const headerActionItems = []; const headerActionItems = [];
// Add send to ComfyUI button for all model types
const sendToWorkflowTitle = translate('modals.model.actions.sendToWorkflow', {}, 'Send to ComfyUI');
const sendToWorkflowButton = `
<button class="modal-send-btn" data-action="send-to-workflow" data-model-type="${modelType}" title="${sendToWorkflowTitle}">
<i class="fas fa-paper-plane"></i>
<span>${translate('modals.model.actions.sendToWorkflowText', {}, 'Send to ComfyUI')}</span>
</button>
`.trim();
headerActionItems.push(indentMarkup(sendToWorkflowButton, 20));
if (creatorActionsMarkup) { if (creatorActionsMarkup) {
headerActionItems.push(creatorActionsMarkup); headerActionItems.push(creatorActionsMarkup);
} }
@@ -343,7 +355,9 @@ export async function showModelModal(model, modelType) {
${versionsTabBadge} ${versionsTabBadge}
</button>`.trim(); </button>`.trim();
const tabsContent = modelType === 'loras' ? const supportsRecipesTab = modelType === 'loras' || modelType === 'checkpoints';
const tabsContent = supportsRecipesTab ?
`<button class="tab-btn active" data-tab="showcase">${examplesText}</button> `<button class="tab-btn active" data-tab="showcase">${examplesText}</button>
<button class="tab-btn" data-tab="description">${descriptionText}</button> <button class="tab-btn" data-tab="description">${descriptionText}</button>
${versionsTabButton} ${versionsTabButton}
@@ -373,7 +387,7 @@ export async function showModelModal(model, modelType) {
</button> </button>
</div>`.trim(); </div>`.trim();
const tabPanesContent = modelType === 'loras' ? const tabPanesContent = supportsRecipesTab ?
`<div id="showcase-tab" class="tab-pane active"> `<div id="showcase-tab" class="tab-pane active">
<div class="example-images-loading"> <div class="example-images-loading">
<i class="fas fa-spinner fa-spin"></i> ${loadingExampleImagesText} <i class="fas fa-spinner fa-spin"></i> ${loadingExampleImagesText}
@@ -615,6 +629,14 @@ export async function showModelModal(model, modelType) {
const activeModalElement = document.getElementById(modalId); const activeModalElement = document.getElementById(modalId);
if (activeModalElement) { if (activeModalElement) {
activeModalElement.dataset.filePath = modelWithFullData.file_path || ''; activeModalElement.dataset.filePath = modelWithFullData.file_path || '';
// Store usage_tips for LoRA models
if (modelType === 'loras' && modelWithFullData.usage_tips) {
activeModalElement.dataset.usageTips = modelWithFullData.usage_tips;
}
// Store sub_type for checkpoint models
if (modelType === 'checkpoints' && modelWithFullData.sub_type) {
activeModalElement.dataset.subType = modelWithFullData.sub_type;
}
} }
updateVersionsTabBadge(updateAvailabilityState.hasUpdateAvailable); updateVersionsTabBadge(updateAvailabilityState.hasUpdateAvailable);
const versionsTabController = initVersionsTab({ const versionsTabController = initVersionsTab({
@@ -644,14 +666,23 @@ export async function showModelModal(model, modelType) {
setupNavigationShortcuts(modelType); setupNavigationShortcuts(modelType);
updateNavigationControls(); updateNavigationControls();
// LoRA specific setup // Model-specific setup
if (modelType === 'loras' || modelType === 'embeddings') { if (modelType === 'loras' || modelType === 'embeddings') {
setupTriggerWordsEditMode(); setupTriggerWordsEditMode();
}
if (modelType == 'loras') { if (modelType === 'loras') {
// Load recipes for this LoRA loadRecipesForModel({
loadRecipesForLora(modelWithFullData.model_name, modelWithFullData.sha256); modelKind: 'lora',
} displayName: modelWithFullData.model_name,
sha256: modelWithFullData.sha256,
});
} else if (modelType === 'checkpoints') {
loadRecipesForModel({
modelKind: 'checkpoint',
displayName: modelWithFullData.model_name,
sha256: modelWithFullData.sha256,
});
} }
// Load example images asynchronously - merge regular and custom images // Load example images asynchronously - merge regular and custom images
@@ -747,6 +778,9 @@ function setupEventHandlers(filePath, modelType) {
case 'nav-next': case 'nav-next':
handleDirectionalNavigation('next', modelType); handleDirectionalNavigation('next', modelType);
break; break;
case 'send-to-workflow':
handleSendToWorkflow(target, modelType);
break;
} }
} }
@@ -1026,6 +1060,70 @@ async function openFileLocation(filePath) {
} }
} }
async function handleSendToWorkflow(target, modelType) {
const filePath = getModalFilePath();
if (!filePath) {
showToast('modals.model.sendToWorkflow.noFilePath', {}, 'error');
return;
}
// Get the current model data from the modal
const modalElement = document.getElementById('modelModal');
const currentFileName = modalElement?.querySelector('#file-name')?.textContent || '';
if (modelType === 'loras') {
// For LoRA: Build syntax from usage tips and send
const usageTipsData = modalElement?.dataset?.usageTips;
const usageTips = usageTipsData ? JSON.parse(usageTipsData) : {};
const loraSyntax = buildLoraSyntax(currentFileName, usageTips);
await sendLoraToWorkflow(loraSyntax, false, 'lora');
} else if (modelType === 'checkpoints') {
// For Checkpoint: Send model path
const subtype = (modalElement?.dataset?.subType || 'checkpoint').toLowerCase();
const isDiffusionModel = subtype === 'diffusion_model';
const widgetName = isDiffusionModel ? 'unet_name' : 'ckpt_name';
const actionTypeText = translate(
isDiffusionModel ? 'uiHelpers.nodeSelector.diffusionModel' : 'uiHelpers.nodeSelector.checkpoint',
{},
isDiffusionModel ? 'Diffusion Model' : 'Checkpoint'
);
const successMessage = translate(
'uiHelpers.workflow.modelUpdated',
{},
'Model updated in workflow'
);
const failureMessage = translate(
'uiHelpers.workflow.modelFailed',
{},
'Failed to update model node'
);
const missingNodesMessage = translate(
'uiHelpers.workflow.noMatchingNodes',
{},
'No compatible nodes available in the current workflow'
);
const missingTargetMessage = translate(
'uiHelpers.workflow.noTargetNodeSelected',
{},
'No target node selected'
);
await sendModelPathToWorkflow(filePath, {
widgetName,
collectionType: MODEL_TYPES.CHECKPOINT,
actionTypeText,
successMessage,
failureMessage,
missingNodesMessage,
missingTargetMessage,
});
} else if (modelType === 'embeddings') {
// For Embedding: Send as LoRA syntax (embedding name only)
const embeddingSyntax = `<embed:${currentFileName}:1>`;
await sendLoraToWorkflow(embeddingSyntax, false, 'embedding');
}
}
// Export the model modal API // Export the model modal API
const modelModal = { const modelModal = {
show: showModelModal, show: showModelModal,

View File

@@ -1,38 +1,47 @@
/** /**
* RecipeTab - Handles the recipes tab in model modals (LoRA specific functionality) * RecipeTab - Handles the recipes tab in model modals.
* Moved to shared directory for consistency
*/ */
import { showToast, copyToClipboard } from '../../utils/uiHelpers.js'; import { showToast, copyToClipboard } from '../../utils/uiHelpers.js';
import { setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js'; import { setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js';
/** /**
* Loads recipes that use the specified Lora and renders them in the tab * Loads recipes that use the specified model and renders them in the tab.
* @param {string} loraName - The display name of the Lora * @param {Object} options
* @param {string} sha256 - The SHA256 hash of the Lora * @param {'lora'|'checkpoint'} options.modelKind - Model kind for copy and endpoint selection
* @param {string} options.displayName - The display name of the model
* @param {string} options.sha256 - The SHA256 hash of the model
*/ */
export function loadRecipesForLora(loraName, sha256) { export function loadRecipesForModel({ modelKind, displayName, sha256 }) {
const recipeTab = document.getElementById('recipes-tab'); const recipeTab = document.getElementById('recipes-tab');
if (!recipeTab) return; if (!recipeTab) return;
const normalizedHash = sha256?.toLowerCase?.() || '';
const modelLabel = getModelLabel(modelKind);
// Show loading state // Show loading state
recipeTab.innerHTML = ` recipeTab.innerHTML = `
<div class="recipes-loading"> <div class="recipes-loading">
<i class="fas fa-spinner fa-spin"></i> Loading recipes... <i class="fas fa-spinner fa-spin"></i> Loading recipes...
</div> </div>
`; `;
// Fetch recipes that use this Lora by hash // Fetch recipes that use this model by hash
fetch(`/api/lm/recipes/for-lora?hash=${encodeURIComponent(sha256.toLowerCase())}`) fetch(`${getRecipesEndpoint(modelKind)}?hash=${encodeURIComponent(normalizedHash)}`)
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
if (!data.success) { if (!data.success) {
throw new Error(data.error || 'Failed to load recipes'); throw new Error(data.error || 'Failed to load recipes');
} }
renderRecipes(recipeTab, data.recipes, loraName, sha256); renderRecipes(recipeTab, data.recipes, {
modelKind,
displayName,
modelHash: normalizedHash,
modelLabel,
});
}) })
.catch(error => { .catch(error => {
console.error('Error loading recipes for Lora:', error); console.error(`Error loading recipes for ${modelLabel}:`, error);
recipeTab.innerHTML = ` recipeTab.innerHTML = `
<div class="recipes-error"> <div class="recipes-error">
<i class="fas fa-exclamation-circle"></i> <i class="fas fa-exclamation-circle"></i>
@@ -46,18 +55,24 @@ export function loadRecipesForLora(loraName, sha256) {
* Renders the recipe cards in the tab * Renders the recipe cards in the tab
* @param {HTMLElement} tabElement - The tab element to render into * @param {HTMLElement} tabElement - The tab element to render into
* @param {Array} recipes - Array of recipe objects * @param {Array} recipes - Array of recipe objects
* @param {string} loraName - The display name of the Lora * @param {Object} options - Render options
* @param {string} loraHash - The hash of the Lora
*/ */
function renderRecipes(tabElement, recipes, loraName, loraHash) { function renderRecipes(tabElement, recipes, options) {
const {
modelKind,
displayName,
modelHash,
modelLabel,
} = options;
if (!recipes || recipes.length === 0) { if (!recipes || recipes.length === 0) {
tabElement.innerHTML = ` tabElement.innerHTML = `
<div class="recipes-empty"> <div class="recipes-empty">
<i class="fas fa-book-open"></i> <i class="fas fa-book-open"></i>
<p>No recipes found that use this Lora.</p> <p>No recipes found that use this ${modelLabel}.</p>
</div> </div>
`; `;
return; return;
} }
@@ -73,13 +88,13 @@ function renderRecipes(tabElement, recipes, loraName, loraHash) {
headerText.appendChild(eyebrow); headerText.appendChild(eyebrow);
const title = document.createElement('h3'); const title = document.createElement('h3');
title.textContent = `${recipes.length} recipe${recipes.length > 1 ? 's' : ''} using this Lora`; title.textContent = `${recipes.length} recipe${recipes.length > 1 ? 's' : ''} using this ${modelLabel}`;
headerText.appendChild(title); headerText.appendChild(title);
const description = document.createElement('p'); const description = document.createElement('p');
description.className = 'recipes-header__description'; description.className = 'recipes-header__description';
description.textContent = loraName ? description.textContent = displayName ?
`Discover workflows crafted for ${loraName}.` : `Discover workflows crafted for ${displayName}.` :
'Discover workflows crafted for this model.'; 'Discover workflows crafted for this model.';
headerText.appendChild(description); headerText.appendChild(description);
@@ -101,7 +116,11 @@ function renderRecipes(tabElement, recipes, loraName, loraHash) {
headerElement.appendChild(viewAllButton); headerElement.appendChild(viewAllButton);
viewAllButton.addEventListener('click', () => { viewAllButton.addEventListener('click', () => {
navigateToRecipesPage(loraName, loraHash); navigateToRecipesPage({
modelKind,
displayName,
modelHash,
});
}); });
const cardGrid = document.createElement('div'); const cardGrid = document.createElement('div');
@@ -280,26 +299,32 @@ function copyRecipeSyntax(recipeId) {
} }
/** /**
* Navigates to the recipes page with filter for the current Lora * Navigates to the recipes page with filter for the current model
* @param {string} loraName - The Lora display name to filter by * @param {Object} options - Navigation options
* @param {string} loraHash - The hash of the Lora to filter by
* @param {boolean} createNew - Whether to open the create recipe dialog
*/ */
function navigateToRecipesPage(loraName, loraHash) { function navigateToRecipesPage({ modelKind, displayName, modelHash }) {
// Close the current modal // Close the current modal
if (window.modalManager) { if (window.modalManager) {
modalManager.closeModal('modelModal'); modalManager.closeModal('modelModal');
} }
// Clear any previous filters first // Clear any previous filters first
removeSessionItem('lora_to_recipe_filterLoraName'); removeSessionItem('lora_to_recipe_filterLoraName');
removeSessionItem('lora_to_recipe_filterLoraHash'); removeSessionItem('lora_to_recipe_filterLoraHash');
removeSessionItem('checkpoint_to_recipe_filterCheckpointName');
removeSessionItem('checkpoint_to_recipe_filterCheckpointHash');
removeSessionItem('viewRecipeId'); removeSessionItem('viewRecipeId');
// Store the LoRA name and hash filter in sessionStorage if (modelKind === 'checkpoint') {
setSessionItem('lora_to_recipe_filterLoraName', loraName); // Store the checkpoint name and hash filter in sessionStorage
setSessionItem('lora_to_recipe_filterLoraHash', loraHash); setSessionItem('checkpoint_to_recipe_filterCheckpointName', displayName);
setSessionItem('checkpoint_to_recipe_filterCheckpointHash', modelHash);
} else {
// Store the LoRA name and hash filter in sessionStorage
setSessionItem('lora_to_recipe_filterLoraName', displayName);
setSessionItem('lora_to_recipe_filterLoraHash', modelHash);
}
// Directly navigate to recipes page // Directly navigate to recipes page
window.location.href = '/loras/recipes'; window.location.href = '/loras/recipes';
} }
@@ -321,7 +346,18 @@ function navigateToRecipeDetails(recipeId) {
// Store the recipe ID in sessionStorage to load on recipes page // Store the recipe ID in sessionStorage to load on recipes page
setSessionItem('viewRecipeId', recipeId); setSessionItem('viewRecipeId', recipeId);
// Directly navigate to recipes page // Directly navigate to recipes page
window.location.href = '/loras/recipes'; window.location.href = '/loras/recipes';
} }
function getRecipesEndpoint(modelKind) {
if (modelKind === 'checkpoint') {
return '/api/lm/recipes/for-checkpoint';
}
return '/api/lm/recipes/for-lora';
}
function getModelLabel(modelKind) {
return modelKind === 'checkpoint' ? 'checkpoint' : 'LoRA';
}

View File

@@ -6,7 +6,7 @@
import { showToast, copyToClipboard, getNSFWLevelName } from '../../../utils/uiHelpers.js'; import { showToast, copyToClipboard, getNSFWLevelName } from '../../../utils/uiHelpers.js';
import { state } from '../../../state/index.js'; import { state } from '../../../state/index.js';
import { getModelApiClient } from '../../../api/modelApiFactory.js'; import { getModelApiClient } from '../../../api/modelApiFactory.js';
import { NSFW_LEVELS } from '../../../utils/constants.js'; import { NSFW_LEVELS, getMatureBlurThreshold } from '../../../utils/constants.js';
import { getNsfwLevelSelector } from '../NsfwLevelSelector.js'; import { getNsfwLevelSelector } from '../NsfwLevelSelector.js';
/** /**
@@ -607,7 +607,8 @@ function applyNsfwLevelChange(mediaWrapper, nsfwLevel) {
} }
mediaWrapper.dataset.nsfwLevel = String(nsfwLevel); mediaWrapper.dataset.nsfwLevel = String(nsfwLevel);
const shouldBlur = state.settings.blur_mature_content && nsfwLevel > NSFW_LEVELS.PG13; const matureBlurThreshold = getMatureBlurThreshold(state.settings);
const shouldBlur = state.settings.blur_mature_content && nsfwLevel >= matureBlurThreshold;
let overlay = mediaWrapper.querySelector('.nsfw-overlay'); let overlay = mediaWrapper.querySelector('.nsfw-overlay');
let toggleBtn = mediaWrapper.querySelector('.toggle-blur-btn'); let toggleBtn = mediaWrapper.querySelector('.toggle-blur-btn');

View File

@@ -6,7 +6,7 @@ import { showToast } from '../../../utils/uiHelpers.js';
import { state } from '../../../state/index.js'; import { state } from '../../../state/index.js';
import { modalManager } from '../../../managers/ModalManager.js'; import { modalManager } from '../../../managers/ModalManager.js';
import { translate } from '../../../utils/i18nHelpers.js'; import { translate } from '../../../utils/i18nHelpers.js';
import { NSFW_LEVELS } from '../../../utils/constants.js'; import { NSFW_LEVELS, getMatureBlurThreshold } from '../../../utils/constants.js';
import { import {
initLazyLoading, initLazyLoading,
initNsfwBlurHandlers, initNsfwBlurHandlers,
@@ -184,7 +184,8 @@ function renderMediaItem(img, index, exampleFiles) {
// Check if media should be blurred // Check if media should be blurred
const nsfwLevel = img.nsfwLevel !== undefined ? img.nsfwLevel : 0; const nsfwLevel = img.nsfwLevel !== undefined ? img.nsfwLevel : 0;
const shouldBlur = state.settings.blur_mature_content && nsfwLevel > NSFW_LEVELS.PG13; const matureBlurThreshold = getMatureBlurThreshold(state.settings);
const shouldBlur = state.settings.blur_mature_content && nsfwLevel >= matureBlurThreshold;
// Determine NSFW warning text based on level // Determine NSFW warning text based on level
let nsfwText = "Mature Content"; let nsfwText = "Mature Content";

View File

@@ -17,6 +17,8 @@ import { onboardingManager } from './managers/OnboardingManager.js';
import { BulkContextMenu } from './components/ContextMenu/BulkContextMenu.js'; import { BulkContextMenu } from './components/ContextMenu/BulkContextMenu.js';
import { createPageContextMenu, createGlobalContextMenu } from './components/ContextMenu/index.js'; import { createPageContextMenu, createGlobalContextMenu } from './components/ContextMenu/index.js';
import { initializeEventManagement } from './utils/eventManagementInit.js'; import { initializeEventManagement } from './utils/eventManagementInit.js';
import { civitaiBaseModelApi } from './api/civitaiBaseModelApi.js';
import { setDynamicBaseModels } from './utils/constants.js';
// Core application class // Core application class
export class AppCore { export class AppCore {
@@ -42,6 +44,10 @@ export class AppCore {
await settingsManager.waitForInitialization(); await settingsManager.waitForInitialization();
console.log('AppCore: Settings initialized'); console.log('AppCore: Settings initialized');
// Initialize dynamic base models (async, non-blocking)
console.log('AppCore: Initializing dynamic base models...');
this.initializeDynamicBaseModels();
// Initialize managers // Initialize managers
state.loadingManager = new LoadingManager(); state.loadingManager = new LoadingManager();
modalManager.initialize(); modalManager.initialize();
@@ -116,6 +122,21 @@ export class AppCore {
window.globalContextMenuInstance = createGlobalContextMenu(); window.globalContextMenuInstance = createGlobalContextMenu();
} }
} }
// Initialize dynamic base models from Civitai API
// This is non-blocking - runs in background
async initializeDynamicBaseModels() {
try {
const result = await civitaiBaseModelApi.getBaseModels();
if (result && result.models) {
setDynamicBaseModels(result.models, result.last_updated);
console.log(`AppCore: Loaded ${result.merged_count} base models (${result.hardcoded_count} hardcoded + ${result.remote_count} remote)`);
}
} catch (error) {
console.warn('AppCore: Failed to load dynamic base models:', error);
// Non-critical error - app continues with hardcoded models
}
}
} }
// Create and export a singleton instance // Create and export a singleton instance

View File

@@ -0,0 +1,357 @@
import { showToast } from '../utils/uiHelpers.js';
import { translate } from '../utils/i18nHelpers.js';
import { getModelApiClient } from '../api/modelApiFactory.js';
import { MODEL_TYPES } from '../api/apiConfig.js';
import { state } from '../state/index.js';
import { modalManager } from './ModalManager.js';
/**
* Manager for downloading missing LoRAs for selected recipes in bulk
*/
export class BulkMissingLoraDownloadManager {
constructor() {
this.loraApiClient = getModelApiClient(MODEL_TYPES.LORA);
this.pendingLoras = [];
this.pendingRecipes = [];
}
/**
* Collect missing LoRAs from selected recipes with deduplication
* @param {Array} selectedRecipes - Array of selected recipe objects
* @returns {Object} - Object containing unique missing LoRAs and statistics
*/
collectMissingLoras(selectedRecipes) {
const uniqueLoras = new Map(); // key: hash or modelVersionId, value: lora object
const missingLorasByRecipe = new Map();
let totalMissingCount = 0;
selectedRecipes.forEach(recipe => {
const missingLoras = [];
if (recipe.loras && Array.isArray(recipe.loras)) {
recipe.loras.forEach(lora => {
// Only include LoRAs not in library and not deleted
if (!lora.inLibrary && !lora.isDeleted) {
const uniqueKey = lora.hash || lora.id || lora.modelVersionId;
if (uniqueKey && !uniqueLoras.has(uniqueKey)) {
// Store the LoRA info
uniqueLoras.set(uniqueKey, {
...lora,
modelId: lora.modelId || lora.model_id,
id: lora.id || lora.modelVersionId,
});
}
missingLoras.push(lora);
totalMissingCount++;
}
});
}
if (missingLoras.length > 0) {
missingLorasByRecipe.set(recipe.id || recipe.file_path, {
recipe,
missingLoras
});
}
});
return {
uniqueLoras: Array.from(uniqueLoras.values()),
uniqueCount: uniqueLoras.size,
totalMissingCount,
missingLorasByRecipe
};
}
/**
* Show confirmation modal for downloading missing LoRAs
* @param {Object} stats - Statistics about missing LoRAs
* @returns {Promise<boolean>} - Whether user confirmed
*/
async showConfirmationModal(stats) {
const { uniqueCount, totalMissingCount, uniqueLoras } = stats;
if (uniqueCount === 0) {
showToast('toast.recipes.noMissingLoras', {}, 'info');
return false;
}
// Store pending data for confirmation
this.pendingLoras = uniqueLoras;
// Update modal content
const messageEl = document.getElementById('bulkDownloadMissingLorasMessage');
const listEl = document.getElementById('bulkDownloadMissingLorasList');
const confirmBtn = document.getElementById('bulkDownloadMissingLorasConfirmBtn');
if (messageEl) {
messageEl.textContent = translate('modals.bulkDownloadMissingLoras.message', {
uniqueCount,
totalCount: totalMissingCount
}, `Found ${uniqueCount} unique missing LoRAs (from ${totalMissingCount} total across selected recipes).`);
}
if (listEl) {
listEl.innerHTML = uniqueLoras.slice(0, 10).map(lora => `
<li>
<span class="lora-name">${lora.name || lora.file_name || 'Unknown'}</span>
${lora.version ? `<span class="lora-version">${lora.version}</span>` : ''}
</li>
`).join('') +
(uniqueLoras.length > 10 ? `
<li class="more-items">${translate('modals.bulkDownloadMissingLoras.moreItems', { count: uniqueLoras.length - 10 }, `...and ${uniqueLoras.length - 10} more`)}</li>
` : '');
}
if (confirmBtn) {
confirmBtn.innerHTML = `
<i class="fas fa-download"></i>
${translate('modals.bulkDownloadMissingLoras.downloadButton', { count: uniqueCount }, `Download ${uniqueCount} LoRA(s)`)}
`;
}
// Show modal
modalManager.showModal('bulkDownloadMissingLorasModal');
// Return a promise that will be resolved when user confirms or cancels
return new Promise((resolve) => {
this.confirmResolve = resolve;
});
}
/**
* Called when user confirms download in modal
*/
async confirmDownload() {
modalManager.closeModal('bulkDownloadMissingLorasModal');
if (this.confirmResolve) {
this.confirmResolve(true);
this.confirmResolve = null;
}
// Execute download
await this.executeDownload(this.pendingLoras);
this.pendingLoras = [];
}
/**
* Download missing LoRAs for selected recipes
* @param {Array} selectedRecipes - Array of selected recipe objects
*/
async downloadMissingLoras(selectedRecipes) {
if (!selectedRecipes || selectedRecipes.length === 0) {
showToast('toast.recipes.noRecipesSelected', {}, 'warning');
return;
}
// Store selected recipes
this.pendingRecipes = selectedRecipes;
// Collect missing LoRAs with deduplication
const stats = this.collectMissingLoras(selectedRecipes);
if (stats.uniqueCount === 0) {
showToast('toast.recipes.noMissingLorasInSelection', {}, 'info');
return;
}
// Show confirmation modal
const confirmed = await this.showConfirmationModal(stats);
if (!confirmed) {
return;
}
}
/**
* Execute the download process
* @param {Array} lorasToDownload - Array of unique LoRAs to download
*/
async executeDownload(lorasToDownload) {
const totalLoras = lorasToDownload.length;
// Get LoRA root directory
const loraRoot = await this.getLoraRoot();
if (!loraRoot) {
showToast('toast.recipes.noLoraRootConfigured', {}, 'error');
return;
}
// Generate batch download ID
const batchDownloadId = Date.now().toString();
// Use default paths
const useDefaultPaths = true;
// Set up WebSocket for progress updates
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
const ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/download-progress?id=${batchDownloadId}`);
// Show download progress UI
const loadingManager = state.loadingManager;
const updateProgress = loadingManager.showDownloadProgress(totalLoras);
let completedDownloads = 0;
let failedDownloads = 0;
let currentLoraProgress = 0;
// Set up WebSocket message handler
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
// Handle download ID confirmation
if (data.type === 'download_id') {
console.log(`Connected to batch download progress with ID: ${data.download_id}`);
return;
}
// Process progress updates
if (data.status === 'progress' && data.download_id && data.download_id.startsWith(batchDownloadId)) {
currentLoraProgress = data.progress;
const currentLora = lorasToDownload[completedDownloads + failedDownloads];
const loraName = currentLora ? (currentLora.name || currentLora.file_name || 'Unknown') : '';
const metrics = {
bytesDownloaded: data.bytes_downloaded,
totalBytes: data.total_bytes,
bytesPerSecond: data.bytes_per_second
};
updateProgress(currentLoraProgress, completedDownloads, loraName, metrics);
// Update status message
if (currentLoraProgress < 3) {
loadingManager.setStatus(
translate('recipes.controls.import.startingDownload',
{ current: completedDownloads + failedDownloads + 1, total: totalLoras },
`Starting download for LoRA ${completedDownloads + failedDownloads + 1}/${totalLoras}`
)
);
} else if (currentLoraProgress > 3 && currentLoraProgress < 100) {
loadingManager.setStatus(
translate('recipes.controls.import.downloadingLoras', {}, `Downloading LoRAs...`)
);
}
}
};
// Wait for WebSocket to connect
await new Promise((resolve, reject) => {
ws.onopen = resolve;
ws.onerror = (error) => {
console.error('WebSocket error:', error);
reject(error);
};
});
// Download each LoRA sequentially
for (let i = 0; i < lorasToDownload.length; i++) {
const lora = lorasToDownload[i];
currentLoraProgress = 0;
loadingManager.setStatus(
translate('recipes.controls.import.startingDownload',
{ current: i + 1, total: totalLoras },
`Starting download for LoRA ${i + 1}/${totalLoras}`
)
);
updateProgress(0, completedDownloads, lora.name || lora.file_name || 'Unknown');
try {
const modelId = lora.modelId || lora.model_id;
const versionId = lora.id || lora.modelVersionId;
if (!modelId && !versionId) {
console.warn(`Skipping LoRA without model/version ID:`, lora);
failedDownloads++;
continue;
}
const response = await this.loraApiClient.downloadModel(
modelId,
versionId,
loraRoot,
'', // Empty relative path, use default paths
useDefaultPaths,
batchDownloadId
);
if (!response.success) {
console.error(`Failed to download LoRA ${lora.name || lora.file_name}: ${response.error}`);
failedDownloads++;
} else {
completedDownloads++;
updateProgress(100, completedDownloads, '');
}
} catch (error) {
console.error(`Error downloading LoRA ${lora.name || lora.file_name}:`, error);
failedDownloads++;
}
}
// Close WebSocket
ws.close();
// Hide loading UI
loadingManager.hide();
// Show completion message
if (failedDownloads === 0) {
showToast('toast.loras.allDownloadSuccessful', { count: completedDownloads }, 'success');
} else {
showToast('toast.loras.downloadPartialSuccess', {
completed: completedDownloads,
total: totalLoras
}, 'warning');
}
// Refresh the recipes list to update LoRA status
if (window.recipeManager) {
window.recipeManager.loadRecipes();
}
}
/**
* Get LoRA root directory from API
* @returns {Promise<string|null>} - LoRA root directory or null
*/
async getLoraRoot() {
try {
// Fetch available LoRA roots from API
const rootsData = await this.loraApiClient.fetchModelRoots();
if (!rootsData || !rootsData.roots || rootsData.roots.length === 0) {
console.error('No LoRA roots available');
return null;
}
// Try to get default root from settings
const defaultRootKey = 'default_lora_root';
const defaultRoot = state.global?.settings?.[defaultRootKey];
// If default root is set and exists in available roots, use it
if (defaultRoot && rootsData.roots.includes(defaultRoot)) {
return defaultRoot;
}
// Otherwise, return the first available root
return rootsData.roots[0];
} catch (error) {
console.error('Error getting LoRA root:', error);
return null;
}
}
}
// Export singleton instance
export const bulkMissingLoraDownloadManager = new BulkMissingLoraDownloadManager();
// Make available globally for HTML onclick handlers
if (typeof window !== 'undefined') {
window.bulkMissingLoraDownloadManager = bulkMissingLoraDownloadManager;
}

View File

@@ -492,7 +492,7 @@ export class DownloadManager {
console.error('WebSocket error:', error); console.error('WebSocket error:', error);
}; };
await this.apiClient.downloadModel( const response = await this.apiClient.downloadModel(
modelId, modelId,
versionId, versionId,
modelRoot, modelRoot,
@@ -502,6 +502,16 @@ export class DownloadManager {
source source
); );
if (response?.skipped) {
this.loadingManager.setStatus(translate('modals.download.status.finalizing'));
updateProgress(100, 0, displayName);
showToast('toast.loras.downloadSkippedByBaseModel', { baseModel: response.base_model || 'Unknown' }, 'warning');
if (closeModal) {
modalManager.closeModal('downloadModal');
}
return true;
}
showToast('toast.loras.downloadCompleted', {}, 'success'); showToast('toast.loras.downloadCompleted', {}, 'success');
if (closeModal) { if (closeModal) {

View File

@@ -291,6 +291,19 @@ export class ModalManager {
}); });
} }
// Register bulkDownloadMissingLorasModal
const bulkDownloadMissingLorasModal = document.getElementById('bulkDownloadMissingLorasModal');
if (bulkDownloadMissingLorasModal) {
this.registerModal('bulkDownloadMissingLorasModal', {
element: bulkDownloadMissingLorasModal,
onClose: () => {
this.getModal('bulkDownloadMissingLorasModal').element.style.display = 'none';
document.body.classList.remove('modal-open');
},
closeOnOutsideClick: true
});
}
document.addEventListener('keydown', this.boundHandleEscape); document.addEventListener('keydown', this.boundHandleEscape);
this.initialized = true; this.initialized = true;
} }

View File

@@ -2,7 +2,14 @@ import { modalManager } from './ModalManager.js';
import { showToast } from '../utils/uiHelpers.js'; import { showToast } from '../utils/uiHelpers.js';
import { state, createDefaultSettings } from '../state/index.js'; import { state, createDefaultSettings } from '../state/index.js';
import { resetAndReload } from '../api/modelApiFactory.js'; import { resetAndReload } from '../api/modelApiFactory.js';
import { DOWNLOAD_PATH_TEMPLATES, MAPPABLE_BASE_MODELS, PATH_TEMPLATE_PLACEHOLDERS, DEFAULT_PATH_TEMPLATES, DEFAULT_PRIORITY_TAG_CONFIG } from '../utils/constants.js'; import {
DOWNLOAD_PATH_TEMPLATES,
MAPPABLE_BASE_MODELS,
PATH_TEMPLATE_PLACEHOLDERS,
DEFAULT_PATH_TEMPLATES,
DEFAULT_PRIORITY_TAG_CONFIG,
getMappableBaseModelsDynamic
} from '../utils/constants.js';
import { translate } from '../utils/i18nHelpers.js'; import { translate } from '../utils/i18nHelpers.js';
import { i18n } from '../i18n/index.js'; import { i18n } from '../i18n/index.js';
import { configureModelCardVideo } from '../components/shared/ModelCard.js'; import { configureModelCardVideo } from '../components/shared/ModelCard.js';
@@ -10,6 +17,8 @@ import { validatePriorityTagString, getPriorityTagSuggestionsMap, invalidatePrio
import { bannerService } from './BannerService.js'; import { bannerService } from './BannerService.js';
import { sidebarManager } from '../components/SidebarManager.js'; import { sidebarManager } from '../components/SidebarManager.js';
const VALID_MATURE_BLUR_LEVELS = new Set(['PG13', 'R', 'X', 'XXX']);
export class SettingsManager { export class SettingsManager {
constructor() { constructor() {
this.initialized = false; this.initialized = false;
@@ -137,11 +146,33 @@ export class SettingsManager {
backendSettings?.metadata_refresh_skip_paths ?? defaults.metadata_refresh_skip_paths backendSettings?.metadata_refresh_skip_paths ?? defaults.metadata_refresh_skip_paths
); );
merged.skip_previously_downloaded_model_versions =
backendSettings?.skip_previously_downloaded_model_versions
?? defaults.skip_previously_downloaded_model_versions;
merged.download_skip_base_models = this.normalizeDownloadSkipBaseModels(
backendSettings?.download_skip_base_models ?? defaults.download_skip_base_models
);
merged.mature_blur_level = this.normalizeMatureBlurLevel(
backendSettings?.mature_blur_level ?? defaults.mature_blur_level
);
Object.keys(merged).forEach(key => this.backendSettingKeys.add(key)); Object.keys(merged).forEach(key => this.backendSettingKeys.add(key));
return merged; return merged;
} }
normalizeMatureBlurLevel(value) {
if (typeof value === 'string') {
const normalized = value.trim().toUpperCase();
if (VALID_MATURE_BLUR_LEVELS.has(normalized)) {
return normalized;
}
}
return 'R';
}
normalizePatternList(value) { normalizePatternList(value) {
if (Array.isArray(value)) { if (Array.isArray(value)) {
const sanitized = value const sanitized = value
@@ -163,6 +194,17 @@ export class SettingsManager {
return []; return [];
} }
getAvailableDownloadSkipBaseModels() {
// Use dynamic base models if available, fallback to hardcoded
const models = getMappableBaseModelsDynamic();
return models.filter(model => model !== 'Other');
}
normalizeDownloadSkipBaseModels(value) {
const allowed = new Set(this.getAvailableDownloadSkipBaseModels());
return this.normalizePatternList(value).filter(model => allowed.has(model));
}
registerStartupMessages(messages = []) { registerStartupMessages(messages = []) {
if (!Array.isArray(messages) || messages.length === 0) { if (!Array.isArray(messages) || messages.length === 0) {
return; return;
@@ -363,6 +405,36 @@ export class SettingsManager {
}); });
} }
const downloadSkipBaseModelsContainer = document.getElementById('downloadSkipBaseModelsContainer');
if (downloadSkipBaseModelsContainer) {
downloadSkipBaseModelsContainer.addEventListener('change', (event) => {
if (event.target instanceof HTMLInputElement && event.target.name === 'downloadSkipBaseModel') {
this.saveDownloadSkipBaseModels();
}
});
}
const downloadSkipBaseModelsToggle = document.getElementById('downloadSkipBaseModelsToggle');
if (downloadSkipBaseModelsToggle) {
downloadSkipBaseModelsToggle.addEventListener('click', () => {
this.toggleDownloadSkipBaseModelsPanel();
});
}
const downloadSkipBaseModelsSearch = document.getElementById('downloadSkipBaseModelsSearch');
if (downloadSkipBaseModelsSearch) {
downloadSkipBaseModelsSearch.addEventListener('input', () => {
this.renderDownloadSkipBaseModels();
});
}
const downloadSkipBaseModelsClear = document.getElementById('downloadSkipBaseModelsClear');
if (downloadSkipBaseModelsClear) {
downloadSkipBaseModelsClear.addEventListener('click', () => {
this.clearDownloadSkipBaseModels();
});
}
this.setupPriorityTagInputs(); this.setupPriorityTagInputs();
this.initializeNavigation(); this.initializeNavigation();
this.initializeSearch(); this.initializeSearch();
@@ -682,6 +754,13 @@ export class SettingsManager {
showOnlySFWCheckbox.checked = state.global.settings.show_only_sfw ?? false; showOnlySFWCheckbox.checked = state.global.settings.show_only_sfw ?? false;
} }
const matureBlurLevelSelect = document.getElementById('matureBlurLevel');
if (matureBlurLevelSelect) {
matureBlurLevelSelect.value = this.normalizeMatureBlurLevel(
state.global.settings.mature_blur_level
);
}
const usePortableCheckbox = document.getElementById('usePortableSettings'); const usePortableCheckbox = document.getElementById('usePortableSettings');
if (usePortableCheckbox) { if (usePortableCheckbox) {
usePortableCheckbox.checked = !!state.global.settings.use_portable_settings; usePortableCheckbox.checked = !!state.global.settings.use_portable_settings;
@@ -707,6 +786,13 @@ export class SettingsManager {
metadataRefreshSkipPathsError.textContent = ''; metadataRefreshSkipPathsError.textContent = '';
} }
this.renderDownloadSkipBaseModels();
const downloadSkipBaseModelsError = document.getElementById('downloadSkipBaseModelsError');
if (downloadSkipBaseModelsError) {
downloadSkipBaseModelsError.textContent = '';
}
this.setDownloadSkipBaseModelsPanelOpen(false);
// Set video autoplay on hover setting // Set video autoplay on hover setting
const autoplayOnHoverCheckbox = document.getElementById('autoplayOnHover'); const autoplayOnHoverCheckbox = document.getElementById('autoplayOnHover');
if (autoplayOnHoverCheckbox) { if (autoplayOnHoverCheckbox) {
@@ -754,6 +840,12 @@ export class SettingsManager {
hideEarlyAccessUpdatesCheckbox.checked = state.global.settings.hide_early_access_updates || false; hideEarlyAccessUpdatesCheckbox.checked = state.global.settings.hide_early_access_updates || false;
} }
const skipPreviouslyDownloadedModelVersionsCheckbox = document.getElementById('skipPreviouslyDownloadedModelVersions');
if (skipPreviouslyDownloadedModelVersionsCheckbox) {
skipPreviouslyDownloadedModelVersionsCheckbox.checked =
state.global.settings.skip_previously_downloaded_model_versions || false;
}
// Set optimize example images setting // Set optimize example images setting
const optimizeExampleImagesCheckbox = document.getElementById('optimizeExampleImages'); const optimizeExampleImagesCheckbox = document.getElementById('optimizeExampleImages');
if (optimizeExampleImagesCheckbox) { if (optimizeExampleImagesCheckbox) {
@@ -1164,10 +1256,7 @@ export class SettingsManager {
throw new Error('No LoRA roots found'); throw new Error('No LoRA roots found');
} }
// Clear existing options except the first one (No Default)
const noDefaultOption = defaultLoraRootSelect.querySelector('option[value=""]');
defaultLoraRootSelect.innerHTML = ''; defaultLoraRootSelect.innerHTML = '';
defaultLoraRootSelect.appendChild(noDefaultOption);
// Add options for each root // Add options for each root
data.roots.forEach(root => { data.roots.forEach(root => {
@@ -1177,9 +1266,8 @@ export class SettingsManager {
defaultLoraRootSelect.appendChild(option); defaultLoraRootSelect.appendChild(option);
}); });
// Set selected value from settings
const defaultRoot = state.global.settings.default_lora_root || ''; const defaultRoot = state.global.settings.default_lora_root || '';
defaultLoraRootSelect.value = defaultRoot; defaultLoraRootSelect.value = data.roots.includes(defaultRoot) ? defaultRoot : data.roots[0];
} catch (error) { } catch (error) {
console.error('Error loading LoRA roots:', error); console.error('Error loading LoRA roots:', error);
@@ -1203,10 +1291,7 @@ export class SettingsManager {
throw new Error('No checkpoint roots found'); throw new Error('No checkpoint roots found');
} }
// Clear existing options except first one (No Default)
const noDefaultOption = defaultCheckpointRootSelect.querySelector('option[value=""]');
defaultCheckpointRootSelect.innerHTML = ''; defaultCheckpointRootSelect.innerHTML = '';
defaultCheckpointRootSelect.appendChild(noDefaultOption);
// Add options for each root // Add options for each root
data.roots.forEach(root => { data.roots.forEach(root => {
@@ -1216,9 +1301,8 @@ export class SettingsManager {
defaultCheckpointRootSelect.appendChild(option); defaultCheckpointRootSelect.appendChild(option);
}); });
// Set selected value from settings
const defaultRoot = state.global.settings.default_checkpoint_root || ''; const defaultRoot = state.global.settings.default_checkpoint_root || '';
defaultCheckpointRootSelect.value = defaultRoot; defaultCheckpointRootSelect.value = data.roots.includes(defaultRoot) ? defaultRoot : data.roots[0];
} catch (error) { } catch (error) {
console.error('Error loading checkpoint roots:', error); console.error('Error loading checkpoint roots:', error);
@@ -1242,10 +1326,7 @@ export class SettingsManager {
throw new Error('No diffusion model roots found'); throw new Error('No diffusion model roots found');
} }
// Clear existing options except first one (No Default)
const noDefaultOption = defaultUnetRootSelect.querySelector('option[value=""]');
defaultUnetRootSelect.innerHTML = ''; defaultUnetRootSelect.innerHTML = '';
defaultUnetRootSelect.appendChild(noDefaultOption);
// Add options for each root // Add options for each root
data.roots.forEach(root => { data.roots.forEach(root => {
@@ -1255,9 +1336,8 @@ export class SettingsManager {
defaultUnetRootSelect.appendChild(option); defaultUnetRootSelect.appendChild(option);
}); });
// Set selected value from settings
const defaultRoot = state.global.settings.default_unet_root || ''; const defaultRoot = state.global.settings.default_unet_root || '';
defaultUnetRootSelect.value = defaultRoot; defaultUnetRootSelect.value = data.roots.includes(defaultRoot) ? defaultRoot : data.roots[0];
} catch (error) { } catch (error) {
console.error('Error loading diffusion model roots:', error); console.error('Error loading diffusion model roots:', error);
@@ -1281,10 +1361,7 @@ export class SettingsManager {
throw new Error('No embedding roots found'); throw new Error('No embedding roots found');
} }
// Clear existing options except first one (No Default)
const noDefaultOption = defaultEmbeddingRootSelect.querySelector('option[value=""]');
defaultEmbeddingRootSelect.innerHTML = ''; defaultEmbeddingRootSelect.innerHTML = '';
defaultEmbeddingRootSelect.appendChild(noDefaultOption);
// Add options for each root // Add options for each root
data.roots.forEach(root => { data.roots.forEach(root => {
@@ -1294,9 +1371,8 @@ export class SettingsManager {
defaultEmbeddingRootSelect.appendChild(option); defaultEmbeddingRootSelect.appendChild(option);
}); });
// Set selected value from settings
const defaultRoot = state.global.settings.default_embedding_root || ''; const defaultRoot = state.global.settings.default_embedding_root || '';
defaultEmbeddingRootSelect.value = defaultRoot; defaultEmbeddingRootSelect.value = data.roots.includes(defaultRoot) ? defaultRoot : data.roots[0];
} catch (error) { } catch (error) {
console.error('Error loading embedding roots:', error); console.error('Error loading embedding roots:', error);
@@ -1395,7 +1471,7 @@ export class SettingsManager {
try { try {
// Save to backend - this triggers path validation // Save to backend - this triggers path validation
await this.saveSetting('extra_folder_paths', extraFolderPaths); await this.saveSetting('extra_folder_paths', extraFolderPaths);
showToast('toast.settings.settingsUpdated', { setting: 'Extra Folder Paths' }, 'success'); showToast('settings.extraFolderPaths.saveSuccess', {}, 'success');
// Add empty row if no valid paths exist for the changed type // Add empty row if no valid paths exist for the changed type
const container = document.getElementById(`extraFolderPaths-${changedModelType}`); const container = document.getElementById(`extraFolderPaths-${changedModelType}`);
@@ -1444,7 +1520,7 @@ export class SettingsManager {
const row = document.createElement('div'); const row = document.createElement('div');
row.className = 'mapping-row'; row.className = 'mapping-row';
const availableModels = MAPPABLE_BASE_MODELS.filter(model => { const availableModels = getMappableBaseModelsDynamic().filter(model => {
const existingMappings = state.global.settings.base_model_path_mappings || {}; const existingMappings = state.global.settings.base_model_path_mappings || {};
return !existingMappings.hasOwnProperty(model) || model === baseModel; return !existingMappings.hasOwnProperty(model) || model === baseModel;
}); });
@@ -1546,7 +1622,7 @@ export class SettingsManager {
const currentValue = select.value; const currentValue = select.value;
// Get available models (not already mapped, except current) // Get available models (not already mapped, except current)
const availableModels = MAPPABLE_BASE_MODELS.filter(model => const availableModels = getMappableBaseModelsDynamic().filter(model =>
!existingMappings.hasOwnProperty(model) || model === currentValue !existingMappings.hasOwnProperty(model) || model === currentValue
); );
@@ -1811,7 +1887,9 @@ export class SettingsManager {
const element = document.getElementById(elementId); const element = document.getElementById(elementId);
if (!element) return; if (!element) return;
const value = element.value; const value = settingKey === 'mature_blur_level'
? this.normalizeMatureBlurLevel(element.value)
: element.value;
try { try {
// Update frontend state with mapped keys // Update frontend state with mapped keys
@@ -1834,7 +1912,12 @@ export class SettingsManager {
showToast('toast.settings.settingsUpdated', { setting: settingKey.replace(/_/g, ' ') }, 'success'); showToast('toast.settings.settingsUpdated', { setting: settingKey.replace(/_/g, ' ') }, 'success');
if (settingKey === 'model_name_display' || settingKey === 'model_card_footer_action' || settingKey === 'update_flag_strategy') { if (
settingKey === 'model_name_display'
|| settingKey === 'model_card_footer_action'
|| settingKey === 'update_flag_strategy'
|| settingKey === 'mature_blur_level'
) {
this.reloadContent(); this.reloadContent();
} }
} catch (error) { } catch (error) {
@@ -2140,6 +2223,190 @@ export class SettingsManager {
} }
} }
renderDownloadSkipBaseModels() {
const container = document.getElementById('downloadSkipBaseModelsContainer');
const searchInput = document.getElementById('downloadSkipBaseModelsSearch');
const emptyState = document.getElementById('downloadSkipBaseModelsEmpty');
if (!container) {
return;
}
const selectedValues = this.normalizeDownloadSkipBaseModels(
state.global.settings.download_skip_base_models
);
const selected = new Set(selectedValues);
const options = this.getAvailableDownloadSkipBaseModels();
const query = (searchInput?.value || '').trim().toLowerCase();
const filteredOptions = query
? options.filter((baseModel) => baseModel.toLowerCase().includes(query))
: options;
container.innerHTML = filteredOptions.map((baseModel) => `
<label class="base-model-skip-option">
<input
type="checkbox"
name="downloadSkipBaseModel"
value="${baseModel}"
${selected.has(baseModel) ? 'checked' : ''}
>
<span>${baseModel}</span>
</label>
`).join('');
if (emptyState) {
emptyState.hidden = filteredOptions.length > 0;
}
this.renderDownloadSkipBaseModelsSummary(selectedValues);
}
renderDownloadSkipBaseModelsSummary(selectedValues = null) {
const summaryElement = document.getElementById('downloadSkipBaseModelsSummary');
if (!summaryElement) {
return;
}
const values = Array.isArray(selectedValues)
? selectedValues
: this.normalizeDownloadSkipBaseModels(state.global.settings.download_skip_base_models);
if (values.length === 0) {
summaryElement.textContent = translate(
'settings.downloadSkipBaseModels.summary.none',
{},
'None selected'
);
return;
}
if (values.length <= 2) {
summaryElement.textContent = values.join(', ');
return;
}
summaryElement.textContent = translate(
'settings.downloadSkipBaseModels.summary.count',
{ count: values.length },
`${values.length} selected`
);
}
setDownloadSkipBaseModelsPanelOpen(isOpen) {
const panel = document.getElementById('downloadSkipBaseModelsPanel');
const toggle = document.getElementById('downloadSkipBaseModelsToggle');
const toggleLabel = toggle?.querySelector('.base-model-skip-toggle-label');
if (!panel || !toggle) {
return;
}
panel.hidden = !isOpen;
toggle.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
if (toggleLabel) {
toggleLabel.textContent = isOpen
? translate('settings.downloadSkipBaseModels.actions.collapse', {}, 'Collapse')
: translate('settings.downloadSkipBaseModels.actions.edit', {}, 'Edit');
}
if (isOpen) {
const searchInput = document.getElementById('downloadSkipBaseModelsSearch');
searchInput?.focus();
}
}
toggleDownloadSkipBaseModelsPanel() {
const panel = document.getElementById('downloadSkipBaseModelsPanel');
if (!panel) {
return;
}
this.setDownloadSkipBaseModelsPanelOpen(panel.hidden);
}
async saveDownloadSkipBaseModels() {
const container = document.getElementById('downloadSkipBaseModelsContainer');
const errorElement = document.getElementById('downloadSkipBaseModelsError');
if (!container) return;
const selected = Array.from(
container.querySelectorAll('input[name="downloadSkipBaseModel"]:checked')
).map((input) => input.value);
const normalized = this.normalizeDownloadSkipBaseModels(selected);
const current = this.normalizeDownloadSkipBaseModels(state.global.settings.download_skip_base_models);
if (normalized.join('|') === current.join('|')) {
if (errorElement) {
errorElement.textContent = '';
}
return;
}
try {
if (errorElement) {
errorElement.textContent = '';
}
await this.saveSetting('download_skip_base_models', normalized);
this.renderDownloadSkipBaseModels();
showToast(
'toast.settings.settingsUpdated',
{ setting: translate('settings.downloadSkipBaseModels.label') },
'success'
);
} catch (error) {
console.error('Failed to save download skip base models:', error);
if (errorElement) {
errorElement.textContent = translate(
'settings.downloadSkipBaseModels.validation.saveFailed',
{ message: error.message },
`Unable to save excluded base models: ${error.message}`
);
}
showToast('toast.settings.settingSaveFailed', { message: error.message }, 'error');
}
}
async clearDownloadSkipBaseModels() {
const searchInput = document.getElementById('downloadSkipBaseModelsSearch');
if (searchInput) {
searchInput.value = '';
}
const current = this.normalizeDownloadSkipBaseModels(
state.global.settings.download_skip_base_models
);
if (current.length === 0) {
this.renderDownloadSkipBaseModels();
return;
}
try {
const errorElement = document.getElementById('downloadSkipBaseModelsError');
if (errorElement) {
errorElement.textContent = '';
}
await this.saveSetting('download_skip_base_models', []);
this.renderDownloadSkipBaseModels();
showToast(
'toast.settings.settingsUpdated',
{ setting: translate('settings.downloadSkipBaseModels.label') },
'success'
);
} catch (error) {
const errorElement = document.getElementById('downloadSkipBaseModelsError');
console.error('Failed to clear download skip base models:', error);
if (errorElement) {
errorElement.textContent = translate(
'settings.downloadSkipBaseModels.validation.saveFailed',
{ message: error.message },
`Unable to save excluded base models: ${error.message}`
);
}
showToast('toast.settings.settingSaveFailed', { message: error.message }, 'error');
}
}
async saveMetadataRefreshSkipPaths() { async saveMetadataRefreshSkipPaths() {
const input = document.getElementById('metadataRefreshSkipPaths'); const input = document.getElementById('metadataRefreshSkipPaths');
const errorElement = document.getElementById('metadataRefreshSkipPathsError'); const errorElement = document.getElementById('metadataRefreshSkipPathsError');

View File

@@ -6,8 +6,31 @@ export class RecipeDataManager {
this.importManager = importManager; this.importManager = importManager;
} }
setupTagInputEnterHandler() {
const tagInput = document.getElementById('tagInput');
if (!tagInput || tagInput.hasEnterAddTagHandler) {
return;
}
tagInput.addEventListener('keydown', (event) => {
if (event.key !== 'Enter') {
return;
}
if (event.isComposing || event.keyCode === 229) {
return;
}
event.preventDefault();
this.addTag();
});
tagInput.hasEnterAddTagHandler = true;
}
showRecipeDetailsStep() { showRecipeDetailsStep() {
this.importManager.stepManager.showStep('detailsStep'); this.importManager.stepManager.showStep('detailsStep');
this.setupTagInputEnterHandler();
// Set default recipe name from prompt or image filename // Set default recipe name from prompt or image filename
const recipeName = document.getElementById('recipeName'); const recipeName = document.getElementById('recipeName');

View File

@@ -66,6 +66,8 @@ class RecipeManager {
active: false, active: false,
loraName: null, loraName: null,
loraHash: null, loraHash: null,
checkpointName: null,
checkpointHash: null,
recipeId: null recipeId: null
}; };
} }
@@ -127,16 +129,20 @@ class RecipeManager {
// Check for Lora filter // Check for Lora filter
const filterLoraName = getSessionItem('lora_to_recipe_filterLoraName'); const filterLoraName = getSessionItem('lora_to_recipe_filterLoraName');
const filterLoraHash = getSessionItem('lora_to_recipe_filterLoraHash'); const filterLoraHash = getSessionItem('lora_to_recipe_filterLoraHash');
const filterCheckpointName = getSessionItem('checkpoint_to_recipe_filterCheckpointName');
const filterCheckpointHash = getSessionItem('checkpoint_to_recipe_filterCheckpointHash');
// Check for specific recipe ID // Check for specific recipe ID
const viewRecipeId = getSessionItem('viewRecipeId'); const viewRecipeId = getSessionItem('viewRecipeId');
// Set custom filter if any parameter is present // Set custom filter if any parameter is present
if (filterLoraName || filterLoraHash || viewRecipeId) { if (filterLoraName || filterLoraHash || filterCheckpointName || filterCheckpointHash || viewRecipeId) {
this.pageState.customFilter = { this.pageState.customFilter = {
active: true, active: true,
loraName: filterLoraName, loraName: filterLoraName,
loraHash: filterLoraHash, loraHash: filterLoraHash,
checkpointName: filterCheckpointName,
checkpointHash: filterCheckpointHash,
recipeId: viewRecipeId recipeId: viewRecipeId
}; };
@@ -164,6 +170,13 @@ class RecipeManager {
loraName; loraName;
filterText = `<span>Recipes using: <span class="lora-name">${displayName}</span></span>`; filterText = `<span>Recipes using: <span class="lora-name">${displayName}</span></span>`;
} else if (this.pageState.customFilter.checkpointName) {
const checkpointName = this.pageState.customFilter.checkpointName;
const displayName = checkpointName.length > 25 ?
checkpointName.substring(0, 22) + '...' :
checkpointName;
filterText = `<span>Recipes using checkpoint: <span class="lora-name">${displayName}</span></span>`;
} else { } else {
filterText = 'Filtered recipes'; filterText = 'Filtered recipes';
} }
@@ -173,6 +186,10 @@ class RecipeManager {
// Add title attribute to show the lora name as a tooltip // Add title attribute to show the lora name as a tooltip
if (this.pageState.customFilter.loraName) { if (this.pageState.customFilter.loraName) {
textElement.setAttribute('title', this.pageState.customFilter.loraName); textElement.setAttribute('title', this.pageState.customFilter.loraName);
} else if (this.pageState.customFilter.checkpointName) {
textElement.setAttribute('title', this.pageState.customFilter.checkpointName);
} else {
textElement.removeAttribute('title');
} }
indicator.classList.remove('hidden'); indicator.classList.remove('hidden');
@@ -199,6 +216,8 @@ class RecipeManager {
active: false, active: false,
loraName: null, loraName: null,
loraHash: null, loraHash: null,
checkpointName: null,
checkpointHash: null,
recipeId: null recipeId: null
}; };
@@ -211,6 +230,8 @@ class RecipeManager {
// Clear any session storage items // Clear any session storage items
removeSessionItem('lora_to_recipe_filterLoraName'); removeSessionItem('lora_to_recipe_filterLoraName');
removeSessionItem('lora_to_recipe_filterLoraHash'); removeSessionItem('lora_to_recipe_filterLoraHash');
removeSessionItem('checkpoint_to_recipe_filterCheckpointName');
removeSessionItem('checkpoint_to_recipe_filterCheckpointHash');
removeSessionItem('viewRecipeId'); removeSessionItem('viewRecipeId');
// Reset and refresh the virtual scroller // Reset and refresh the virtual scroller

View File

@@ -24,6 +24,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
optimize_example_images: true, optimize_example_images: true,
auto_download_example_images: false, auto_download_example_images: false,
blur_mature_content: true, blur_mature_content: true,
mature_blur_level: 'R',
autoplay_on_hover: false, autoplay_on_hover: false,
display_density: 'default', display_density: 'default',
card_info_display: 'always', card_info_display: 'always',
@@ -37,6 +38,8 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
hide_early_access_updates: false, hide_early_access_updates: false,
auto_organize_exclusions: [], auto_organize_exclusions: [],
metadata_refresh_skip_paths: [], metadata_refresh_skip_paths: [],
skip_previously_downloaded_model_versions: false,
download_skip_base_models: [],
}); });
export function createDefaultSettings() { export function createDefaultSettings() {

View File

@@ -30,8 +30,9 @@ export function rewriteCivitaiUrl(sourceUrl, mediaType = null, mode = Optimizati
try { try {
const url = new URL(sourceUrl); const url = new URL(sourceUrl);
// Check if it's a CivitAI image domain // Check if it's a CivitAI CDN domain (supports all subdomains like image-b2.civitai.com)
if (url.hostname.toLowerCase() !== 'image.civitai.com') { const hostname = url.hostname.toLowerCase();
if (hostname === 'civitai.com' || !hostname.endsWith('.civitai.com')) {
return [sourceUrl, false]; return [sourceUrl, false];
} }
@@ -112,7 +113,8 @@ export function isCivitaiUrl(url) {
if (!url) return false; if (!url) return false;
try { try {
const parsed = new URL(url); const parsed = new URL(url);
return parsed.hostname.toLowerCase() === 'image.civitai.com'; const hostname = parsed.hostname.toLowerCase();
return hostname.endsWith('.civitai.com') && hostname !== 'civitai.com';
} catch (e) { } catch (e) {
return false; return false;
} }

View File

@@ -50,6 +50,9 @@ export const BASE_MODELS = {
SVD: "SVD", SVD: "SVD",
LTXV: "LTXV", LTXV: "LTXV",
LTXV2: "LTXV2", LTXV2: "LTXV2",
LTXV_2_3: "LTXV 2.3",
COGVIDE_X: "CogVideoX",
MOCHI: "Mochi",
WAN_VIDEO: "Wan Video", WAN_VIDEO: "Wan Video",
WAN_VIDEO_1_3B_T2V: "Wan Video 1.3B t2v", WAN_VIDEO_1_3B_T2V: "Wan Video 1.3B t2v",
WAN_VIDEO_14B_T2V: "Wan Video 14B t2v", WAN_VIDEO_14B_T2V: "Wan Video 14B t2v",
@@ -58,7 +61,12 @@ export const BASE_MODELS = {
WAN_VIDEO_2_2_TI2V_5B: "Wan Video 2.2 TI2V-5B", WAN_VIDEO_2_2_TI2V_5B: "Wan Video 2.2 TI2V-5B",
WAN_VIDEO_2_2_T2V_A14B: "Wan Video 2.2 T2V-A14B", WAN_VIDEO_2_2_T2V_A14B: "Wan Video 2.2 T2V-A14B",
WAN_VIDEO_2_2_I2V_A14B: "Wan Video 2.2 I2V-A14B", WAN_VIDEO_2_2_I2V_A14B: "Wan Video 2.2 I2V-A14B",
WAN_VIDEO_2_5_T2V: "Wan Video 2.5 T2V",
WAN_VIDEO_2_5_I2V: "Wan Video 2.5 I2V",
HUNYUAN_VIDEO: "Hunyuan Video", HUNYUAN_VIDEO: "Hunyuan Video",
// Other models
ANIMA: "Anima",
PONY_V7: "Pony V7",
// Default // Default
UNKNOWN: "Other" UNKNOWN: "Other"
}; };
@@ -151,6 +159,9 @@ export const BASE_MODEL_ABBREVIATIONS = {
[BASE_MODELS.SVD]: 'SVD', [BASE_MODELS.SVD]: 'SVD',
[BASE_MODELS.LTXV]: 'LTXV', [BASE_MODELS.LTXV]: 'LTXV',
[BASE_MODELS.LTXV2]: 'LTV2', [BASE_MODELS.LTXV2]: 'LTV2',
[BASE_MODELS.LTXV_2_3]: 'LTX',
[BASE_MODELS.COGVIDE_X]: 'CVX',
[BASE_MODELS.MOCHI]: 'MCHI',
[BASE_MODELS.WAN_VIDEO]: 'WAN', [BASE_MODELS.WAN_VIDEO]: 'WAN',
[BASE_MODELS.WAN_VIDEO_1_3B_T2V]: 'WAN', [BASE_MODELS.WAN_VIDEO_1_3B_T2V]: 'WAN',
[BASE_MODELS.WAN_VIDEO_14B_T2V]: 'WAN', [BASE_MODELS.WAN_VIDEO_14B_T2V]: 'WAN',
@@ -159,8 +170,28 @@ export const BASE_MODEL_ABBREVIATIONS = {
[BASE_MODELS.WAN_VIDEO_2_2_TI2V_5B]: 'WAN', [BASE_MODELS.WAN_VIDEO_2_2_TI2V_5B]: 'WAN',
[BASE_MODELS.WAN_VIDEO_2_2_T2V_A14B]: 'WAN', [BASE_MODELS.WAN_VIDEO_2_2_T2V_A14B]: 'WAN',
[BASE_MODELS.WAN_VIDEO_2_2_I2V_A14B]: 'WAN', [BASE_MODELS.WAN_VIDEO_2_2_I2V_A14B]: 'WAN',
[BASE_MODELS.WAN_VIDEO_2_5_T2V]: 'WAN',
[BASE_MODELS.WAN_VIDEO_2_5_I2V]: 'WAN',
[BASE_MODELS.HUNYUAN_VIDEO]: 'HYV', [BASE_MODELS.HUNYUAN_VIDEO]: 'HYV',
// Other diffusion models
[BASE_MODELS.AURAFLOW]: 'AF',
[BASE_MODELS.CHROMA]: 'CHR',
[BASE_MODELS.PIXART_A]: 'PXA',
[BASE_MODELS.PIXART_E]: 'PXE',
[BASE_MODELS.HUNYUAN_1]: 'HY',
[BASE_MODELS.LUMINA]: 'L',
[BASE_MODELS.KOLORS]: 'KLR',
[BASE_MODELS.NOOBAI]: 'NAI',
[BASE_MODELS.ILLUSTRIOUS]: 'IL',
[BASE_MODELS.PONY]: 'PONY',
[BASE_MODELS.PONY_V7]: 'PNY7',
[BASE_MODELS.HIDREAM]: 'HID',
[BASE_MODELS.QWEN]: 'QWEN',
[BASE_MODELS.ZIMAGE_TURBO]: 'ZIT',
[BASE_MODELS.ZIMAGE_BASE]: 'ZIB',
[BASE_MODELS.ANIMA]: 'ANI',
// Default // Default
[BASE_MODELS.UNKNOWN]: 'OTH' [BASE_MODELS.UNKNOWN]: 'OTH'
}; };
@@ -309,6 +340,15 @@ export const NSFW_LEVELS = {
BLOCKED: 32 BLOCKED: 32
}; };
export const VALID_MATURE_BLUR_LEVELS = ['PG13', 'R', 'X', 'XXX'];
export function getMatureBlurThreshold(settings = {}) {
const rawValue = settings?.mature_blur_level;
const normalizedValue = typeof rawValue === 'string' ? rawValue.trim().toUpperCase() : '';
const levelName = VALID_MATURE_BLUR_LEVELS.includes(normalizedValue) ? normalizedValue : 'R';
return NSFW_LEVELS[levelName] ?? NSFW_LEVELS.R;
}
// Node type constants // Node type constants
export const NODE_TYPES = { export const NODE_TYPES = {
LORA_LOADER: 1, LORA_LOADER: 1,
@@ -340,18 +380,20 @@ export const BASE_MODEL_CATEGORIES = {
'Stable Diffusion 3.x': [BASE_MODELS.SD_3, BASE_MODELS.SD_3_5, BASE_MODELS.SD_3_5_MEDIUM, BASE_MODELS.SD_3_5_LARGE, BASE_MODELS.SD_3_5_LARGE_TURBO], 'Stable Diffusion 3.x': [BASE_MODELS.SD_3, BASE_MODELS.SD_3_5, BASE_MODELS.SD_3_5_MEDIUM, BASE_MODELS.SD_3_5_LARGE, BASE_MODELS.SD_3_5_LARGE_TURBO],
'SDXL': [BASE_MODELS.SDXL, BASE_MODELS.SDXL_LIGHTNING, BASE_MODELS.SDXL_HYPER], 'SDXL': [BASE_MODELS.SDXL, BASE_MODELS.SDXL_LIGHTNING, BASE_MODELS.SDXL_HYPER],
'Video Models': [ 'Video Models': [
BASE_MODELS.SVD, BASE_MODELS.LTXV, BASE_MODELS.LTXV2, BASE_MODELS.HUNYUAN_VIDEO, BASE_MODELS.WAN_VIDEO, BASE_MODELS.SVD, BASE_MODELS.LTXV, BASE_MODELS.LTXV2, BASE_MODELS.LTXV_2_3,
BASE_MODELS.WAN_VIDEO_1_3B_T2V, BASE_MODELS.WAN_VIDEO_14B_T2V, BASE_MODELS.COGVIDE_X, BASE_MODELS.MOCHI, BASE_MODELS.HUNYUAN_VIDEO,
BASE_MODELS.WAN_VIDEO, BASE_MODELS.WAN_VIDEO_1_3B_T2V, BASE_MODELS.WAN_VIDEO_14B_T2V,
BASE_MODELS.WAN_VIDEO_14B_I2V_480P, BASE_MODELS.WAN_VIDEO_14B_I2V_720P, BASE_MODELS.WAN_VIDEO_14B_I2V_480P, BASE_MODELS.WAN_VIDEO_14B_I2V_720P,
BASE_MODELS.WAN_VIDEO_2_2_TI2V_5B, BASE_MODELS.WAN_VIDEO_2_2_T2V_A14B, BASE_MODELS.WAN_VIDEO_2_2_TI2V_5B, BASE_MODELS.WAN_VIDEO_2_2_T2V_A14B,
BASE_MODELS.WAN_VIDEO_2_2_I2V_A14B BASE_MODELS.WAN_VIDEO_2_2_I2V_A14B, BASE_MODELS.WAN_VIDEO_2_5_T2V,
BASE_MODELS.WAN_VIDEO_2_5_I2V
], ],
'Flux Models': [BASE_MODELS.FLUX_1_D, BASE_MODELS.FLUX_1_S, BASE_MODELS.FLUX_1_KONTEXT, BASE_MODELS.FLUX_1_KREA, BASE_MODELS.FLUX_2_D, BASE_MODELS.FLUX_2_KLEIN_9B, BASE_MODELS.FLUX_2_KLEIN_9B_BASE, BASE_MODELS.FLUX_2_KLEIN_4B, BASE_MODELS.FLUX_2_KLEIN_4B_BASE], 'Flux Models': [BASE_MODELS.FLUX_1_D, BASE_MODELS.FLUX_1_S, BASE_MODELS.FLUX_1_KONTEXT, BASE_MODELS.FLUX_1_KREA, BASE_MODELS.FLUX_2_D, BASE_MODELS.FLUX_2_KLEIN_9B, BASE_MODELS.FLUX_2_KLEIN_9B_BASE, BASE_MODELS.FLUX_2_KLEIN_4B, BASE_MODELS.FLUX_2_KLEIN_4B_BASE],
'Other Models': [ 'Other Models': [
BASE_MODELS.ILLUSTRIOUS, BASE_MODELS.PONY, BASE_MODELS.HIDREAM, BASE_MODELS.ILLUSTRIOUS, BASE_MODELS.PONY, BASE_MODELS.PONY_V7, BASE_MODELS.HIDREAM,
BASE_MODELS.QWEN, BASE_MODELS.AURAFLOW, BASE_MODELS.CHROMA, BASE_MODELS.ZIMAGE_TURBO, BASE_MODELS.ZIMAGE_BASE, BASE_MODELS.QWEN, BASE_MODELS.AURAFLOW, BASE_MODELS.CHROMA, BASE_MODELS.ZIMAGE_TURBO, BASE_MODELS.ZIMAGE_BASE,
BASE_MODELS.PIXART_A, BASE_MODELS.PIXART_E, BASE_MODELS.HUNYUAN_1, BASE_MODELS.PIXART_A, BASE_MODELS.PIXART_E, BASE_MODELS.HUNYUAN_1,
BASE_MODELS.LUMINA, BASE_MODELS.KOLORS, BASE_MODELS.NOOBAI, BASE_MODELS.LUMINA, BASE_MODELS.KOLORS, BASE_MODELS.NOOBAI, BASE_MODELS.ANIMA,
BASE_MODELS.UNKNOWN BASE_MODELS.UNKNOWN
] ]
}; };
@@ -369,3 +411,94 @@ export const DEFAULT_PRIORITY_TAG_CONFIG = {
checkpoint: DEFAULT_PRIORITY_TAG_ENTRIES.join(', '), checkpoint: DEFAULT_PRIORITY_TAG_ENTRIES.join(', '),
embedding: DEFAULT_PRIORITY_TAG_ENTRIES.join(', ') embedding: DEFAULT_PRIORITY_TAG_ENTRIES.join(', ')
}; };
// ============================================================================
// Dynamic Base Model Support
// ============================================================================
/**
* Dynamic base model cache
* Stores models fetched from Civitai API
*/
let dynamicBaseModels = null;
let dynamicBaseModelsTimestamp = null;
const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
/**
* Set dynamic base models (called after fetching from API)
* @param {Array} models - Array of base model names
* @param {string} timestamp - ISO timestamp of fetch
*/
export function setDynamicBaseModels(models, timestamp) {
dynamicBaseModels = models;
dynamicBaseModelsTimestamp = timestamp;
}
/**
* Get dynamic base models
* @returns {Object|null} { models, timestamp } or null if not set
*/
export function getDynamicBaseModels() {
if (!dynamicBaseModels) return null;
// Check if cache is expired
if (dynamicBaseModelsTimestamp) {
const age = Date.now() - new Date(dynamicBaseModelsTimestamp).getTime();
if (age > CACHE_TTL_MS) {
dynamicBaseModels = null;
dynamicBaseModelsTimestamp = null;
return null;
}
}
return {
models: dynamicBaseModels,
timestamp: dynamicBaseModelsTimestamp
};
}
/**
* Get merged base models (hardcoded + dynamic)
* Returns unique sorted list of all available base models
* @returns {Array} Sorted array of base model names
*/
export function getMergedBaseModels() {
const hardcoded = Object.values(BASE_MODELS);
const dynamic = getDynamicBaseModels();
if (!dynamic || !dynamic.models) {
return hardcoded.sort();
}
// Merge and deduplicate
const merged = new Set([...hardcoded, ...dynamic.models]);
return Array.from(merged).sort();
}
/**
* Get mappable base models (for UI selection)
* Excludes 'Other' value
* @returns {Array} Sorted array of base model names (excluding 'Other')
*/
export function getMappableBaseModelsDynamic() {
const merged = getMergedBaseModels();
return merged.filter(model => model !== 'Other');
}
/**
* Clear dynamic base models cache
*/
export function clearDynamicBaseModels() {
dynamicBaseModels = null;
dynamicBaseModelsTimestamp = null;
}
/**
* Check if dynamic base models cache is valid
* @returns {boolean}
*/
export function isDynamicBaseModelsCacheValid() {
if (!dynamicBaseModels || !dynamicBaseModelsTimestamp) return false;
const age = Date.now() - new Date(dynamicBaseModelsTimestamp).getTime();
return age <= CACHE_TTL_MS;
}

View File

@@ -184,14 +184,13 @@ function filterByFolder(folderPath) {
} }
export function openCivitaiByMetadata(civitaiId, versionId, modelName = null) { export function openCivitaiByMetadata(civitaiId, versionId, modelName = null) {
if (civitaiId) { if (versionId) {
let url = `https://civitai.com/models/${civitaiId}`; // Use model-versions endpoint which auto-redirects to correct model page
if (versionId) { window.open(`https://civitai.com/model-versions/${versionId}`, '_blank');
url += `?modelVersionId=${versionId}`; } else if (civitaiId) {
} window.open(`https://civitai.com/models/${civitaiId}`, '_blank');
window.open(url, '_blank');
} else if (modelName) { } else if (modelName) {
// 如果没有ID尝试使用名称搜索 // Fallback: search by name
window.open(`https://civitai.com/models?query=${encodeURIComponent(modelName)}`, '_blank'); window.open(`https://civitai.com/models?query=${encodeURIComponent(modelName)}`, '_blank');
} }
} }

View File

@@ -13,6 +13,7 @@
<div class="context-menu-item" data-action="refresh-metadata"><i class="fas fa-sync"></i> {{ t('loras.contextMenu.refreshMetadata') }}</div> <div class="context-menu-item" data-action="refresh-metadata"><i class="fas fa-sync"></i> {{ t('loras.contextMenu.refreshMetadata') }}</div>
<div class="context-menu-item" data-action="relink-civitai"><i class="fas fa-link"></i> {{ t('loras.contextMenu.relinkCivitai') }}</div> <div class="context-menu-item" data-action="relink-civitai"><i class="fas fa-link"></i> {{ t('loras.contextMenu.relinkCivitai') }}</div>
<div class="context-menu-item" data-action="copyname"><i class="fas fa-copy"></i> {{ t('loras.contextMenu.copyFilename') }}</div> <div class="context-menu-item" data-action="copyname"><i class="fas fa-copy"></i> {{ t('loras.contextMenu.copyFilename') }}</div>
<div class="context-menu-item" data-action="sendworkflow"><i class="fas fa-paper-plane"></i> {{ t('checkpoints.contextMenu.sendToWorkflow') }}</div>
<div class="context-menu-item" data-action="preview"><i class="fas fa-folder-open"></i> {{ t('loras.contextMenu.openExamples') }}</div> <div class="context-menu-item" data-action="preview"><i class="fas fa-folder-open"></i> {{ t('loras.contextMenu.openExamples') }}</div>
<div class="context-menu-item" data-action="download-examples"><i class="fas fa-download"></i> {{ t('loras.contextMenu.downloadExamples') }}</div> <div class="context-menu-item" data-action="download-examples"><i class="fas fa-download"></i> {{ t('loras.contextMenu.downloadExamples') }}</div>
<div class="context-menu-item" data-action="replace-preview"><i class="fas fa-image"></i> {{ t('loras.contextMenu.replacePreview') }}</div> <div class="context-menu-item" data-action="replace-preview"><i class="fas fa-image"></i> {{ t('loras.contextMenu.replacePreview') }}</div>

View File

@@ -87,6 +87,9 @@
<i class="fas fa-redo"></i> <span>{{ t('loras.bulkOperations.resumeMetadataRefresh') }}</span> <i class="fas fa-redo"></i> <span>{{ t('loras.bulkOperations.resumeMetadataRefresh') }}</span>
</div> </div>
<div class="context-menu-separator"></div> <div class="context-menu-separator"></div>
<div class="context-menu-item" data-action="download-missing-loras">
<i class="fas fa-download"></i> <span>{{ t('loras.bulkOperations.downloadMissingLoras') }}</span>
</div>
<div class="context-menu-item" data-action="move-all"> <div class="context-menu-item" data-action="move-all">
<i class="fas fa-folder-open"></i> <span>{{ t('loras.bulkOperations.moveAll') }}</span> <i class="fas fa-folder-open"></i> <span>{{ t('loras.bulkOperations.moveAll') }}</span>
</div> </div>

View File

@@ -80,4 +80,32 @@
<button class="primary-btn" data-action="confirm-check-updates">{{ t('modals.checkUpdates.action') }}</button> <button class="primary-btn" data-action="confirm-check-updates">{{ t('modals.checkUpdates.action') }}</button>
</div> </div>
</div> </div>
</div>
<!-- Bulk Download Missing LoRAs Confirmation Modal -->
<div id="bulkDownloadMissingLorasModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>{{ t('modals.bulkDownloadMissingLoras.title') }}</h2>
<span class="close" onclick="modalManager.closeModal('bulkDownloadMissingLorasModal')">&times;</span>
</div>
<div class="modal-body">
<p class="confirmation-message" id="bulkDownloadMissingLorasMessage"></p>
<div class="bulk-download-loras-preview" id="bulkDownloadMissingLorasPreview">
<p class="preview-title">{{ t('modals.bulkDownloadMissingLoras.previewTitle') }}</p>
<ul class="bulk-download-loras-list" id="bulkDownloadMissingLorasList"></ul>
</div>
<p class="confirmation-note">
<i class="fas fa-info-circle"></i>
{{ t('modals.bulkDownloadMissingLoras.note') }}
</p>
</div>
<div class="modal-actions">
<button class="secondary-btn" onclick="modalManager.closeModal('bulkDownloadMissingLorasModal')">{{ t('common.actions.cancel') }}</button>
<button class="primary-btn" id="bulkDownloadMissingLorasConfirmBtn" onclick="bulkMissingLoraDownloadManager.confirmDownload()">
<i class="fas fa-download"></i>
{{ t('modals.bulkDownloadMissingLoras.downloadButton') }}
</button>
</div>
</div>
</div> </div>

View File

@@ -281,6 +281,26 @@
</div> </div>
</div> </div>
</div> </div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="matureBlurLevel">
{{ t('settings.contentFiltering.matureBlurThreshold') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.contentFiltering.matureBlurThresholdHelp') }}"></i>
</label>
</div>
<div class="setting-control select-control">
<select id="matureBlurLevel"
onchange="settingsManager.saveSelectSetting('matureBlurLevel', 'mature_blur_level')">
<option value="PG13">{{ t('settings.contentFiltering.matureBlurThresholdOptions.pg13') }}</option>
<option value="R">{{ t('settings.contentFiltering.matureBlurThresholdOptions.r') }}</option>
<option value="X">{{ t('settings.contentFiltering.matureBlurThresholdOptions.x') }}</option>
<option value="XXX">{{ t('settings.contentFiltering.matureBlurThresholdOptions.xxx') }}</option>
</select>
</div>
</div>
</div>
</div> </div>
<!-- Video Settings --> <!-- Video Settings -->
@@ -464,9 +484,7 @@
</label> </label>
</div> </div>
<div class="setting-control select-control"> <div class="setting-control select-control">
<select id="defaultLoraRoot" onchange="settingsManager.saveSelectSetting('defaultLoraRoot', 'default_lora_root')"> <select id="defaultLoraRoot" onchange="settingsManager.saveSelectSetting('defaultLoraRoot', 'default_lora_root')"></select>
<option value="">{{ t('settings.folderSettings.noDefault') }}</option>
</select>
</div> </div>
</div> </div>
</div> </div>
@@ -480,9 +498,7 @@
</label> </label>
</div> </div>
<div class="setting-control select-control"> <div class="setting-control select-control">
<select id="defaultCheckpointRoot" onchange="settingsManager.saveSelectSetting('defaultCheckpointRoot', 'default_checkpoint_root')"> <select id="defaultCheckpointRoot" onchange="settingsManager.saveSelectSetting('defaultCheckpointRoot', 'default_checkpoint_root')"></select>
<option value="">{{ t('settings.folderSettings.noDefault') }}</option>
</select>
</div> </div>
</div> </div>
</div> </div>
@@ -496,9 +512,7 @@
</label> </label>
</div> </div>
<div class="setting-control select-control"> <div class="setting-control select-control">
<select id="defaultUnetRoot" onchange="settingsManager.saveSelectSetting('defaultUnetRoot', 'default_unet_root')"> <select id="defaultUnetRoot" onchange="settingsManager.saveSelectSetting('defaultUnetRoot', 'default_unet_root')"></select>
<option value="">{{ t('settings.folderSettings.noDefault') }}</option>
</select>
</div> </div>
</div> </div>
</div> </div>
@@ -512,9 +526,7 @@
</label> </label>
</div> </div>
<div class="setting-control select-control"> <div class="setting-control select-control">
<select id="defaultEmbeddingRoot" onchange="settingsManager.saveSelectSetting('defaultEmbeddingRoot', 'default_embedding_root')"> <select id="defaultEmbeddingRoot" onchange="settingsManager.saveSelectSetting('defaultEmbeddingRoot', 'default_embedding_root')"></select>
<option value="">{{ t('settings.folderSettings.noDefault') }}</option>
</select>
</div> </div>
</div> </div>
</div> </div>
@@ -525,7 +537,7 @@
<div class="settings-subsection-header"> <div class="settings-subsection-header">
<h4> <h4>
{{ t('settings.extraFolderPaths.title') }} {{ t('settings.extraFolderPaths.title') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.extraFolderPaths.help') }}"></i> <i class="fas fa-sync-alt restart-required-icon" title="{{ t('settings.extraFolderPaths.restartRequired') }}"></i>
</h4> </h4>
</div> </div>
<div class="setting-item"> <div class="setting-item">
@@ -723,6 +735,64 @@
</div> </div>
</div> </div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="skipPreviouslyDownloadedModelVersions">
{{ t('settings.skipPreviouslyDownloadedModelVersions.label') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.skipPreviouslyDownloadedModelVersions.help') }}"></i>
</label>
</div>
<div class="setting-control">
<label class="toggle-switch">
<input type="checkbox" id="skipPreviouslyDownloadedModelVersions"
onchange="settingsManager.saveToggleSetting('skipPreviouslyDownloadedModelVersions', 'skip_previously_downloaded_model_versions')">
<span class="toggle-slider"></span>
</label>
</div>
</div>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="downloadSkipBaseModelsToggle">
{{ t('settings.downloadSkipBaseModels.label') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.downloadSkipBaseModels.help') }}"></i>
</label>
</div>
<div class="setting-control">
<button
type="button"
id="downloadSkipBaseModelsToggle"
class="secondary-btn base-model-skip-toggle"
aria-expanded="false"
>
<span id="downloadSkipBaseModelsSummary">{{ t('settings.downloadSkipBaseModels.summary.none') }}</span>
<span class="base-model-skip-toggle-label">{{ t('settings.downloadSkipBaseModels.actions.edit') }}</span>
</button>
</div>
</div>
<div id="downloadSkipBaseModelsPanel" class="base-model-skip-panel" hidden>
<div class="base-model-skip-toolbar">
<input
type="text"
id="downloadSkipBaseModelsSearch"
class="base-model-skip-search"
placeholder="{{ t('settings.downloadSkipBaseModels.searchPlaceholder') }}"
/>
<button type="button" class="text-btn base-model-skip-clear" id="downloadSkipBaseModelsClear">
{{ t('settings.downloadSkipBaseModels.actions.clear') }}
</button>
</div>
<div id="downloadSkipBaseModelsContainer" class="base-model-skip-list"></div>
<div id="downloadSkipBaseModelsEmpty" class="base-model-skip-empty" hidden>
{{ t('settings.downloadSkipBaseModels.empty') }}
</div>
</div>
<div class="settings-input-error-message" id="downloadSkipBaseModelsError"></div>
</div>
<!-- Priority Tags --> <!-- Priority Tags -->
<div class="setting-item priority-tags-item"> <div class="setting-item priority-tags-item">
<div class="setting-row priority-tags-header-row"> <div class="setting-row priority-tags-header-row">

View File

@@ -29,22 +29,52 @@
<div class="param-group info-item"> <div class="param-group info-item">
<div class="param-header"> <div class="param-header">
<label>Prompt</label> <label>Prompt</label>
<button class="copy-btn" id="copyPromptBtn" title="Copy Prompt"> <div class="param-actions">
<i class="fas fa-copy"></i> <button class="copy-btn" id="copyPromptBtn" title="Copy Prompt">
</button> <i class="fas fa-copy"></i>
</button>
<button class="edit-btn" id="editPromptBtn" title="Edit Prompt">
<i class="fas fa-pencil-alt"></i>
</button>
</div>
</div> </div>
<div class="param-content" id="recipePrompt"></div> <div class="param-content" id="recipePrompt"></div>
<div class="param-editor" id="recipePromptEditor">
<textarea
class="param-textarea"
id="recipePromptInput"
placeholder="Enter prompt"
></textarea>
<div class="param-editor-hint">
{{ t('toast.recipes.promptEditorHint') }}
</div>
</div>
</div> </div>
<!-- Negative Prompt --> <!-- Negative Prompt -->
<div class="param-group info-item"> <div class="param-group info-item">
<div class="param-header"> <div class="param-header">
<label>Negative Prompt</label> <label>Negative Prompt</label>
<button class="copy-btn" id="copyNegativePromptBtn" title="Copy Negative Prompt"> <div class="param-actions">
<i class="fas fa-copy"></i> <button class="copy-btn" id="copyNegativePromptBtn" title="Copy Negative Prompt">
</button> <i class="fas fa-copy"></i>
</button>
<button class="edit-btn" id="editNegativePromptBtn" title="Edit Negative Prompt">
<i class="fas fa-pencil-alt"></i>
</button>
</div>
</div> </div>
<div class="param-content" id="recipeNegativePrompt"></div> <div class="param-content" id="recipeNegativePrompt"></div>
<div class="param-editor" id="recipeNegativePromptEditor">
<textarea
class="param-textarea"
id="recipeNegativePromptInput"
placeholder="Enter negative prompt"
></textarea>
<div class="param-editor-hint">
{{ t('toast.recipes.promptEditorHint') }}
</div>
</div>
</div> </div>
<!-- Other Parameters --> <!-- Other Parameters -->
@@ -65,6 +95,9 @@
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax"> <button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
<i class="fas fa-copy"></i> <i class="fas fa-copy"></i>
</button> </button>
<button class="action-btn send-recipe-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
<i class="fas fa-paper-plane"></i>
</button>
</div> </div>
</div> </div>
<div class="recipe-resources-list"> <div class="recipe-resources-list">

View File

@@ -131,6 +131,102 @@ def test_save_paths_logs_warning_when_upsert_fails(
assert "Failed to save folder paths: boom" in caplog.text assert "Failed to save folder paths: boom" in caplog.text
def test_save_paths_repairs_empty_default_roots(monkeypatch: pytest.MonkeyPatch, tmp_path):
folder_paths = _setup_config_environment(monkeypatch, tmp_path)
class FakeSettingsService:
def get_libraries(self):
return {
"comfyui": {
"folder_paths": {key: list(value) for key, value in folder_paths.items()},
"default_lora_root": "",
"default_checkpoint_root": "",
"default_embedding_root": "",
}
}
def rename_library(self, *_):
raise AssertionError("rename_library should not be invoked")
def upsert_library(self, name: str, **payload):
self.name = name
self.payload = payload
fake_settings = FakeSettingsService()
monkeypatch.setattr(settings_manager_module, "settings", fake_settings)
config_module.Config()
assert fake_settings.name == "comfyui"
assert fake_settings.payload["default_lora_root"] == folder_paths["loras"][0].replace("\\", "/")
assert fake_settings.payload["default_checkpoint_root"] == folder_paths["checkpoints"][0].replace("\\", "/")
assert fake_settings.payload["default_embedding_root"] == folder_paths["embeddings"][0].replace("\\", "/")
def test_save_paths_repairs_stale_default_roots(monkeypatch: pytest.MonkeyPatch, tmp_path):
folder_paths = _setup_config_environment(monkeypatch, tmp_path)
class FakeSettingsService:
def get_libraries(self):
return {
"comfyui": {
"folder_paths": {key: list(value) for key, value in folder_paths.items()},
"default_lora_root": "/stale/loras",
"default_checkpoint_root": "/stale/checkpoints",
"default_embedding_root": "/stale/embeddings",
}
}
def rename_library(self, *_):
raise AssertionError("rename_library should not be invoked")
def upsert_library(self, name: str, **payload):
self.name = name
self.payload = payload
fake_settings = FakeSettingsService()
monkeypatch.setattr(settings_manager_module, "settings", fake_settings)
config_module.Config()
assert fake_settings.name == "comfyui"
assert fake_settings.payload["default_lora_root"] == folder_paths["loras"][0].replace("\\", "/")
assert fake_settings.payload["default_checkpoint_root"] == folder_paths["checkpoints"][0].replace("\\", "/")
assert fake_settings.payload["default_embedding_root"] == folder_paths["embeddings"][0].replace("\\", "/")
def test_save_paths_keeps_valid_default_roots(monkeypatch: pytest.MonkeyPatch, tmp_path):
folder_paths = _setup_config_environment(monkeypatch, tmp_path)
class FakeSettingsService:
def get_libraries(self):
return {
"comfyui": {
"folder_paths": {key: list(value) for key, value in folder_paths.items()},
"default_lora_root": folder_paths["loras"][0],
"default_checkpoint_root": folder_paths["checkpoints"][0],
"default_embedding_root": folder_paths["embeddings"][0],
}
}
def rename_library(self, *_):
raise AssertionError("rename_library should not be invoked")
def upsert_library(self, name: str, **payload):
self.name = name
self.payload = payload
fake_settings = FakeSettingsService()
monkeypatch.setattr(settings_manager_module, "settings", fake_settings)
config_module.Config()
assert fake_settings.name == "comfyui"
assert fake_settings.payload["default_lora_root"] == folder_paths["loras"][0].replace("\\", "/")
assert fake_settings.payload["default_checkpoint_root"] == folder_paths["checkpoints"][0].replace("\\", "/")
assert fake_settings.payload["default_embedding_root"] == folder_paths["embeddings"][0].replace("\\", "/")
def test_save_paths_removes_template_default_library(monkeypatch, tmp_path): def test_save_paths_removes_template_default_library(monkeypatch, tmp_path):
folder_paths = _setup_config_environment(monkeypatch, tmp_path) folder_paths = _setup_config_environment(monkeypatch, tmp_path)
@@ -322,3 +418,182 @@ def test_extra_paths_deduplication(monkeypatch, tmp_path):
assert config_instance.loras_roots == [str(loras_dir)] assert config_instance.loras_roots == [str(loras_dir)]
assert config_instance.extra_loras_roots == [str(extra_loras_dir)] assert config_instance.extra_loras_roots == [str(extra_loras_dir)]
def test_apply_library_settings_ignores_extra_lora_path_overlapping_primary_symlink(
monkeypatch, tmp_path, caplog
):
"""Extra LoRA paths should be ignored when they resolve to the same target as a primary root."""
real_loras_dir = tmp_path / "loras_real"
real_loras_dir.mkdir()
loras_link = tmp_path / "loras_link"
loras_link.symlink_to(real_loras_dir, target_is_directory=True)
config_instance = config_module.Config()
library_config = {
"folder_paths": {
"loras": [str(loras_link)],
"checkpoints": [],
"unet": [],
"embeddings": [],
},
"extra_folder_paths": {
"loras": [str(real_loras_dir)],
"checkpoints": [],
"unet": [],
"embeddings": [],
},
}
with caplog.at_level("WARNING", logger=config_module.logger.name):
config_instance.apply_library_settings(library_config)
assert config_instance.loras_roots == [str(loras_link)]
assert config_instance.extra_loras_roots == []
warning_messages = [
record.message
for record in caplog.records
if record.levelname == "WARNING"
and "same lora folder" in record.message.lower()
]
assert len(warning_messages) == 1
assert "comfyui model paths" in warning_messages[0].lower()
assert "extra folder paths" in warning_messages[0].lower()
assert "duplicate items" in warning_messages[0].lower()
def test_apply_library_settings_detects_overlap_case_insensitively(
monkeypatch, tmp_path, caplog
):
"""Overlap detection should use case-insensitive comparison on Windows-like paths."""
real_loras_dir = tmp_path / "loras_real"
real_loras_dir.mkdir()
loras_link = tmp_path / "loras_link"
loras_link.symlink_to(real_loras_dir, target_is_directory=True)
original_exists = config_module.os.path.exists
original_realpath = config_module.os.path.realpath
original_normcase = config_module.os.path.normcase
def fake_exists(path):
if isinstance(path, str) and path.lower() in {
str(loras_link).lower(),
str(real_loras_dir).lower(),
str(loras_link).upper().lower(),
str(real_loras_dir).upper().lower(),
}:
return True
return original_exists(path)
def fake_realpath(path, *args, **kwargs):
if isinstance(path, str):
lowered = path.lower()
if lowered == str(loras_link).lower():
return str(real_loras_dir)
if lowered == str(real_loras_dir).lower():
return str(real_loras_dir)
return original_realpath(path, *args, **kwargs)
monkeypatch.setattr(config_module.os.path, "exists", fake_exists)
monkeypatch.setattr(config_module.os.path, "realpath", fake_realpath)
monkeypatch.setattr(
config_module.os.path,
"normcase",
lambda value: original_normcase(value).lower(),
)
config_instance = config_module.Config()
primary_path = str(loras_link).replace("loras_link", "LORAS_LINK")
extra_path = str(real_loras_dir).replace("loras_real", "loras_real")
library_config = {
"folder_paths": {
"loras": [primary_path],
"checkpoints": [],
"unet": [],
"embeddings": [],
},
"extra_folder_paths": {
"loras": [extra_path.upper()],
"checkpoints": [],
"unet": [],
"embeddings": [],
},
}
with caplog.at_level("WARNING", logger=config_module.logger.name):
config_instance.apply_library_settings(library_config)
assert config_instance.loras_roots == [primary_path]
assert config_instance.extra_loras_roots == []
assert any("same lora folder" in record.message.lower() for record in caplog.records)
def test_apply_library_settings_ignores_missing_extra_lora_paths(monkeypatch, tmp_path, caplog):
"""Missing extra paths should be ignored without overlap warnings."""
loras_dir = tmp_path / "loras"
loras_dir.mkdir()
missing_extra = tmp_path / "missing_loras"
config_instance = config_module.Config()
library_config = {
"folder_paths": {
"loras": [str(loras_dir)],
"checkpoints": [],
"unet": [],
"embeddings": [],
},
"extra_folder_paths": {
"loras": [str(missing_extra)],
"checkpoints": [],
"unet": [],
"embeddings": [],
},
}
with caplog.at_level("WARNING", logger=config_module.logger.name):
config_instance.apply_library_settings(library_config)
assert config_instance.loras_roots == [str(loras_dir)]
assert config_instance.extra_loras_roots == []
assert not any("same lora folder" in record.message.lower() for record in caplog.records)
def test_apply_library_settings_ignores_extra_lora_path_overlapping_primary_root_symlink(
tmp_path, caplog
):
"""Extra LoRA paths should be ignored when already reachable via a first-level symlink under the primary root."""
loras_dir = tmp_path / "loras"
loras_dir.mkdir()
external_dir = tmp_path / "external_loras"
external_dir.mkdir()
link_dir = loras_dir / "link"
link_dir.symlink_to(external_dir, target_is_directory=True)
config_instance = config_module.Config()
library_config = {
"folder_paths": {
"loras": [str(loras_dir)],
"checkpoints": [],
"unet": [],
"embeddings": [],
},
"extra_folder_paths": {
"loras": [str(external_dir)],
"checkpoints": [],
"unet": [],
"embeddings": [],
},
}
with caplog.at_level("WARNING", logger=config_module.logger.name):
config_instance.apply_library_settings(library_config)
assert config_instance.loras_roots == [str(loras_dir)]
assert config_instance.extra_loras_roots == []
assert any(
"same lora folder" in record.message.lower()
for record in caplog.records
)

View File

@@ -15,6 +15,8 @@ const {
})); }));
const fetchApiMock = vi.fn(); const fetchApiMock = vi.fn();
const settingGetMock = vi.fn();
const settingSetMock = vi.fn();
const caretHelperInstance = { const caretHelperInstance = {
getBeforeCursor: vi.fn(() => ''), getBeforeCursor: vi.fn(() => ''),
getCursorOffset: vi.fn(() => ({ left: 0, top: 0 })), getCursorOffset: vi.fn(() => ({ left: 0, top: 0 })),
@@ -37,6 +39,12 @@ vi.mock(APP_MODULE, () => ({
canvas: { canvas: {
ds: { scale: 1 }, ds: { scale: 1 },
}, },
extensionManager: {
setting: {
get: settingGetMock,
set: settingSetMock,
},
},
registerExtension: vi.fn(), registerExtension: vi.fn(),
}, },
})); }));
@@ -55,6 +63,23 @@ describe('AutoComplete widget interactions', () => {
document.head.querySelectorAll('style').forEach((styleEl) => styleEl.remove()); document.head.querySelectorAll('style').forEach((styleEl) => styleEl.remove());
Element.prototype.scrollIntoView = vi.fn(); Element.prototype.scrollIntoView = vi.fn();
fetchApiMock.mockReset(); fetchApiMock.mockReset();
settingGetMock.mockReset();
settingSetMock.mockReset();
settingGetMock.mockImplementation((key) => {
if (key === 'loramanager.autocomplete_append_comma') {
return true;
}
if (key === 'loramanager.autocomplete_accept_key') {
return 'both';
}
if (key === 'loramanager.prompt_tag_autocomplete') {
return true;
}
if (key === 'loramanager.tag_space_replacement') {
return false;
}
return undefined;
});
caretHelperInstance.getBeforeCursor.mockReset(); caretHelperInstance.getBeforeCursor.mockReset();
caretHelperInstance.getCursorOffset.mockReset(); caretHelperInstance.getCursorOffset.mockReset();
caretHelperInstance.getBeforeCursor.mockReturnValue(''); caretHelperInstance.getBeforeCursor.mockReturnValue('');
@@ -82,7 +107,7 @@ describe('AutoComplete widget interactions', () => {
document.body.append(input); document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE); const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input, 'loras', { debounceDelay: 0, showPreview: false }); const autoComplete = new AutoComplete(input,'loras', { debounceDelay: 0, showPreview: false });
input.value = 'example'; input.value = 'example';
input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('input', { bubbles: true }));
@@ -125,25 +150,251 @@ describe('AutoComplete widget interactions', () => {
document.body.append(input); document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE); const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input, 'loras', { debounceDelay: 0, showPreview: false }); const autoComplete = new AutoComplete(input,'loras', { debounceDelay: 0, showPreview: false });
await autoComplete.insertSelection('models/example.safetensors'); await autoComplete.insertSelection('models/example.safetensors');
expect(fetchApiMock).toHaveBeenCalledWith( expect(fetchApiMock).toHaveBeenCalledWith(
'/lm/loras/usage-tips-by-path?relative_path=models%2Fexample.safetensors', '/lm/loras/usage-tips-by-path?relative_path=models%2Fexample.safetensors',
); );
expect(input.value).toContain('<lora:example:1.5:0.9>, '); expect(input.value).toContain('<lora:example:1.5:0.9>,');
expect(autoComplete.dropdown.style.display).toBe('none'); expect(autoComplete.dropdown.style.display).toBe('none');
expect(input.focus).toHaveBeenCalled(); expect(input.focus).toHaveBeenCalled();
expect(input.setSelectionRange).toHaveBeenCalled(); expect(input.setSelectionRange).toHaveBeenCalled();
}); });
it('accepts the selected suggestion with Tab', async () => {
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
const input = document.createElement('textarea');
input.value = 'example';
input.selectionStart = input.value.length;
input.focus = vi.fn();
input.setSelectionRange = vi.fn();
document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input,'custom_words', { showPreview: false });
autoComplete.items = ['example_completion'];
autoComplete.selectedIndex = 0;
autoComplete.isVisible = true;
const insertSelectionSpy = vi.spyOn(autoComplete,'insertSelection').mockResolvedValue();
const tabEvent = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true });
input.dispatchEvent(tabEvent);
expect(tabEvent.defaultPrevented).toBe(true);
expect(insertSelectionSpy).toHaveBeenCalledWith('example_completion');
});
it('accepts the selected suggestion with Enter', async () => {
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
const input = document.createElement('textarea');
input.value = 'example';
input.selectionStart = input.value.length;
input.focus = vi.fn();
input.setSelectionRange = vi.fn();
document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input,'custom_words', { showPreview: false });
autoComplete.items = ['example_completion'];
autoComplete.selectedIndex = 0;
autoComplete.isVisible = true;
const insertSelectionSpy = vi.spyOn(autoComplete,'insertSelection').mockResolvedValue();
const enterEvent = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true });
input.dispatchEvent(enterEvent);
expect(enterEvent.defaultPrevented).toBe(true);
expect(insertSelectionSpy).toHaveBeenCalledWith('example_completion');
});
it('prefers the latest best match when Tab is pressed before debounced suggestions fully refresh', async () => {
caretHelperInstance.getBeforeCursor.mockReturnValue('loop');
const input = document.createElement('textarea');
input.value = 'loop';
input.selectionStart = input.value.length;
input.focus = vi.fn();
input.setSelectionRange = vi.fn();
document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input,'prompt', { showPreview: false, minChars: 1 });
autoComplete.searchType = 'custom_words';
autoComplete.items = [
{ tag_name: 'looking_to_the_side', category: 0, post_count: 1000 },
{ tag_name: 'loop', category: 0, post_count: 500 },
];
autoComplete.currentSearchTerm = 'loo';
autoComplete.selectedIndex = 0;
autoComplete.isVisible = true;
const insertSelectionSpy = vi.spyOn(autoComplete,'insertSelection').mockResolvedValue();
const tabEvent = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true });
input.dispatchEvent(tabEvent);
expect(tabEvent.defaultPrevented).toBe(true);
expect(autoComplete.selectedIndex).toBe(1);
expect(insertSelectionSpy).toHaveBeenCalledWith('loop');
});
it('accepts the first available suggestion with Tab even if delayed auto-selection has not happened yet', async () => {
caretHelperInstance.getBeforeCursor.mockReturnValue('loop');
const input = document.createElement('textarea');
input.value = 'loop';
input.selectionStart = input.value.length;
input.focus = vi.fn();
input.setSelectionRange = vi.fn();
document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input,'custom_words', { showPreview: false });
autoComplete.items = ['loop'];
autoComplete.selectedIndex = -1;
autoComplete.isVisible = true;
const insertSelectionSpy = vi.spyOn(autoComplete,'insertSelection').mockResolvedValue();
const tabEvent = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true });
input.dispatchEvent(tabEvent);
expect(tabEvent.defaultPrevented).toBe(true);
expect(autoComplete.selectedIndex).toBe(0);
expect(insertSelectionSpy).toHaveBeenCalledWith('loop');
});
it('only accepts with Tab when autocomplete accept key is set to tab_only', async () => {
settingGetMock.mockImplementation((key) => {
if (key === 'loramanager.autocomplete_append_comma') {
return true;
}
if (key === 'loramanager.autocomplete_accept_key') {
return 'tab_only';
}
if (key === 'loramanager.prompt_tag_autocomplete') {
return true;
}
if (key === 'loramanager.tag_space_replacement') {
return false;
}
return undefined;
});
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
const input = document.createElement('textarea');
input.value = 'example';
input.selectionStart = input.value.length;
input.focus = vi.fn();
input.setSelectionRange = vi.fn();
document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input,'custom_words', { showPreview: false });
autoComplete.items = ['example_completion'];
autoComplete.selectedIndex = 0;
autoComplete.isVisible = true;
const insertSelectionSpy = vi.spyOn(autoComplete,'insertSelection').mockResolvedValue();
const enterEvent = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true });
input.dispatchEvent(enterEvent);
expect(enterEvent.defaultPrevented).toBe(false);
expect(insertSelectionSpy).not.toHaveBeenCalled();
const tabEvent = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true });
input.dispatchEvent(tabEvent);
expect(tabEvent.defaultPrevented).toBe(true);
expect(insertSelectionSpy).toHaveBeenCalledWith('example_completion');
});
it('only accepts with Enter when autocomplete accept key is set to enter_only', async () => {
settingGetMock.mockImplementation((key) => {
if (key === 'loramanager.autocomplete_append_comma') {
return true;
}
if (key === 'loramanager.autocomplete_accept_key') {
return 'enter_only';
}
if (key === 'loramanager.prompt_tag_autocomplete') {
return true;
}
if (key === 'loramanager.tag_space_replacement') {
return false;
}
return undefined;
});
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
const input = document.createElement('textarea');
input.value = 'example';
input.selectionStart = input.value.length;
input.focus = vi.fn();
input.setSelectionRange = vi.fn();
document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input,'custom_words', { showPreview: false });
autoComplete.items = ['example_completion'];
autoComplete.selectedIndex = 0;
autoComplete.isVisible = true;
const insertSelectionSpy = vi.spyOn(autoComplete,'insertSelection').mockResolvedValue();
const tabEvent = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true });
input.dispatchEvent(tabEvent);
expect(tabEvent.defaultPrevented).toBe(false);
expect(insertSelectionSpy).not.toHaveBeenCalled();
const enterEvent = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true });
input.dispatchEvent(enterEvent);
expect(enterEvent.defaultPrevented).toBe(true);
expect(insertSelectionSpy).toHaveBeenCalledWith('example_completion');
});
it('does not intercept Tab when the dropdown is not visible', async () => {
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
const input = document.createElement('textarea');
input.value = 'example';
input.selectionStart = input.value.length;
input.focus = vi.fn();
input.setSelectionRange = vi.fn();
document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input,'custom_words', { showPreview: false });
autoComplete.items = ['example_completion'];
autoComplete.selectedIndex = 0;
autoComplete.isVisible = false;
const insertSelectionSpy = vi.spyOn(autoComplete,'insertSelection').mockResolvedValue();
const tabEvent = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true });
input.dispatchEvent(tabEvent);
expect(tabEvent.defaultPrevented).toBe(false);
expect(insertSelectionSpy).not.toHaveBeenCalled();
});
it('highlights multiple include tokens while ignoring excluded ones', async () => { it('highlights multiple include tokens while ignoring excluded ones', async () => {
const input = document.createElement('textarea'); const input = document.createElement('textarea');
document.body.append(input); document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE); const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input, 'loras', { showPreview: false }); const autoComplete = new AutoComplete(input,'loras', { showPreview: false });
const highlighted = autoComplete.highlightMatch( const highlighted = autoComplete.highlightMatch(
'models/flux/beta-detail.safetensors', 'models/flux/beta-detail.safetensors',
@@ -160,7 +411,7 @@ describe('AutoComplete widget interactions', () => {
it('handles arrow key navigation with virtual scrolling', async () => { it('handles arrow key navigation with virtual scrolling', async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const mockItems = Array.from({ length: 50 }, (_, i) => `model_${i.toString().padStart(2, '0')}.safetensors`); const mockItems = Array.from({ length: 50 }, (_, i) => `model_${i.toString().padStart(2,'0')}.safetensors`);
fetchApiMock.mockResolvedValue({ fetchApiMock.mockResolvedValue({
json: () => Promise.resolve({ success: true, relative_paths: mockItems }), json: () => Promise.resolve({ success: true, relative_paths: mockItems }),
@@ -173,7 +424,7 @@ describe('AutoComplete widget interactions', () => {
document.body.append(input); document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE); const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input, 'loras', { const autoComplete = new AutoComplete(input,'loras', {
debounceDelay: 0, debounceDelay: 0,
showPreview: false, showPreview: false,
enableVirtualScroll: true, enableVirtualScroll: true,
@@ -216,7 +467,7 @@ describe('AutoComplete widget interactions', () => {
it('maintains selection when scrolling to invisible items', async () => { it('maintains selection when scrolling to invisible items', async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const mockItems = Array.from({ length: 100 }, (_, i) => `item_${i.toString().padStart(3, '0')}.safetensors`); const mockItems = Array.from({ length: 100 }, (_, i) => `item_${i.toString().padStart(3,'0')}.safetensors`);
fetchApiMock.mockResolvedValue({ fetchApiMock.mockResolvedValue({
json: () => Promise.resolve({ success: true, relative_paths: mockItems }), json: () => Promise.resolve({ success: true, relative_paths: mockItems }),
@@ -231,7 +482,7 @@ describe('AutoComplete widget interactions', () => {
document.body.append(input); document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE); const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input, 'loras', { const autoComplete = new AutoComplete(input,'loras', {
debounceDelay: 0, debounceDelay: 0,
showPreview: false, showPreview: false,
enableVirtualScroll: true, enableVirtualScroll: true,
@@ -289,7 +540,7 @@ describe('AutoComplete widget interactions', () => {
document.body.append(input); document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE); const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input, 'prompt', { const autoComplete = new AutoComplete(input,'prompt', {
debounceDelay: 0, debounceDelay: 0,
showPreview: false, showPreview: false,
minChars: 1, minChars: 1,
@@ -302,7 +553,7 @@ describe('AutoComplete widget interactions', () => {
await autoComplete.insertSelection('looking_to_the_side'); await autoComplete.insertSelection('looking_to_the_side');
expect(input.value).toBe('looking_to_the_side, '); expect(input.value).toBe('looking_to_the_side,');
expect(autoComplete.dropdown.style.display).toBe('none'); expect(autoComplete.dropdown.style.display).toBe('none');
expect(input.focus).toHaveBeenCalled(); expect(input.focus).toHaveBeenCalled();
}); });
@@ -328,7 +579,7 @@ describe('AutoComplete widget interactions', () => {
document.body.append(input); document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE); const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input, 'prompt', { const autoComplete = new AutoComplete(input,'prompt', {
debounceDelay: 0, debounceDelay: 0,
showPreview: false, showPreview: false,
minChars: 1, minChars: 1,
@@ -342,7 +593,7 @@ describe('AutoComplete widget interactions', () => {
await autoComplete.insertSelection('1girl'); await autoComplete.insertSelection('1girl');
expect(input.value).toBe('hello 1girl, '); expect(input.value).toBe('hello 1girl,');
}); });
it('replaces entire phrase for underscore tag match (e.g., "blue hair" -> "blue_hair")', async () => { it('replaces entire phrase for underscore tag match (e.g., "blue hair" -> "blue_hair")', async () => {
@@ -366,7 +617,7 @@ describe('AutoComplete widget interactions', () => {
document.body.append(input); document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE); const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input, 'prompt', { const autoComplete = new AutoComplete(input,'prompt', {
debounceDelay: 0, debounceDelay: 0,
showPreview: false, showPreview: false,
minChars: 1, minChars: 1,
@@ -380,7 +631,7 @@ describe('AutoComplete widget interactions', () => {
await autoComplete.insertSelection('blue_hair'); await autoComplete.insertSelection('blue_hair');
expect(input.value).toBe('blue_hair, '); expect(input.value).toBe('blue_hair,');
}); });
it('handles multi-word phrase with preceding text correctly', async () => { it('handles multi-word phrase with preceding text correctly', async () => {
@@ -403,7 +654,7 @@ describe('AutoComplete widget interactions', () => {
document.body.append(input); document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE); const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input, 'prompt', { const autoComplete = new AutoComplete(input,'prompt', {
debounceDelay: 0, debounceDelay: 0,
showPreview: false, showPreview: false,
minChars: 1, minChars: 1,
@@ -417,7 +668,7 @@ describe('AutoComplete widget interactions', () => {
await autoComplete.insertSelection('looking_to_the_side'); await autoComplete.insertSelection('looking_to_the_side');
expect(input.value).toBe('1girl, looking_to_the_side, '); expect(input.value).toBe('1girl, looking_to_the_side,');
}); });
it('replaces entire command and search term when using command mode with multi-word phrase', async () => { it('replaces entire command and search term when using command mode with multi-word phrase', async () => {
@@ -442,7 +693,7 @@ describe('AutoComplete widget interactions', () => {
document.body.append(input); document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE); const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input, 'prompt', { const autoComplete = new AutoComplete(input,'prompt', {
debounceDelay: 0, debounceDelay: 0,
showPreview: false, showPreview: false,
minChars: 1, minChars: 1,
@@ -458,7 +709,7 @@ describe('AutoComplete widget interactions', () => {
await autoComplete.insertSelection('looking_to_the_side'); await autoComplete.insertSelection('looking_to_the_side');
// Command part should be replaced along with search term // Command part should be replaced along with search term
expect(input.value).toBe('looking_to_the_side, '); expect(input.value).toBe('looking_to_the_side,');
}); });
it('replaces only last token when multi-word query does not exactly match selected tag', async () => { it('replaces only last token when multi-word query does not exactly match selected tag', async () => {
@@ -483,7 +734,7 @@ describe('AutoComplete widget interactions', () => {
document.body.append(input); document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE); const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input, 'prompt', { const autoComplete = new AutoComplete(input,'prompt', {
debounceDelay: 0, debounceDelay: 0,
showPreview: false, showPreview: false,
minChars: 1, minChars: 1,
@@ -498,7 +749,7 @@ describe('AutoComplete widget interactions', () => {
await autoComplete.insertSelection('blue_hair'); await autoComplete.insertSelection('blue_hair');
// Only "blue" should be replaced, not the entire phrase // Only "blue" should be replaced, not the entire phrase
expect(input.value).toBe('looking to the blue_hair, '); expect(input.value).toBe('looking to the blue_hair,');
}); });
it('handles multiple consecutive spaces in multi-word phrase correctly', async () => { it('handles multiple consecutive spaces in multi-word phrase correctly', async () => {
@@ -522,7 +773,7 @@ describe('AutoComplete widget interactions', () => {
document.body.append(input); document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE); const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input, 'prompt', { const autoComplete = new AutoComplete(input,'prompt', {
debounceDelay: 0, debounceDelay: 0,
showPreview: false, showPreview: false,
minChars: 1, minChars: 1,
@@ -537,7 +788,7 @@ describe('AutoComplete widget interactions', () => {
await autoComplete.insertSelection('looking_to_the_side'); await autoComplete.insertSelection('looking_to_the_side');
// Multiple spaces should be normalized to single underscores for matching // Multiple spaces should be normalized to single underscores for matching
expect(input.value).toBe('looking_to_the_side, '); expect(input.value).toBe('looking_to_the_side,');
}); });
it('handles command mode with partial match replacing only last token', async () => { it('handles command mode with partial match replacing only last token', async () => {
@@ -561,7 +812,7 @@ describe('AutoComplete widget interactions', () => {
document.body.append(input); document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE); const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input, 'prompt', { const autoComplete = new AutoComplete(input,'prompt', {
debounceDelay: 0, debounceDelay: 0,
showPreview: false, showPreview: false,
minChars: 1, minChars: 1,
@@ -577,7 +828,7 @@ describe('AutoComplete widget interactions', () => {
await autoComplete.insertSelection('blue_hair'); await autoComplete.insertSelection('blue_hair');
// In command mode, the entire command + search term should be replaced // In command mode, the entire command + search term should be replaced
expect(input.value).toBe('blue_hair, '); expect(input.value).toBe('blue_hair,');
}); });
it('replaces entire phrase when selected tag starts with underscore version of search term (prefix match)', async () => { it('replaces entire phrase when selected tag starts with underscore version of search term (prefix match)', async () => {
@@ -601,7 +852,7 @@ describe('AutoComplete widget interactions', () => {
document.body.append(input); document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE); const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input, 'prompt', { const autoComplete = new AutoComplete(input,'prompt', {
debounceDelay: 0, debounceDelay: 0,
showPreview: false, showPreview: false,
minChars: 1, minChars: 1,
@@ -616,7 +867,7 @@ describe('AutoComplete widget interactions', () => {
await autoComplete.insertSelection('looking_to_the_side'); await autoComplete.insertSelection('looking_to_the_side');
// Entire phrase should be replaced with selected tag (with underscores) // Entire phrase should be replaced with selected tag (with underscores)
expect(input.value).toBe('looking_to_the_side, '); expect(input.value).toBe('looking_to_the_side,');
}); });
it('inserts tag with underscores regardless of space replacement setting', async () => { it('inserts tag with underscores regardless of space replacement setting', async () => {
@@ -639,7 +890,7 @@ describe('AutoComplete widget interactions', () => {
document.body.append(input); document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE); const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input, 'prompt', { const autoComplete = new AutoComplete(input,'prompt', {
debounceDelay: 0, debounceDelay: 0,
showPreview: false, showPreview: false,
minChars: 1, minChars: 1,
@@ -653,7 +904,287 @@ describe('AutoComplete widget interactions', () => {
await autoComplete.insertSelection('blue_hair'); await autoComplete.insertSelection('blue_hair');
// Tag should be inserted with underscores, not spaces // Tag should be inserted with underscores, not spaces
expect(input.value).toBe('blue_hair, '); expect(input.value).toBe('blue_hair,');
});
it('omits the trailing comma when the append comma setting is disabled', async () => {
settingGetMock.mockImplementation((key) => {
if (key === 'loramanager.autocomplete_append_comma') {
return false;
}
if (key === 'loramanager.prompt_tag_autocomplete') {
return true;
}
if (key === 'loramanager.tag_space_replacement') {
return false;
}
return undefined;
});
const mockTags = [
{ tag_name: 'blue_hair', category: 0, post_count: 45000 },
];
caretHelperInstance.getBeforeCursor.mockReturnValue('blue hair');
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
const input = document.createElement('textarea');
input.value = 'blue hair';
input.selectionStart = input.value.length;
input.focus = vi.fn();
input.setSelectionRange = vi.fn();
document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input,'prompt', {
debounceDelay: 0,
showPreview: false,
minChars: 1,
});
autoComplete.searchType = 'custom_words';
autoComplete.activeCommand = null;
autoComplete.items = mockTags;
autoComplete.selectedIndex = 0;
autoComplete.currentSearchTerm = 'blue hair';
await autoComplete.insertSelection('blue_hair');
expect(input.value).toBe('blue_hair ');
});
it('uses persisted autocomplete metadata as the next search start when comma append is disabled', async () => {
vi.useFakeTimers();
settingGetMock.mockImplementation((key) => {
if (key === 'loramanager.autocomplete_append_comma') {
return false;
}
if (key === 'loramanager.prompt_tag_autocomplete') {
return true;
}
if (key === 'loramanager.tag_space_replacement') {
return false;
}
return undefined;
});
fetchApiMock.mockResolvedValue({
json: () => Promise.resolve({ success: true, words: [{ tag_name: 'cat_ears', category: 0, post_count: 1234 }] }),
});
caretHelperInstance.getBeforeCursor.mockReturnValue('1girl cat');
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
const input = document.createElement('textarea');
input.value = '1girl cat';
input.selectionStart = input.value.length;
input.focus = vi.fn();
input.setSelectionRange = vi.fn();
input._autocompleteMetadataWidget = {
value: {
version: 1,
textWidgetName: 'text',
lastAccepted: {
start: 0,
end: 6,
insertedText: '1girl ',
textSnapshot: '1girl ',
},
},
};
document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input,'prompt', {
debounceDelay: 0,
showPreview: false,
minChars: 1,
});
expect(autoComplete.getSearchTerm(input.value)).toBe('cat');
input.dispatchEvent(new Event('input', { bubbles: true }));
await vi.runAllTimersAsync();
await Promise.resolve();
expect(fetchApiMock).toHaveBeenCalledWith('/lm/custom-words/search?enriched=true&search=cat&limit=100');
});
it('invalidates stale autocomplete metadata and falls back to delimiter-based matching', async () => {
settingGetMock.mockImplementation((key) => {
if (key === 'loramanager.autocomplete_append_comma') {
return false;
}
if (key === 'loramanager.prompt_tag_autocomplete') {
return true;
}
if (key === 'loramanager.tag_space_replacement') {
return false;
}
return undefined;
});
caretHelperInstance.getBeforeCursor.mockReturnValue('1boy cat');
const metadataWidget = {
value: {
version: 1,
textWidgetName: 'text',
lastAccepted: {
start: 0,
end: 6,
insertedText: '1girl ',
textSnapshot: '1girl ',
},
},
};
const input = document.createElement('textarea');
input.value = '1boy cat';
input.selectionStart = input.value.length;
input._autocompleteMetadataWidget = metadataWidget;
document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input,'prompt', {
debounceDelay: 0,
showPreview: false,
minChars: 1,
});
expect(autoComplete.getSearchTerm(input.value)).toBe('1boy cat');
expect(metadataWidget.value.lastAccepted).toBeUndefined();
});
it('does not duplicate the first character when accepting a suggestion after a trailing space', async () => {
settingGetMock.mockImplementation((key) => {
if (key === 'loramanager.autocomplete_append_comma') {
return false;
}
if (key === 'loramanager.prompt_tag_autocomplete') {
return true;
}
if (key === 'loramanager.tag_space_replacement') {
return false;
}
return undefined;
});
const mockTags = [
{ tag_name: '1girl', category: 4, post_count: 500000 },
];
caretHelperInstance.getBeforeCursor.mockReturnValue('1girl ');
const input = document.createElement('textarea');
input.value = '1girl ';
input.selectionStart = input.value.length;
input.focus = vi.fn();
input.setSelectionRange = vi.fn();
document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input,'prompt', {
debounceDelay: 0,
showPreview: false,
minChars: 1,
});
autoComplete.searchType = 'custom_words';
autoComplete.activeCommand = null;
autoComplete.items = mockTags;
autoComplete.selectedIndex = 0;
await autoComplete.insertSelection('1girl');
expect(input.value).toBe('1girl ');
});
it('treats a newline as a hard boundary after dismissing autocomplete', async () => {
vi.useFakeTimers();
fetchApiMock.mockResolvedValue({
json: () => Promise.resolve({ success: true, words: [{ tag_name: '1girl', category: 4, post_count: 500000 }] }),
});
const input = document.createElement('textarea');
input.value = '1gi\n';
input.selectionStart = input.value.length;
document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input,'prompt', {
debounceDelay: 0,
showPreview: false,
minChars: 1,
});
caretHelperInstance.getBeforeCursor.mockReturnValue('1gi');
autoComplete.handleInput('1gi');
await vi.runAllTimersAsync();
await Promise.resolve();
expect(fetchApiMock).toHaveBeenCalled();
fetchApiMock.mockClear();
autoComplete.hide();
caretHelperInstance.getBeforeCursor.mockReturnValue('1gi\n');
input.dispatchEvent(new Event('input', { bubbles: true }));
await vi.runAllTimersAsync();
await Promise.resolve();
expect(autoComplete.getSearchTerm(input.value)).toBe('');
expect(fetchApiMock).not.toHaveBeenCalled();
expect(autoComplete.isVisible).toBe(false);
});
it('omits the trailing comma for LoRA insertions when the setting is disabled', async () => {
settingGetMock.mockImplementation((key) => {
if (key === 'loramanager.autocomplete_append_comma') {
return false;
}
if (key === 'loramanager.prompt_tag_autocomplete') {
return true;
}
if (key === 'loramanager.tag_space_replacement') {
return false;
}
return undefined;
});
fetchApiMock.mockImplementation((url) => {
if (url.includes('usage-tips-by-path')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
success: true,
usage_tips: JSON.stringify({ strength: '1.2' }),
}),
});
}
return Promise.resolve({
json: () => Promise.resolve({ success: true, relative_paths: ['models/example.safetensors'] }),
});
});
caretHelperInstance.getBeforeCursor.mockReturnValue('alpha, example');
const input = document.createElement('textarea');
input.value = 'alpha, example';
input.selectionStart = input.value.length;
input.focus = vi.fn();
input.setSelectionRange = vi.fn();
document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input,'loras', { debounceDelay: 0, showPreview: false });
await autoComplete.insertSelection('models/example.safetensors');
expect(input.value).toContain('<lora:example:1.2>');
expect(input.value).not.toContain('<lora:example:1.2>,');
}); });
it('replaces entire phrase when selected tag ends with underscore version of search term (suffix match)', async () => { it('replaces entire phrase when selected tag ends with underscore version of search term (suffix match)', async () => {
@@ -677,7 +1208,7 @@ describe('AutoComplete widget interactions', () => {
document.body.append(input); document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE); const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input, 'prompt', { const autoComplete = new AutoComplete(input,'prompt', {
debounceDelay: 0, debounceDelay: 0,
showPreview: false, showPreview: false,
minChars: 1, minChars: 1,
@@ -692,6 +1223,6 @@ describe('AutoComplete widget interactions', () => {
await autoComplete.insertSelection('looking_to_the_side'); await autoComplete.insertSelection('looking_to_the_side');
// Entire phrase should be replaced with selected tag // Entire phrase should be replaced with selected tag
expect(input.value).toBe('looking_to_the_side, '); expect(input.value).toBe('looking_to_the_side,');
}); });
}); });

View File

@@ -245,16 +245,28 @@ describe('Interaction-level regression coverage', () => {
<div class="param-group info-item"> <div class="param-group info-item">
<div class="param-header"> <div class="param-header">
<label>Prompt</label> <label>Prompt</label>
<button class="copy-btn" id="copyPromptBtn" title="Copy Prompt"><i class="fas fa-copy"></i></button> <div class="param-actions">
<button class="copy-btn" id="copyPromptBtn" title="Copy Prompt"><i class="fas fa-copy"></i></button>
<button class="edit-btn" id="editPromptBtn" title="Edit Prompt"><i class="fas fa-pencil-alt"></i></button>
</div>
</div> </div>
<div class="param-content" id="recipePrompt"></div> <div class="param-content" id="recipePrompt"></div>
<div class="param-editor" id="recipePromptEditor">
<textarea class="param-textarea" id="recipePromptInput"></textarea>
</div>
</div> </div>
<div class="param-group info-item"> <div class="param-group info-item">
<div class="param-header"> <div class="param-header">
<label>Negative Prompt</label> <label>Negative Prompt</label>
<button class="copy-btn" id="copyNegativePromptBtn" title="Copy Negative Prompt"><i class="fas fa-copy"></i></button> <div class="param-actions">
<button class="copy-btn" id="copyNegativePromptBtn" title="Copy Negative Prompt"><i class="fas fa-copy"></i></button>
<button class="edit-btn" id="editNegativePromptBtn" title="Edit Negative Prompt"><i class="fas fa-pencil-alt"></i></button>
</div>
</div> </div>
<div class="param-content" id="recipeNegativePrompt"></div> <div class="param-content" id="recipeNegativePrompt"></div>
<div class="param-editor" id="recipeNegativePromptEditor">
<textarea class="param-textarea" id="recipeNegativePromptInput"></textarea>
</div>
</div> </div>
<div class="other-params" id="recipeOtherParams"></div> <div class="other-params" id="recipeOtherParams"></div>
</div> </div>
@@ -324,6 +336,208 @@ describe('Interaction-level regression coverage', () => {
expect(recipeModal.currentRecipe.title).toBe('Updated Title'); expect(recipeModal.currentRecipe.title).toBe('Updated Title');
}); });
it('saves prompt edits on Enter while preserving Shift+Enter for new lines', async () => {
document.body.innerHTML = `
<div id="recipeModal" class="modal">
<div class="modal-content">
<header class="recipe-modal-header">
<h2 id="recipeModalTitle">Recipe Details</h2>
<div class="recipe-tags-container">
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
<div class="recipe-tags-tooltip" id="recipeTagsTooltip">
<div class="tooltip-content" id="recipeTagsTooltipContent"></div>
</div>
</div>
</header>
<div class="modal-body">
<div class="recipe-top-section">
<div class="recipe-preview-container" id="recipePreviewContainer">
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
</div>
<div class="info-section recipe-gen-params">
<div class="gen-params-container">
<div class="param-group info-item">
<div class="param-header">
<label>Prompt</label>
<div class="param-actions">
<button class="copy-btn" id="copyPromptBtn" title="Copy Prompt"><i class="fas fa-copy"></i></button>
<button class="edit-btn" id="editPromptBtn" title="Edit Prompt"><i class="fas fa-pencil-alt"></i></button>
</div>
</div>
<div class="param-content" id="recipePrompt"></div>
<div class="param-editor" id="recipePromptEditor">
<textarea class="param-textarea" id="recipePromptInput"></textarea>
</div>
</div>
<div class="param-group info-item">
<div class="param-header">
<label>Negative Prompt</label>
<div class="param-actions">
<button class="copy-btn" id="copyNegativePromptBtn" title="Copy Negative Prompt"><i class="fas fa-copy"></i></button>
<button class="edit-btn" id="editNegativePromptBtn" title="Edit Negative Prompt"><i class="fas fa-pencil-alt"></i></button>
</div>
</div>
<div class="param-content" id="recipeNegativePrompt"></div>
<div class="param-editor" id="recipeNegativePromptEditor">
<textarea class="param-textarea" id="recipeNegativePromptInput"></textarea>
</div>
</div>
<div class="other-params" id="recipeOtherParams"></div>
</div>
</div>
</div>
<div class="info-section recipe-bottom-section">
<div class="recipe-section-header">
<h3>Resources</h3>
<div class="recipe-section-actions">
<span id="recipeLorasCount"><i class="fas fa-layer-group"></i> 0 LoRAs</span>
</div>
</div>
<div class="recipe-loras-list" id="recipeLorasList"></div>
</div>
</div>
</div>
</div>
`;
const { RecipeModal } = await import('../../../static/js/components/RecipeModal.js');
const recipeModal = new RecipeModal();
recipeModal.showRecipeDetails({
id: 'recipe-2',
file_path: '/recipes/prompt.json',
title: 'Prompt Recipe',
tags: [],
file_url: '',
preview_url: '',
source_path: '',
gen_params: {
prompt: 'old prompt',
negative_prompt: 'keep negative',
steps: 30,
cfg_scale: 7,
},
loras: [],
});
document.getElementById('editPromptBtn').click();
const textarea = document.getElementById('recipePromptInput');
textarea.value = 'new prompt text';
textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', shiftKey: true, bubbles: true }));
await flushAsyncTasks();
expect(updateRecipeMetadataMock).not.toHaveBeenCalled();
textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
await updateRecipeMetadataMock.mock.results[0].value;
await flushAsyncTasks();
expect(updateRecipeMetadataMock).toHaveBeenCalledWith('/recipes/prompt.json', {
gen_params: {
prompt: 'new prompt text',
negative_prompt: 'keep negative',
steps: 30,
cfg_scale: 7,
},
});
expect(document.getElementById('recipePrompt').textContent).toBe('new prompt text');
expect(recipeModal.currentRecipe.gen_params.prompt).toBe('new prompt text');
});
it('cancels negative prompt edits on Escape without saving', async () => {
document.body.innerHTML = `
<div id="recipeModal" class="modal">
<div class="modal-content">
<header class="recipe-modal-header">
<h2 id="recipeModalTitle">Recipe Details</h2>
<div class="recipe-tags-container">
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
<div class="recipe-tags-tooltip" id="recipeTagsTooltip">
<div class="tooltip-content" id="recipeTagsTooltipContent"></div>
</div>
</div>
</header>
<div class="modal-body">
<div class="recipe-top-section">
<div class="recipe-preview-container" id="recipePreviewContainer">
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
</div>
<div class="info-section recipe-gen-params">
<div class="gen-params-container">
<div class="param-group info-item">
<div class="param-header">
<label>Prompt</label>
<div class="param-actions">
<button class="copy-btn" id="copyPromptBtn" title="Copy Prompt"><i class="fas fa-copy"></i></button>
<button class="edit-btn" id="editPromptBtn" title="Edit Prompt"><i class="fas fa-pencil-alt"></i></button>
</div>
</div>
<div class="param-content" id="recipePrompt"></div>
<div class="param-editor" id="recipePromptEditor">
<textarea class="param-textarea" id="recipePromptInput"></textarea>
</div>
</div>
<div class="param-group info-item">
<div class="param-header">
<label>Negative Prompt</label>
<div class="param-actions">
<button class="copy-btn" id="copyNegativePromptBtn" title="Copy Negative Prompt"><i class="fas fa-copy"></i></button>
<button class="edit-btn" id="editNegativePromptBtn" title="Edit Negative Prompt"><i class="fas fa-pencil-alt"></i></button>
</div>
</div>
<div class="param-content" id="recipeNegativePrompt"></div>
<div class="param-editor" id="recipeNegativePromptEditor">
<textarea class="param-textarea" id="recipeNegativePromptInput"></textarea>
</div>
</div>
<div class="other-params" id="recipeOtherParams"></div>
</div>
</div>
</div>
<div class="info-section recipe-bottom-section">
<div class="recipe-section-header">
<h3>Resources</h3>
<div class="recipe-section-actions">
<span id="recipeLorasCount"><i class="fas fa-layer-group"></i> 0 LoRAs</span>
</div>
</div>
<div class="recipe-loras-list" id="recipeLorasList"></div>
</div>
</div>
</div>
</div>
`;
const { RecipeModal } = await import('../../../static/js/components/RecipeModal.js');
const recipeModal = new RecipeModal();
recipeModal.showRecipeDetails({
id: 'recipe-3',
file_path: '/recipes/negative.json',
title: 'Negative Recipe',
tags: [],
file_url: '',
preview_url: '',
source_path: '',
gen_params: {
prompt: '',
negative_prompt: 'existing negative',
steps: 20,
},
loras: [],
});
document.getElementById('editNegativePromptBtn').click();
const textarea = document.getElementById('recipeNegativePromptInput');
textarea.value = 'changed negative';
textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
expect(updateRecipeMetadataMock).not.toHaveBeenCalled();
expect(modalManagerMock.closeModal).not.toHaveBeenCalled();
expect(document.getElementById('recipeNegativePrompt').textContent).toBe('existing negative');
expect(document.getElementById('recipeNegativePromptEditor').classList.contains('active')).toBe(false);
});
it('processes global context menu actions for downloads and cleanup', async () => { it('processes global context menu actions for downloads and cleanup', async () => {
document.body.innerHTML = ` document.body.innerHTML = `
<div id="globalContextMenu" class="context-menu"> <div id="globalContextMenu" class="context-menu">

View File

@@ -37,6 +37,13 @@ const updateConnectedTriggerWords = vi.fn();
const mergeLoras = vi.fn(); const mergeLoras = vi.fn();
const getAllGraphNodes = vi.fn(); const getAllGraphNodes = vi.fn();
const getNodeFromGraph = vi.fn(); const getNodeFromGraph = vi.fn();
const getWidgetByName = vi.fn((node, name) =>
node?.widgets?.find((widget) => widget?.name === name) ?? null
);
const getWidgetSerializedValue = vi.fn((node, name) => {
const index = node?.widgets?.findIndex((widget) => widget?.name === name) ?? -1;
return index >= 0 ? node.widgets_values?.[index] : undefined;
});
vi.mock(UTILS_MODULE, () => ({ vi.mock(UTILS_MODULE, () => ({
collectActiveLorasFromChain, collectActiveLorasFromChain,
@@ -47,6 +54,8 @@ vi.mock(UTILS_MODULE, () => ({
}, },
getAllGraphNodes, getAllGraphNodes,
getNodeFromGraph, getNodeFromGraph,
getWidgetByName,
getWidgetSerializedValue,
LORA_PATTERN: /<lora:([^:]+):([-\d.]+)(?::([-\d.]+))?>/g, LORA_PATTERN: /<lora:([^:]+):([-\d.]+)(?::([-\d.]+))?>/g,
})); }));
@@ -71,6 +80,9 @@ describe("Lora Loader trigger word updates", () => {
mergeLoras.mockClear(); mergeLoras.mockClear();
mergeLoras.mockImplementation(() => [{ name: "Alpha", active: true }]); mergeLoras.mockImplementation(() => [{ name: "Alpha", active: true }]);
getWidgetByName.mockClear();
getWidgetSerializedValue.mockClear();
addLorasWidget.mockClear(); addLorasWidget.mockClear();
addLorasWidget.mockImplementation((_node, _name, _opts, callback) => ({ addLorasWidget.mockImplementation((_node, _name, _opts, callback) => ({
widget: { value: [], callback }, widget: { value: [], callback },
@@ -89,14 +101,21 @@ describe("Lora Loader trigger word updates", () => {
// Create mock widget (AUTOCOMPLETE_TEXT_LORAS type created by Vue widgets) // Create mock widget (AUTOCOMPLETE_TEXT_LORAS type created by Vue widgets)
const inputWidget = { const inputWidget = {
name: "text",
value: "", value: "",
options: {}, options: {},
callback: null, // Will be set by onNodeCreated callback: null, // Will be set by onNodeCreated
}; };
const metadataWidget = {
name: "__autocomplete_metadata_text",
value: { version: 1, textWidgetName: "text" },
options: {},
};
const node = { const node = {
comfyClass: "Lora Loader (LoraManager)", comfyClass: "Lora Loader (LoraManager)",
widgets: [inputWidget], widgets: [metadataWidget, inputWidget],
addInput: vi.fn(), addInput: vi.fn(),
graph: {}, graph: {},
}; };
@@ -106,6 +125,7 @@ describe("Lora Loader trigger word updates", () => {
// The widget is now the AUTOCOMPLETE_TEXT_LORAS type, created automatically by Vue widgets // The widget is now the AUTOCOMPLETE_TEXT_LORAS type, created automatically by Vue widgets
expect(node.inputWidget).toBe(inputWidget); expect(node.inputWidget).toBe(inputWidget);
expect(node.lorasWidget).toBeDefined(); expect(node.lorasWidget).toBeDefined();
expect(getWidgetByName).toHaveBeenCalledWith(node, "text");
// The callback should have been set up by onNodeCreated // The callback should have been set up by onNodeCreated
const inputCallback = inputWidget.callback; const inputCallback = inputWidget.callback;

View File

@@ -82,24 +82,35 @@ vi.mock(MODEL_VERSIONS_MODULE, () => ({
})); }));
vi.mock(RECIPE_TAB_MODULE, () => ({ vi.mock(RECIPE_TAB_MODULE, () => ({
loadRecipesForLora: vi.fn(), loadRecipesForModel: vi.fn(),
})); }));
vi.mock(I18N_HELPERS_MODULE, () => ({ vi.mock(I18N_HELPERS_MODULE, () => ({
translate: vi.fn((_, __, fallback) => fallback || ''), translate: vi.fn((_, __, fallback) => fallback || ''),
})); }));
vi.mock('../../../static/js/api/apiConfig.js', () => ({
MODEL_TYPES: {
LORA: 'loras',
CHECKPOINT: 'checkpoints',
EMBEDDING: 'embeddings'
}
}));
vi.mock(API_FACTORY, () => ({ vi.mock(API_FACTORY, () => ({
getModelApiClient: vi.fn(), getModelApiClient: vi.fn(),
})); }));
describe('Model metadata interactions keep file path in sync', () => { describe('Model metadata interactions keep file path in sync', () => {
let getModelApiClient; let getModelApiClient;
let loadRecipesForModel;
beforeEach(async () => { beforeEach(async () => {
document.body.innerHTML = ''; document.body.innerHTML = '';
({ getModelApiClient } = await import(API_FACTORY)); ({ getModelApiClient } = await import(API_FACTORY));
({ loadRecipesForModel } = await import(RECIPE_TAB_MODULE));
getModelApiClient.mockReset(); getModelApiClient.mockReset();
loadRecipesForModel.mockReset();
}); });
afterEach(() => { afterEach(() => {
@@ -198,4 +209,33 @@ describe('Model metadata interactions keep file path in sync', () => {
expect(saveModelMetadata).toHaveBeenCalledWith('models/Qwen.testing.safetensors', { notes: 'Updated notes' }); expect(saveModelMetadata).toHaveBeenCalledWith('models/Qwen.testing.safetensors', { notes: 'Updated notes' });
}); });
}); });
it('shows recipes tab for checkpoint modals and loads linked recipes by hash', async () => {
const fetchModelMetadata = vi.fn().mockResolvedValue(null);
getModelApiClient.mockReturnValue({
fetchModelMetadata,
saveModelMetadata: vi.fn(),
});
const { showModelModal } = await import(MODAL_MODULE);
await showModelModal(
{
model_name: 'Flux Base',
file_path: 'models/checkpoints/flux-base.safetensors',
file_name: 'flux-base.safetensors',
sha256: 'ABC123',
civitai: {},
},
'checkpoints',
);
expect(document.querySelector('.tab-btn[data-tab="recipes"]')).not.toBeNull();
expect(loadRecipesForModel).toHaveBeenCalledWith({
modelKind: 'checkpoint',
displayName: 'Flux Base',
sha256: 'ABC123',
});
});
}); });

View File

@@ -80,13 +80,21 @@ vi.mock(MODEL_VERSIONS_MODULE, () => ({
})); }));
vi.mock(RECIPE_TAB_MODULE, () => ({ vi.mock(RECIPE_TAB_MODULE, () => ({
loadRecipesForLora: vi.fn(), loadRecipesForModel: vi.fn(),
})); }));
vi.mock(I18N_HELPERS_MODULE, () => ({ vi.mock(I18N_HELPERS_MODULE, () => ({
translate: vi.fn((_, __, fallback) => fallback || ''), translate: vi.fn((_, __, fallback) => fallback || ''),
})); }));
vi.mock('../../../static/js/api/apiConfig.js', () => ({
MODEL_TYPES: {
LORA: 'loras',
CHECKPOINT: 'checkpoints',
EMBEDDING: 'embeddings'
}
}));
vi.mock(API_FACTORY, () => ({ vi.mock(API_FACTORY, () => ({
getModelApiClient: vi.fn(), getModelApiClient: vi.fn(),
})); }));

View File

@@ -50,6 +50,13 @@ const getAllGraphNodes = vi.fn();
const getNodeFromGraph = vi.fn(); const getNodeFromGraph = vi.fn();
const getNodeKey = vi.fn(); const getNodeKey = vi.fn();
const getLinkFromGraph = vi.fn(); const getLinkFromGraph = vi.fn();
const getWidgetByName = vi.fn((node, name) =>
node?.widgets?.find((widget) => widget?.name === name) ?? null
);
const getWidgetSerializedValue = vi.fn((node, name) => {
const index = node?.widgets?.findIndex((widget) => widget?.name === name) ?? -1;
return index >= 0 ? node.widgets_values?.[index] : undefined;
});
const chainCallback = vi.fn((proto, property, callback) => { const chainCallback = vi.fn((proto, property, callback) => {
proto[property] = callback; proto[property] = callback;
}); });
@@ -68,6 +75,8 @@ vi.mock(UTILS_MODULE, async (importOriginal) => {
getNodeFromGraph, getNodeFromGraph,
getNodeKey, getNodeKey,
getLinkFromGraph, getLinkFromGraph,
getWidgetByName,
getWidgetSerializedValue,
}; };
}); });
@@ -98,6 +107,9 @@ describe("Node mode change handling", () => {
mergeLoras.mockClear(); mergeLoras.mockClear();
mergeLoras.mockImplementation(() => [{ name: "Alpha", active: true }]); mergeLoras.mockImplementation(() => [{ name: "Alpha", active: true }]);
getWidgetByName.mockClear();
getWidgetSerializedValue.mockClear();
addLorasWidget.mockClear(); addLorasWidget.mockClear();
addLorasWidget.mockImplementation((_node, _name, _opts, callback) => ({ addLorasWidget.mockImplementation((_node, _name, _opts, callback) => ({
widget: { value: [], callback }, widget: { value: [], callback },
@@ -119,8 +131,13 @@ describe("Node mode change handling", () => {
await extension.beforeRegisterNodeDef(nodeType, nodeData, {}); await extension.beforeRegisterNodeDef(nodeType, nodeData, {});
// Create widgets with proper structure for lora_stacker.js // Include a hidden metadata widget ahead of the actual text widget to match runtime ordering.
// Widget at index 0 is the AUTOCOMPLETE_TEXT_LORAS widget (created by Vue widgets) const metadataWidget = {
name: "__autocomplete_metadata_text",
value: { version: 1, textWidgetName: "text" },
options: {},
};
const inputWidget = { const inputWidget = {
name: "text", name: "text",
value: "", value: "",
@@ -139,7 +156,7 @@ describe("Node mode change handling", () => {
node = { node = {
comfyClass: "Lora Stacker (LoraManager)", comfyClass: "Lora Stacker (LoraManager)",
widgets: [inputWidget, lorasWidget], widgets: [metadataWidget, inputWidget, lorasWidget],
lorasWidget, lorasWidget,
addInput: vi.fn(), addInput: vi.fn(),
mode: 0, // Initial mode mode: 0, // Initial mode
@@ -189,11 +206,18 @@ describe("Node mode change handling", () => {
const nodeType = { comfyClass: "Lora Loader (LoraManager)", prototype: {} }; const nodeType = { comfyClass: "Lora Loader (LoraManager)", prototype: {} };
await extension.beforeRegisterNodeDef(nodeType, {}, {}); await extension.beforeRegisterNodeDef(nodeType, {}, {});
// Widget at index 0 is the AUTOCOMPLETE_TEXT_LORAS widget (created by Vue widgets) const metadataWidget = {
name: "__autocomplete_metadata_text",
value: { version: 1, textWidgetName: "text" },
options: {},
};
node = { node = {
comfyClass: "Lora Loader (LoraManager)", comfyClass: "Lora Loader (LoraManager)",
widgets: [ widgets: [
metadataWidget,
{ {
name: "text",
value: "", value: "",
options: {}, options: {},
callback: null, // Will be set by onNodeCreated callback: null, // Will be set by onNodeCreated

View File

@@ -0,0 +1,106 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
showToast: vi.fn(),
}));
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
translate: (_key, _params, fallback) => fallback ?? '',
}));
describe('RecipeDataManager tag input Enter behavior', () => {
beforeEach(() => {
vi.resetModules();
document.body.innerHTML = `
<input id="tagInput" type="text" />
<div id="tagsContainer"></div>
`;
});
it('adds a tag when pressing Enter in tag input', async () => {
const { RecipeDataManager } = await import('../../../static/js/managers/import/RecipeDataManager.js');
const importManager = {
recipeTags: [],
stepManager: { showStep: vi.fn() },
};
const manager = new RecipeDataManager(importManager);
manager.setupTagInputEnterHandler();
const tagInput = document.getElementById('tagInput');
tagInput.value = 'portrait';
tagInput.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
expect(importManager.recipeTags).toEqual(['portrait']);
expect(tagInput.value).toBe('');
expect(document.getElementById('tagsContainer').textContent).toContain('portrait');
});
it('does not register duplicate Enter handlers when setup runs multiple times', async () => {
const { RecipeDataManager } = await import('../../../static/js/managers/import/RecipeDataManager.js');
const importManager = {
recipeTags: [],
stepManager: { showStep: vi.fn() },
};
const manager = new RecipeDataManager(importManager);
manager.setupTagInputEnterHandler();
manager.setupTagInputEnterHandler();
const tagInput = document.getElementById('tagInput');
tagInput.value = 'anime';
tagInput.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
expect(importManager.recipeTags).toEqual(['anime']);
});
it('ignores Enter while IME composition is active', async () => {
const { RecipeDataManager } = await import('../../../static/js/managers/import/RecipeDataManager.js');
const importManager = {
recipeTags: [],
stepManager: { showStep: vi.fn() },
};
const manager = new RecipeDataManager(importManager);
manager.setupTagInputEnterHandler();
const tagInput = document.getElementById('tagInput');
tagInput.value = '未確定';
const event = new KeyboardEvent('keydown', {
key: 'Enter',
bubbles: true,
cancelable: true,
});
Object.defineProperty(event, 'isComposing', { value: true });
tagInput.dispatchEvent(event);
expect(importManager.recipeTags).toEqual([]);
expect(tagInput.value).toBe('未確定');
expect(event.defaultPrevented).toBe(false);
});
it('ignores keyCode 229 fallback during composition', async () => {
const { RecipeDataManager } = await import('../../../static/js/managers/import/RecipeDataManager.js');
const importManager = {
recipeTags: [],
stepManager: { showStep: vi.fn() },
};
const manager = new RecipeDataManager(importManager);
manager.setupTagInputEnterHandler();
const tagInput = document.getElementById('tagInput');
tagInput.value = '候補';
const event = new KeyboardEvent('keydown', {
key: 'Enter',
bubbles: true,
cancelable: true,
});
Object.defineProperty(event, 'keyCode', { value: 229 });
tagInput.dispatchEvent(event);
expect(importManager.recipeTags).toEqual([]);
expect(tagInput.value).toBe('候補');
expect(event.defaultPrevented).toBe(false);
});
});

View File

@@ -0,0 +1,182 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
vi.mock('../../../static/js/managers/ModalManager.js', () => ({
modalManager: {
closeModal: vi.fn(),
},
}));
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
showToast: vi.fn(),
}));
vi.mock('../../../static/js/state/index.js', () => {
const settings = {};
return {
state: {
global: {
settings,
},
},
createDefaultSettings: () => ({
language: 'en',
skip_previously_downloaded_model_versions: false,
download_skip_base_models: [],
}),
};
});
vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
resetAndReload: vi.fn(),
}));
vi.mock('../../../static/js/utils/constants.js', () => ({
DOWNLOAD_PATH_TEMPLATES: {},
DEFAULT_PATH_TEMPLATES: {},
MAPPABLE_BASE_MODELS: ['Flux.1 D', 'Pony', 'SDXL 1.0', 'Other'],
PATH_TEMPLATE_PLACEHOLDERS: {},
DEFAULT_PRIORITY_TAG_CONFIG: {
lora: 'character, style',
checkpoint: 'base, guide',
embedding: 'hint',
},
getMappableBaseModelsDynamic: () => ['Flux.1 D', 'Pony', 'SDXL 1.0', 'Other'],
}));
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
translate: (key, params, fallback) => {
if (key === 'settings.downloadSkipBaseModels.summary.none') {
return 'None selected';
}
if (key === 'settings.downloadSkipBaseModels.summary.count') {
return `${params?.count ?? 0} selected`;
}
return fallback ?? '';
},
}));
vi.mock('../../../static/js/i18n/index.js', () => ({
i18n: {
getCurrentLocale: () => 'en',
setLanguage: vi.fn().mockResolvedValue(),
},
}));
vi.mock('../../../static/js/components/shared/ModelCard.js', () => ({
configureModelCardVideo: vi.fn(),
}));
vi.mock('../../../static/js/managers/BannerService.js', () => ({
bannerService: {
registerBanner: vi.fn(),
},
}));
vi.mock('../../../static/js/components/SidebarManager.js', () => ({
sidebarManager: {
setSidebarEnabled: vi.fn().mockResolvedValue(),
},
}));
import { SettingsManager } from '../../../static/js/managers/SettingsManager.js';
import { state } from '../../../static/js/state/index.js';
const createManager = () => {
const initSettingsSpy = vi
.spyOn(SettingsManager.prototype, 'initializeSettings')
.mockResolvedValue();
const initializeSpy = vi
.spyOn(SettingsManager.prototype, 'initialize')
.mockImplementation(() => {});
const manager = new SettingsManager();
initSettingsSpy.mockRestore();
initializeSpy.mockRestore();
return manager;
};
const appendDownloadSkipUi = () => {
document.body.innerHTML = `
<button id="downloadSkipBaseModelsToggle" aria-expanded="false">
<span id="downloadSkipBaseModelsSummary"></span>
<span class="base-model-skip-toggle-label"></span>
</button>
<div id="downloadSkipBaseModelsPanel" hidden>
<input id="downloadSkipBaseModelsSearch" />
<button id="downloadSkipBaseModelsClear" type="button">Clear</button>
<div id="downloadSkipBaseModelsContainer"></div>
<div id="downloadSkipBaseModelsEmpty" hidden></div>
</div>
<div id="downloadSkipBaseModelsError"></div>
`;
};
describe('SettingsManager download skip base models UI', () => {
beforeEach(() => {
document.body.innerHTML = '';
vi.clearAllMocks();
state.global.settings = {
skip_previously_downloaded_model_versions: false,
download_skip_base_models: [],
};
});
it('renders a compact summary for selected base models', () => {
appendDownloadSkipUi();
state.global.settings.download_skip_base_models = ['Flux.1 D', 'Pony'];
const manager = createManager();
manager.renderDownloadSkipBaseModels();
expect(document.getElementById('downloadSkipBaseModelsSummary').textContent).toBe('Flux.1 D, Pony');
expect(document.querySelectorAll('#downloadSkipBaseModelsContainer input')).toHaveLength(3);
});
it('filters the list using the search input and shows an empty state', () => {
appendDownloadSkipUi();
state.global.settings.download_skip_base_models = ['Flux.1 D'];
const manager = createManager();
const searchInput = document.getElementById('downloadSkipBaseModelsSearch');
searchInput.value = 'pony';
manager.renderDownloadSkipBaseModels();
expect(document.querySelectorAll('#downloadSkipBaseModelsContainer input')).toHaveLength(1);
expect(document.querySelector('#downloadSkipBaseModelsContainer input').value).toBe('Pony');
searchInput.value = 'zzz';
manager.renderDownloadSkipBaseModels();
expect(document.querySelectorAll('#downloadSkipBaseModelsContainer input')).toHaveLength(0);
expect(document.getElementById('downloadSkipBaseModelsEmpty').hidden).toBe(false);
});
it('initializes the previously-downloaded-version toggle from settings', () => {
document.body.innerHTML = '<input id="skipPreviouslyDownloadedModelVersions" type="checkbox" />';
state.global.settings.skip_previously_downloaded_model_versions = true;
const manager = createManager();
manager.loadSettingsToUI();
expect(document.getElementById('skipPreviouslyDownloadedModelVersions').checked).toBe(true);
});
it('saves the previously-downloaded-version toggle with the expected setting key', async () => {
document.body.innerHTML = '<input id="skipPreviouslyDownloadedModelVersions" type="checkbox" checked />';
const manager = createManager();
manager.saveSetting = vi.fn().mockResolvedValue();
manager.applyFrontendSettings = vi.fn();
await manager.saveToggleSetting(
'skipPreviouslyDownloadedModelVersions',
'skip_previously_downloaded_model_versions',
);
expect(manager.saveSetting).toHaveBeenCalledWith(
'skip_previously_downloaded_model_versions',
true,
);
});
});

View File

@@ -42,6 +42,7 @@ vi.mock('../../../static/js/utils/constants.js', () => ({
checkpoint: 'base, guide', checkpoint: 'base, guide',
embedding: 'hint', embedding: 'hint',
}, },
getMappableBaseModelsDynamic: () => [],
})); }));
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({ vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({

View File

@@ -6,6 +6,7 @@ const initializePageFeaturesMock = vi.fn();
const getCurrentPageStateMock = vi.fn(); const getCurrentPageStateMock = vi.fn();
const getSessionItemMock = vi.fn(); const getSessionItemMock = vi.fn();
const removeSessionItemMock = vi.fn(); const removeSessionItemMock = vi.fn();
const getStorageItemMock = vi.fn();
const RecipeContextMenuMock = vi.fn(); const RecipeContextMenuMock = vi.fn();
const refreshVirtualScrollMock = vi.fn(); const refreshVirtualScrollMock = vi.fn();
const refreshRecipesMock = vi.fn(); const refreshRecipesMock = vi.fn();
@@ -51,6 +52,7 @@ vi.mock('../../../static/js/state/index.js', () => ({
vi.mock('../../../static/js/utils/storageHelpers.js', () => ({ vi.mock('../../../static/js/utils/storageHelpers.js', () => ({
getSessionItem: getSessionItemMock, getSessionItem: getSessionItemMock,
removeSessionItem: removeSessionItemMock, removeSessionItem: removeSessionItemMock,
getStorageItem: getStorageItemMock,
})); }));
vi.mock('../../../static/js/components/ContextMenu/index.js', () => ({ vi.mock('../../../static/js/components/ContextMenu/index.js', () => ({
@@ -117,11 +119,14 @@ describe('RecipeManager', () => {
const map = { const map = {
lora_to_recipe_filterLoraName: 'Flux Dream', lora_to_recipe_filterLoraName: 'Flux Dream',
lora_to_recipe_filterLoraHash: 'abc123', lora_to_recipe_filterLoraHash: 'abc123',
checkpoint_to_recipe_filterCheckpointName: null,
checkpoint_to_recipe_filterCheckpointHash: null,
viewRecipeId: '42', viewRecipeId: '42',
}; };
return map[key] ?? null; return map[key] ?? null;
}); });
removeSessionItemMock.mockImplementation(() => { }); removeSessionItemMock.mockImplementation(() => { });
getStorageItemMock.mockImplementation((_, defaultValue = null) => defaultValue);
renderRecipesPage(); renderRecipesPage();
@@ -166,6 +171,8 @@ describe('RecipeManager', () => {
active: true, active: true,
loraName: 'Flux Dream', loraName: 'Flux Dream',
loraHash: 'abc123', loraHash: 'abc123',
checkpointName: null,
checkpointHash: null,
recipeId: '42', recipeId: '42',
}); });
@@ -177,6 +184,8 @@ describe('RecipeManager', () => {
expect(removeSessionItemMock).toHaveBeenCalledWith('lora_to_recipe_filterLoraName'); expect(removeSessionItemMock).toHaveBeenCalledWith('lora_to_recipe_filterLoraName');
expect(removeSessionItemMock).toHaveBeenCalledWith('lora_to_recipe_filterLoraHash'); expect(removeSessionItemMock).toHaveBeenCalledWith('lora_to_recipe_filterLoraHash');
expect(removeSessionItemMock).toHaveBeenCalledWith('checkpoint_to_recipe_filterCheckpointName');
expect(removeSessionItemMock).toHaveBeenCalledWith('checkpoint_to_recipe_filterCheckpointHash');
expect(removeSessionItemMock).toHaveBeenCalledWith('viewRecipeId'); expect(removeSessionItemMock).toHaveBeenCalledWith('viewRecipeId');
expect(pageState.customFilter.active).toBe(false); expect(pageState.customFilter.active).toBe(false);
expect(indicator.classList.contains('hidden')).toBe(true); expect(indicator.classList.contains('hidden')).toBe(true);
@@ -227,4 +236,36 @@ describe('RecipeManager', () => {
await manager.refreshRecipes(); await manager.refreshRecipes();
expect(refreshRecipesMock).toHaveBeenCalledTimes(1); expect(refreshRecipesMock).toHaveBeenCalledTimes(1);
}); });
it('restores checkpoint recipe filter state and indicator text', async () => {
getSessionItemMock.mockImplementation((key) => {
const map = {
lora_to_recipe_filterLoraName: null,
lora_to_recipe_filterLoraHash: null,
checkpoint_to_recipe_filterCheckpointName: 'Flux Base',
checkpoint_to_recipe_filterCheckpointHash: 'ckpt123',
viewRecipeId: null,
};
return map[key] ?? null;
});
const manager = new RecipeManager();
await manager.initialize();
expect(pageState.customFilter).toEqual({
active: true,
loraName: null,
loraHash: null,
checkpointName: 'Flux Base',
checkpointHash: 'ckpt123',
recipeId: null,
});
const indicator = document.getElementById('customFilterIndicator');
const filterText = indicator.querySelector('#customFilterText');
expect(filterText.innerHTML).toContain('Recipes using checkpoint:');
expect(filterText.innerHTML).toContain('Flux Base');
expect(filterText.getAttribute('title')).toBe('Flux Base');
});
}); });

View File

@@ -15,7 +15,8 @@ describe('state module', () => {
expect(defaultSettings).toMatchObject({ expect(defaultSettings).toMatchObject({
civitai_api_key: '', civitai_api_key: '',
language: 'en', language: 'en',
blur_mature_content: true blur_mature_content: true,
mature_blur_level: 'R'
}); });
expect(defaultSettings.download_path_templates).toEqual(DEFAULT_PATH_TEMPLATES); expect(defaultSettings.download_path_templates).toEqual(DEFAULT_PATH_TEMPLATES);

View File

@@ -94,6 +94,37 @@ describe('civitaiUtils', () => {
expect(wasRewritten).toBe(false); expect(wasRewritten).toBe(false);
expect(rewritten).toBe('not-a-valid-url'); expect(rewritten).toBe('not-a-valid-url');
}); });
it('should rewrite URLs from CivitAI CDN subdomains', () => {
const originalUrl = 'https://image-b2.civitai.com/file/civitai-media-cache/original=true/sample.png';
const [rewritten, wasRewritten] = rewriteCivitaiUrl(originalUrl, 'image', OptimizationMode.THUMBNAIL);
expect(wasRewritten).toBe(true);
expect(rewritten).toBe('https://image-b2.civitai.com/file/civitai-media-cache/width=450,optimized=true/sample.png');
});
it('should handle URLs with explicit port numbers', () => {
const originalUrl = 'https://image.civitai.com:443/checkpoints/original=true/test.png';
const [rewritten, wasRewritten] = rewriteCivitaiUrl(originalUrl, 'image', OptimizationMode.THUMBNAIL);
expect(wasRewritten).toBe(true);
// JavaScript URL.toString() removes default HTTPS port (443)
expect(rewritten).toBe('https://image.civitai.com/checkpoints/width=450,optimized=true/test.png');
});
it('should handle case-insensitive hostnames', () => {
const testCases = [
'https://IMAGE.CIVITAI.COM/original=true/test.png',
'https://Image.Civitai.Com/original=true/test.png',
'https://image-b2.CIVITAI.com/original=true/test.png',
];
for (const url of testCases) {
const [rewritten, wasRewritten] = rewriteCivitaiUrl(url, 'image', OptimizationMode.THUMBNAIL);
expect(wasRewritten).toBe(true);
expect(rewritten).toContain('width=450,optimized=true');
}
});
}); });
describe('getOptimizedUrl', () => { describe('getOptimizedUrl', () => {
@@ -157,6 +188,23 @@ describe('civitaiUtils', () => {
expect(isCivitaiUrl('https://image.civitai.com/')).toBe(true); expect(isCivitaiUrl('https://image.civitai.com/')).toBe(true);
}); });
it('should return true for CivitAI CDN subdomains', () => {
expect(isCivitaiUrl('https://image-b2.civitai.com/file/test.png')).toBe(true);
expect(isCivitaiUrl('https://image-b3.civitai.com/test.jpg')).toBe(true);
expect(isCivitaiUrl('https://cdn.civitai.com/test.png')).toBe(true);
});
it('should return true for CivitAI URLs with explicit ports', () => {
expect(isCivitaiUrl('https://image.civitai.com:443/test.png')).toBe(true);
expect(isCivitaiUrl('https://image-b2.civitai.com:443/file/test.jpg')).toBe(true);
});
it('should handle case-insensitive hostnames', () => {
expect(isCivitaiUrl('https://IMAGE.CIVITAI.COM/test.png')).toBe(true);
expect(isCivitaiUrl('https://Image.Civitai.Com/test.png')).toBe(true);
expect(isCivitaiUrl('https://image-b2.CIVITAI.com/test.png')).toBe(true);
});
it('should return false for non-CivitAI URLs', () => { it('should return false for non-CivitAI URLs', () => {
expect(isCivitaiUrl('https://example.com/image.jpg')).toBe(false); expect(isCivitaiUrl('https://example.com/image.jpg')).toBe(false);
expect(isCivitaiUrl('https://civitai.com/image.jpg')).toBe(false); expect(isCivitaiUrl('https://civitai.com/image.jpg')).toBe(false);

View File

@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest';
import { NSFW_LEVELS, getMatureBlurThreshold } from '../../../static/js/utils/constants.js';
describe('getMatureBlurThreshold', () => {
it('returns configured PG13 threshold', () => {
expect(getMatureBlurThreshold({ mature_blur_level: 'PG13' })).toBe(NSFW_LEVELS.PG13);
});
it('normalizes lowercase values', () => {
expect(getMatureBlurThreshold({ mature_blur_level: 'x' })).toBe(NSFW_LEVELS.X);
});
it('falls back to R when value is invalid or missing', () => {
expect(getMatureBlurThreshold({ mature_blur_level: 'invalid' })).toBe(NSFW_LEVELS.R);
expect(getMatureBlurThreshold({})).toBe(NSFW_LEVELS.R);
});
});

View File

@@ -0,0 +1,151 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { APP_MODULE, UTILS_MODULE } = vi.hoisted(() => ({
APP_MODULE: new URL("../../../scripts/app.js", import.meta.url).pathname,
UTILS_MODULE: new URL("../../../web/comfyui/utils.js", import.meta.url).pathname,
}));
vi.mock(APP_MODULE, () => ({
app: {
graph: null,
registerExtension: vi.fn(),
ui: {
settings: {
getSettingValue: vi.fn(),
},
},
},
}));
describe("LoRA chain traversal", () => {
let collectActiveLorasFromChain;
beforeEach(async () => {
vi.resetModules();
({ collectActiveLorasFromChain } = await import(UTILS_MODULE));
});
function createGraph(nodes, links) {
const graph = {
_nodes: nodes,
links,
getNodeById(id) {
return nodes.find((node) => node.id === id) ?? null;
},
};
nodes.forEach((node) => {
node.graph = graph;
});
return graph;
}
it("aggregates active LoRAs through a combiner with multiple LORA_STACK inputs", () => {
const randomizerA = {
id: 1,
comfyClass: "Lora Randomizer (LoraManager)",
mode: 0,
widgets: [
{
name: "loras",
value: [
{ name: "Alpha", active: true },
{ name: "Ignored", active: false },
],
},
],
inputs: [],
outputs: [],
};
const randomizerB = {
id: 2,
comfyClass: "Lora Randomizer (LoraManager)",
mode: 0,
widgets: [
{
name: "loras",
value: [{ name: "Beta", active: true }],
},
],
inputs: [],
outputs: [],
};
const combiner = {
id: 3,
comfyClass: "Lora Stack Combiner (LoraManager)",
mode: 0,
widgets: [],
inputs: [
{ name: "lora_stack_a", type: "LORA_STACK", link: 11 },
{ name: "lora_stack_b", type: "LORA_STACK", link: 12 },
],
outputs: [],
};
const loader = {
id: 4,
comfyClass: "Lora Loader (LoraManager)",
mode: 0,
widgets: [],
inputs: [{ name: "lora_stack", type: "LORA_STACK", link: 13 }],
outputs: [],
};
createGraph(
[randomizerA, randomizerB, combiner, loader],
{
11: { origin_id: 1, target_id: 3 },
12: { origin_id: 2, target_id: 3 },
13: { origin_id: 3, target_id: 4 },
}
);
const result = collectActiveLorasFromChain(loader);
expect([...result]).toEqual(["Alpha", "Beta"]);
});
it("stops propagation when the combiner is inactive", () => {
const randomizer = {
id: 1,
comfyClass: "Lora Randomizer (LoraManager)",
mode: 0,
widgets: [
{
name: "loras",
value: [{ name: "Alpha", active: true }],
},
],
inputs: [],
outputs: [],
};
const combiner = {
id: 2,
comfyClass: "Lora Stack Combiner (LoraManager)",
mode: 2,
widgets: [],
inputs: [{ name: "lora_stack_a", type: "LORA_STACK", link: 21 }],
outputs: [],
};
const loader = {
id: 3,
comfyClass: "Lora Loader (LoraManager)",
mode: 0,
widgets: [],
inputs: [{ name: "lora_stack", type: "LORA_STACK", link: 22 }],
outputs: [],
};
createGraph(
[randomizer, combiner, loader],
{
21: { origin_id: 1, target_id: 2 },
22: { origin_id: 2, target_id: 3 },
}
);
const result = collectActiveLorasFromChain(loader);
expect(result.size).toBe(0);
});
});

View File

@@ -2,7 +2,10 @@ import pytest
from aiohttp import web from aiohttp import web
from aiohttp.test_utils import make_mocked_request from aiohttp.test_utils import make_mocked_request
from py.middleware.csp_middleware import REMOTE_MEDIA_SOURCES, relax_csp_for_remote_media from py.middleware.csp_middleware import (
REMOTE_MEDIA_SOURCES,
relax_csp_for_remote_media,
)
DEFAULT_CSP = ( DEFAULT_CSP = (
"default-src 'self'; " "default-src 'self'; "
@@ -40,7 +43,9 @@ async def _invoke_middleware(
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_relax_csp_appends_remote_sources_and_preserves_existing_directives() -> None: async def test_relax_csp_appends_remote_sources_and_preserves_existing_directives() -> (
None
):
response = await _invoke_middleware("/some-path", web.Response()) response = await _invoke_middleware("/some-path", web.Response())
header_value = response.headers.get("Content-Security-Policy") header_value = response.headers.get("Content-Security-Policy")
assert header_value is not None assert header_value is not None
@@ -48,16 +53,17 @@ async def test_relax_csp_appends_remote_sources_and_preserves_existing_directive
directives = _parse_directives(header_value) directives = _parse_directives(header_value)
# Existing directives remain intact # Existing directives remain intact
assert directives["script-src"] == ["'self'", "'unsafe-inline'", "'unsafe-eval'", "blob:"] assert directives["script-src"] == [
"'self'",
"'unsafe-inline'",
"'unsafe-eval'",
"blob:",
]
assert directives["img-src"][:3] == ["'self'", "data:", "blob:"] assert directives["img-src"][:3] == ["'self'", "data:", "blob:"]
# Remote media hosts are added once to the relevant directives # Remote media hosts are added once to the relevant directives
for source in REMOTE_MEDIA_SOURCES: for source in REMOTE_MEDIA_SOURCES:
assert source in directives["img-src"] assert source in directives["img-src"]
assert "media-src" in directives
assert directives["media-src"][0] == "'self'"
for source in REMOTE_MEDIA_SOURCES:
assert source in directives["media-src"] assert source in directives["media-src"]

View File

@@ -0,0 +1,109 @@
"""Tests for preset strength behavior in LoraCyclerLM."""
from unittest.mock import AsyncMock
import pytest
from py.nodes.lora_cycler import LoraCyclerLM
from py.services import service_registry
@pytest.fixture
def cycler_node():
return LoraCyclerLM()
@pytest.fixture
def cycler_config():
return {
"current_index": 1,
"model_strength": 0.8,
"clip_strength": 0.6,
"use_same_clip_strength": False,
"use_preset_strength": True,
"preset_strength_scale": 1.5,
"include_no_lora": False,
}
@pytest.mark.asyncio
async def test_cycler_uses_scaled_preset_strength_when_available(
cycler_node, cycler_config, mock_scanner, monkeypatch
):
monkeypatch.setattr(
service_registry.ServiceRegistry,
"get_lora_scanner",
AsyncMock(return_value=mock_scanner),
)
mock_scanner._cache.raw_data = [
{
"file_name": "preset_lora.safetensors",
"file_path": "/models/loras/preset_lora.safetensors",
"folder": "",
"usage_tips": '{"strength": 0.7, "clipStrength": 0.5}',
}
]
result = await cycler_node.cycle(cycler_config)
assert result["result"][0] == [
("/models/loras/preset_lora.safetensors", 1.05, 0.75)
]
@pytest.mark.asyncio
async def test_cycler_falls_back_to_manual_strength_when_preset_missing(
cycler_node, cycler_config, mock_scanner, monkeypatch
):
monkeypatch.setattr(
service_registry.ServiceRegistry,
"get_lora_scanner",
AsyncMock(return_value=mock_scanner),
)
mock_scanner._cache.raw_data = [
{
"file_name": "manual_lora.safetensors",
"file_path": "/models/loras/manual_lora.safetensors",
"folder": "",
"usage_tips": "",
}
]
result = await cycler_node.cycle(cycler_config)
assert result["result"][0] == [
("/models/loras/manual_lora.safetensors", 0.8, 0.6)
]
@pytest.mark.asyncio
async def test_cycler_syncs_clip_to_model_when_same_clip_strength_enabled(
cycler_node, cycler_config, mock_scanner, monkeypatch
):
monkeypatch.setattr(
service_registry.ServiceRegistry,
"get_lora_scanner",
AsyncMock(return_value=mock_scanner),
)
mock_scanner._cache.raw_data = [
{
"file_name": "preset_lora.safetensors",
"file_path": "/models/loras/preset_lora.safetensors",
"folder": "",
"usage_tips": '{"strength": 0.7, "clipStrength": 0.3}',
}
]
result = await cycler_node.cycle(
{
**cycler_config,
"use_same_clip_strength": True,
}
)
assert result["result"][0] == [
("/models/loras/preset_lora.safetensors", 1.05, 1.05)
]

View File

@@ -0,0 +1,201 @@
import types
import pytest
from py.nodes.lora_loader import LoraLoaderLM, LoraTextLoaderLM
class _ModelContainer:
def __init__(self, diffusion_model):
self.diffusion_model = diffusion_model
class _Model:
def __init__(self, diffusion_model):
self.model = _ModelContainer(diffusion_model)
def test_lora_loader_standard_model_uses_comfy_loader(monkeypatch):
loader = LoraLoaderLM()
model = _Model(object())
clip = object()
monkeypatch.setattr(
"py.nodes.lora_loader.get_lora_info_absolute",
lambda name: (f"/abs/{name}.safetensors", [f"{name}_trigger"]),
)
load_calls = []
def mock_load_torch_file(path, safe_load=True):
load_calls.append((path, safe_load))
return {"path": path}
def mock_load_lora_for_models(model_arg, clip_arg, lora_arg, model_strength, clip_strength):
return model_arg, clip_arg
monkeypatch.setattr("comfy.utils.load_torch_file", mock_load_torch_file)
monkeypatch.setattr("comfy.sd.load_lora_for_models", mock_load_lora_for_models)
result_model, result_clip, trigger_words, loaded_loras = loader.load_loras(
model,
"",
clip=clip,
loras={
"__value__": [
{"active": True, "name": "demo", "strength": 0.75, "clipStrength": 0.5},
]
},
)
assert result_model is model
assert result_clip is clip
assert load_calls == [("/abs/demo.safetensors", True)]
assert trigger_words == "demo_trigger"
assert loaded_loras == "<lora:demo:0.75:0.5>"
def test_lora_loader_formats_widget_lora_names_with_colons(monkeypatch):
loader = LoraLoaderLM()
model = _Model(object())
clip = object()
monkeypatch.setattr(
"py.nodes.lora_loader.get_lora_info_absolute",
lambda name: (f"/abs/{name}.safetensors", [f"{name}_trigger"]),
)
monkeypatch.setattr("comfy.utils.load_torch_file", lambda path, safe_load=True: {"path": path})
monkeypatch.setattr(
"comfy.sd.load_lora_for_models",
lambda model_arg, clip_arg, lora_arg, model_strength, clip_strength: (model_arg, clip_arg),
)
_, _, trigger_words, loaded_loras = loader.load_loras(
model,
"",
clip=clip,
loras={
"__value__": [
{"active": True, "name": "demo:variant", "strength": 0.75, "clipStrength": 0.5},
{"active": True, "name": "demo:single", "strength": 0.3},
]
},
)
assert trigger_words == "demo:variant_trigger,, demo:single_trigger"
assert loaded_loras == "<lora:demo:variant:0.75:0.5> <lora:demo:single:0.3>"
def test_lora_loader_flux_model_uses_flux_helper(monkeypatch):
flux_model = _Model(type("ComfyFluxWrapper", (), {})())
loader = LoraLoaderLM()
monkeypatch.setattr(
"py.nodes.lora_loader.get_lora_info_absolute",
lambda name: (f"/abs/{name}.safetensors", [f"{name}_trigger"]),
)
calls = []
def mock_nunchaku_load_lora(model_arg, lora_name, strength):
calls.append((lora_name, strength))
return model_arg
monkeypatch.setattr("py.nodes.lora_loader.nunchaku_load_lora", mock_nunchaku_load_lora)
_, _, trigger_words, loaded_loras = loader.load_loras(
flux_model,
"",
lora_stack=[("stack_lora.safetensors", 0.4, 0.2)],
loras={"__value__": [{"active": True, "name": "widget_lora", "strength": 0.8}]},
)
assert calls == [("stack_lora.safetensors", 0.4), ("/abs/widget_lora.safetensors", 0.8)]
assert trigger_words == "stack_lora_trigger,, widget_lora_trigger"
assert loaded_loras == "<lora:stack_lora:0.4> <lora:widget_lora:0.8>"
def test_lora_loader_qwen_model_batches_loras(monkeypatch):
qwen_model = _Model(type("NunchakuQwenImageTransformer2DModel", (), {})())
loader = LoraLoaderLM()
monkeypatch.setattr(
"py.nodes.lora_loader.get_lora_info_absolute",
lambda name: (f"/abs/{name}.safetensors", [f"{name}_trigger"]),
)
batched_calls = []
def mock_nunchaku_load_qwen_loras(model_arg, lora_configs):
batched_calls.append((model_arg, lora_configs))
return model_arg
monkeypatch.setattr("py.nodes.lora_loader._get_nunchaku_load_qwen_loras", lambda: mock_nunchaku_load_qwen_loras)
_, result_clip, trigger_words, loaded_loras = loader.load_loras(
qwen_model,
"",
clip="clip",
lora_stack=[("stack_qwen.safetensors", 0.6, 0.1)],
loras={"__value__": [{"active": True, "name": "widget_qwen", "strength": 0.9, "clipStrength": 0.3}]},
)
assert result_clip == "clip"
assert len(batched_calls) == 1
assert batched_calls[0][0] is qwen_model
assert batched_calls[0][1] == [
("/abs/stack_qwen.safetensors", 0.6),
("/abs/widget_qwen.safetensors", 0.9),
]
assert trigger_words == "stack_qwen_trigger,, widget_qwen_trigger"
assert loaded_loras == "<lora:stack_qwen:0.6> <lora:widget_qwen:0.9>"
def test_lora_text_loader_qwen_batches_text_and_stack(monkeypatch):
qwen_model = _Model(type("NunchakuQwenImageTransformer2DModel", (), {})())
loader = LoraTextLoaderLM()
monkeypatch.setattr(
"py.nodes.lora_loader.get_lora_info_absolute",
lambda name: (f"/abs/{name}.safetensors", [f"{name}_trigger"]),
)
batched_calls = []
monkeypatch.setattr(
"py.nodes.lora_loader._get_nunchaku_load_qwen_loras",
lambda: (lambda model_arg, lora_configs: batched_calls.append(lora_configs) or model_arg),
)
_, _, trigger_words, loaded_loras = loader.load_loras_from_text(
qwen_model,
"<lora:text_qwen:1.2:0.4>",
clip="clip",
lora_stack=[("stack_qwen.safetensors", 0.6, 0.1)],
)
assert batched_calls == [[("/abs/stack_qwen.safetensors", 0.6), ("/abs/text_qwen.safetensors", 1.2)]]
assert trigger_words == "stack_qwen_trigger,, text_qwen_trigger"
assert loaded_loras == "<lora:stack_qwen:0.6> <lora:text_qwen:1.2>"
def test_lora_loader_qwen_model_raises_clear_error_when_helper_import_fails(monkeypatch):
qwen_model = _Model(type("NunchakuQwenImageTransformer2DModel", (), {})())
loader = LoraLoaderLM()
monkeypatch.setattr(
"py.nodes.lora_loader.get_lora_info_absolute",
lambda name: (f"/abs/{name}.safetensors", [f"{name}_trigger"]),
)
monkeypatch.setattr(
"py.nodes.lora_loader._get_nunchaku_load_qwen_loras",
lambda: (_ for _ in ()).throw( # pragma: no branch
RuntimeError("Qwen-Image LoRA loading requires the ComfyUI runtime with its torch dependency available.")
),
)
with pytest.raises(RuntimeError, match="Qwen-Image LoRA loading requires the ComfyUI runtime"):
loader.load_loras(
qwen_model,
"",
lora_stack=[("stack_qwen.safetensors", 0.6, 0.1)],
)

View File

@@ -0,0 +1,51 @@
from py.nodes.lora_stack_combiner import LoraStackCombinerLM
def test_combine_stacks_preserves_order():
node = LoraStackCombinerLM()
stack_a = [
("folder/a.safetensors", 0.7, 0.6),
("folder/b.safetensors", 0.8, 0.8),
]
stack_b = [
("folder/c.safetensors", 1.0, 0.9),
]
(combined_stack,) = node.combine_stacks(stack_a, stack_b)
assert combined_stack == stack_a + stack_b
def test_combine_stacks_returns_second_when_first_empty():
node = LoraStackCombinerLM()
stack_b = [("folder/c.safetensors", 1.0, 0.9)]
(combined_stack,) = node.combine_stacks([], stack_b)
assert combined_stack == stack_b
def test_combine_stacks_returns_first_when_second_empty():
node = LoraStackCombinerLM()
stack_a = [("folder/a.safetensors", 0.7, 0.6)]
(combined_stack,) = node.combine_stacks(stack_a, [])
assert combined_stack == stack_a
def test_combine_stacks_returns_empty_when_both_empty():
node = LoraStackCombinerLM()
(combined_stack,) = node.combine_stacks([], [])
assert combined_stack == []
def test_combine_stacks_allows_duplicate_entries():
node = LoraStackCombinerLM()
duplicate_entry = ("folder/shared.safetensors", 0.9, 0.5)
(combined_stack,) = node.combine_stacks([duplicate_entry], [duplicate_entry])
assert combined_stack == [duplicate_entry, duplicate_entry]

View File

@@ -0,0 +1,153 @@
import json
import numpy as np
import piexif
from PIL import Image
from py.nodes.save_image import SaveImageLM
class _DummyTensor:
def __init__(self, array):
self._array = array
self.shape = array.shape
def cpu(self):
return self
def numpy(self):
return self._array
def _make_image():
return _DummyTensor(
np.array(
[
[[0.0, 0.1, 0.2], [0.3, 0.4, 0.5]],
[[0.6, 0.7, 0.8], [0.9, 1.0, 0.0]],
],
dtype="float32",
)
)
def _configure_save_paths(monkeypatch, tmp_path):
monkeypatch.setattr("folder_paths.get_output_directory", lambda: str(tmp_path), raising=False)
monkeypatch.setattr(
"folder_paths.get_save_image_path",
lambda *_args, **_kwargs: (str(tmp_path), "sample", 1, "", "sample"),
raising=False,
)
def _configure_metadata(monkeypatch, metadata_dict):
monkeypatch.setattr("py.nodes.save_image.get_metadata", lambda: {"raw": "metadata"})
monkeypatch.setattr(
"py.nodes.save_image.MetadataProcessor.to_dict",
lambda raw_metadata, node_id: metadata_dict,
)
def test_save_image_defaults_to_writing_png_metadata(monkeypatch, tmp_path):
_configure_save_paths(monkeypatch, tmp_path)
_configure_metadata(monkeypatch, {"prompt": "prompt text", "seed": 123})
node = SaveImageLM()
node.save_images([_make_image()], "ComfyUI", "png", id="node-1")
image_path = tmp_path / "sample_00001_.png"
with Image.open(image_path) as img:
assert img.info["parameters"] == "prompt text\nSeed: 123"
def test_save_image_skips_png_parameters_when_metadata_disabled_and_keeps_workflow(
monkeypatch, tmp_path
):
_configure_save_paths(monkeypatch, tmp_path)
_configure_metadata(monkeypatch, {"prompt": "prompt text", "seed": 123})
node = SaveImageLM()
workflow = {"nodes": [{"id": 1}]}
node.save_images(
[_make_image()],
"ComfyUI",
"png",
id="node-1",
embed_workflow=True,
extra_pnginfo={"workflow": workflow},
save_with_metadata=False,
)
image_path = tmp_path / "sample_00001_.png"
with Image.open(image_path) as img:
assert "parameters" not in img.info
assert img.info["workflow"] == json.dumps(workflow)
def test_save_image_skips_jpeg_metadata_when_disabled(monkeypatch, tmp_path):
_configure_save_paths(monkeypatch, tmp_path)
_configure_metadata(monkeypatch, {"prompt": "prompt text", "seed": 123})
node = SaveImageLM()
node.save_images(
[_make_image()],
"ComfyUI",
"jpeg",
id="node-1",
save_with_metadata=False,
)
image_path = tmp_path / "sample_00001_.jpg"
exif_dict = piexif.load(str(image_path))
assert piexif.ExifIFD.UserComment not in exif_dict.get("Exif", {})
def test_save_image_skips_webp_metadata_when_disabled(monkeypatch, tmp_path):
_configure_save_paths(monkeypatch, tmp_path)
_configure_metadata(monkeypatch, {"prompt": "prompt text", "seed": 123})
node = SaveImageLM()
node.save_images(
[_make_image()],
"ComfyUI",
"webp",
id="node-1",
save_with_metadata=False,
)
image_path = tmp_path / "sample_00001_.webp"
exif_dict = piexif.load(str(image_path))
assert piexif.ExifIFD.UserComment not in exif_dict.get("Exif", {})
def test_process_image_returns_passthrough_result_and_ui_images(monkeypatch, tmp_path):
_configure_save_paths(monkeypatch, tmp_path)
_configure_metadata(monkeypatch, {"prompt": "prompt text", "seed": 123})
images = [_make_image()]
node = SaveImageLM()
result = node.process_image(images, id="node-1")
assert result["result"] == (images,)
assert result["ui"] == {
"images": [{"filename": "sample_00001_.png", "subfolder": "", "type": "output"}]
}
def test_process_image_returns_empty_ui_images_when_save_fails(monkeypatch, tmp_path):
_configure_save_paths(monkeypatch, tmp_path)
_configure_metadata(monkeypatch, {"prompt": "prompt text", "seed": 123})
def _raise_save_error(*args, **kwargs):
raise OSError("disk full")
monkeypatch.setattr(Image.Image, "save", _raise_save_error)
images = [_make_image()]
node = SaveImageLM()
result = node.process_image(images, id="node-1")
assert result["result"] == (images,)
assert result["ui"] == {"images": []}

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