Compare commits

..

25 Commits

Author SHA1 Message Date
Will Miao ce8a95abf7 chore(release): bump version to v1.1.9 2026-07-21 22:22:39 +08:00
Will Miao c8e7e543d6 fix(api): remove overstrict model type validation in getApiEndpoints
The validation in getApiEndpoints threw for page types not in
MODEL_TYPES (e.g. 'recipes'), crashing the recipes page initialization
when FilterManager calls it via createBaseModelTags(). The throw was
synchronous and outside the fetch().catch() chain, causing an uncaught
promise rejection that aborted the entire app initialization.

getApiEndpoints is a URL builder -- validation belongs to callers that
need strict type checking (they already use isValidModelType()). For
non-model-type pages like recipes, the generated URLs are correct
(the backend does have /api/lm/recipes/* routes).

Fixes regression from f53f859a (feat(filter): add debounced tag search).
2026-07-21 22:09:30 +08:00
Will Miao a9dbb15ffa fix(create_hook_lora): lazy import comfy.hooks/comfy.utils to fix CI pipeline (#744) 2026-07-21 18:44:04 +08:00
Will Miao cf64043f7d fix(security): add library root containment check for delete/move/rename operations (#1028) 2026-07-21 15:23:38 +08:00
Will Miao ccaff92c18 fix(nodes): register Create Hook LoRA node in workflow target registries 2026-07-21 14:56:39 +08:00
Will Miao 585b5c922a feat(nodes): add Create Hook LoRA (LoraManager) node for multi-LoRA hook pipelines 2026-07-21 09:44:28 +08:00
Will Miao ea80c2224c fix(download): prevent path traversal in download template resolution (#1028) 2026-07-20 21:08:43 +08:00
willmiao 8b0f56c1a6 docs: auto-update supporters list in README 2026-07-20 12:42:20 +00:00
Will Miao 8022d12f03 chore(release): bump version to v1.1.8 2026-07-20 20:42:04 +08:00
Will Miao 3939f7f91b chore: Update lora manager basic example workflow 2026-07-20 20:20:35 +08:00
Will Miao aebf2e37dd fix(filter): apply preset on full tile click and suppress i18n double-translate warnings
- Move preset apply handler from span.preset-name to div.filter-preset so
  clicking anywhere on the tile triggers the preset, not just the label text.
- Add whitespace heuristic in showToast() to skip translate() for plain
  messages that are already translated at the call site. This prevents
  i18next from logging 'Translation key not found' for pre-translated
  strings like 'Preset "name" applied'.
2026-07-20 17:57:14 +08:00
Will Miao f53f859a71 feat(filter): add debounced tag search with backend search-tags endpoint 2026-07-20 17:37:47 +08:00
Will Miao d916375abe fix(checkpoint): populate hash index from pre-computed metadata to prevent repeated hash re-calculation (#1002) 2026-07-20 12:24:54 +08:00
Will Miao 57983df4bd fix(recipe): resolve recipe metadata update bugs in cache sort, allowed fields, and bulk API routing
- Use safe .get() in RecipeCache._resort_locked instead of itemgetter to prevent KeyError when recipe missing created_date; align sort key with _sort_cache_sync (prefer modified, fallback created_date, fallback 0)
- Add base_model to allowed_fields in persistence_service.update_recipe() so the field passes validation
- Route bulk base model updates through updateRecipeMetadata() on recipes page instead of generic saveModelMetadata(), matching existing isRecipesPage pattern used in setBulkFavorites and saveBulkTags
2026-07-20 11:20:06 +08:00
Will Miao c68d7559a0 fix(widget): correct reorder drop indicator position when container is scrolled
The drop indicator top position was calculated using only
getBoundingClientRect() offsets (post-CSS-transform viewport space)
without accounting for container.scrollTop (pre-transform layout space).
This caused the indicator to drift upward as the user scrolled down,
eventually disappearing entirely.

Fixed by adding container.scrollTop to the position calculation and
only dividing the GBCR visual-diff portion by scale, since scrollTop
is already in pre-transform coordinate space.
2026-07-19 22:40:07 +08:00
Will Miao 9a8f5bf2d6 fix(ui): reposition download settings before AI provider section 2026-07-19 17:57:02 +08:00
Will Miao a8d742b031 feat(metadata): add CivArchive API toggle and provider fallback order settings
- Add enable_civarchive_api toggle (default on) to allow disabling
  CivArchive to avoid its rate-limit windows entirely
- Add metadata_provider_order dropdown with two presets:
  CivitAI → CivArchive → Archive DB (default) and
  CivitAI → Archive DB → CivArchive
- Wire both settings through backend (metadata_service, settings_manager,
  misc_handlers) and frontend (SettingsManager, state, settings modal)
- Reorder Metadata section in settings modal: toggles → status/management
  → fallback order, for natural top-down workflow
- Make update_metadata_providers() log the effective provider chain
  using actually-registered providers rather than settings assumptions
- Add 5 test cases covering all provider-combination paths
- Complete i18n translations for 6 new keys across all 9 non-English locales
2026-07-19 17:51:50 +08:00
Will Miao c27e4d1bfc feat(cache): opportunistic cache sync on metadata read with in-place update
- Add PersistentModelCache.update_single_model() for lightweight targeted
  SQL update (single row + incremental tag/hash deltas, no full table scan)
- Add ModelScanner.sync_cache_from_metadata() with compare-first logic:
  skips entirely when cache is already in sync; when stale, updates the
  entry in-place (O(1) instead of O(n) remove+append), incrementally
  adjusts tag counts/hash index/version index, and resorts only when
  sort-relevant fields changed
- Wire sync_cache_from_metadata() into BaseModelService.get_model_metadata()
  via fire-and-forget asyncio.create_task — disk I/O is already paid for
- Include identity re-validation guard against concurrent cache replacement
- Add 16 tests covering _cache_entries_differ, sync_cache_from_metadata
  (no-change, in-place, fallback, conditional resort), and
  update_single_model (insert, tag delta, hash delta)
2026-07-19 08:32:21 +08:00
Will Miao d15a8aa9a2 fix(workflow): accept non-string widget values and support GlobalSeed node in gen-params (#1026) 2026-07-18 22:57:20 +08:00
Will Miao 74a7d12ca4 fix(test): add missing options mock in LoraInfoWidget test 2026-07-18 22:14:03 +08:00
Will Miao 2f94a9773e feat(workflow): redirect gen-params updates to connected Primitive nodes (#1026)
When a KSampler marked as 'Send Gen Params Target' has widget inputs
wired to Primitive nodes (PrimitiveNode, PrimitiveInt, PrimitiveFloat,
etc.), sending gen params from the Lora Manager UI now updates the
Primitive node's value instead of the KSampler widget. This is
necessary because ComfyUI's execution engine reads from the connected
input, ignoring the widget value when a wire is present.

Also fix two minor issues found during review:
- Remove unnecessary String() wrapping on numeric gen params (seed,
  steps, cfg) to preserve native types through the JSON/WS path
- Correct misleading isNodeEnabled comment: LGraphEventMode values
  are 0=Always, 2=Never, 4=Bypass (not 'Normal/Enabled')
2026-07-18 22:10:48 +08:00
Will Miao 37bdfa21ea fix(standalone): ensure sys.path includes script dir for python_embeded compatibility (#1025) 2026-07-18 21:22:25 +08:00
Will Miao f0bf2728c9 fix(downloads): accept download_id in history delete/retry endpoints, add unique index 2026-07-18 21:10:42 +08:00
Will Miao dc715aa273 fix(download): fallback to downloadUrl when all mirrors are deleted
When Civitai returns 404 for /models/{id} (e.g. due to Civitai API bug
where un-deleted models still get 404), the fallback to CivArchive
provides metadata.  However CivArchive may return mirrors with every
entry marked deletedAt, while the file's downloadUrl is still valid.

Before this fix, _build_download_urls_from_file_info used an if/else
that skipped the downloadUrl fallback whenever the mirrors array was
non-empty, even when all mirrors were filtered out.  Now downloadUrl
is always tried when no usable mirror remains.

Also deduplicated the inline mirror-processing code at the second call
site by replacing it with a call to the shared helper.
2026-07-18 18:28:32 +08:00
Will Miao 7ee2361e87 fix(config): remove stale 'default' library entry and consolidate example images on startup 2026-07-18 17:25:36 +08:00
71 changed files with 22784 additions and 20077 deletions
+2 -2
View File
File diff suppressed because one or more lines are too long
+5
View File
@@ -17,6 +17,7 @@ try: # pragma: no cover - import fallback for pytest collection
from .py.nodes.lora_cycler import LoraCyclerLM from .py.nodes.lora_cycler import LoraCyclerLM
from .py.nodes.lora_info import LoraInfoLM from .py.nodes.lora_info import LoraInfoLM
from .py.nodes.lora_syntax_to_path import LoraSyntaxToPath from .py.nodes.lora_syntax_to_path import LoraSyntaxToPath
from .py.nodes.create_hook_lora import CreateHookLoraLM
from .py.metadata_collector import init as init_metadata_collector from .py.metadata_collector import init as init_metadata_collector
except ( except (
ImportError ImportError
@@ -62,6 +63,9 @@ except (
LoraSyntaxToPath = importlib.import_module( LoraSyntaxToPath = importlib.import_module(
"py.nodes.lora_syntax_to_path" "py.nodes.lora_syntax_to_path"
).LoraSyntaxToPath ).LoraSyntaxToPath
CreateHookLoraLM = importlib.import_module(
"py.nodes.create_hook_lora"
).CreateHookLoraLM
init_metadata_collector = importlib.import_module("py.metadata_collector").init init_metadata_collector = importlib.import_module("py.metadata_collector").init
NODE_CLASS_MAPPINGS = { NODE_CLASS_MAPPINGS = {
@@ -83,6 +87,7 @@ NODE_CLASS_MAPPINGS = {
LoraCyclerLM.NAME: LoraCyclerLM, LoraCyclerLM.NAME: LoraCyclerLM,
LoraInfoLM.NAME: LoraInfoLM, LoraInfoLM.NAME: LoraInfoLM,
LoraSyntaxToPath.NAME: LoraSyntaxToPath, LoraSyntaxToPath.NAME: LoraSyntaxToPath,
CreateHookLoraLM.NAME: CreateHookLoraLM,
} }
WEB_DIRECTORY = "./web/comfyui" WEB_DIRECTORY = "./web/comfyui"
+138 -125
View File
@@ -10,13 +10,17 @@
"Brennok", "Brennok",
"2018cfh", "2018cfh",
"Insomnia Art Designs", "Insomnia Art Designs",
"Rob Williams",
"Arlecchino Shion", "Arlecchino Shion",
"Charles Blakemore", "Charles Blakemore",
"Rob Williams",
"$MetaSamsara", "$MetaSamsara",
"W+K+White", "W+K+White",
"stone9k", "stone9k",
"Rosenthal",
"Francisco Tatis",
"Mozzel",
"Gingko Biloba", "Gingko Biloba",
"Birdy",
"Kiba", "Kiba",
"onesecondinosaur", "onesecondinosaur",
"Christian Byrne", "Christian Byrne",
@@ -27,22 +31,25 @@
"Phil", "Phil",
"Carl G.", "Carl G.",
"Dsperado", "Dsperado",
"Rosenthal",
"ClockDaemon", "ClockDaemon",
"Francisco Tatis",
"Tobi_Swagg", "Tobi_Swagg",
"SG",
"jmack",
"Andrew Wilson", "Andrew Wilson",
"Greybush", "Greybush",
"Ricky Carter", "Ricky Carter",
"JongWon Han", "JongWon Han",
"VantAI", "VantAI",
"レプサイ",
"Michael Wong", "Michael Wong",
"Illrigger", "Illrigger",
"Tom Corrigan", "Tom Corrigan",
"JackieWang", "JackieWang",
"FreelancerZ", "FreelancerZ",
"fnkylove", "fnkylove",
"Lilleman",
"Robert Stacey", "Robert Stacey",
"PM",
"Edgar Tejeda", "Edgar Tejeda",
"Liam MacDougal", "Liam MacDougal",
"Polymorphic Indeterminate", "Polymorphic Indeterminate",
@@ -50,9 +57,7 @@
"Dogwalkerbr", "Dogwalkerbr",
"Skalabananen", "Skalabananen",
"Marc Whiffen", "Marc Whiffen",
"Birdy",
"itismyelement", "itismyelement",
"Mozzel",
"quarz", "quarz",
"Reno Lam", "Reno Lam",
"jean jahren", "jean jahren",
@@ -65,40 +70,46 @@
"Jonathan Ross", "Jonathan Ross",
"KD", "KD",
"Omnidex", "Omnidex",
"Nazono_hito", "Nolife_M",
"Melville Parrish",
"daniel dove", "daniel dove",
"Lustre",
"Tyler Trebuchon", "Tyler Trebuchon",
"Release Cabrakan", "Release Cabrakan",
"JW Sin", "JW Sin",
"Alex", "Alex",
"SG", "bh",
"carozzz", "carozzz",
"Marlon Daniels",
"James Dooley", "James Dooley",
"zenbound", "zenbound",
"Buzzard", "Buzzard",
"jmack", "Aaron Bleuer",
"LacesOut!",
"Adam Shaw", "Adam Shaw",
"Mark Corneglio", "Mark Corneglio",
"SarcasticHashtag", "SarcasticHashtag",
"RedrockVP", "RedrockVP",
"James Todd", "James Todd",
"Wicked Choices by ASLPro3D", "Wicked Choices by ASLPro3D",
"FinalyFree",
"Weasyl",
"Steven Pfeiffer", "Steven Pfeiffer",
"レプサイ",
"Timmy", "Timmy",
"Johnny", "Johnny",
"Cory Paza",
"Tak", "Tak",
"Lisster", "Lisster",
"runte3221", "runte3221",
"Big Red", "Big Red",
"whudunit", "whudunit",
"Luc Job",
"dl0901dm", "dl0901dm",
"corde",
"Yushio", "Yushio",
"Vik71it", "Vik71it",
"Bishoujoker", "Bishoujoker",
"Echo", "Echo",
"Lilleman",
"PM",
"Todd Keck", "Todd Keck",
"Briton Heilbrun", "Briton Heilbrun",
"wildnut", "wildnut",
@@ -108,53 +119,51 @@
"BadassArabianMofo", "BadassArabianMofo",
"Pascal Dahle", "Pascal Dahle",
"Greg", "Greg",
"Sangheili460",
"MagnaInsomnia",
"Akira_HentAI",
"Karl P.",
"MiraiKuriyamaSy", "MiraiKuriyamaSy",
"otaku fra", "otaku fra",
"lmsupporter", "lmsupporter",
"andrew.tappan", "andrew.tappan",
"Takkan", "Takkan",
"N/A",
"Greenmoustache",
"zounic", "zounic",
"wfpearl", "wfpearl",
"ElitaSSJ4", "ElitaSSJ4",
"Matt+J", "Matt+J",
"Jack B Nimble", "Jack B Nimble",
"Melville Parrish",
"Lustre",
"bh",
"Jwk0205", "Jwk0205",
"Marlon Daniels",
"Starkselle", "Starkselle",
"Aaron Bleuer", "Olive",
"LacesOut!",
"greebles", "greebles",
"Some Guy Named Barry", "Some Guy Named Barry",
"Resist's Creations - Spicy Edition 🔥", "Resist's Creations - Spicy Edition 🔥",
"M Postkasse", "M Postkasse",
"Wolffen", "Wolffen",
"wamekukyouzin",
"drum matthieu",
"Jacob Hoehler", "Jacob Hoehler",
"FinalyFree", "DogmaR34",
"Matt Wenzel", "Matt Wenzel",
"Weasyl",
"Lex Song", "Lex Song",
"Cory Paza", "Christopher Michel",
"Gonzalo Andre Allendes Lopez", "Gonzalo Andre Allendes Lopez",
"Serge Bekenkamp",
"Jimmy Ledbetter", "Jimmy Ledbetter",
"Luc Job", "LeoZero",
"Philip Hempel", "Philip Hempel",
"corde",
"nwalker94", "nwalker94",
"dan", "dan",
"aai", "aai",
"Tori", "Tori",
"Mouthlessman",
"Ran C", "Ran C",
"ViperC", "ViperC",
"Sangheili460",
"MagnaInsomnia",
"Akira_HentAI",
"Karl P.",
"Adam Taylor", "Adam Taylor",
"Weird_With_A_Beard", "Weird_With_A_Beard",
"N/A",
"The Spawn", "The Spawn",
"graysock", "graysock",
"Pozadine1", "Pozadine1",
@@ -162,7 +171,8 @@
"AIGooner", "AIGooner",
"Luc", "Luc",
"ProtonPrince", "ProtonPrince",
"Greenmoustache", "DiffDuck",
"elu3199",
"fancypants", "fancypants",
"John+Edwards", "John+Edwards",
"Joboshy", "Joboshy",
@@ -172,42 +182,39 @@
"contrite831", "contrite831",
"Dan", "Dan",
"Bro Xie", "Bro Xie",
"yer fey",
"batblue", "batblue",
"carey6409", "carey6409",
"Olive",
"太郎 ゲーム", "太郎 ゲーム",
"Roslynd",
"jinxedx", "jinxedx",
"Neco28",
"David Ortega",
"AELOX", "AELOX",
"Gooohokrbe", "Gooohokrbe",
"Dankin-Pics", "Dankin-Pics",
"Nicfit23", "Nicfit23",
"Cristian Vazquez", "Cristian Vazquez",
"wamekukyouzin",
"OldBones", "OldBones",
"drum matthieu",
"Dogmaster",
"Frank Nitty", "Frank Nitty",
"Magic Noob", "Magic Noob",
"Christopher Michel",
"Zach Gonser", "Zach Gonser",
"Serge Bekenkamp",
"DougPeterson", "DougPeterson",
"LeoZero",
"Antonio Pontes", "Antonio Pontes",
"nahinahi9", "Bruce",
"kushiroK9",
"Kevin John Duck", "Kevin John Duck",
"Dustin Chen", "Dustin Chen",
"Kevin Christopher",
"Blackfish95", "Blackfish95",
"Mouthlessman",
"Paul Kroll", "Paul Kroll",
"Penfore", "Penfore",
"Bas Imagineer", "Bas Imagineer",
"John Statham",
"Gordon Cole", "Gordon Cole",
"AbstractAss", "AbstractAss",
"Dušan Ryban", "Dušan Ryban",
"decoy", "decoy",
"DiffDuck",
"elu3199",
"Hasturkun", "Hasturkun",
"Jon Sandman", "Jon Sandman",
"Ubivis", "Ubivis",
@@ -222,34 +229,34 @@
"MJG", "MJG",
"David LaVallee", "David LaVallee",
"linnfrey", "linnfrey",
"ae",
"Tr4shP4nda",
"Jackthemind", "Jackthemind",
"griffin+dahlberg", "griffin+dahlberg",
"jeaness", "jeaness",
"takyamtom", "takyamtom",
"Brian M",
"Josef Lanzl", "Josef Lanzl",
"Nerezza", "Nerezza",
"yer fey", "sanborondon",
"Error_Rule34_Not_found", "Error_Rule34_Not_found",
"aezin", "aezin",
"jcay015", "jcay015",
"Erik Lopez", "Erik Lopez",
"Roslynd",
"Mateo Curić", "Mateo Curić",
"Geolog", "Geolog",
"Neco28",
"Cosmosis", "Cosmosis",
"Eris3D", "Eris3D",
"David Ortega", "m",
"FloPro4Sho", "FloPro4Sho",
"Jamie Ogletree",
"a _", "a _",
"Jeff", "Jeff",
"Bruce",
"Steven Owens", "Steven Owens",
"James Coleman", "James Coleman",
"Kevin Christopher",
"Chad Idk", "Chad Idk",
"dd", "dd",
"John Statham", "Sam",
"sjon kreutz", "sjon kreutz",
"yuxz69", "yuxz69",
"LarsesFPC", "LarsesFPC",
@@ -257,8 +264,6 @@
"esthe", "esthe",
"AlexDuKaNa", "AlexDuKaNa",
"地獄の禄", "地獄の禄",
"ae",
"Tr4shP4nda",
"Gamalonia", "Gamalonia",
"capn", "capn",
"Joseph", "Joseph",
@@ -272,12 +277,16 @@
"Hailshem", "Hailshem",
"Naomi Hale Danchi", "Naomi Hale Danchi",
"epicgamer0020690", "epicgamer0020690",
"Joshua Porrata",
"SuBu",
"RedPIXel",
"Wind",
"IamAyam", "IamAyam",
"Andrew", "Andrew",
"Brian M",
"Robert Wegemund", "Robert Wegemund",
"sanborondon", "Littlehuggy",
"confiscated Zyra", "Andrew Marshall",
"Brian Buie",
"Taylor Funk", "Taylor Funk",
"Thought2Form", "Thought2Form",
"Gerald Welly", "Gerald Welly",
@@ -285,15 +294,19 @@
"Sadlip", "Sadlip",
"Tee Gee", "Tee Gee",
"tarek helmi", "tarek helmi",
"Joey Callahan",
"Max Marklund", "Max Marklund",
"m", "Mike Simone",
"Pierce McBride", "Pierce McBride",
"Joshua Gray", "Joshua Gray",
"Pronredn", "Pronredn",
"Mikko Hemilä", "Mikko Hemilä",
"Jamie Ogletree", "Jacob McDaniel",
"X",
"Temikus", "Temikus",
"Artokun",
"Michael Taylor", "Michael Taylor",
"Derek Baker",
"lh qwe", "lh qwe",
"Martial", "Martial",
"conner", "conner",
@@ -305,24 +318,21 @@
"Decx _", "Decx _",
"Yuji Kaneko", "Yuji Kaneko",
"Rops Alot", "Rops Alot",
"Sam",
"Ace Ventura", "Ace Ventura",
"四糸凜音", "四糸凜音",
"Xeeosat", "Xeeosat",
"Douglas Gaspar", "Douglas Gaspar",
"Saya",
"George", "George",
"dw", "dw",
"FrxzenSnxw",
"WRL_SPR", "WRL_SPR",
"momokai", "momokai",
"몽타주", "몽타주",
"kudari", "kudari",
"ken", "ken",
"Crocket", "Crocket",
"Joshua Porrata",
"keemun", "keemun",
"SuBu",
"RedPIXel",
"Wind",
"Nexus", "Nexus",
"Ramneek“Guy”Ashok", "Ramneek“Guy”Ashok",
"squid_actually", "squid_actually",
@@ -337,37 +347,36 @@
"KitKatM", "KitKatM",
"socrasteeze", "socrasteeze",
"OrganicArtifact", "OrganicArtifact",
"ResidentDeviant",
"MudkipMedkitz", "MudkipMedkitz",
"deanbrian", "deanbrian",
"Alex Wortman", "Alex Wortman",
"Cody", "Cody",
"emadsultan", "emadsultan",
"InformedViewz",
"CHKeeho80",
"Bubbafett",
"leaf",
"Adam Rinehart",
"Pitpe11",
"TheD1rtyD03",
"gzmzmvp", "gzmzmvp",
"Richard", "Richard",
"奚明 刘", "奚明 刘",
"Littlehuggy",
"Aberr", "Aberr",
"Gregory Kozhemiak", "Gregory Kozhemiak",
"준희 김", "준희 김",
"Brian Buie",
"Eric Whitney", "Eric Whitney",
"Joey Callahan",
"Ivan Tadic", "Ivan Tadic",
"Tomohiro Baba", "Tomohiro Baba",
"Mike Simone",
"Noora", "Noora",
"John J Linehan", "John J Linehan",
"Mattssn",
"Elliot E", "Elliot E",
"Morgandel", "Morgandel",
"Theerat Jiramate", "Theerat Jiramate",
"Noah", "Noah",
"Jacob McDaniel",
"X",
"Sloan Steddy", "Sloan Steddy",
"Artokun",
"hexxish", "hexxish",
"Derek Baker",
"Steam Steam", "Steam Steam",
"NICHOLAS BAXLEY", "NICHOLAS BAXLEY",
"CryptoTraderJK", "CryptoTraderJK",
@@ -378,23 +387,14 @@
"Fotek Design", "Fotek Design",
"Nihongasuki", "Nihongasuki",
"MadSpin", "MadSpin",
"FrxzenSnxw",
"inbijiburu", "inbijiburu",
"Nick “Loadstone” D", "Nick “Loadstone” D",
"starbugx", "starbugx",
"dc7431", "dc7431",
"ResidentDeviant",
"Ginnie", "Ginnie",
"Raku", "Raku",
"InformedViewz",
"CHKeeho80",
"Bubbafett",
"leaf",
"Vir", "Vir",
"Skyfire83", "Skyfire83",
"Adam Rinehart",
"Pitpe11",
"TheD1rtyD03",
"moonpetal", "moonpetal",
"g9p0o", "g9p0o",
"Pkrsky", "Pkrsky",
@@ -403,6 +403,8 @@
"SpringBootisTrash", "SpringBootisTrash",
"carsten", "carsten",
"ikok", "ikok",
"quantenmecha",
"Jason+Nash",
"DarkRoast", "DarkRoast",
"Nasty+Hobbit", "Nasty+Hobbit",
"letzte", "letzte",
@@ -414,12 +416,15 @@
"David Schenck", "David Schenck",
"Wolfe7D1", "Wolfe7D1",
"Draven T", "Draven T",
"Time Valentine",
"elleshar666", "elleshar666",
"ACTUALLY_the_Real_Willem_Dafoe", "ACTUALLY_the_Real_Willem_Dafoe",
"Михал Михалыч", "Михал Михалыч",
"Matt",
"Aquatic Coffee", "Aquatic Coffee",
"Kauffy", "Kauffy",
"ethanfel", "ethanfel",
"SPJ",
"Focuschannel", "Focuschannel",
"Edward Kennedy", "Edward Kennedy",
"Nick Kage", "Nick Kage",
@@ -432,12 +437,13 @@
"notedfakes", "notedfakes",
"Michael Scott", "Michael Scott",
"Pat Hen", "Pat Hen",
"Saya", "Solixer",
"Jordan Shaw", "Jordan Shaw",
"Wes Sims", "Wes Sims",
"Donor4115", "Donor4115",
"g unit", "g unit",
"Jimmy Borup", "Jimmy Borup",
"Manu Thetug",
"Filippo Ferrari", "Filippo Ferrari",
"JC", "JC",
"Prompt Pirate", "Prompt Pirate",
@@ -451,6 +457,11 @@
"SomeDude", "SomeDude",
"nanana", "nanana",
"raf8osz", "raf8osz",
"Bob+Barker",
"D",
"Dark_Pest",
"Eldithor",
"Alex",
"Karru", "Karru",
"ChaChanoKo", "ChaChanoKo",
"redcarrot", "redcarrot",
@@ -467,36 +478,34 @@
"Doug+Rintoul", "Doug+Rintoul",
"Noor", "Noor",
"Yorunai", "Yorunai",
"quantenmecha",
"Jason+Nash",
"cocona", "cocona",
"blikkies", "blikkies",
"JBsuede", "JBsuede",
"Time Valentine",
"Shock Shockor", "Shock Shockor",
"りん あめ", "りん あめ",
"Matt",
"Goldwaters", "Goldwaters",
"Zude", "Zude",
"Joaquin Hierrezuelo",
"Frogmilk", "Frogmilk",
"SPJ", "Sean voets",
"Kyler", "Kyler",
"Kor", "Kor",
"Joseph Hanson",
"John Rednoulf",
"Bryan Rutkowski", "Bryan Rutkowski",
"Justin Blaylock", "Justin Blaylock",
"aRtFuL_DodGeR", "aRtFuL_DodGeR",
"Steven",
"TenaciousD", "TenaciousD",
"Dmitry Ryzhov", "Dmitry Ryzhov",
"Edward Ten Eyck", "Edward Ten Eyck",
"Billy Gladky", "Billy Gladky",
"Probis", "Probis",
"Solixer",
"Pete Pain", "Pete Pain",
"ItsGeneralButtNaked", "ItsGeneralButtNaked",
"RHopkirk", "RHopkirk",
"jinksta187", "jinksta187",
"robin.kok.", "robin.kok.",
"Manu Thetug",
"Maxim", "Maxim",
"Karlanx", "Karlanx",
"Lyavph", "Lyavph",
@@ -504,6 +513,7 @@
"Youguang", "Youguang",
"andrewzpong", "andrewzpong",
"BossGame", "BossGame",
"Marcus thronico",
"lrdchs", "lrdchs",
"Tree Tagger", "Tree Tagger",
"Inversity", "Inversity",
@@ -511,6 +521,15 @@
"Kevinj", "Kevinj",
"Mitchell Robson", "Mitchell Robson",
"POPPIN", "POPPIN",
"PoorStudent",
"Alex+Zaw",
"Supporter",
"ExLightSaber",
"Mobius2020",
"YaboiRay",
"Sildoren",
"Darv",
"Seon+Song",
"2turbo", "2turbo",
"Dmitry+Viznesenskiy", "Dmitry+Viznesenskiy",
"tanjin90", "tanjin90",
@@ -528,11 +547,6 @@
"Inkognito", "Inkognito",
"G", "G",
"Tan+Huynh", "Tan+Huynh",
"Bob+Barker",
"D",
"Dark_Pest",
"Eldithor",
"Alex",
"BillyBoy84", "BillyBoy84",
"Buecyb99", "Buecyb99",
"Welkor", "Welkor",
@@ -545,28 +559,27 @@
"G", "G",
"Ronan Delevacq", "Ronan Delevacq",
"Christian Schäfer", "Christian Schäfer",
"Leslie Andrew Ridings",
"Dave Abraham", "Dave Abraham",
"Joaquin Hierrezuelo",
"Locrospiel", "Locrospiel",
"Sean voets",
"Jarrid Lee", "Jarrid Lee",
"Poophead27 Blyat", "Poophead27 Blyat",
"Joseph Hanson",
"John Rednoulf",
"Kyron Mahan", "Kyron Mahan",
"Mythspire", "Mythspire",
"Boba Smith", "Boba Smith",
"TBitz33", "TBitz33",
"Anonym dkjglfleeoeldldldlkf", "Anonym dkjglfleeoeldldldlkf",
"MR.Bear", "MR.Bear",
"matt",
"somethingtosay8", "somethingtosay8",
"Ezokewn", "Ezokewn",
"Terminuz",
"ivistorm", "ivistorm",
"SendingRavens", "SendingRavens",
"Sauv", "Sauv",
"Steven",
"JackJohnnyJim", "JackJohnnyJim",
"Khánh Đặng", "Khánh Đặng",
"Borte",
"Michael Docherty", "Michael Docherty",
"Ted Cart", "Ted Cart",
"Sage Himeros", "Sage Himeros",
@@ -574,6 +587,7 @@
"Paul Hartsuyker", "Paul Hartsuyker",
"elitassj", "elitassj",
"Tigon", "Tigon",
"SkibidiRizzler",
"Tania Nayelli Fernandez", "Tania Nayelli Fernandez",
"Draconach", "Draconach",
"Jacob Winter", "Jacob Winter",
@@ -581,6 +595,8 @@
"Andrew Wilkinson", "Andrew Wilkinson",
"David", "David",
"Meilo", "Meilo",
"Nacho Ferrando",
"Marcos Tortosa Carmona",
"Dkom22", "Dkom22",
"shinonomeiro", "shinonomeiro",
"Snille", "Snille",
@@ -589,7 +605,6 @@
"xybrightsummer", "xybrightsummer",
"jreedatchison", "jreedatchison",
"PhilW", "PhilW",
"Marcus thronico",
"Janik", "Janik",
"Cruel", "Cruel",
"MRBlack", "MRBlack",
@@ -601,6 +616,13 @@
"Scott", "Scott",
"Muratoraccio", "Muratoraccio",
"D", "D",
"Somebody",
"Celestial+Kitten",
"TequiTequi",
"Homero+Banda",
"bakeliteboy",
"Nick",
"てぃんてぃんひーろー",
"Gold_miner_ego", "Gold_miner_ego",
"IshouI;_;", "IshouI;_;",
"Monix", "Monix",
@@ -620,17 +642,8 @@
"you+halo9", "you+halo9",
"cloudghost", "cloudghost",
"Yongkwan+Lee", "Yongkwan+Lee",
"PoorStudent",
"lucites", "lucites",
"Alex+Zaw",
"Mobius2020",
"ExLightSaber",
"YaboiRay",
"nickname", "nickname",
"Sildoren",
"Darv",
"Seon+Song",
"Somebody",
"Balut+Omelette", "Balut+Omelette",
"eriick", "eriick",
"Lev+Lanevskiy", "Lev+Lanevskiy",
@@ -651,37 +664,35 @@
"Vinarus", "Vinarus",
"Josh Snyder", "Josh Snyder",
"ja s", "ja s",
"Leslie Andrew Ridings",
"Doug Mason", "Doug Mason",
"scoreswazey", "scoreswazey",
"Oliverfish",
"Owen Gwosdz", "Owen Gwosdz",
"Room Light", "Room Light",
"Patryk Serious", "Patryk Serious",
"AZ Party Oasis", "AZ Party Oasis",
"Devil Lude", "Gentle Sartori",
"Snorklebort", "Snorklebort",
"David Murcko", "David Murcko",
"vinter",
"TheFusion", "TheFusion",
"Jack Dole", "Jack Dole",
"matt",
"3zS4QNQ4", "3zS4QNQ4",
"Terminuz",
"max blo", "max blo",
"Matt M.", "Matt M.",
"Ivan Imes", "Ivan Imes",
"J M", "J M",
"Slacks",
"Bouya shaka", "Bouya shaka",
"Jack Lawfield", "Jack Lawfield",
"Borte",
"Maso", "Maso",
"Homero Banda",
"yyuvuvu", "yyuvuvu",
"Eric Ketchum",
"Nomki", "Nomki",
"Kevin Wallace", "Kevin Wallace",
"ChicRic", "ChicRic",
"BastardSama", "BastardSama",
"mercur", "mercur",
"SkibidiRizzler",
"Never_M", "Never_M",
"Kalle Björk", "Kalle Björk",
"Yavizu3d", "Yavizu3d",
@@ -689,9 +700,7 @@
"Teriak47", "Teriak47",
"Just me", "Just me",
"Raf Stahelin", "Raf Stahelin",
"Nacho Ferrando",
"Вячеслав Маринин", "Вячеслав Маринин",
"Marcos Tortosa Carmona",
"Cola Matthew", "Cola Matthew",
"OniNoKen", "OniNoKen",
"Iain Wisely", "Iain Wisely",
@@ -734,6 +743,15 @@
"SelfishMedic", "SelfishMedic",
"adderleighn", "adderleighn",
"EnragedAntelope", "EnragedAntelope",
"Brandon+G",
"fazefour33",
"plonk",
"Kotetsu",
"o",
"Tony+V",
"Anvil+G",
"draganjankovic1975dj528",
"MrSEIGE88",
"yarsev", "yarsev",
"M+Alsulaiti", "M+Alsulaiti",
"Mark+Staaf", "Mark+Staaf",
@@ -751,11 +769,6 @@
"miduzza", "miduzza",
"KB", "KB",
"shw", "shw",
"Celestial+Kitten",
"bakeliteboy",
"TequiTequi",
"Homero+Banda",
"Nick",
"Jim", "Jim",
"JoL", "JoL",
"YoruHime", "YoruHime",
@@ -781,14 +794,14 @@
"han b", "han b",
"Nico", "Nico",
"Maximilian Krischan", "Maximilian Krischan",
"Banana Joe", "socialcat",
"proto merp", "proto merp",
"_ G3n", "_ G3n",
"Brandon Thomas", "Brandon Thomas",
"Donovan Jenkins", "Donovan Jenkins",
"Hans Meier", "Hans Meier",
"Dustin Hendel", "Dustin Hendel",
"sicarius", "jboul",
"Michael Eid", "Michael Eid",
"Liberation", "Liberation",
"Bob barker", "Bob barker",
@@ -805,7 +818,6 @@
"jumpd", "jumpd",
"John C", "John C",
"Rim", "Rim",
"Oliverfish",
"yfx507", "yfx507",
"uruksayshi", "uruksayshi",
"Jairus Knudsen", "Jairus Knudsen",
@@ -814,24 +826,25 @@
"nk8", "nk8",
"lylepaul", "lylepaul",
"Middo", "Middo",
"Gary Chaboya",
"Forbidden Atelier", "Forbidden Atelier",
"Thomas Sankowski", "Thomas Sankowski",
"DrB", "DrB",
"Nimhloth", "Nimhloth",
"Adictedtohumping", "Adictedtohumping",
"Moneymaker412K", "Moneymaker412K",
"vinter", "Tsani Prodanov",
"Towelie", "Towelie",
"Jean-françois SEMA", "Jean-françois SEMA",
"Myrthrac",
"Taylor Dominy",
"Andrew Ly", "Andrew Ly",
"Slacks",
"Glenn Hoetker", "Glenn Hoetker",
"john Greene", "john Greene",
"Faburizu", "Faburizu",
"jimyjomson", "jimyjomson",
"JaeHyun Jang", "JaeHyun Jang",
"Michael Hicks", "Michael Hicks",
"Homero Banda",
"Chase Kwon", "Chase Kwon",
"Bob Ling", "Bob Ling",
"Inyoshu", "Inyoshu",
@@ -858,5 +871,5 @@
"Somebody", "Somebody",
"CK" "CK"
], ],
"totalCount": 855 "totalCount": 868
} }
File diff suppressed because one or more lines are too long
+2202 -2194
View File
File diff suppressed because it is too large Load Diff
+10 -2
View File
@@ -233,7 +233,7 @@
"presetNamePlaceholder": "Preset name...", "presetNamePlaceholder": "Preset name...",
"baseModel": "Base Model", "baseModel": "Base Model",
"baseModelSearchPlaceholder": "Search base models...", "baseModelSearchPlaceholder": "Search base models...",
"modelTags": "Tags (Top 20)", "modelTags": "Tags",
"modelTypes": "Model Types", "modelTypes": "Model Types",
"license": "License", "license": "License",
"noCreditRequired": "No Credit Required", "noCreditRequired": "No Credit Required",
@@ -241,6 +241,8 @@
"allowSellingGeneratedContentTooltip": "Allow selling generated images", "allowSellingGeneratedContentTooltip": "Allow selling generated images",
"noCreditRequiredTooltip": "Use the model without crediting the creator", "noCreditRequiredTooltip": "Use the model without crediting the creator",
"noTags": "No tags", "noTags": "No tags",
"tagSearchPlaceholder": "Search tags...",
"noTagMatches": "No tags match the current search.",
"autoTags": "Auto Tags", "autoTags": "Auto Tags",
"noBaseModelMatches": "No base models match the current search.", "noBaseModelMatches": "No base models match the current search.",
"clearAll": "Clear All Filters", "clearAll": "Clear All Filters",
@@ -640,7 +642,13 @@
"preparing": "Preparing download...", "preparing": "Preparing download...",
"connecting": "Connecting to download server...", "connecting": "Connecting to download server...",
"completed": "Completed", "completed": "Completed",
"downloadComplete": "Download completed successfully" "downloadComplete": "Download completed successfully",
"enableCivarchiveApi": "Enable CivArchive API as metadata provider",
"enableCivarchiveApiHelp": "When on, CivArchive API is used as a fallback source for model metadata (e.g. for models deleted from CivitAI). Turn off to avoid CivArchive rate limits entirely.",
"providerOrder": "Metadata provider fallback order",
"providerOrderHelp": "CivitAI API is always tried first. Choose the order of the remaining providers when looking up metadata.",
"providerOrderCivitaiArchiveSqlite": "CivitAI → CivArchive → Archive DB",
"providerOrderCivitaiSqliteArchive": "CivitAI → Archive DB → CivArchive"
}, },
"proxySettings": { "proxySettings": {
"enableProxy": "Enable App-level Proxy", "enableProxy": "Enable App-level Proxy",
+2202 -2194
View File
File diff suppressed because it is too large Load Diff
+2202 -2194
View File
File diff suppressed because it is too large Load Diff
+2202 -2194
View File
File diff suppressed because it is too large Load Diff
+2202 -2194
View File
File diff suppressed because it is too large Load Diff
+2202 -2194
View File
File diff suppressed because it is too large Load Diff
+2202 -2194
View File
File diff suppressed because it is too large Load Diff
+2202 -2194
View File
File diff suppressed because it is too large Load Diff
+2202 -2194
View File
File diff suppressed because it is too large Load Diff
+41
View File
@@ -359,6 +359,47 @@ class Config:
"Failed to rename legacy 'default' library: %s", rename_error "Failed to rename legacy 'default' library: %s", rename_error
) )
# Clean up a stale "default" library entry that has no meaningful
# paths configured (e.g. leftover bootstrap artifact). This only
# fires when "comfyui" already exists so we never delete the last
# remaining library.
if (
"default" in libraries
and "comfyui" in libraries
and isinstance(default_library, Mapping)
):
default_folder_paths = _normalize_library_folder_paths(
default_library
)
default_extra_paths = default_library.get("extra_folder_paths", {})
has_meaningful_paths = bool(default_folder_paths) or bool(
default_extra_paths
) or any(
default_library.get(key)
for key in (
"default_lora_root",
"default_checkpoint_root",
"default_unet_root",
"default_embedding_root",
"recipes_path",
)
)
if not has_meaningful_paths:
try:
settings_service.delete_library("default")
libraries_changed = True
logger.info(
"Removed stale 'default' library entry "
"with no meaningful paths configured"
)
libraries = settings_service.get_libraries()
comfy_library = libraries.get("comfyui", {})
except Exception as delete_error:
logger.debug(
"Failed to remove stale 'default' library: %s",
delete_error,
)
default_lora_root = _resolve_valid_default_root( default_lora_root = _resolve_valid_default_root(
comfy_library.get("default_lora_root", ""), comfy_library.get("default_lora_root", ""),
list(self.loras_roots or []), list(self.loras_roots or []),
+117
View File
@@ -0,0 +1,117 @@
"""Create Hook LoRA (LoraManager) — multi-LoRA hook node compatible with ComfyUI's built-in hook pipeline.
Produces ``("HOOKS",)`` output that chains seamlessly with downstream hook consumers
(ConditioningSetProperties, SetHookKeyframes, CombineHooks, SetClipHooks, etc.).
"""
from __future__ import annotations
import logging
import os
from ..utils.utils import get_lora_info_absolute
from .utils import (
FlexibleOptionalInputType,
any_type,
apply_lora_syntax_format,
get_loras_list,
)
logger = logging.getLogger(__name__)
class CreateHookLoraLM:
NAME = "Create Hook LoRA (LoraManager)"
CATEGORY = "Lora Manager/hooks"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"text": (
"AUTOCOMPLETE_TEXT_LORAS",
{
"placeholder": "Search LoRAs to add...",
"tooltip": (
"Search and select LoRAs. Each LoRA gets its own "
"model/clip strength. Hooks chain with prev_hooks."
),
},
),
},
"optional": FlexibleOptionalInputType(any_type),
}
RETURN_TYPES = ("HOOKS", "STRING", "STRING")
RETURN_NAMES = ("HOOKS", "trigger_words", "active_loras")
FUNCTION = "create_hook"
def create_hook(self, text: str, **kwargs):
"""Create a HookGroup from the selected LoRAs, chained with prev_hooks.
Each active LoRA from the widget is loaded and wrapped in a WeightHook
via :func:`comfy.hooks.create_hook_lora`. All hooks are combined into a
single group and returned alongside trigger words and a human-readable
summary of the active LoRAs.
"""
del text # used by the frontend widget only
# Lazy imports: comfy is not available in CI/test environment at module level
import comfy.hooks # type: ignore # noqa: C0415
import comfy.utils # type: ignore # noqa: C0415
prev_hooks: comfy.hooks.HookGroup | None = kwargs.get("prev_hooks")
hook_group = prev_hooks.clone() if prev_hooks is not None else comfy.hooks.HookGroup()
all_trigger_words: list[str] = []
active_loras: list[tuple[str, float, float]] = []
for lora in get_loras_list(kwargs):
if not lora.get("active", False):
continue
lora_name = apply_lora_syntax_format(lora["name"])
model_strength = float(lora["strength"])
clip_strength = float(lora.get("clipStrength", model_strength))
# Skip useless no-op entries (both strengths are zero)
if model_strength == 0.0 and clip_strength == 0.0:
continue
lora_path, trigger_words = get_lora_info_absolute(lora_name)
if not lora_path or not os.path.isfile(lora_path):
logger.warning("LoRA '%s' not found — skipping", lora_name)
continue
try:
lora_weights = comfy.utils.load_torch_file(lora_path, safe_load=True)
lora_hooks = comfy.hooks.create_hook_lora(
lora=lora_weights,
strength_model=model_strength,
strength_clip=clip_strength,
)
except Exception:
logger.exception("Failed to load LoRA '%s' — skipping", lora_name)
continue
hook_group = hook_group.clone_and_combine(lora_hooks)
active_loras.append((lora_name, model_strength, clip_strength))
all_trigger_words.extend(trigger_words)
# Format trigger words (group mode separator)
trigger_words_text = ",, ".join(all_trigger_words) if all_trigger_words else ""
# Format active LoRAs summary
formatted_loras = []
for name, model_s, clip_s in active_loras:
if abs(model_s - clip_s) > 0.001:
formatted_loras.append(
f"<lora:{name}:{model_s}:{clip_s}>"
)
else:
formatted_loras.append(f"<lora:{name}:{model_s}>")
active_loras_text = " ".join(formatted_loras)
return (hook_group, trigger_words_text, active_loras_text)
+7 -3
View File
@@ -1570,7 +1570,11 @@ class SettingsHandler:
else: else:
self._settings.set(key, value) self._settings.set(key, value)
if key == "enable_metadata_archive_db": if key in (
"enable_metadata_archive_db",
"enable_civarchive_api",
"metadata_provider_order",
):
await self._metadata_provider_updater() await self._metadata_provider_updater()
if key in self._PROXY_KEYS: if key in self._PROXY_KEYS:
@@ -3471,7 +3475,7 @@ class NodeRegistryHandler:
status=400, status=400,
) )
if not isinstance(value, str) or not value: if value is None or (isinstance(value, str) and not value):
return web.json_response( return web.json_response(
{"success": False, "error": "Missing value parameter"}, status=400 {"success": False, "error": "Missing value parameter"}, status=400
) )
@@ -3578,7 +3582,7 @@ class NodeRegistryHandler:
status=400, status=400,
) )
if not isinstance(value, str) or not value: if value is None or (isinstance(value, str) and not value):
return web.json_response( return web.json_response(
{"success": False, "error": "Missing value parameter"}, status=400 {"success": False, "error": "Missing value parameter"}, status=400
) )
+39 -8
View File
@@ -973,6 +973,8 @@ class ModelQueryHandler:
limit = int(request.query.get("limit", "20")) limit = int(request.query.get("limit", "20"))
if limit < 0: if limit < 0:
limit = 20 limit = 20
elif limit > 200:
limit = 20
top_tags = await self._service.get_top_tags(limit) top_tags = await self._service.get_top_tags(limit)
return web.json_response({"success": True, "tags": top_tags}) return web.json_response({"success": True, "tags": top_tags})
except Exception as exc: except Exception as exc:
@@ -981,6 +983,22 @@ class ModelQueryHandler:
{"success": False, "error": "Internal server error"}, status=500 {"success": False, "error": "Internal server error"}, status=500
) )
async def search_tags(self, request: web.Request) -> web.Response:
try:
query = request.query.get("q", "")
limit = int(request.query.get("limit", "20"))
if limit < 0:
limit = 20
elif limit > 200:
limit = 20
tags = await self._service.search_tags(query, limit)
return web.json_response({"success": True, "tags": tags})
except Exception as exc:
self._logger.error("Error searching tags: %s", exc, exc_info=True)
return web.json_response(
{"success": False, "error": "Internal server error"}, status=500
)
async def get_base_models(self, request: web.Request) -> web.Response: async def get_base_models(self, request: web.Request) -> web.Response:
try: try:
limit = int(request.query.get("limit", "20")) limit = int(request.query.get("limit", "20"))
@@ -1787,14 +1805,20 @@ class ModelDownloadHandler:
async def delete_download_history_item(self, request: web.Request) -> web.Response: async def delete_download_history_item(self, request: web.Request) -> web.Response:
try: try:
item_id = int(request.query.get("id", "0")) download_id = request.query.get("download_id")
if not item_id: id_str = request.query.get("id")
item_id = int(id_str) if id_str else None
if not download_id and not item_id:
return web.json_response( return web.json_response(
{"success": False, "error": "id is required"}, status=400 {"success": False, "error": "id or download_id is required"},
status=400,
) )
service = await DownloadQueueService.get_instance() service = await DownloadQueueService.get_instance()
deleted = await service.delete_history_item(item_id) deleted = await service.delete_history_item(
id=item_id, download_id=download_id
)
return web.json_response({"success": deleted}) return web.json_response({"success": deleted})
except Exception as exc: except Exception as exc:
self._logger.error( self._logger.error(
@@ -1804,14 +1828,20 @@ class ModelDownloadHandler:
async def retry_download_from_history(self, request: web.Request) -> web.Response: async def retry_download_from_history(self, request: web.Request) -> web.Response:
try: try:
item_id = int(request.query.get("id", "0")) download_id = request.query.get("download_id")
if not item_id: id_str = request.query.get("id")
item_id = int(id_str) if id_str else None
if not download_id and not item_id:
return web.json_response( return web.json_response(
{"success": False, "error": "id is required"}, status=400 {"success": False, "error": "id or download_id is required"},
status=400,
) )
service = await DownloadQueueService.get_instance() service = await DownloadQueueService.get_instance()
item = await service.retry_from_history(item_id) item = await service.retry_from_history(
item_id=item_id, download_id=download_id
)
if item is None: if item is None:
return web.json_response( return web.json_response(
{"success": False, "error": "History item not found or not retryable"}, {"success": False, "error": "History item not found or not retryable"},
@@ -2935,6 +2965,7 @@ class ModelHandlerSet:
"bulk_delete_models": self.management.bulk_delete_models, "bulk_delete_models": self.management.bulk_delete_models,
"verify_duplicates": self.management.verify_duplicates, "verify_duplicates": self.management.verify_duplicates,
"get_top_tags": self.query.get_top_tags, "get_top_tags": self.query.get_top_tags,
"search_tags": self.query.search_tags,
"get_base_models": self.query.get_base_models, "get_base_models": self.query.get_base_models,
"get_model_types": self.query.get_model_types, "get_model_types": self.query.get_model_types,
"scan_models": self.query.scan_models, "scan_models": self.query.scan_models,
+55 -6
View File
@@ -72,6 +72,7 @@ class RecipeHandlerSet:
"save_recipe": self.management.save_recipe, "save_recipe": self.management.save_recipe,
"delete_recipe": self.management.delete_recipe, "delete_recipe": self.management.delete_recipe,
"get_top_tags": self.query.get_top_tags, "get_top_tags": self.query.get_top_tags,
"search_tags": self.query.search_tags,
"get_base_models": self.query.get_base_models, "get_base_models": self.query.get_base_models,
"get_roots": self.query.get_roots, "get_roots": self.query.get_roots,
"get_folders": self.query.get_folders, "get_folders": self.query.get_folders,
@@ -317,12 +318,11 @@ class RecipeQueryHandler:
raise RuntimeError("Recipe scanner unavailable") raise RuntimeError("Recipe scanner unavailable")
limit = int(request.query.get("limit", "20")) limit = int(request.query.get("limit", "20"))
cache = await recipe_scanner.get_cached_data() if limit < 0:
limit = 20
tag_counts: Dict[str, int] = {} elif limit > 200:
for recipe in getattr(cache, "raw_data", []): limit = 20
for tag in recipe.get("tags", []) or []: tag_counts = await self._get_recipe_tag_counts(recipe_scanner)
tag_counts[tag] = tag_counts.get(tag, 0) + 1
sorted_tags = [ sorted_tags = [
{"tag": tag, "count": count} for tag, count in tag_counts.items() {"tag": tag, "count": count} for tag, count in tag_counts.items()
@@ -333,6 +333,55 @@ class RecipeQueryHandler:
self._logger.error("Error retrieving top tags: %s", exc, exc_info=True) self._logger.error("Error retrieving top tags: %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 search_tags(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")
query = request.query.get("q", "")
limit = int(request.query.get("limit", "20"))
if limit < 0:
limit = 20
elif limit > 200:
limit = 20
tag_counts = await self._get_recipe_tag_counts(recipe_scanner)
normalized_query = (query or "").strip().lower()
if not normalized_query:
sorted_tags = [
{"tag": tag, "count": count} for tag, count in tag_counts.items()
]
sorted_tags.sort(key=lambda entry: entry["count"], reverse=True)
return web.json_response(
{"success": True, "tags": sorted_tags[: (limit if limit > 0 else 20)]}
)
matched = [
{"tag": tag, "count": count}
for tag, count in tag_counts.items()
if normalized_query in tag.lower()
]
matched.sort(key=lambda entry: entry["count"], reverse=True)
if limit == 0:
result = matched
else:
result = matched[:limit]
return web.json_response({"success": True, "tags": result})
except Exception as exc:
self._logger.error("Error searching recipe tags: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def _get_recipe_tag_counts(self, recipe_scanner) -> Dict[str, int]:
"""Compute tag->count mapping from cached recipe data."""
cache = await recipe_scanner.get_cached_data()
tag_counts: Dict[str, int] = {}
for recipe in getattr(cache, "raw_data", []):
for tag in recipe.get("tags", []) or []:
tag_counts[tag] = tag_counts.get(tag, 0) + 1
return tag_counts
async def get_base_models(self, request: web.Request) -> web.Response: async def get_base_models(self, request: web.Request) -> web.Response:
try: try:
await self._ensure_dependencies_ready() await self._ensure_dependencies_ready()
+1
View File
@@ -46,6 +46,7 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
"GET", "/api/lm/{prefix}/auto-organize-progress", "get_auto_organize_progress" "GET", "/api/lm/{prefix}/auto-organize-progress", "get_auto_organize_progress"
), ),
RouteDefinition("GET", "/api/lm/{prefix}/top-tags", "get_top_tags"), RouteDefinition("GET", "/api/lm/{prefix}/top-tags", "get_top_tags"),
RouteDefinition("GET", "/api/lm/{prefix}/search-tags", "search_tags"),
RouteDefinition("GET", "/api/lm/{prefix}/base-models", "get_base_models"), RouteDefinition("GET", "/api/lm/{prefix}/base-models", "get_base_models"),
RouteDefinition("GET", "/api/lm/{prefix}/model-types", "get_model_types"), RouteDefinition("GET", "/api/lm/{prefix}/model-types", "get_model_types"),
RouteDefinition("GET", "/api/lm/{prefix}/scan", "scan_models"), RouteDefinition("GET", "/api/lm/{prefix}/scan", "scan_models"),
+1
View File
@@ -29,6 +29,7 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("POST", "/api/lm/recipes/save", "save_recipe"), RouteDefinition("POST", "/api/lm/recipes/save", "save_recipe"),
RouteDefinition("DELETE", "/api/lm/recipe/{recipe_id}", "delete_recipe"), RouteDefinition("DELETE", "/api/lm/recipe/{recipe_id}", "delete_recipe"),
RouteDefinition("GET", "/api/lm/recipes/top-tags", "get_top_tags"), RouteDefinition("GET", "/api/lm/recipes/top-tags", "get_top_tags"),
RouteDefinition("GET", "/api/lm/recipes/search-tags", "search_tags"),
RouteDefinition("GET", "/api/lm/recipes/base-models", "get_base_models"), RouteDefinition("GET", "/api/lm/recipes/base-models", "get_base_models"),
RouteDefinition("GET", "/api/lm/recipes/roots", "get_roots"), RouteDefinition("GET", "/api/lm/recipes/roots", "get_roots"),
RouteDefinition("GET", "/api/lm/recipes/folders", "get_folders"), RouteDefinition("GET", "/api/lm/recipes/folders", "get_folders"),
+24
View File
@@ -804,6 +804,12 @@ class BaseModelService(ABC):
"""Get top tags sorted by frequency""" """Get top tags sorted by frequency"""
return await self.scanner.get_top_tags(limit) return await self.scanner.get_top_tags(limit)
async def search_tags(
self, query: str, limit: int = 50
) -> List[Dict]:
"""Search tags by substring, sorted by frequency"""
return await self.scanner.search_tags(query, limit)
async def get_base_models(self, limit: int = 20) -> List[Dict]: async def get_base_models(self, limit: int = 20) -> List[Dict]:
"""Get base models sorted by frequency""" """Get base models sorted by frequency"""
return await self.scanner.get_base_models(limit) return await self.scanner.get_base_models(limit)
@@ -1092,6 +1098,11 @@ class BaseModelService(ABC):
Listing/search endpoints return lightweight cache entries; this method performs Listing/search endpoints return lightweight cache entries; this method performs
a lazy read of the on-disk metadata snapshot when callers need full detail. a lazy read of the on-disk metadata snapshot when callers need full detail.
As a beneficial side effect, the in-memory and persistent caches are
opportunistically synchronised with the on-disk metadata this keeps the
caches fresh even when a ``.metadata.json`` file was edited outside of the
normal save path (e.g. manually or by an external script).
""" """
metadata, should_skip = await MetadataManager.load_metadata( metadata, should_skip = await MetadataManager.load_metadata(
file_path, self.metadata_class file_path, self.metadata_class
@@ -1109,6 +1120,19 @@ class BaseModelService(ABC):
MetadataManager.save_metadata(file_path, metadata) MetadataManager.save_metadata(file_path, metadata)
) )
# Opportunistically sync the in-memory + persistent caches.
# The .metadata.json disk read is already paid for; the sync only
# performs work when the cache is actually stale, and uses targeted,
# in-place operations to minimise overhead even with large model sets.
#
# Fire-and-forget by design: the task is intentionally untracked.
# sync_cache_from_metadata handles its own errors internally.
asyncio.create_task(
self.scanner.sync_cache_from_metadata(
file_path, metadata.to_dict()
)
)
return self.filter_civitai_data(metadata.to_dict().get("civitai", {})) return self.filter_civitai_data(metadata.to_dict().get("civitai", {}))
async def get_model_description(self, file_path: str) -> Optional[str]: async def get_model_description(self, file_path: str) -> Optional[str]:
+25
View File
@@ -114,6 +114,13 @@ class CheckpointScanner(ModelScanner):
and metadata.hash_status == "completed" and metadata.hash_status == "completed"
and metadata.sha256 and metadata.sha256
): ):
# Ensure the in-memory hash index is populated even when
# the hash was already computed and persisted to the metadata
# file. Without this, usage tracking (and any other caller
# that queries get_hash_by_filename first) will miss on every
# lookup and keep calling back into this method, creating a
# tight loop that never populates the index.
self._hash_index.add_entry(metadata.sha256.lower(), file_path)
return metadata.sha256 return metadata.sha256
async with self._hash_calculation_lock: async with self._hash_calculation_lock:
@@ -125,6 +132,7 @@ class CheckpointScanner(ModelScanner):
and metadata.hash_status == "completed" and metadata.hash_status == "completed"
and metadata.sha256 and metadata.sha256
): ):
self._hash_index.add_entry(metadata.sha256.lower(), file_path)
return metadata.sha256 return metadata.sha256
task = self._hash_calculation_tasks.get(real_path) task = self._hash_calculation_tasks.get(real_path)
@@ -175,6 +183,9 @@ class CheckpointScanner(ModelScanner):
# Check if hash is already calculated # Check if hash is already calculated
if metadata.hash_status == "completed" and metadata.sha256: if metadata.hash_status == "completed" and metadata.sha256:
# Populate the in-memory hash index even for pre-computed
# hashes, mirroring the fix in calculate_hash_for_model.
self._hash_index.add_entry(metadata.sha256.lower(), file_path)
return metadata.sha256 return metadata.sha256
# Update status to calculating # Update status to calculating
@@ -193,6 +204,20 @@ class CheckpointScanner(ModelScanner):
# Update hash index # Update hash index
self._hash_index.add_entry(sha256.lower(), file_path) self._hash_index.add_entry(sha256.lower(), file_path)
# Update the in-memory cache entry so that subsequent
# _persist_current_cache / _save_persistent_cache calls
# write the hash back to the SQLite models table. Without
# this the hash only lives in the metadata file and the
# in-memory hash index, both of which are lost across
# restarts, causing the same re-computation loop on the
# next session.
if self._cache is not None and self._cache.raw_data:
for entry in self._cache.raw_data:
if entry.get("file_path") == file_path:
entry["sha256"] = sha256.lower()
entry["hash_status"] = "completed"
break
logger.info(f"Hash calculated for checkpoint: {file_path}") logger.info(f"Hash calculated for checkpoint: {file_path}")
return sha256 return sha256
+27 -29
View File
@@ -682,7 +682,10 @@ class DownloadManager:
u for u in download_urls if not u.startswith(CIVITAI_DOWNLOAD_URL_PREFIXES) u for u in download_urls if not u.startswith(CIVITAI_DOWNLOAD_URL_PREFIXES)
] ]
download_urls = non_civitai_urls + civitai_urls download_urls = non_civitai_urls + civitai_urls
else:
# Fallback: when mirrors is empty or all mirrors have been deleted,
# use the file's downloadUrl directly (e.g. CivitAI download endpoint).
if not download_urls:
download_url = file_info.get("downloadUrl") download_url = file_info.get("downloadUrl")
if download_url: if download_url:
download_urls.append(normalize_civitai_download_url(download_url)) download_urls.append(normalize_civitai_download_url(download_url))
@@ -1386,7 +1389,17 @@ class DownloadManager:
# Update save directory with relative path if provided # Update save directory with relative path if provided
if relative_path: if relative_path:
base_save_dir = save_dir
save_dir = os.path.join(save_dir, relative_path) save_dir = os.path.join(save_dir, relative_path)
# Security: validate path containment after joining
resolved_dir = os.path.realpath(os.path.normpath(save_dir))
base_dir = os.path.realpath(os.path.normpath(base_save_dir))
if not resolved_dir.startswith(base_dir + os.sep) and resolved_dir != base_dir:
logger.warning(
"Path traversal detected: %s escapes %s",
resolved_dir, base_dir,
)
return {"success": False, "error": "Download path is outside allowed directory"}
# Create directory if it doesn't exist # Create directory if it doesn't exist
os.makedirs(save_dir, exist_ok=True) os.makedirs(save_dir, exist_ok=True)
@@ -1520,35 +1533,8 @@ class DownloadManager:
if not file_info: if not file_info:
return {"success": False, "error": "No suitable file found in metadata"} return {"success": False, "error": "No suitable file found in metadata"}
mirrors = file_info.get("mirrors") or []
download_urls = []
if mirrors:
for mirror in mirrors:
if mirror.get("deletedAt") is None and mirror.get("url"):
download_urls.append(
normalize_civitai_download_url(mirror["url"])
)
# When source is 'civarchive', prioritize non-Civitai URLs download_urls = self._build_download_urls_from_file_info(file_info, source=source)
# This avoids failed downloads from deleted Civitai models
if source == "civarchive" and len(download_urls) > 1:
civitai_urls = [
u
for u in download_urls
if u.startswith(CIVITAI_DOWNLOAD_URL_PREFIXES)
]
non_civitai_urls = [
u
for u in download_urls
if not u.startswith(CIVITAI_DOWNLOAD_URL_PREFIXES)
]
download_urls = non_civitai_urls + civitai_urls
else:
download_url = file_info.get("downloadUrl")
if download_url:
download_urls.append(
normalize_civitai_download_url(download_url)
)
if not download_urls: if not download_urls:
return {"success": False, "error": "No mirror URL found"} return {"success": False, "error": "No mirror URL found"}
@@ -1851,6 +1837,9 @@ class DownloadManager:
model_tags, model_type model_tags, model_type
) )
if not first_tag:
first_tag = "no tags" # Default if no tags available
# Format the template with available data # Format the template with available data
formatted_path = path_template formatted_path = path_template
formatted_path = formatted_path.replace("{base_model}", mapped_base_model) formatted_path = formatted_path.replace("{base_model}", mapped_base_model)
@@ -1866,6 +1855,15 @@ class DownloadManager:
if model_type == "embedding": if model_type == "embedding":
formatted_path = formatted_path.replace(" ", "_") formatted_path = formatted_path.replace(" ", "_")
# Sanitize the resolved path to prevent path traversal:
# - Strip leading slashes (prevents os.path.join from treating path as absolute)
# - Collapse double slashes from empty placeholder substitutions
# - Strip trailing slashes for cleanliness
formatted_path = formatted_path.lstrip("/")
while "//" in formatted_path:
formatted_path = formatted_path.replace("//", "/")
formatted_path = formatted_path.rstrip("/")
return formatted_path return formatted_path
async def _execute_download( async def _execute_download(
+43 -18
View File
@@ -74,6 +74,8 @@ class DownloadQueueService:
); );
CREATE INDEX IF NOT EXISTS idx_dh_completed ON download_history(completed_at DESC); CREATE INDEX IF NOT EXISTS idx_dh_completed ON download_history(completed_at DESC);
CREATE INDEX IF NOT EXISTS idx_dh_status ON download_history(status); CREATE INDEX IF NOT EXISTS idx_dh_status ON download_history(status);
CREATE UNIQUE INDEX IF NOT EXISTS idx_dh_download_id
ON download_history(download_id) WHERE download_id IS NOT NULL;
""" """
@classmethod @classmethod
@@ -390,7 +392,7 @@ class DownloadQueueService:
) )
conn.execute( conn.execute(
""" """
INSERT INTO download_history ( INSERT OR IGNORE INTO download_history (
download_id, model_id, model_version_id, model_name, download_id, model_id, model_version_id, model_name,
version_name, thumbnail_url, status, error, file_path, version_name, thumbnail_url, status, error, file_path,
bytes_downloaded, total_bytes, completed_at bytes_downloaded, total_bytes, completed_at
@@ -547,17 +549,27 @@ class DownloadQueueService:
"offset": offset, "offset": offset,
} }
async def delete_history_item(self, id: int) -> bool: async def delete_history_item(
"""Delete a single history entry by its *id*. self, id: Optional[int] = None, download_id: Optional[str] = None
) -> bool:
"""Delete a single history entry by *download_id* (preferred) or *id*.
Returns ``True`` if a row was deleted. Returns ``True`` if a row was deleted.
""" """
async with self._lock: async with self._lock:
conn = self._get_conn() conn = self._get_conn()
cursor = conn.execute( if download_id:
"DELETE FROM download_history WHERE id = ?", cursor = conn.execute(
(id,), "DELETE FROM download_history WHERE download_id = ?",
) (download_id,),
)
elif id is not None:
cursor = conn.execute(
"DELETE FROM download_history WHERE id = ?",
(id,),
)
else:
return False
conn.commit() conn.commit()
return cursor.rowcount > 0 return cursor.rowcount > 0
@@ -614,21 +626,34 @@ class DownloadQueueService:
# Retry # Retry
# ------------------------------------------------------------------ # ------------------------------------------------------------------
async def retry_from_history(self, item_id: int) -> Optional[dict[str, Any]]: async def retry_from_history(
self,
item_id: Optional[int] = None,
download_id: Optional[str] = None,
) -> Optional[dict[str, Any]]:
"""Re-queue a failed or canceled download from history. """Re-queue a failed or canceled download from history.
Looks up the history record by its primary key. If the status is Looks up the history record by *download_id* (preferred) or
``failed`` or ``canceled`` a new queue entry is created with the *item_id*. If the status is ``failed`` or ``canceled`` a new
same model metadata and a fresh download id, and the original queue entry is created with the same model metadata and a fresh
history entry is **deleted** to prevent exponential growth when download id, and the original history entry is **deleted** to
the retried item is later canceled or fails again and re-retried. prevent exponential growth when the retried item is later
canceled or fails again and re-retried.
""" """
async with self._lock: async with self._lock:
conn = self._get_conn() conn = self._get_conn()
row = conn.execute( if download_id:
"SELECT * FROM download_history WHERE id = ?", row = conn.execute(
(item_id,), "SELECT * FROM download_history WHERE download_id = ?",
).fetchone() (download_id,),
).fetchone()
elif item_id is not None:
row = conn.execute(
"SELECT * FROM download_history WHERE id = ?",
(item_id,),
).fetchone()
else:
return None
if row is None: if row is None:
return None return None
status = str(row["status"]) status = str(row["status"])
@@ -660,7 +685,7 @@ class DownloadQueueService:
) )
conn.execute( conn.execute(
"DELETE FROM download_history WHERE id = ?", "DELETE FROM download_history WHERE id = ?",
(item_id,), (row["id"],),
) )
conn.commit() conn.commit()
queued = conn.execute( queued = conn.execute(
+69 -16
View File
@@ -15,6 +15,17 @@ from .service_registry import ServiceRegistry
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_PROVIDER_DISPLAY_NAMES = {
"civitai_api": "CivitAI",
"civarchive_api": "CivArchive",
"sqlite": "Archive DB",
}
_PRESET_PROVIDER_ORDERS = {
"civitai_archive_sqlite": ["civitai_api", "civarchive_api", "sqlite"],
"civitai_sqlite_archive": ["civitai_api", "sqlite", "civarchive_api"],
}
async def initialize_metadata_providers(): async def initialize_metadata_providers():
"""Initialize and configure all metadata providers based on settings""" """Initialize and configure all metadata providers based on settings"""
provider_manager = await ModelMetadataProviderManager.get_instance() provider_manager = await ModelMetadataProviderManager.get_instance()
@@ -26,7 +37,9 @@ async def initialize_metadata_providers():
# Get settings # Get settings
settings_manager = get_settings_manager() settings_manager = get_settings_manager()
enable_archive_db = settings_manager.get('enable_metadata_archive_db', False) enable_archive_db = settings_manager.get('enable_metadata_archive_db', False)
enable_civarchive_api = settings_manager.get('enable_civarchive_api', True)
provider_order = settings_manager.get('metadata_provider_order', 'civitai_archive_sqlite')
providers = [] providers = []
# Initialize archive database provider if enabled # Initialize archive database provider if enabled
@@ -59,27 +72,48 @@ async def initialize_metadata_providers():
except Exception as e: except Exception as e:
logger.error(f"Failed to initialize Civitai API metadata provider: {e}") logger.error(f"Failed to initialize Civitai API metadata provider: {e}")
# Register CivArchive provider, and all add to fallback providers # Register CivArchive provider when enabled. Civitai API is always
try: # preferred (better metadata); CivArchive mainly recovers metadata for
civarchive_client = await ServiceRegistry.get_civarchive_client() # models deleted from Civitai, so it can be turned off to avoid its long
civarchive_provider = CivArchiveModelMetadataProvider(civarchive_client) # rate-limit windows entirely.
provider_manager.register_provider('civarchive_api', civarchive_provider) if enable_civarchive_api:
providers.append(('civarchive_api', civarchive_provider)) try:
logger.debug("CivArchive metadata provider registered (also included in fallback)") civarchive_client = await ServiceRegistry.get_civarchive_client()
except Exception as e: civarchive_provider = CivArchiveModelMetadataProvider(civarchive_client)
logger.error(f"Failed to initialize CivArchive metadata provider: {e}") provider_manager.register_provider('civarchive_api', civarchive_provider)
providers.append(('civarchive_api', civarchive_provider))
logger.debug("CivArchive metadata provider registered (also included in fallback)")
except Exception as e:
logger.error(f"Failed to initialize CivArchive metadata provider: {e}")
else:
logger.debug("CivArchive metadata provider disabled by setting 'enable_civarchive_api'")
# Preset fallback orderings (see module-level _PRESET_PROVIDER_ORDERS).
# civitai_api is always first (better metadata); the remaining providers
# are arranged by the configured preset. Providers that are not
# registered (disabled/unavailable) are simply skipped, so each preset
# degrades gracefully.
desired_order = _PRESET_PROVIDER_ORDERS.get(
provider_order, _PRESET_PROVIDER_ORDERS["civitai_archive_sqlite"]
)
# Set up fallback provider based on available providers # Set up fallback provider based on available providers
if len(providers) > 1: if len(providers) > 1:
# Always use Civitai API (it has better metadata), then CivArchive API, then Archive DB
ordered_providers: list[tuple[str, ModelMetadataProvider]] = [] ordered_providers: list[tuple[str, ModelMetadataProvider]] = []
ordered_providers.extend([p for p in providers if p[0] == 'civitai_api']) for name in desired_order:
ordered_providers.extend([p for p in providers if p[0] == 'civarchive_api']) ordered_providers.extend([p for p in providers if p[0] == name])
ordered_providers.extend([p for p in providers if p[0] == 'sqlite']) # Include any provider not covered by the preset (defensive) at the end
for p in providers:
if p not in ordered_providers:
ordered_providers.append(p)
if ordered_providers: if ordered_providers:
fallback_provider = FallbackMetadataProvider(ordered_providers) fallback_provider = FallbackMetadataProvider(ordered_providers)
provider_manager.register_provider('fallback', fallback_provider, is_default=True) provider_manager.register_provider('fallback', fallback_provider, is_default=True)
logger.debug(
"Metadata fallback provider order: %s",
", ".join(name for name, _ in ordered_providers),
)
elif len(providers) == 1: elif len(providers) == 1:
# Only one provider available, set it as default # Only one provider available, set it as default
provider_name, provider = providers[0] provider_name, provider = providers[0]
@@ -96,11 +130,30 @@ async def update_metadata_providers():
# Get current settings # Get current settings
settings_manager = get_settings_manager() settings_manager = get_settings_manager()
enable_archive_db = settings_manager.get('enable_metadata_archive_db', False) enable_archive_db = settings_manager.get('enable_metadata_archive_db', False)
enable_civarchive_api = settings_manager.get('enable_civarchive_api', True)
provider_order = settings_manager.get('metadata_provider_order', 'civitai_archive_sqlite')
# Reinitialize all providers with new settings # Reinitialize all providers with new settings
provider_manager = await initialize_metadata_providers() provider_manager = await initialize_metadata_providers()
logger.info(f"Updated metadata providers, archive_db enabled: {enable_archive_db}") # Build effective provider chain for logging (use actually-registered
# providers, not just settings, so a failed init is reflected correctly)
registered = set(provider_manager.providers.keys())
desired = _PRESET_PROVIDER_ORDERS.get(
provider_order, _PRESET_PROVIDER_ORDERS["civitai_archive_sqlite"]
)
chain = "".join(
_PROVIDER_DISPLAY_NAMES[p]
for p in desired
if p in registered and p in _PROVIDER_DISPLAY_NAMES
)
logger.info(
"Updated metadata providers: archive_db=%s, civarchive_api=%s, chain=%s",
enable_archive_db,
enable_civarchive_api,
chain,
)
return provider_manager return provider_manager
except Exception as e: except Exception as e:
logger.error(f"Failed to update metadata providers: {e}") logger.error(f"Failed to update metadata providers: {e}")
+4
View File
@@ -8,6 +8,7 @@ from abc import ABC, abstractmethod
from ..utils.utils import calculate_relative_path_for_model, remove_empty_dirs from ..utils.utils import calculate_relative_path_for_model, remove_empty_dirs
from ..utils.constants import AUTO_ORGANIZE_BATCH_SIZE from ..utils.constants import AUTO_ORGANIZE_BATCH_SIZE
from ..services.settings_manager import get_settings_manager from ..services.settings_manager import get_settings_manager
from ..services.model_lifecycle_service import _require_path_in_library_roots
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -493,6 +494,9 @@ class ModelMoveService:
Dictionary with move result Dictionary with move result
""" """
try: try:
_require_path_in_library_roots(file_path, self.scanner, label="Source path")
_require_path_in_library_roots(target_path, self.scanner, label="Target path")
if use_default_paths: if use_default_paths:
# Find the model in cache to get metadata # Find the model in cache to get metadata
cache = await self.scanner.get_cached_data() cache = await self.scanner.get_cached_data()
+40
View File
@@ -48,6 +48,35 @@ async def delete_model_artifacts(
return deleted return deleted
def _require_path_in_library_roots(file_path: str, scanner, *, label: str = "path") -> None:
"""Raise ``ValueError`` if *file_path* is not inside a configured model root.
Uses ``os.path.realpath()`` to resolve symlinks before comparing,
so symlink-based escapes are also caught. Skips when the scanner
does not expose ``get_model_roots`` or the list is empty.
"""
roots = None
if hasattr(scanner, "get_model_roots"):
try:
roots = scanner.get_model_roots()
except NotImplementedError:
roots = None
if not roots:
return
resolved = os.path.realpath(os.path.normpath(file_path))
for root in roots:
root_resolved = os.path.realpath(os.path.normpath(root))
if resolved == root_resolved or resolved.startswith(root_resolved + os.sep):
return
raise ValueError(
f"{label} '{file_path}' is outside configured library directories"
)
class ModelLifecycleService: class ModelLifecycleService:
"""Co-ordinate destructive and mutating model operations.""" """Co-ordinate destructive and mutating model operations."""
@@ -74,6 +103,8 @@ class ModelLifecycleService:
if not file_path: if not file_path:
raise ValueError("Model path is required") raise ValueError("Model path is required")
_require_path_in_library_roots(file_path, self._scanner, label="File path")
cache = await self._scanner.get_cached_data() cache = await self._scanner.get_cached_data()
cached_entry = None cached_entry = None
@@ -182,6 +213,8 @@ class ModelLifecycleService:
if not file_path: if not file_path:
raise ValueError("Model path is required") raise ValueError("Model path is required")
_require_path_in_library_roots(file_path, self._scanner, label="File path")
metadata_path = os.path.splitext(file_path)[0] + ".metadata.json" metadata_path = os.path.splitext(file_path)[0] + ".metadata.json"
metadata = await self._metadata_loader(metadata_path) metadata = await self._metadata_loader(metadata_path)
metadata["exclude"] = True metadata["exclude"] = True
@@ -229,6 +262,8 @@ class ModelLifecycleService:
if not file_path: if not file_path:
raise ValueError("Model path is required") raise ValueError("Model path is required")
_require_path_in_library_roots(file_path, self._scanner, label="File path")
if not os.path.exists(file_path): if not os.path.exists(file_path):
raise ValueError("Model file does not exist") raise ValueError("Model file does not exist")
@@ -270,6 +305,9 @@ class ModelLifecycleService:
if not file_paths: if not file_paths:
raise ValueError("No file paths provided for deletion") raise ValueError("No file paths provided for deletion")
for path in file_paths:
_require_path_in_library_roots(path, self._scanner, label="File path")
return await self._scanner.bulk_delete_models(file_paths) return await self._scanner.bulk_delete_models(file_paths)
async def rename_model( async def rename_model(
@@ -280,6 +318,8 @@ class ModelLifecycleService:
if not file_path or not new_file_name: if not file_path or not new_file_name:
raise ValueError("File path and new file name are required") raise ValueError("File path and new file name are required")
_require_path_in_library_roots(file_path, self._scanner, label="File path")
invalid_chars = {"/", "\\", ":", "*", "?", '"', "<", ">", "|"} invalid_chars = {"/", "\\", ":", "*", "?", '"', "<", ">", "|"}
if any(char in new_file_name for char in invalid_chars): if any(char in new_file_name for char in invalid_chars):
raise ValueError("Invalid characters in file name") raise ValueError("Invalid characters in file name")
+244 -2
View File
@@ -14,7 +14,7 @@ from ..utils.metadata_manager import MetadataManager
from ..utils.civitai_utils import resolve_license_info from ..utils.civitai_utils import resolve_license_info
from .model_cache import ModelCache from .model_cache import ModelCache
from .model_hash_index import ModelHashIndex from .model_hash_index import ModelHashIndex
from .model_lifecycle_service import delete_model_artifacts from .model_lifecycle_service import delete_model_artifacts, _require_path_in_library_roots
from .service_registry import ServiceRegistry from .service_registry import ServiceRegistry
from .websocket_manager import ws_manager from .websocket_manager import ws_manager
from .persistent_model_cache import get_persistent_cache from .persistent_model_cache import get_persistent_cache
@@ -1394,6 +1394,9 @@ class ModelScanner:
base_name = os.path.splitext(os.path.basename(source_path))[0] base_name = os.path.splitext(os.path.basename(source_path))[0]
source_dir = os.path.dirname(source_path) source_dir = os.path.dirname(source_path)
_require_path_in_library_roots(source_path, self, label="Source path")
_require_path_in_library_roots(target_path, self, label="Target path")
os.makedirs(target_path, exist_ok=True) os.makedirs(target_path, exist_ok=True)
@@ -1566,6 +1569,218 @@ class ModelScanner:
return cache_entry if metadata else True return cache_entry if metadata else True
async def sync_cache_from_metadata(
self, file_path: str, metadata_dict: Dict[str, Any]
) -> bool:
"""Opportunistically sync in-memory and persistent caches from metadata.
Builds a prospective cache entry from *metadata_dict* (deserialized
``.metadata.json`` content) and compares it against the current cache
entry. When the two are already identical this method returns
``False`` without touching anything avoiding the overhead of
``update_single_model_cache``, which always removes and re-inserts
the entry, triggers a full resort, and persists via the heavyweight
``save_cache()``.
When differences are detected the update is applied **in-place** with
targeted operations:
* The existing ``raw_data`` entry is modified rather than removed and
re-appended (O(1) instead of O(n)).
* Tag counts and the hash index are updated incrementally.
* The version index is rebuilt only for the affected entry.
* ``resort()`` is called **only** when a sort-relevant field changed
(``model_name`` / ``file_name`` for name-sort, ``modified`` for
date-sort, ``size`` for size-sort).
* The persistent (SQLite) cache receives a targeted single-row update
via :meth:`PersistentModelCache.update_single_model` rather than a
full-table ``save_cache()``.
Returns:
``True`` if any cache update was performed, ``False`` if the
caches were already in sync.
.. note::
This is a **best-effort** operation. Failures are logged but
never propagated callers should fire-and-forget via
:func:`asyncio.create_task`.
"""
try:
return await self._sync_cache_from_metadata_impl(
file_path, metadata_dict
)
except Exception:
logger.warning(
"sync_cache_from_metadata failed for %s",
file_path,
exc_info=True,
)
return False
async def _sync_cache_from_metadata_impl(
self, file_path: str, metadata_dict: Dict[str, Any]
) -> bool:
cache = await self.get_cached_data()
# Locate the existing cache entry -----------------------------------
existing_idx: Optional[int] = None
existing_entry: Optional[Dict[str, Any]] = None
for i, item in enumerate(cache.raw_data):
if item.get("file_path") == file_path:
existing_entry = item
existing_idx = i
break
# Build the desired entry from metadata ------------------------------
folder_value = (
existing_entry.get("folder", "")
if existing_entry
else self._calculate_folder(file_path)
)
desired_entry = self._build_cache_entry(
metadata_dict,
folder=folder_value,
file_path_override=file_path,
)
# Ensure sha256 is populated (defensive — metadata should have it)
if (
not desired_entry.get("sha256")
and file_path
and os.path.exists(file_path)
):
try:
sha256 = await calculate_sha256(file_path)
if sha256:
desired_entry["sha256"] = sha256.lower()
except Exception:
pass
# Not in cache at all — delegate to the full update path ------------
if existing_entry is None:
result = await self.update_single_model_cache(
file_path, file_path, metadata_dict
)
return bool(result)
# Compare — skip everything if already in sync -----------------------
if not self._cache_entries_differ(existing_entry, desired_entry):
return False
# Re-validate: the cache may have been replaced concurrently
# (e.g. by _apply_scan_result). Use identity check, not equality,
# so we detect when the raw_data list was swapped out from under us.
if self._cache is None or not any(
item is existing_entry for item in self._cache.raw_data
):
return False
# ---- Differences detected: apply targeted, in-place updates --------
# Snapshot old values for delta computations
old_tags = list(existing_entry.get("tags") or [])
old_sha256: str = existing_entry.get("sha256", "") or ""
old_model_name: str = existing_entry.get("model_name", "") or ""
old_file_name: str = existing_entry.get("file_name", "") or ""
old_modified: float = float(existing_entry.get("modified", 0.0) or 0.0)
old_size: int = int(existing_entry.get("size", 0) or 0)
old_civitai = existing_entry.get("civitai")
# ---- In-place update of the cache entry ----
existing_entry.clear()
existing_entry.update(desired_entry)
# ---- Incremental tag count update ----
new_tags: set = set(desired_entry.get("tags") or [])
old_tag_set: set = set(old_tags)
for tag in old_tag_set - new_tags:
current = self._tags_count.get(tag, 0)
if current <= 1:
self._tags_count.pop(tag, None)
else:
self._tags_count[tag] = current - 1
for tag in new_tags - old_tag_set:
self._tags_count[tag] = self._tags_count.get(tag, 0) + 1
# ---- Incremental hash index update ----
new_sha = (desired_entry.get("sha256", "") or "").lower()
old_sha = (old_sha256 or "").lower()
if new_sha != old_sha:
if old_sha:
self._hash_index.remove_by_path(file_path)
if new_sha:
self._hash_index.add_entry(new_sha, file_path)
# ---- Incremental version index update ----
new_civitai = desired_entry.get("civitai")
if old_civitai != new_civitai:
temp_old = {
"file_path": file_path,
"file_name": old_file_name,
"civitai": old_civitai,
}
cache.remove_from_version_index(temp_old)
cache.add_to_version_index(existing_entry)
# ---- Conditional resort (only when sort-key fields changed) ----
need_resort = False
_last = cache._last_sort
sort_key: Optional[str] = _last[0] if _last != (None, None) else None
if sort_key == "name":
if (
old_model_name != desired_entry.get("model_name", "")
or old_file_name != desired_entry.get("file_name", "")
):
need_resort = True
elif sort_key == "date":
if old_modified != float(desired_entry.get("modified", 0.0) or 0.0):
need_resort = True
elif sort_key == "size":
if old_size != int(desired_entry.get("size", 0) or 0):
need_resort = True
if need_resort:
await cache.resort()
# ---- Targeted SQL update (single row, not full save_cache) ----
persistent = getattr(self, "_persistent_cache", None)
if persistent is not None:
old_item_for_sql: Dict[str, Any] = {
"file_path": file_path,
"tags": old_tags,
"sha256": old_sha256,
}
await asyncio.get_event_loop().run_in_executor(
None,
persistent.update_single_model,
self.model_type,
desired_entry,
old_item_for_sql,
)
return True
@staticmethod
def _cache_entries_differ(a: Dict[str, Any], b: Dict[str, Any]) -> bool:
"""Return ``True`` when two cache-entry dicts differ in any field.
Tag lists are compared order-insensitively; all other keys use
standard equality.
"""
a_tags = sorted(a.get("tags") or [])
b_tags = sorted(b.get("tags") or [])
if a_tags != b_tags:
return True
all_keys = set(a.keys()) | set(b.keys())
for key in all_keys:
if key == "tags":
continue
if a.get(key) != b.get(key):
return True
return False
def has_hash(self, sha256: str) -> bool: def has_hash(self, sha256: str) -> bool:
"""Check if a model with given hash exists""" """Check if a model with given hash exists"""
return self._hash_index.has_hash(sha256.lower()) return self._hash_index.has_hash(sha256.lower())
@@ -1618,7 +1833,32 @@ class ModelScanner:
if limit == 0: if limit == 0:
return sorted_tags return sorted_tags
return sorted_tags[:limit] return sorted_tags[:limit]
async def search_tags(
self, query: str, limit: int = 50
) -> List[Dict[str, any]]:
"""Search tags by case-insensitive substring match, sorted by count.
If query is empty, behaves like get_top_tags (returns top ``limit``
tags). If limit is 0, all matching tags are returned.
"""
await self.get_cached_data()
normalized_query = (query or "").strip().lower()
if not normalized_query:
return await self.get_top_tags(limit if limit > 0 else 20)
matched = [
{"tag": tag, "count": count}
for tag, count in self._tags_count.items()
if normalized_query in tag.lower()
]
matched.sort(key=lambda x: x["count"], reverse=True)
if limit == 0:
return matched
return matched[:limit]
async def get_base_models(self, limit: int = 20) -> List[Dict[str, any]]: async def get_base_models(self, limit: int = 20) -> List[Dict[str, any]]:
"""Get base models sorted by count. If limit is 0, return all.""" """Get base models sorted by count. If limit is 0, return all."""
cache = await self.get_cached_data() cache = await self.get_cached_data()
@@ -1734,6 +1974,8 @@ class ModelScanner:
break break
try: try:
_require_path_in_library_roots(file_path, self, label="File path")
target_dir = os.path.dirname(file_path) target_dir = os.path.dirname(file_path)
base_name = os.path.basename(file_path) base_name = os.path.basename(file_path)
file_name, main_extension = os.path.splitext(base_name) file_name, main_extension = os.path.splitext(base_name)
+89
View File
@@ -587,6 +587,95 @@ class PersistentModelCache:
placeholders = ", ".join(["?"] * len(self._MODEL_COLUMNS)) placeholders = ", ".join(["?"] * len(self._MODEL_COLUMNS))
return f"INSERT INTO models ({columns}) VALUES ({placeholders})" return f"INSERT INTO models ({columns}) VALUES ({placeholders})"
def update_single_model(
self,
model_type: str,
new_item: Dict,
old_item: Optional[Dict] = None,
) -> None:
"""Update a single model row in the persistent cache.
A lightweight alternative to :meth:`save_cache` that performs a targeted
DELETE + INSERT for the model row and computes incremental tag / hash-index
deltas from *old_item*. When *old_item* is omitted the previous tags and
hash are not cleaned up (callers should only omit it for brand-new entries).
All operations run inside a single transaction so readers see a consistent
view.
"""
if not self.is_enabled():
return
if not self._schema_initialized:
self._initialize_schema()
if not self._schema_initialized:
return
file_path: Optional[str] = new_item.get("file_path")
if not file_path:
return
try:
with self._db_lock:
conn = self._connect()
try:
conn.execute("PRAGMA foreign_keys = ON")
conn.execute("BEGIN")
# --- model row (DELETE + INSERT = upsert) ---
conn.execute(
"DELETE FROM models WHERE model_type = ? AND file_path = ?",
(model_type, file_path),
)
row = self._prepare_model_row(model_type, new_item)
conn.execute(self._insert_model_sql(), row)
# --- tags ---
new_tags: set = set(new_item.get("tags") or [])
old_tags: set = set(old_item.get("tags") or []) if old_item else set()
tags_to_delete = old_tags - new_tags
tags_to_insert = new_tags - old_tags
if tags_to_delete:
conn.executemany(
"DELETE FROM model_tags WHERE model_type = ? AND file_path = ? AND tag = ?",
[(model_type, file_path, t) for t in tags_to_delete],
)
if tags_to_insert:
conn.executemany(
"INSERT INTO model_tags (model_type, file_path, tag) VALUES (?, ?, ?)",
[(model_type, file_path, t) for t in tags_to_insert],
)
# --- hash_index ---
new_sha: Optional[str] = (new_item.get("sha256") or "").lower() or None
old_sha: Optional[str] = (
(old_item.get("sha256") or "").lower() or None
) if old_item else None
if new_sha != old_sha:
if old_sha:
conn.execute(
"DELETE FROM hash_index WHERE model_type = ? AND sha256 = ? AND file_path = ?",
(model_type, old_sha, file_path),
)
if new_sha:
conn.execute(
"INSERT OR IGNORE INTO hash_index (model_type, sha256, file_path) VALUES (?, ?, ?)",
(model_type, new_sha, file_path),
)
conn.execute("COMMIT")
except Exception:
conn.execute("ROLLBACK")
raise
finally:
conn.close()
except Exception as exc:
logger.warning(
"Failed to update single model in persistent cache (%s): %s",
file_path,
exc,
)
def _load_tags(self, conn: sqlite3.Connection, model_type: str) -> Dict[str, List[str]]: def _load_tags(self, conn: sqlite3.Connection, model_type: str) -> Dict[str, List[str]]:
tag_rows = conn.execute( tag_rows = conn.execute(
"SELECT file_path, tag FROM model_tags WHERE model_type = ?", "SELECT file_path, tag FROM model_tags WHERE model_type = ?",
+6 -2
View File
@@ -1,7 +1,6 @@
import asyncio import asyncio
from typing import Iterable, List, Dict, Optional from typing import Iterable, List, Dict, Optional
from dataclasses import dataclass, field from dataclasses import dataclass, field
from operator import itemgetter
from natsort import natsorted from natsort import natsorted
@@ -149,5 +148,10 @@ class RecipeCache:
) )
if not name_only: if not name_only:
self.sorted_by_date = sorted( self.sorted_by_date = sorted(
self.raw_data, key=itemgetter("created_date", "file_path"), reverse=True self.raw_data,
key=lambda x: (
x.get("modified", x.get("created_date", 0)),
x.get("file_path", ""),
),
reverse=True,
) )
+2 -1
View File
@@ -216,11 +216,12 @@ class RecipePersistenceService:
"preview_nsfw_level", "preview_nsfw_level",
"favorite", "favorite",
"gen_params", "gen_params",
"base_model",
) )
if not any(key in updates for key in allowed_fields): 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 or gen_params)" "At least one field to update must be provided (title or tags or source_path or preview_nsfw_level or favorite or gen_params or base_model)"
) )
if "gen_params" in updates and not isinstance(updates["gen_params"], dict): if "gen_params" in updates and not isinstance(updates["gen_params"], dict):
+2
View File
@@ -65,6 +65,8 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
"onboarding_completed": False, "onboarding_completed": False,
"dismissed_banners": [], "dismissed_banners": [],
"enable_metadata_archive_db": False, "enable_metadata_archive_db": False,
"enable_civarchive_api": True,
"metadata_provider_order": "civitai_archive_sqlite",
"proxy_enabled": False, "proxy_enabled": False,
"proxy_host": "", "proxy_host": "",
"proxy_port": "", "proxy_port": "",
+1
View File
@@ -12,6 +12,7 @@ NODE_TYPES = {
"Lora Loader (LoraManager)": 1, "Lora Loader (LoraManager)": 1,
"Lora Stacker (LoraManager)": 2, "Lora Stacker (LoraManager)": 2,
"WanVideo Lora Select (LoraManager)": 3, "WanVideo Lora Select (LoraManager)": 3,
"Create Hook LoRA (LoraManager)": 4,
} }
# Default ComfyUI node color when bgcolor is null # Default ComfyUI node color when bgcolor is null
+29
View File
@@ -113,6 +113,35 @@ def get_model_folder(model_hash: str, library_name: Optional[str] = None) -> str
exc, exc,
) )
return legacy_folder return legacy_folder
elif not os.path.exists(resolved_folder):
# Reverse migration: when consolidating from multi-library to
# single-library mode (e.g. after "default" was cleaned up), look
# for existing example images inside library-named subdirectories
# and bring them back to the root level.
root = get_example_images_root()
if root:
try:
for entry in os.listdir(root):
entry_path = os.path.join(root, entry)
if not os.path.isdir(entry_path):
continue
if is_hash_folder(entry) or entry == "_deleted":
continue
if not _library_folder_has_only_hash_dirs(entry_path):
continue
legacy = os.path.join(entry_path, normalized_hash)
if os.path.exists(legacy):
shutil.move(legacy, resolved_folder)
logger.info(
"Consolidated example images from '%s' to '%s'",
legacy, resolved_folder,
)
break
except OSError as exc:
logger.error(
"Failed to consolidate example images during "
"library merge: %s", exc,
)
return resolved_folder return resolved_folder
+6
View File
@@ -488,6 +488,12 @@ def calculate_relative_path_for_model(
if model_type == "embedding": if model_type == "embedding":
formatted_path = formatted_path.replace(" ", "_") formatted_path = formatted_path.replace(" ", "_")
# Sanitize the resolved path to prevent path traversal
formatted_path = formatted_path.lstrip("/")
while "//" in formatted_path:
formatted_path = formatted_path.replace("//", "/")
formatted_path = formatted_path.rstrip("/")
return formatted_path return formatted_path
+1 -1
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.1.7" version = "1.1.9"
license = {file = "LICENSE"} license = {file = "LICENSE"}
dependencies = [ dependencies = [
"aiohttp", "aiohttp",
+4
View File
@@ -1,6 +1,10 @@
import os import os
import sys import sys
import json import json
# Ensure the script's directory is on sys.path so that py.* imports resolve
# regardless of the current working directory (e.g. when launched via
# ComfyUI's python_embeded from the ComfyUI root directory).
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from py.middleware.cache_middleware import cache_control from py.middleware.cache_middleware import cache_control
from py.middleware.error_middleware import api_json_error from py.middleware.error_middleware import api_json_error
from py.utils.settings_paths import ensure_settings_file from py.utils.settings_paths import ensure_settings_file
+5
View File
@@ -274,6 +274,11 @@
font-style: italic; font-style: italic;
} }
/* Inline extra tags (selected but not in top-20/appended after API results) */
.filter-tag.extra-tag {
border-style: dashed;
}
/* Ensure solid border and full opacity when active or excluded */ /* Ensure solid border and full opacity when active or excluded */
.filter-tag.special-tag.active, .filter-tag.special-tag.active,
.filter-tag.special-tag.exclude { .filter-tag.special-tag.exclude {
+1 -4
View File
@@ -49,10 +49,6 @@ export const MODEL_CONFIG = {
* @returns {Object} Object containing all API endpoints for the model type * @returns {Object} Object containing all API endpoints for the model type
*/ */
export function getApiEndpoints(modelType) { export function getApiEndpoints(modelType) {
if (!Object.values(MODEL_TYPES).includes(modelType)) {
throw new Error(`Invalid model type: ${modelType}`);
}
return { return {
// Base CRUD operations // Base CRUD operations
list: `/api/lm/${modelType}/list`, list: `/api/lm/${modelType}/list`,
@@ -93,6 +89,7 @@ export function getApiEndpoints(modelType) {
// Query operations // Query operations
scan: `/api/lm/${modelType}/scan`, scan: `/api/lm/${modelType}/scan`,
topTags: `/api/lm/${modelType}/top-tags`, topTags: `/api/lm/${modelType}/top-tags`,
searchTags: `/api/lm/${modelType}/search-tags`,
baseModels: `/api/lm/${modelType}/base-models`, baseModels: `/api/lm/${modelType}/base-models`,
roots: `/api/lm/${modelType}/roots`, roots: `/api/lm/${modelType}/roots`,
folders: `/api/lm/${modelType}/folders`, folders: `/api/lm/${modelType}/folders`,
+7 -1
View File
@@ -1665,13 +1665,19 @@ export class BulkManager {
cancelled = true; cancelled = true;
}); });
const isRecipesPage = state.currentPageType === 'recipes';
for (const filepath of state.selectedModels) { for (const filepath of state.selectedModels) {
if (cancelled) { if (cancelled) {
showToast('toast.api.operationCancelled', {}, 'info'); showToast('toast.api.operationCancelled', {}, 'info');
break; break;
} }
try { try {
await getModelApiClient().saveModelMetadata(filepath, { base_model: newBaseModel }); if (isRecipesPage) {
await updateRecipeMetadata(filepath, { base_model: newBaseModel });
} else {
await getModelApiClient().saveModelMetadata(filepath, { base_model: newBaseModel });
}
successCount++; successCount++;
} catch (error) { } catch (error) {
errorCount++; errorCount++;
+123 -24
View File
@@ -1,6 +1,7 @@
import { getCurrentPageState } from '../state/index.js'; import { getCurrentPageState } from '../state/index.js';
import { showToast, updatePanelPositions } from '../utils/uiHelpers.js'; import { showToast, updatePanelPositions } from '../utils/uiHelpers.js';
import { getModelApiClient } from '../api/modelApiFactory.js'; import { getModelApiClient } from '../api/modelApiFactory.js';
import { getApiEndpoints } from '../api/apiConfig.js';
import { removeStorageItem, setStorageItem, getStorageItem } from '../utils/storageHelpers.js'; import { removeStorageItem, setStorageItem, getStorageItem } from '../utils/storageHelpers.js';
import { MODEL_TYPE_DISPLAY_NAMES } from '../utils/constants.js'; import { MODEL_TYPE_DISPLAY_NAMES } from '../utils/constants.js';
import { translate } from '../utils/i18nHelpers.js'; import { translate } from '../utils/i18nHelpers.js';
@@ -24,6 +25,12 @@ export class FilterManager {
this.baseModelOptions = []; this.baseModelOptions = [];
this.tagsLoaded = false; this.tagsLoaded = false;
// Tag search state
this.modelTagsSearchInput = document.getElementById('modelTagsSearchInput');
this.tagSearchDebounceTimer = null;
this.tagSearchAbortController = null;
this.tagSearchQuery = '';
// Initialize preset manager // Initialize preset manager
this.presetManager = new FilterPresetManager({ this.presetManager = new FilterPresetManager({
page: this.currentPage, page: this.currentPage,
@@ -123,6 +130,60 @@ export class FilterManager {
this.renderBaseModelTags(); this.renderBaseModelTags();
}); });
} }
if (this.modelTagsSearchInput) {
this.modelTagsSearchInput.addEventListener('input', () => {
clearTimeout(this.tagSearchDebounceTimer);
this.tagSearchDebounceTimer = setTimeout(() => {
this.handleTagSearchInput();
}, 150);
});
}
}
handleTagSearchInput() {
const query = (this.modelTagsSearchInput?.value || '').trim();
const trimmedQuery = query.toLowerCase();
if (trimmedQuery === this.tagSearchQuery) return;
this.tagSearchQuery = trimmedQuery;
if (!trimmedQuery) {
// Empty query: reload top tags (default/common view)
this.loadTopTags();
return;
}
this.searchTags(trimmedQuery);
}
async searchTags(query) {
// Abort any in-flight search request
if (this.tagSearchAbortController) {
this.tagSearchAbortController.abort();
}
this.tagSearchAbortController = new AbortController();
const controller = this.tagSearchAbortController;
try {
const tagsEndpoint = `${getApiEndpoints(this.currentPage).searchTags}?q=${encodeURIComponent(query)}&limit=20`;
const response = await fetch(tagsEndpoint, { signal: controller.signal });
if (!response.ok) throw new Error('Failed to search tags');
const data = await response.json();
if (controller.signal.aborted) return; // stale response
if (data.success && data.tags) {
this.createTagFilterElements(data.tags);
} else {
throw new Error('Invalid response format');
}
} catch (error) {
if (error.name === 'AbortError') return; // expected, ignore
console.error('Error searching tags:', error);
const tagsContainer = document.getElementById('modelTagsFilter');
if (tagsContainer) {
tagsContainer.innerHTML = '<div class="tags-error">Failed to search tags</div>';
}
const emptyState = document.getElementById('modelTagsEmptyState');
if (emptyState) emptyState.hidden = true;
}
} }
getNormalizedSearchQuery(input) { getNormalizedSearchQuery(input) {
@@ -146,15 +207,24 @@ export class FilterManager {
} }
async loadTopTags() { async loadTopTags() {
// Abort any in-flight tag search request
if (this.tagSearchAbortController) {
this.tagSearchAbortController.abort();
this.tagSearchAbortController = null;
}
this.tagSearchQuery = '';
try { try {
// Show loading state // Show loading state
const tagsContainer = document.getElementById('modelTagsFilter'); const tagsContainer = document.getElementById('modelTagsFilter');
const emptyState = document.getElementById('modelTagsEmptyState');
if (!tagsContainer) return; if (!tagsContainer) return;
if (emptyState) emptyState.hidden = true;
tagsContainer.innerHTML = '<div class="tags-loading">Loading tags...</div>'; tagsContainer.innerHTML = '<div class="tags-loading">Loading tags...</div>';
// Determine the API endpoint based on the page type // Determine the API endpoint based on the page type
const tagsEndpoint = `/api/lm/${this.currentPage}/top-tags?limit=20`; const tagsEndpoint = `${getApiEndpoints(this.currentPage).topTags}?limit=20`;
const response = await fetch(tagsEndpoint); const response = await fetch(tagsEndpoint);
if (!response.ok) throw new Error('Failed to fetch tags'); if (!response.ok) throw new Error('Failed to fetch tags');
@@ -179,29 +249,38 @@ export class FilterManager {
createTagFilterElements(tags) { createTagFilterElements(tags) {
const tagsContainer = document.getElementById('modelTagsFilter'); const tagsContainer = document.getElementById('modelTagsFilter');
const emptyState = document.getElementById('modelTagsEmptyState');
if (!tagsContainer) return; if (!tagsContainer) return;
tagsContainer.innerHTML = ''; tagsContainer.innerHTML = '';
if (emptyState) emptyState.hidden = true;
// Collect existing tag names from the API response // Collect existing tag names from the API response
const existingTagNames = new Set(tags.map(t => t.tag)); const existingTagNames = new Set(tags.map(t => t.tag));
// Add any active filter tags that aren't in the top 20 // Collect active filter tags that aren't in the response (excluding __no_tags__)
const missingSelectedTags = [];
if (this.filters.tags) { if (this.filters.tags) {
Object.keys(this.filters.tags).forEach(tagName => { Object.keys(this.filters.tags).forEach(tagName => {
// Skip special tags like __no_tags__
if (tagName.startsWith('__')) return; if (tagName.startsWith('__')) return;
if (!existingTagNames.has(tagName)) { if (!existingTagNames.has(tagName)) {
// Add this tag to the list with count 0 (unknown) missingSelectedTags.push({ tag: tagName, count: 0 });
tags.push({ tag: tagName, count: 0 });
existingTagNames.add(tagName); existingTagNames.add(tagName);
} }
}); });
} }
// Append missing selected tags after the API results so they appear inline
for (const t of missingSelectedTags) {
tags.push(t);
}
if (!tags.length) { if (!tags.length) {
tagsContainer.innerHTML = `<div class="no-tags">No ${this.currentPage === 'recipes' ? 'recipe ' : ''}tags available</div>`; if (this.tagSearchQuery) {
if (emptyState) emptyState.hidden = false;
} else {
tagsContainer.innerHTML = `<div class="no-tags">No ${this.currentPage === 'recipes' ? 'recipe ' : ''}tags available</div>`;
}
return; return;
} }
@@ -209,6 +288,10 @@ export class FilterManager {
const tagEl = document.createElement('div'); const tagEl = document.createElement('div');
tagEl.className = 'filter-tag tag-filter'; tagEl.className = 'filter-tag tag-filter';
const tagName = tag.tag; const tagName = tag.tag;
if (missingSelectedTags.some(t => t.tag === tagName)) {
tagEl.classList.add('extra-tag');
}
tagEl.dataset.tag = tagName; tagEl.dataset.tag = tagName;
// Show count only if it's > 0 (known count) // Show count only if it's > 0 (known count)
@@ -234,26 +317,28 @@ export class FilterManager {
tagsContainer.appendChild(tagEl); tagsContainer.appendChild(tagEl);
}); });
// Add "No tags" as a special filter at the end // Add "No tags" as a special filter at the end (skip during search)
const noTagsEl = document.createElement('div'); if (!this.tagSearchQuery) {
noTagsEl.className = 'filter-tag tag-filter special-tag'; const noTagsEl = document.createElement('div');
const noTagsLabel = translate('header.filter.noTags', {}, 'No tags'); noTagsEl.className = 'filter-tag tag-filter special-tag';
const noTagsKey = '__no_tags__'; const noTagsLabel = translate('header.filter.noTags', {}, 'No tags');
noTagsEl.dataset.tag = noTagsKey; const noTagsKey = '__no_tags__';
noTagsEl.innerHTML = noTagsLabel; noTagsEl.dataset.tag = noTagsKey;
noTagsEl.innerHTML = noTagsLabel;
noTagsEl.addEventListener('click', async () => { noTagsEl.addEventListener('click', async () => {
const currentState = (this.filters.tags && this.filters.tags[noTagsKey]) || 'none'; const currentState = (this.filters.tags && this.filters.tags[noTagsKey]) || 'none';
const newState = this.getNextTriStateState(currentState); const newState = this.getNextTriStateState(currentState);
this.setTagFilterState(noTagsKey, newState); this.setTagFilterState(noTagsKey, newState);
this.applyTagElementState(noTagsEl, newState); this.applyTagElementState(noTagsEl, newState);
this.updateActiveFiltersCount(); this.updateActiveFiltersCount();
await this.applyFilters(false); await this.applyFilters(false);
}); });
tagsContainer.appendChild(noTagsEl); tagsContainer.appendChild(noTagsEl);
}
this.updateTagSelections(); this.updateTagSelections();
} }
@@ -341,7 +426,7 @@ export class FilterManager {
if (!baseModelTagsContainer) return; if (!baseModelTagsContainer) return;
// Set the API endpoint based on current page // Set the API endpoint based on current page
const apiEndpoint = `/api/lm/${this.currentPage}/base-models?limit=0`; const apiEndpoint = `${getApiEndpoints(this.currentPage).baseModels}?limit=0`;
// Fetch base models // Fetch base models
fetch(apiEndpoint) fetch(apiEndpoint)
@@ -721,6 +806,16 @@ export class FilterManager {
tagLogic: 'any' tagLogic: 'any'
}); });
// Clear tag search input and reset search state
if (this.modelTagsSearchInput) {
this.modelTagsSearchInput.value = '';
}
this.tagSearchQuery = '';
if (this.tagSearchAbortController) {
this.tagSearchAbortController.abort();
this.tagSearchAbortController = null;
}
// Update tag logic toggle UI // Update tag logic toggle UI
this.updateTagLogicToggleUI(); this.updateTagLogicToggleUI();
@@ -731,6 +826,10 @@ export class FilterManager {
// Update UI // Update UI
this.updateTagSelections(); this.updateTagSelections();
this.updateActiveFiltersCount(); this.updateActiveFiltersCount();
// Reload tag area to drop any non-top-20 tags from the deactivated preset
if (this.tagsLoaded) {
await this.loadTopTags();
}
this.presetManager.renderPresets(); // Re-render to remove active state this.presetManager.renderPresets(); // Re-render to remove active state
// Remove from local Storage // Remove from local Storage
+13 -19
View File
@@ -478,11 +478,9 @@ export class FilterPresetManager {
const pageState = getCurrentPageState(); const pageState = getCurrentPageState();
pageState.filters = this.filterManager.cloneFilters(); pageState.filters = this.filterManager.cloneFilters();
// If tags haven't been loaded yet, load them first // Refresh tag display so preset's non-top-20 tags appear inline
if (!this.filterManager.tagsLoaded) { await this.filterManager.loadTopTags();
await this.filterManager.loadTopTags(); this.filterManager.tagsLoaded = true;
this.filterManager.tagsLoaded = true;
}
// Check again after async operation // Check again after async operation
if (requestId !== this.applyPresetRequestId) return; if (requestId !== this.applyPresetRequestId) return;
@@ -745,8 +743,16 @@ export class FilterPresetManager {
presetEl.classList.add('active'); presetEl.classList.add('active');
} }
presetEl.addEventListener('click', (e) => { // Apply preset on click (toggle if already active)
e.stopPropagation(); // Bind to the whole .filter-preset div so clicking anywhere inside triggers apply
presetEl.addEventListener('click', async () => {
this.cancelPendingDelete();
if (this.activePreset === preset.name) {
await this.filterManager.clearFilters();
} else {
await this.applyPreset(preset.name);
}
}); });
const presetName = document.createElement('span'); const presetName = document.createElement('span');
@@ -759,18 +765,6 @@ export class FilterPresetManager {
deleteBtn.innerHTML = '<i class="fas fa-times"></i>'; deleteBtn.innerHTML = '<i class="fas fa-times"></i>';
deleteBtn.title = translate('header.filter.presetDeleteTooltip', {}, 'Delete preset'); deleteBtn.title = translate('header.filter.presetDeleteTooltip', {}, 'Delete preset');
// Apply preset on name click (toggle if already active)
presetName.addEventListener('click', async (e) => {
e.stopPropagation();
this.cancelPendingDelete();
if (this.activePreset === preset.name) {
await this.filterManager.clearFilters();
} else {
await this.applyPreset(preset.name);
}
});
// Two-step delete on delete button click // Two-step delete on delete button click
deleteBtn.addEventListener('click', (e) => { deleteBtn.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
+10
View File
@@ -2346,6 +2346,16 @@ export class SettingsManager {
enableMetadataArchiveCheckbox.checked = state.global.settings.enable_metadata_archive_db || false; enableMetadataArchiveCheckbox.checked = state.global.settings.enable_metadata_archive_db || false;
} }
const enableCivarchiveApiCheckbox = document.getElementById('enableCivarchiveApi');
if (enableCivarchiveApiCheckbox) {
enableCivarchiveApiCheckbox.checked = state.global.settings.enable_civarchive_api ?? true;
}
const metadataProviderOrderSelect = document.getElementById('metadataProviderOrder');
if (metadataProviderOrderSelect) {
metadataProviderOrderSelect.value = state.global.settings.metadata_provider_order || 'civitai_archive_sqlite';
}
// Load status // Load status
await this.updateMetadataArchiveStatus(); await this.updateMetadataArchiveStatus();
} catch (error) { } catch (error) {
+2
View File
@@ -13,6 +13,8 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
language: 'en', language: 'en',
show_only_sfw: false, show_only_sfw: false,
enable_metadata_archive_db: false, enable_metadata_archive_db: false,
enable_civarchive_api: true,
metadata_provider_order: 'civitai_archive_sqlite',
proxy_enabled: false, proxy_enabled: false,
proxy_type: 'http', proxy_type: 'http',
proxy_host: '', proxy_host: '',
+6 -3
View File
@@ -369,21 +369,24 @@ export function getMatureBlurThreshold(settings = {}) {
export const NODE_TYPES = { export const NODE_TYPES = {
LORA_LOADER: 1, LORA_LOADER: 1,
LORA_STACKER: 2, LORA_STACKER: 2,
WAN_VIDEO_LORA_SELECT: 3 WAN_VIDEO_LORA_SELECT: 3,
HOOK_LORA: 4
}; };
// Node type names to IDs mapping // Node type names to IDs mapping
export const NODE_TYPE_NAMES = { export const NODE_TYPE_NAMES = {
"Lora Loader (LoraManager)": NODE_TYPES.LORA_LOADER, "Lora Loader (LoraManager)": NODE_TYPES.LORA_LOADER,
"Lora Stacker (LoraManager)": NODE_TYPES.LORA_STACKER, "Lora Stacker (LoraManager)": NODE_TYPES.LORA_STACKER,
"WanVideo Lora Select (LoraManager)": NODE_TYPES.WAN_VIDEO_LORA_SELECT "WanVideo Lora Select (LoraManager)": NODE_TYPES.WAN_VIDEO_LORA_SELECT,
"Create Hook LoRA (LoraManager)": NODE_TYPES.HOOK_LORA
}; };
// Node type icons // Node type icons
export const NODE_TYPE_ICONS = { export const NODE_TYPE_ICONS = {
[NODE_TYPES.LORA_LOADER]: "fas fa-l", [NODE_TYPES.LORA_LOADER]: "fas fa-l",
[NODE_TYPES.LORA_STACKER]: "fas fa-s", [NODE_TYPES.LORA_STACKER]: "fas fa-s",
[NODE_TYPES.WAN_VIDEO_LORA_SELECT]: "fas fa-w" [NODE_TYPES.WAN_VIDEO_LORA_SELECT]: "fas fa-w",
[NODE_TYPES.HOOK_LORA]: "fas fa-h"
}; };
// Default ComfyUI node color when bgcolor is null // Default ComfyUI node color when bgcolor is null
+40 -5
View File
@@ -141,6 +141,20 @@ const PARAM_TO_WIDGET_CANDIDATES = {
scheduler: ['scheduler'], scheduler: ['scheduler'],
}; };
// ---------------------------------------------------------------------------
// Node-type-specific widget name overrides.
// Keys are ComfyUI node class names (e.g. "GlobalSeed //Inspire").
// Values are partial PARAM_TO_WIDGET_CANDIDATES maps; the per-node candidates
// are tried *before* the global ones. Only the params listed here are
// overridden — every other param still uses the global candidates.
// ---------------------------------------------------------------------------
const NODE_TYPE_WIDGET_OVERRIDES = {
// Inspire Pack — Global Seed node stores the seed in a widget named "value"
'GlobalSeed //Inspire': {
seed: ['value'],
},
};
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Parse a combined sampler+scheduler value (space-separated or underscore) // Parse a combined sampler+scheduler value (space-separated or underscore)
// e.g., "Euler a Karras", "DPM++ 2M beta", "er_sde_beta" // e.g., "Euler a Karras", "DPM++ 2M beta", "er_sde_beta"
@@ -235,7 +249,7 @@ function resolveSamplerScheduler(rawValue) {
// Find which gen params can be sent to a given node, matching by widget names // Find which gen params can be sent to a given node, matching by widget names
// Returns array of { widgetName, value } objects // Returns array of { widgetName, value } objects
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function findMatchingWidgets(nodeWidgetNames, resolvedParams) { function findMatchingWidgets(nodeWidgetNames, resolvedParams, nodeType) {
if (!nodeWidgetNames || !Array.isArray(nodeWidgetNames) || nodeWidgetNames.length === 0) { if (!nodeWidgetNames || !Array.isArray(nodeWidgetNames) || nodeWidgetNames.length === 0) {
return []; return [];
} }
@@ -243,6 +257,26 @@ function findMatchingWidgets(nodeWidgetNames, resolvedParams) {
const widgetSet = new Set(nodeWidgetNames.map(w => String(w).toLowerCase())); const widgetSet = new Set(nodeWidgetNames.map(w => String(w).toLowerCase()));
const updates = []; const updates = [];
// Resolve node-type-specific overrides (if any)
const typeOverrides =
nodeType && typeof nodeType === 'string'
? (NODE_TYPE_WIDGET_OVERRIDES[nodeType] || {})
: {};
/**
* Build the effective candidate list for a parameter:
* type-specific overrides (if any) come first, then the global candidates.
*/
function getCandidates(key) {
const global = PARAM_TO_WIDGET_CANDIDATES[key] || [key];
const extra = typeOverrides[key];
if (extra && Array.isArray(extra) && extra.length > 0) {
// Prepend type-specific candidates; keep global as fallback
return [...extra, ...global];
}
return global;
}
// Simple numeric/string params: seed, steps, cfg // Simple numeric/string params: seed, steps, cfg
const simpleParams = [ const simpleParams = [
{ key: 'seed', value: resolvedParams.seed }, { key: 'seed', value: resolvedParams.seed },
@@ -251,10 +285,10 @@ function findMatchingWidgets(nodeWidgetNames, resolvedParams) {
]; ];
for (const { key, value } of simpleParams) { for (const { key, value } of simpleParams) {
if (value === undefined || value === null || value === '') continue; if (value === undefined || value === null || value === '') continue;
const candidates = PARAM_TO_WIDGET_CANDIDATES[key] || [key]; const candidates = getCandidates(key);
for (const candidate of candidates) { for (const candidate of candidates) {
if (widgetSet.has(candidate.toLowerCase())) { if (widgetSet.has(candidate.toLowerCase())) {
updates.push({ widgetName: candidate, value: String(value) }); updates.push({ widgetName: candidate, value });
break; break;
} }
} }
@@ -262,7 +296,7 @@ function findMatchingWidgets(nodeWidgetNames, resolvedParams) {
// Sampler // Sampler
if (resolvedParams.sampler) { if (resolvedParams.sampler) {
const candidates = PARAM_TO_WIDGET_CANDIDATES.sampler; const candidates = getCandidates('sampler');
for (const candidate of candidates) { for (const candidate of candidates) {
if (widgetSet.has(candidate.toLowerCase())) { if (widgetSet.has(candidate.toLowerCase())) {
updates.push({ widgetName: candidate, value: resolvedParams.sampler }); updates.push({ widgetName: candidate, value: resolvedParams.sampler });
@@ -273,7 +307,7 @@ function findMatchingWidgets(nodeWidgetNames, resolvedParams) {
// Scheduler // Scheduler
if (resolvedParams.scheduler) { if (resolvedParams.scheduler) {
const candidates = PARAM_TO_WIDGET_CANDIDATES.scheduler; const candidates = getCandidates('scheduler');
for (const candidate of candidates) { for (const candidate of candidates) {
if (widgetSet.has(candidate.toLowerCase())) { if (widgetSet.has(candidate.toLowerCase())) {
updates.push({ widgetName: candidate, value: resolvedParams.scheduler }); updates.push({ widgetName: candidate, value: resolvedParams.scheduler });
@@ -290,6 +324,7 @@ export {
SCHEDULER_SUFFIXES, SCHEDULER_SUFFIXES,
SCHEDULER_ONLY_VALUES, SCHEDULER_ONLY_VALUES,
PARAM_TO_WIDGET_CANDIDATES, PARAM_TO_WIDGET_CANDIDATES,
NODE_TYPE_WIDGET_OVERRIDES,
parseCombinedSamplerName, parseCombinedSamplerName,
resolveSamplerScheduler, resolveSamplerScheduler,
findMatchingWidgets, findMatchingWidgets,
+7 -4
View File
@@ -134,7 +134,10 @@ export async function copyToClipboard(text, successMessage = null) {
} }
export function showToast(key, params = {}, type = 'info', fallback = null) { export function showToast(key, params = {}, type = 'info', fallback = null) {
const message = translate(key, params, fallback); // Plain messages (contain spaces) are not i18n dot-notation keys — use verbatim
// to avoid spurious "Translation key not found" warnings from i18next
const isPlainMessage = typeof key === 'string' && /\s/.test(key);
const message = isPlainMessage ? key : translate(key, params, fallback);
const toast = document.createElement('div'); const toast = document.createElement('div');
toast.className = `toast toast-${type}`; toast.className = `toast toast-${type}`;
toast.textContent = message; toast.textContent = message;
@@ -605,7 +608,7 @@ function isNodeEnabled(node) {
if (!node) { if (!node) {
return false; return false;
} }
// ComfyUI node mode: 0 = Normal/Enabled, others = Always/Never/OnEvent // ComfyUI node mode (LGraphEventMode): 0 = Always, 2 = Never, 4 = Bypass
return node.mode === undefined || node.mode === 0; return node.mode === undefined || node.mode === 0;
} }
@@ -1144,8 +1147,8 @@ export async function sendGenParamsToWorkflow(genParams) {
const node = targetNodes[nodeKey]; const node = targetNodes[nodeKey];
if (!node) continue; if (!node) continue;
const widgetNames = node.widget_names || []; const widgetNames = getWidgetNames(node);
const updates = findMatchingWidgets(widgetNames, raw); const updates = findMatchingWidgets(widgetNames, raw, node.type_name);
if (updates.length === 0) { if (updates.length === 0) {
showToast(`Node "${node.title || node.type}" has no matching widgets for these parameters`, {}, 'warning'); showToast(`Node "${node.title || node.type}" has no matching widgets for these parameters`, {}, 'warning');
+5
View File
@@ -251,10 +251,15 @@
<button class="tag-logic-option" data-value="all" title="{{ t('header.filter.tagLogicAll') }}">{{ t('header.filter.all') }}</button> <button class="tag-logic-option" data-value="all" title="{{ t('header.filter.tagLogicAll') }}">{{ t('header.filter.all') }}</button>
</div> </div>
</div> </div>
<input type="text" id="modelTagsSearchInput" class="filter-search-input"
placeholder="{{ t('header.filter.tagSearchPlaceholder') }}" autocomplete="off">
<div class="filter-tags" id="modelTagsFilter"> <div class="filter-tags" id="modelTagsFilter">
<!-- Top tags will be dynamically inserted here --> <!-- Top tags will be dynamically inserted here -->
<div class="tags-loading">{{ t('common.status.loading') }}</div> <div class="tags-loading">{{ t('common.status.loading') }}</div>
</div> </div>
<div id="modelTagsEmptyState" class="filter-empty-state" hidden>
{{ t('header.filter.noTagMatches') }}
</div>
</div> </div>
{% if current_page == 'loras' or current_page == 'checkpoints' %} {% if current_page == 'loras' or current_page == 'checkpoints' %}
<div class="filter-section"> <div class="filter-section">
+80 -43
View File
@@ -144,6 +144,46 @@
</div> </div>
</div> </div>
<div class="settings-subsection">
<div class="settings-subsection-header">
<h4>{{ t('settings.sections.downloads') }}</h4>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="downloadBackend">{{ t('settings.downloadBackend.label') }}</label>
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.downloadBackend.help') }}"></i>
<a class="settings-action-link" href="https://github.com/willmiao/ComfyUI-Lora-Manager/wiki/Aria2-Download-Backend-(Experimental)" target="_blank" rel="noopener" aria-label="{{ t('settings.aria2HelpLink') }}" title="{{ t('settings.aria2HelpLink') }}">
<i class="fas fa-question-circle" aria-hidden="true"></i>
</a>
</div>
<div class="setting-control select-control">
<select id="downloadBackend" onchange="settingsManager.saveSelectSetting('downloadBackend', 'download_backend')">
<option value="python">{{ t('settings.downloadBackend.options.python') }}</option>
<option value="aria2">{{ t('settings.downloadBackend.options.aria2') }}</option>
</select>
</div>
</div>
</div>
<div class="setting-item" id="aria2PathSetting" style="display: none;">
<div class="setting-row">
<div class="setting-info">
<label for="aria2cPath">{{ t('settings.aria2cPath.label') }}</label>
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.aria2cPath.help') }}"></i>
</div>
<div class="setting-control">
<div class="text-input-wrapper">
<input type="text"
id="aria2cPath"
placeholder="{{ t('settings.aria2cPath.placeholder') }}"
onblur="settingsManager.saveInputSetting('aria2cPath', 'aria2c_path')"
onkeydown="if(event.key === 'Enter') { this.blur(); }" />
</div>
</div>
</div>
</div>
</div>
<!-- AI Provider Configuration (BYOK) --> <!-- AI Provider Configuration (BYOK) -->
<div class="settings-subsection"> <div class="settings-subsection">
<div class="settings-subsection-header"> <div class="settings-subsection-header">
@@ -250,46 +290,6 @@
{{ provider_models_json | safe }} {{ provider_models_json | safe }}
</script> </script>
<div class="settings-subsection">
<div class="settings-subsection-header">
<h4>{{ t('settings.sections.downloads') }}</h4>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="downloadBackend">{{ t('settings.downloadBackend.label') }}</label>
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.downloadBackend.help') }}"></i>
<a class="settings-action-link" href="https://github.com/willmiao/ComfyUI-Lora-Manager/wiki/Aria2-Download-Backend-(Experimental)" target="_blank" rel="noopener" aria-label="{{ t('settings.aria2HelpLink') }}" title="{{ t('settings.aria2HelpLink') }}">
<i class="fas fa-question-circle" aria-hidden="true"></i>
</a>
</div>
<div class="setting-control select-control">
<select id="downloadBackend" onchange="settingsManager.saveSelectSetting('downloadBackend', 'download_backend')">
<option value="python">{{ t('settings.downloadBackend.options.python') }}</option>
<option value="aria2">{{ t('settings.downloadBackend.options.aria2') }}</option>
</select>
</div>
</div>
</div>
<div class="setting-item" id="aria2PathSetting" style="display: none;">
<div class="setting-row">
<div class="setting-info">
<label for="aria2cPath">{{ t('settings.aria2cPath.label') }}</label>
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.aria2cPath.help') }}"></i>
</div>
<div class="setting-control">
<div class="text-input-wrapper">
<input type="text"
id="aria2cPath"
placeholder="{{ t('settings.aria2cPath.placeholder') }}"
onblur="settingsManager.saveInputSetting('aria2cPath', 'aria2c_path')"
onkeydown="if(event.key === 'Enter') { this.blur(); }" />
</div>
</div>
</div>
</div>
</div>
<!-- Backup --> <!-- Backup -->
<div class="settings-subsection"> <div class="settings-subsection">
<div class="settings-subsection-header"> <div class="settings-subsection-header">
@@ -1401,7 +1401,26 @@
<div class="settings-input-error-message" id="metadataRefreshSkipPathsError"></div> <div class="settings-input-error-message" id="metadataRefreshSkipPathsError"></div>
</div> </div>
<!-- Metadata Archive --> <!-- CivArchive API provider toggle -->
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="enableCivarchiveApi">
{{ t('settings.metadataArchive.enableCivarchiveApi') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.metadataArchive.enableCivarchiveApiHelp') }}"></i>
</label>
</div>
<div class="setting-control">
<label class="toggle-switch">
<input type="checkbox" id="enableCivarchiveApi"
onchange="settingsManager.saveToggleSetting('enableCivarchiveApi', 'enable_civarchive_api')">
<span class="toggle-slider"></span>
</label>
</div>
</div>
</div>
<!-- Metadata Archive DB -->
<div class="setting-item"> <div class="setting-item">
<div class="setting-row"> <div class="setting-row">
<div class="setting-info"> <div class="setting-info">
@@ -1419,13 +1438,13 @@
</div> </div>
</div> </div>
</div> </div>
<div class="setting-item"> <div class="setting-item">
<div class="metadata-archive-status" id="metadataArchiveStatus"> <div class="metadata-archive-status" id="metadataArchiveStatus">
<!-- Status will be populated by JavaScript --> <!-- Status will be populated by JavaScript -->
</div> </div>
</div> </div>
<div class="setting-item"> <div class="setting-item">
<div class="setting-row"> <div class="setting-row">
<div class="setting-info"> <div class="setting-info">
@@ -1444,6 +1463,24 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Metadata provider fallback order -->
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="metadataProviderOrder">
{{ t('settings.metadataArchive.providerOrder') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.metadataArchive.providerOrderHelp') }}"></i>
</label>
</div>
<div class="setting-control select-control">
<select id="metadataProviderOrder" onchange="settingsManager.saveSelectSetting('metadataProviderOrder', 'metadata_provider_order')">
<option value="civitai_archive_sqlite">{{ t('settings.metadataArchive.providerOrderCivitaiArchiveSqlite') }}</option>
<option value="civitai_sqlite_archive">{{ t('settings.metadataArchive.providerOrderCivitaiSqliteArchive') }}</option>
</select>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
+70
View File
@@ -823,3 +823,73 @@ def test_apply_library_settings_ignores_extra_lora_path_overlapping_primary_root
"same lora folder" in record.message.lower() "same lora folder" in record.message.lower()
for record in caplog.records for record in caplog.records
) )
def test_save_paths_removes_stale_empty_default_when_comfyui_exists(
monkeypatch: pytest.MonkeyPatch, tmp_path,
):
"""When an empty-shell 'default' library coexists with 'comfyui', the
stale 'default' entry should be removed and 'comfyui' activated."""
folder_paths = _setup_config_environment(monkeypatch, tmp_path)
class FakeSettingsService:
def __init__(self):
# Replicate the user's settings.json: empty default + populated comfyui
self.libraries = {
"default": {
"folder_paths": {},
"extra_folder_paths": {},
"default_lora_root": "",
"default_checkpoint_root": "",
"default_unet_root": "",
"default_embedding_root": "",
"recipes_path": "",
},
"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],
},
}
# No active_library key — get_active_library_name() falls back to
# dict order, returning "default".
self.active_library = "default"
self.delete_calls: list[str] = []
self.upsert_calls: list[tuple[str, dict]] = []
def get_libraries(self):
return dict(self.libraries)
def delete_library(self, name: str):
self.delete_calls.append(name)
self.libraries.pop(name, None)
def rename_library(self, *_):
raise AssertionError("rename_library should not be invoked")
def get_active_library_name(self):
return self.active_library
def upsert_library(self, name: str, **payload):
self.upsert_calls.append((name, payload))
self.libraries[name] = {**payload}
if payload.get("activate"):
self.active_library = name
fake_settings = FakeSettingsService()
monkeypatch.setattr(settings_manager_module, "settings", fake_settings)
config_module.Config()
assert fake_settings.delete_calls == ["default"]
assert "default" not in fake_settings.libraries
assert set(fake_settings.libraries.keys()) == {"comfyui"}
assert len(fake_settings.upsert_calls) == 1
name, payload = fake_settings.upsert_calls[0]
assert name == "comfyui"
assert payload["activate"] is True
assert fake_settings.active_library == "comfyui"
+1
View File
@@ -85,6 +85,7 @@ sys.modules['comfy.utils'] = comfy_mock.utils
sys.modules['comfy.sd'] = comfy_mock.sd sys.modules['comfy.sd'] = comfy_mock.sd
sys.modules['comfy.model_management'] = comfy_mock.model_management sys.modules['comfy.model_management'] = comfy_mock.model_management
sys.modules['comfy.comfy_types'] = comfy_mock.comfy_types sys.modules['comfy.comfy_types'] = comfy_mock.comfy_types
sys.modules['comfy.hooks'] = MockModule("comfy.hooks")
execution_mock = MockModule("execution") execution_mock = MockModule("execution")
execution_mock.PromptExecutor = mock.MagicMock() execution_mock.PromptExecutor = mock.MagicMock()
@@ -113,6 +113,8 @@ function renderControlsDom(pageKey) {
<div id="baseModelEmptyState" hidden></div> <div id="baseModelEmptyState" hidden></div>
<div id="filterPresets" class="filter-presets"></div> <div id="filterPresets" class="filter-presets"></div>
<div id="modelTagsFilter" class="filter-tags"></div> <div id="modelTagsFilter" class="filter-tags"></div>
<input id="modelTagsSearchInput" />
<div id="modelTagsEmptyState" hidden></div>
<button class="clear-filter"></button> <button class="clear-filter"></button>
</div> </div>
<div class="controls"> <div class="controls">
@@ -961,4 +963,198 @@ describe('PageControls favorites, sorting, and duplicates scenarios', () => {
expect(stateModule.state.bulkMode).toBe(true); expect(stateModule.state.bulkMode).toBe(true);
expect(pageState.duplicatesMode).toBe(true); expect(pageState.duplicatesMode).toBe(true);
}); });
describe('tag search', () => {
it('fetches /search-tags when typing in the tag search input (debounced)', async () => {
vi.useFakeTimers();
const searchTagsUrls = [];
global.fetch = vi.fn((url) => {
if (url.includes('/search-tags')) {
searchTagsUrls.push(url);
return Promise.resolve({
ok: true,
json: async () => ({ success: true, tags: [{ tag: 'anime', count: 3 }] }),
});
}
if (url.includes('/top-tags')) {
return Promise.resolve({ ok: true, json: async () => ({ success: true, tags: [] }) });
}
if (url.includes('/base-models')) {
return Promise.resolve({ ok: true, json: async () => ({ success: true, base_models: [] }) });
}
if (url.includes('/model-types')) {
return Promise.resolve({ ok: true, json: async () => ({ success: true, model_types: [] }) });
}
return Promise.resolve({ ok: true, json: async () => ({ success: true }) });
});
renderControlsDom('loras');
const stateModule = await import('../../../static/js/state/index.js');
stateModule.initPageState('loras');
const { FilterManager } = await import('../../../static/js/managers/FilterManager.js');
const manager = new FilterManager({ page: 'loras' });
// Open the panel so tags load
manager.toggleFilterPanel();
await vi.runAllTimersAsync();
const input = document.getElementById('modelTagsSearchInput');
input.value = 'ani';
input.dispatchEvent(new Event('input', { bubbles: true }));
// Before debounce fires, no search-tags call yet
expect(searchTagsUrls.length).toBe(0);
// Advance past the 150ms debounce
vi.advanceTimersByTime(160);
await vi.runAllTimersAsync();
expect(searchTagsUrls.length).toBe(1);
expect(searchTagsUrls[0]).toContain('/search-tags');
expect(searchTagsUrls[0]).toContain('q=ani');
vi.useRealTimers();
});
it('renders selected-but-missing tags in a dedicated group at the top', async () => {
vi.useFakeTimers();
global.fetch = vi.fn((url) => {
if (url.includes('/search-tags')) {
return Promise.resolve({
ok: true,
json: async () => ({ success: true, tags: [{ tag: 'anime', count: 3 }] }),
});
}
if (url.includes('/top-tags')) {
return Promise.resolve({ ok: true, json: async () => ({ success: true, tags: [] }) });
}
if (url.includes('/base-models')) {
return Promise.resolve({ ok: true, json: async () => ({ success: true, base_models: [] }) });
}
if (url.includes('/model-types')) {
return Promise.resolve({ ok: true, json: async () => ({ success: true, model_types: [] }) });
}
return Promise.resolve({ ok: true, json: async () => ({ success: true }) });
});
renderControlsDom('loras');
const stateModule = await import('../../../static/js/state/index.js');
stateModule.initPageState('loras');
const { FilterManager } = await import('../../../static/js/managers/FilterManager.js');
const manager = new FilterManager({ page: 'loras' });
// Pre-seed an active tag filter that won't appear in search results
manager.filters.tags = { 'my-custom-tag': 'include' };
// Open panel and let top-tags load (empty)
manager.toggleFilterPanel();
await vi.runAllTimersAsync();
// Type a search query
const input = document.getElementById('modelTagsSearchInput');
input.value = 'ani';
input.dispatchEvent(new Event('input', { bubbles: true }));
vi.advanceTimersByTime(160);
await vi.runAllTimersAsync();
const container = document.getElementById('modelTagsFilter');
const extraTag = container.querySelector('.filter-tag.extra-tag');
expect(extraTag).not.toBeNull();
expect(extraTag.dataset.tag).toBe('my-custom-tag');
// The search result tag should also be present
const resultTag = container.querySelector('.filter-tag.tag-filter[data-tag="anime"]');
expect(resultTag).not.toBeNull();
vi.useRealTimers();
});
it('shows empty state when search returns no matches and no selected tags', async () => {
vi.useFakeTimers();
global.fetch = vi.fn((url) => {
if (url.includes('/search-tags')) {
return Promise.resolve({ ok: true, json: async () => ({ success: true, tags: [] }) });
}
if (url.includes('/top-tags')) {
return Promise.resolve({ ok: true, json: async () => ({ success: true, tags: [] }) });
}
if (url.includes('/base-models')) {
return Promise.resolve({ ok: true, json: async () => ({ success: true, base_models: [] }) });
}
if (url.includes('/model-types')) {
return Promise.resolve({ ok: true, json: async () => ({ success: true, model_types: [] }) });
}
return Promise.resolve({ ok: true, json: async () => ({ success: true }) });
});
renderControlsDom('loras');
const stateModule = await import('../../../static/js/state/index.js');
stateModule.initPageState('loras');
const { FilterManager } = await import('../../../static/js/managers/FilterManager.js');
const manager = new FilterManager({ page: 'loras' });
manager.toggleFilterPanel();
await vi.runAllTimersAsync();
const input = document.getElementById('modelTagsSearchInput');
input.value = 'zzz';
input.dispatchEvent(new Event('input', { bubbles: true }));
vi.advanceTimersByTime(160);
await vi.runAllTimersAsync();
const emptyState = document.getElementById('modelTagsEmptyState');
expect(emptyState.hidden).toBe(false);
vi.useRealTimers();
});
it('reloads top tags when search input is cleared', async () => {
vi.useFakeTimers();
let topTagsCallCount = 0;
global.fetch = vi.fn((url) => {
if (url.includes('/search-tags')) {
return Promise.resolve({ ok: true, json: async () => ({ success: true, tags: [{ tag: 'anime', count: 3 }] }) });
}
if (url.includes('/top-tags')) {
topTagsCallCount++;
return Promise.resolve({ ok: true, json: async () => ({ success: true, tags: [] }) });
}
if (url.includes('/base-models')) {
return Promise.resolve({ ok: true, json: async () => ({ success: true, base_models: [] }) });
}
if (url.includes('/model-types')) {
return Promise.resolve({ ok: true, json: async () => ({ success: true, model_types: [] }) });
}
return Promise.resolve({ ok: true, json: async () => ({ success: true }) });
});
renderControlsDom('loras');
const stateModule = await import('../../../static/js/state/index.js');
stateModule.initPageState('loras');
const { FilterManager } = await import('../../../static/js/managers/FilterManager.js');
const manager = new FilterManager({ page: 'loras' });
manager.toggleFilterPanel();
await vi.runAllTimersAsync();
const callsAfterOpen = topTagsCallCount;
expect(callsAfterOpen).toBeGreaterThanOrEqual(1);
// Type, then clear
const input = document.getElementById('modelTagsSearchInput');
input.value = 'ani';
input.dispatchEvent(new Event('input', { bubbles: true }));
vi.advanceTimersByTime(160);
await vi.runAllTimersAsync();
input.value = '';
input.dispatchEvent(new Event('input', { bubbles: true }));
vi.advanceTimersByTime(160);
await vi.runAllTimersAsync();
// An additional top-tags call should have happened after clearing
expect(topTagsCallCount).toBeGreaterThan(callsAfterOpen);
vi.useRealTimers();
});
});
}); });
+54 -4
View File
@@ -9,6 +9,7 @@ import {
parseCombinedSamplerName, parseCombinedSamplerName,
resolveSamplerScheduler, resolveSamplerScheduler,
findMatchingWidgets, findMatchingWidgets,
NODE_TYPE_WIDGET_OVERRIDES,
} from '../../../static/js/utils/genParamsMapper.js'; } from '../../../static/js/utils/genParamsMapper.js';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -204,9 +205,9 @@ describe('findMatchingWidgets', () => {
it('matches seed to seed widget', () => { it('matches seed to seed widget', () => {
const updates = findMatchingWidgets(['seed', 'steps', 'cfg', 'sampler_name', 'scheduler'], resolved); const updates = findMatchingWidgets(['seed', 'steps', 'cfg', 'sampler_name', 'scheduler'], resolved);
expect(updates).toContainEqual({ widgetName: 'seed', value: '42' }); expect(updates).toContainEqual({ widgetName: 'seed', value: 42 });
expect(updates).toContainEqual({ widgetName: 'steps', value: '30' }); expect(updates).toContainEqual({ widgetName: 'steps', value: 30 });
expect(updates).toContainEqual({ widgetName: 'cfg', value: '7' }); expect(updates).toContainEqual({ widgetName: 'cfg', value: 7 });
expect(updates).toContainEqual({ widgetName: 'sampler_name', value: 'euler_ancestral' }); expect(updates).toContainEqual({ widgetName: 'sampler_name', value: 'euler_ancestral' });
expect(updates).toContainEqual({ widgetName: 'scheduler', value: 'karras' }); expect(updates).toContainEqual({ widgetName: 'scheduler', value: 'karras' });
}); });
@@ -221,7 +222,7 @@ describe('findMatchingWidgets', () => {
const updates = findMatchingWidgets(['noise_seed', 'steps', 'cfg', 'sampler_name', 'scheduler'], resolved); const updates = findMatchingWidgets(['noise_seed', 'steps', 'cfg', 'sampler_name', 'scheduler'], resolved);
const seedUpdate = updates.find(u => u.widgetName === 'noise_seed'); const seedUpdate = updates.find(u => u.widgetName === 'noise_seed');
expect(seedUpdate).toBeDefined(); expect(seedUpdate).toBeDefined();
expect(seedUpdate.value).toBe('42'); expect(seedUpdate.value).toBe(42);
}); });
it('matches rgthree-style sampler widget name', () => { it('matches rgthree-style sampler widget name', () => {
@@ -243,4 +244,53 @@ describe('findMatchingWidgets', () => {
const updates = findMatchingWidgets(['seed', 'steps', 'cfg', 'sampler_name', 'scheduler'], resolved); const updates = findMatchingWidgets(['seed', 'steps', 'cfg', 'sampler_name', 'scheduler'], resolved);
expect(updates.map(u => u.widgetName)).toEqual(['seed', 'steps', 'cfg', 'sampler_name', 'scheduler']); expect(updates.map(u => u.widgetName)).toEqual(['seed', 'steps', 'cfg', 'sampler_name', 'scheduler']);
}); });
// --- node-type-specific overrides ---
it('matches GlobalSeed //Inspire value widget for seed param', () => {
const updates = findMatchingWidgets(
['value', 'mode', 'action', 'last_seed'],
{ seed: 42 },
'GlobalSeed //Inspire'
);
expect(updates).toHaveLength(1);
expect(updates[0]).toEqual({ widgetName: 'value', value: 42 });
});
it('ignores nodeType when it does not match any override entry', () => {
const updates = findMatchingWidgets(
['value', 'mode', 'action', 'last_seed'],
{ seed: 42 },
'SomeOtherNode'
);
expect(updates).toEqual([]);
});
it('still falls back to global candidates when override candidates do not match', () => {
// GlobalSeed override does not include steps — should use global candidate "steps"
const updates = findMatchingWidgets(
['steps', 'cfg', 'sampler_name'],
{ steps: 20 },
'GlobalSeed //Inspire'
);
expect(updates).toHaveLength(1);
expect(updates[0]).toEqual({ widgetName: 'steps', value: 20 });
});
it('prefers overrides when both override and global candidates match', () => {
// If a hypothetical node has both "value" and "seed" widgets AND a
// GlobalSeed override, the override candidate "value" should take precedence
const updates = findMatchingWidgets(
['seed', 'noise_seed', 'value', 'mode'],
{ seed: 99 },
'GlobalSeed //Inspire'
);
expect(updates).toHaveLength(1);
expect(updates[0].widgetName).toBe('value');
});
it('omits nodeType argument and still matches via global candidates', () => {
const updates = findMatchingWidgets(['seed', 'steps', 'cfg'], { seed: 7 });
expect(updates).toHaveLength(1);
expect(updates[0]).toEqual({ widgetName: 'seed', value: 7 });
});
}); });
+50
View File
@@ -36,3 +36,53 @@ async def test_model_query_handler_rejects_negative_limit_for_base_models():
await handler.get_base_models(SimpleNamespace(query={"limit": "-1"})) await handler.get_base_models(SimpleNamespace(query={"limit": "-1"}))
assert service.received_limit == 20 assert service.received_limit == 20
class DummySearchTagsService:
"""Minimal service stub recording search_tags arguments."""
def __init__(self, result=None):
self.received_query = None
self.received_limit = None
self._result = result or []
async def search_tags(self, query, limit):
self.received_query = query
self.received_limit = limit
return self._result
@pytest.mark.asyncio
async def test_model_query_handler_search_tags_passes_query_and_limit():
service = DummySearchTagsService(result=[{"tag": "anime", "count": 3}])
handler = ModelQueryHandler(service=service, logger=logging.getLogger(__name__))
response = await handler.search_tags(
SimpleNamespace(query={"q": "ani", "limit": "50"})
)
payload = json.loads(response.text)
assert payload["success"] is True
assert payload["tags"] == [{"tag": "anime", "count": 3}]
assert service.received_query == "ani"
assert service.received_limit == 50
@pytest.mark.asyncio
async def test_model_query_handler_search_tags_defaults_limit_to_20():
service = DummySearchTagsService()
handler = ModelQueryHandler(service=service, logger=logging.getLogger(__name__))
await handler.search_tags(SimpleNamespace(query={}))
assert service.received_limit == 20
@pytest.mark.asyncio
async def test_model_query_handler_search_tags_clamps_negative_limit():
service = DummySearchTagsService()
handler = ModelQueryHandler(service=service, logger=logging.getLogger(__name__))
await handler.search_tags(SimpleNamespace(query={"limit": "-5"}))
assert service.received_limit == 20
@@ -1189,6 +1189,65 @@ def test_relative_path_sanitizes_model_and_version_placeholders():
assert relative_path == "Fancy_Model/Version_One" assert relative_path == "Fancy_Model/Version_One"
def test_relative_path_empty_first_tag_fallback():
"""Test that empty first_tag falls back to 'no tags'."""
manager = DownloadManager()
settings_manager = get_settings_manager()
settings_manager.settings["download_path_templates"]["lora"] = (
"{base_model}/{first_tag}"
)
version_info = {
"baseModel": "SDXL",
"model": {"name": "Test Model", "tags": []},
"creator": {"username": "Author"},
}
relative_path = manager._calculate_relative_path(version_info, "lora")
assert relative_path == "SDXL/no tags"
def test_relative_path_empty_base_model_and_first_tag():
"""Test that empty base_model + empty first_tag does NOT produce a leading slash."""
manager = DownloadManager()
settings_manager = get_settings_manager()
settings_manager.settings["download_path_templates"]["lora"] = (
"{base_model}/{first_tag}"
)
version_info = {
"baseModel": "",
"model": {"name": "Test Model", "tags": []},
"creator": {"username": "Author"},
}
relative_path = manager._calculate_relative_path(version_info, "lora")
assert not relative_path.startswith("/")
assert relative_path == "no tags"
def test_relative_path_sanitizes_double_slashes():
"""Test that empty placeholder substitutions don't produce double slashes."""
manager = DownloadManager()
settings_manager = get_settings_manager()
settings_manager.settings["download_path_templates"]["lora"] = (
"{base_model}/{first_tag}/{author}"
)
version_info = {
"baseModel": "SDXL",
"model": {"name": "Test Model", "tags": []},
"creator": {"username": "Author"},
}
relative_path = manager._calculate_relative_path(version_info, "lora")
assert "//" not in relative_path
assert relative_path == "SDXL/no tags/Author"
def test_distribute_preview_to_entries_moves_and_copies(tmp_path): def test_distribute_preview_to_entries_moves_and_copies(tmp_path):
"""Test that preview distribution moves file to first entry and copies to others.""" """Test that preview distribution moves file to first entry and copies to others."""
manager = DownloadManager() manager = DownloadManager()
@@ -0,0 +1,193 @@
"""Unit tests for DownloadQueueService history operations.
Covers the new ``download_id``-based code paths in
``delete_history_item`` and ``retry_from_history``, plus backward
compatibility with ``id``.
"""
from pathlib import Path
import pytest
from py.services.download_queue_service import DownloadQueueService
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_service(tmp_path: Path) -> DownloadQueueService:
"""Create a DownloadQueueService backed by a temporary database."""
return DownloadQueueService(db_path=str(tmp_path / "queue.sqlite"))
async def _seed(
svc: DownloadQueueService,
download_id: str,
status: str = "failed",
) -> tuple[int, str]:
"""Insert a history row and return (autoincrement id, download_id)."""
row_id = await svc.add_to_history(
download_id=download_id,
model_id=1,
model_version_id=100,
model_name="TestModel",
version_name="v1",
status=status,
)
return row_id, download_id
# ---------------------------------------------------------------------------
# delete_history_item
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_delete_by_download_id(tmp_path: Path) -> None:
"""delete_history_item(download_id=...) removes the correct row."""
svc = _make_service(tmp_path)
rid, did = await _seed(svc, "dl-aaa")
deleted = await svc.delete_history_item(download_id=did)
assert deleted is True
# Verify gone from history
history = await svc.get_history()
assert len(history["items"]) == 0
@pytest.mark.asyncio
async def test_delete_by_id_legacy(tmp_path: Path) -> None:
"""delete_history_item(id=...) still works (backward compat)."""
svc = _make_service(tmp_path)
rid, _did = await _seed(svc, "dl-bbb")
deleted = await svc.delete_history_item(id=rid)
assert deleted is True
history = await svc.get_history()
assert len(history["items"]) == 0
@pytest.mark.asyncio
async def test_delete_no_params_returns_false(tmp_path: Path) -> None:
"""Calling delete_history_item with no params returns False."""
svc = _make_service(tmp_path)
await _seed(svc, "dl-ccc")
deleted = await svc.delete_history_item()
assert deleted is False
# Row is still there
history = await svc.get_history()
assert len(history["items"]) == 1
@pytest.mark.asyncio
async def test_delete_download_id_precedence(tmp_path: Path) -> None:
"""When both id and download_id are given, download_id is used."""
svc = _make_service(tmp_path)
# Insert two rows
rid_a, did_a = await _seed(svc, "dl-aaa")
rid_b, did_b = await _seed(svc, "dl-bbb")
# Delete by download_id while also passing the *wrong* id
deleted = await svc.delete_history_item(id=rid_b, download_id=did_a)
assert deleted is True
history = await svc.get_history()
ids_left = [it["id"] for it in history["items"]]
assert rid_a not in ids_left # dl-aaa was deleted
assert rid_b in ids_left # dl-bbb (wrong id) was ignored
# ---------------------------------------------------------------------------
# retry_from_history
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_retry_by_download_id(tmp_path: Path) -> None:
"""retry_from_history(download_id=...) re-queues and deletes history."""
svc = _make_service(tmp_path)
rid, did = await _seed(svc, "dl-fail", status="failed")
item = await svc.retry_from_history(download_id=did)
assert item is not None
assert item["status"] == "queued"
# History row must be deleted (the bug fix)
history = await svc.get_history()
ids_in_history = [it["id"] for it in history["items"]]
assert rid not in ids_in_history
# Queue must contain the new item
queue = await svc.get_queue()
assert len(queue) == 1
@pytest.mark.asyncio
async def test_retry_by_download_id_canceled(tmp_path: Path) -> None:
"""retry_from_history works for 'canceled' status too."""
svc = _make_service(tmp_path)
rid, did = await _seed(svc, "dl-cancel", status="canceled")
item = await svc.retry_from_history(download_id=did)
assert item is not None
assert item["status"] == "queued"
history = await svc.get_history()
assert len(history["items"]) == 0
@pytest.mark.asyncio
async def test_retry_by_id_legacy(tmp_path: Path) -> None:
"""retry_from_history(item_id=...) still works (backward compat)."""
svc = _make_service(tmp_path)
rid, _did = await _seed(svc, "dl-legacy", status="failed")
item = await svc.retry_from_history(item_id=rid)
assert item is not None
assert item["status"] == "queued"
history = await svc.get_history()
assert len(history["items"]) == 0
@pytest.mark.asyncio
async def test_retry_no_params_returns_none(tmp_path: Path) -> None:
"""Calling retry_from_history with no params returns None."""
svc = _make_service(tmp_path)
await _seed(svc, "dl-none", status="failed")
item = await svc.retry_from_history()
assert item is None
# History untouched
history = await svc.get_history()
assert len(history["items"]) == 1
@pytest.mark.asyncio
async def test_retry_non_retryable_status(tmp_path: Path) -> None:
"""retry_from_history returns None for 'completed' status."""
svc = _make_service(tmp_path)
_rid, did = await _seed(svc, "dl-ok", status="completed")
item = await svc.retry_from_history(download_id=did)
assert item is None
# History untouched
history = await svc.get_history()
assert len(history["items"]) == 1
@pytest.mark.asyncio
async def test_retry_unknown_download_id(tmp_path: Path) -> None:
"""retry_from_history returns None for a non-existent download_id."""
svc = _make_service(tmp_path)
await _seed(svc, "dl-real", status="failed")
item = await svc.retry_from_history(download_id="dl-nope")
assert item is None
+111
View File
@@ -60,3 +60,114 @@ async def test_get_metadata_provider_returns_fallback_as_is(monkeypatch):
provider = await metadata_service.get_metadata_provider() provider = await metadata_service.get_metadata_provider()
assert provider is fallback assert provider is fallback
# ---------------------------------------------------------------------------
# initialize_metadata_providers — provider gating + fallback ordering
# ---------------------------------------------------------------------------
def _stub_settings(**overrides):
"""Minimal settings stub returning configured values."""
base = {
"enable_metadata_archive_db": False,
"enable_civarchive_api": True,
"metadata_provider_order": "civitai_archive_sqlite",
}
base.update(overrides)
return SimpleNamespace(get=lambda key, default=None: base.get(key, default))
async def _run_initialize(monkeypatch, settings):
# Fresh provider manager for each test
monkeypatch.setattr(
metadata_service.ModelMetadataProviderManager,
"get_instance",
AsyncMock(return_value=metadata_service.ModelMetadataProviderManager()),
)
monkeypatch.setattr(
metadata_service, "get_settings_manager", lambda: settings
)
monkeypatch.setattr(
metadata_service.ServiceRegistry,
"get_civitai_client",
AsyncMock(return_value=object()),
)
monkeypatch.setattr(
metadata_service.ServiceRegistry,
"get_civarchive_client",
AsyncMock(return_value=object()),
)
# Make MetadataArchiveManager report a usable db path when enabled
fake_archive = SimpleNamespace(get_database_path=lambda: "/tmp/fake.db")
monkeypatch.setattr(
metadata_service, "MetadataArchiveManager", lambda _base: fake_archive
)
# Pretend the db file exists
monkeypatch.setattr(metadata_service.os.path, "exists", lambda _p: True)
manager = await metadata_service.initialize_metadata_providers()
return manager
def _fallback_provider_order(manager):
"""Return the ordered list of provider labels inside the fallback provider."""
fallback = manager.providers.get("fallback")
assert isinstance(fallback, FallbackMetadataProvider), "expected a fallback provider"
return list(fallback._provider_labels)
@pytest.mark.asyncio
async def test_initialize_providers_default_order(monkeypatch):
settings = _stub_settings(enable_metadata_archive_db=True)
manager = await _run_initialize(monkeypatch, settings)
assert _fallback_provider_order(manager) == ["civitai_api", "civarchive_api", "sqlite"]
@pytest.mark.asyncio
async def test_initialize_providers_prefer_sqlite_order(monkeypatch):
settings = _stub_settings(
enable_metadata_archive_db=True,
metadata_provider_order="civitai_sqlite_archive",
)
manager = await _run_initialize(monkeypatch, settings)
assert _fallback_provider_order(manager) == ["civitai_api", "sqlite", "civarchive_api"]
@pytest.mark.asyncio
async def test_initialize_providers_disables_civarchive(monkeypatch):
settings = _stub_settings(
enable_metadata_archive_db=True,
enable_civarchive_api=False,
)
manager = await _run_initialize(monkeypatch, settings)
# civarchive_api must not be registered at all
assert "civarchive_api" not in manager.providers
assert _fallback_provider_order(manager) == ["civitai_api", "sqlite"]
@pytest.mark.asyncio
async def test_initialize_providers_skips_unavailable_sqlite_in_preset(monkeypatch):
# Preset wants sqlite before civarchive, but archive db is disabled ->
# sqlite is unavailable and must be skipped, civarchive stays.
settings = _stub_settings(
enable_metadata_archive_db=False,
metadata_provider_order="civitai_sqlite_archive",
)
manager = await _run_initialize(monkeypatch, settings)
assert _fallback_provider_order(manager) == ["civitai_api", "civarchive_api"]
@pytest.mark.asyncio
async def test_initialize_providers_single_provider_when_only_civitai(monkeypatch):
# Both archive db and civarchive disabled -> only civitai_api remains,
# which takes the single-provider path (registered as default, no fallback).
settings = _stub_settings(
enable_metadata_archive_db=False,
enable_civarchive_api=False,
)
manager = await _run_initialize(monkeypatch, settings)
assert "fallback" not in manager.providers
assert manager.default_provider == "civitai_api"
+154 -1
View File
@@ -3,11 +3,164 @@ from pathlib import Path
import pytest import pytest
from py.services.model_lifecycle_service import ModelLifecycleService from py.services.model_lifecycle_service import ModelLifecycleService, _require_path_in_library_roots
from py.utils.metadata_manager import MetadataManager from py.utils.metadata_manager import MetadataManager
from py.utils.models import LoraMetadata from py.utils.models import LoraMetadata
class ScannerWithRoots:
def __init__(self, roots):
self._roots = list(roots)
def get_model_roots(self):
return self._roots
class TestRequirePathInLibraryRoots:
def test_accepts_path_within_root(self, tmp_path):
root = tmp_path / "loras"
root.mkdir()
model = root / "model.safetensors"
model.write_text("")
scanner = ScannerWithRoots([str(root)])
_require_path_in_library_roots(str(model), scanner)
def test_rejects_path_outside_roots(self, tmp_path):
root = tmp_path / "loras"
root.mkdir()
outside = tmp_path / "outside" / "model.safetensors"
outside.parent.mkdir(parents=True)
outside.write_text("")
scanner = ScannerWithRoots([str(root)])
with pytest.raises(ValueError, match="outside configured library"):
_require_path_in_library_roots(str(outside), scanner)
def test_passes_when_no_roots_configured(self, tmp_path):
f = tmp_path / "model.safetensors"
f.write_text("")
scanner = ScannerWithRoots([])
_require_path_in_library_roots(str(f), scanner)
def test_accepts_path_matching_root_exactly(self, tmp_path):
root = tmp_path / "loras"
root.mkdir()
scanner = ScannerWithRoots([str(root)])
_require_path_in_library_roots(str(root), scanner)
def test_rejects_symlink_escape(self, tmp_path):
root = tmp_path / "loras"
root.mkdir()
model = root / "model.safetensors"
model.write_text("")
outside_dir = tmp_path / "outside"
outside_dir.mkdir()
outside_file = outside_dir / "escaped.safetensors"
outside_file.write_text("")
symlink = root / "link.safetensors"
symlink.symlink_to(outside_file)
scanner = ScannerWithRoots([str(root)])
with pytest.raises(ValueError, match="outside configured library"):
_require_path_in_library_roots(str(symlink), scanner)
class ScannerForDelete:
def __init__(self, raw_data, roots, model_type="lora"):
self.model_type = model_type
self.cache = DummyCache(raw_data)
self._hash_index = DummyHashIndex()
self._roots = list(roots)
self._persist_calls = []
def get_model_roots(self):
return self._roots
async def get_cached_data(self):
return self.cache
async def _persist_current_cache(self):
self._persist_calls.append(True)
@pytest.mark.asyncio
async def test_delete_model_rejects_path_outside_roots(tmp_path: Path):
root = tmp_path / "loras"
root.mkdir()
model = root / "model.safetensors"
model.write_bytes(b"data")
scanner = ScannerForDelete(
raw_data=[{"file_path": str(model)}],
roots=[str(root)],
)
service = ModelLifecycleService(
scanner=scanner,
metadata_manager=DummyMetadataManager({"civitai": {"modelId": 1}}),
metadata_loader=lambda x: {},
)
# Path within root should work (model file exists)
result = await service.delete_model(str(model))
assert result["success"] is True
# Path outside root should be rejected
outside = tmp_path / "outside.safetensors"
outside.write_bytes(b"data")
scanner2 = ScannerForDelete(
raw_data=[],
roots=[str(root)],
)
service2 = ModelLifecycleService(
scanner=scanner2,
metadata_manager=DummyMetadataManager({}),
metadata_loader=lambda x: {},
)
with pytest.raises(ValueError, match="outside configured library"):
await service2.delete_model(str(outside))
@pytest.mark.asyncio
async def test_rename_model_rejects_path_outside_roots(tmp_path: Path):
root = tmp_path / "loras"
root.mkdir()
scanner = ScannerWithRoots([str(root)])
service = ModelLifecycleService(
scanner=scanner,
metadata_manager=DummyMetadataManager({}),
metadata_loader=lambda x: {},
)
outside = tmp_path / "outside.safetensors"
outside.write_bytes(b"data")
with pytest.raises(ValueError, match="outside configured library"):
await service.rename_model(file_path=str(outside), new_file_name="new_name")
@pytest.mark.asyncio
async def test_bulk_delete_rejects_any_path_outside_roots(tmp_path: Path):
root = tmp_path / "loras"
root.mkdir()
model_ok = root / "model.safetensors"
model_ok.write_bytes(b"data")
outside = tmp_path / "outside.safetensors"
outside.write_bytes(b"data")
scanner = ScannerWithRoots([str(root)])
service = ModelLifecycleService(
scanner=scanner,
metadata_manager=DummyMetadataManager({}),
metadata_loader=lambda x: {},
)
with pytest.raises(ValueError, match="outside configured library"):
await service.bulk_delete_models([str(model_ok), str(outside)])
class DummyCache: class DummyCache:
def __init__(self, raw_data): def __init__(self, raw_data):
self.raw_data = raw_data self.raw_data = raw_data
+307
View File
@@ -667,3 +667,310 @@ async def test_log_duplicate_filename_summary_silent_when_no_duplicates(tmp_path
# No warning should be logged when there are no duplicates # No warning should be logged when there are no duplicates
for record in caplog.records: for record in caplog.records:
assert "Duplicate filename conflict detected" not in record.message assert "Duplicate filename conflict detected" not in record.message
# ── _cache_entries_differ ────────────────────────────────────────────
@pytest.mark.parametrize(
"a_tags, b_tags, expect_differ",
[
(["alpha", "beta"], ["beta", "alpha"], False), # order-insensitive
(["alpha"], ["alpha", "beta"], True), # count differs
([], ["alpha"], True),
(None, [], False), # None ≈ []
(["alpha"], None, True),
],
)
def test_cache_entries_differ_tags(a_tags, b_tags, expect_differ):
base = {"file_path": "/m/a.safetensors", "model_name": "A", "size": 1}
entry_a = {**base, "tags": a_tags}
entry_b = {**base, "tags": b_tags}
assert ModelScanner._cache_entries_differ(entry_a, entry_b) == expect_differ
def test_cache_entries_differ_identical():
entry = {
"file_path": "/m/a.safetensors", "model_name": "A", "size": 1,
"tags": ["x"], "civitai": {"id": 1}, "notes": "hi",
}
assert ModelScanner._cache_entries_differ(entry, dict(entry)) is False
def test_cache_entries_differ_field_changed():
a = {"file_path": "/m/a.safetensors", "model_name": "A", "size": 1}
b = {**a, "model_name": "B"}
assert ModelScanner._cache_entries_differ(a, b) is True
def test_cache_entries_differ_extra_key():
a = {"file_path": "/m/a.safetensors", "model_name": "A"}
b = {**a, "extra_field": "value"}
assert ModelScanner._cache_entries_differ(a, b) is True
# ── sync_cache_from_metadata ─────────────────────────────────────────
def _make_cache_entry(**overrides) -> dict:
entry = {
"file_path": "/m/a.safetensors",
"model_name": "TestModel",
"file_name": "a",
"folder": "",
"size": 100,
"modified": 10.0,
"sha256": "abc123",
"base_model": "SD1.5",
"preview_url": "",
"preview_nsfw_level": 0,
"from_civitai": True,
"favorite": False,
"notes": "old note",
"usage_tips": "{}",
"metadata_source": None,
"exclude": False,
"db_checked": False,
"last_checked_at": 0.0,
"tags": ["alpha"],
"civitai": {"id": 111, "modelId": 222, "name": "v1"},
"civitai_deleted": False,
"skip_metadata_refresh": False,
"hf_url": "",
"license_flags": 113,
"hash_status": "completed",
}
entry.update(overrides)
return entry
@pytest.mark.asyncio
async def test_sync_cache_no_change(tmp_path: Path):
"""When metadata matches the cache entry, return False and mutate nothing."""
scanner = DummyScanner(tmp_path)
entry = _make_cache_entry()
scanner._cache = ModelCache(
raw_data=[dict(entry)], folders=[], name_display_mode="model_name"
)
await scanner._cache.resort()
scanner._tags_count = {"alpha": 1}
scanner._hash_index.add_entry("abc123", "/m/a.safetensors")
# metadata_dict that would produce the identical cache entry
metadata_dict = {
"file_path": "/m/a.safetensors",
"model_name": "TestModel",
"file_name": "a",
"folder": "",
"size": 100,
"modified": 10.0,
"sha256": "abc123",
"base_model": "SD1.5",
"preview_url": "",
"preview_nsfw_level": 0,
"from_civitai": True,
"favorite": False,
"notes": "old note",
"usage_tips": "{}",
"tags": ["alpha"],
"civitai": {"id": 111, "modelId": 222, "name": "v1"},
"hf_url": "",
}
changed = await scanner.sync_cache_from_metadata(
"/m/a.safetensors", metadata_dict
)
assert changed is False
# Verify cache was NOT mutated
cached = await scanner.get_cached_data()
assert cached.raw_data[0]["notes"] == "old note"
@pytest.mark.asyncio
async def test_sync_cache_in_place_update(tmp_path: Path):
"""When metadata differs, update the cache entry in-place."""
scanner = DummyScanner(tmp_path)
entry = _make_cache_entry(notes="old note", tags=["alpha"], model_name="OldName")
scanner._cache = ModelCache(
raw_data=[dict(entry)], folders=[], name_display_mode="model_name"
)
await scanner._cache.resort()
scanner._tags_count = {"alpha": 1}
scanner._hash_index.add_entry("abc123", "/m/a.safetensors")
# Capture the exact dict object in raw_data before sync
original_entry_ref = scanner._cache.raw_data[0]
metadata_dict = {
"file_path": "/m/a.safetensors",
"model_name": "NewName",
"file_name": "a",
"folder": "",
"size": 100,
"modified": 10.0,
"sha256": "abc123",
"base_model": "SD1.5",
"preview_url": "",
"preview_nsfw_level": 0,
"from_civitai": True,
"favorite": False,
"notes": "new note",
"usage_tips": "{}",
"tags": ["beta", "gamma"],
"civitai": {"id": 111, "modelId": 222, "name": "v1"},
"hf_url": "",
}
changed = await scanner.sync_cache_from_metadata(
"/m/a.safetensors", metadata_dict
)
assert changed is True
cached = await scanner.get_cached_data()
updated = cached.raw_data[0]
# In-place: the same dict object persisted in raw_data
assert updated is original_entry_ref
assert updated["notes"] == "new note"
assert updated["model_name"] == "NewName"
assert sorted(updated["tags"]) == ["beta", "gamma"]
# Tag counts updated incrementally
assert scanner._tags_count.get("alpha", 0) == 0
assert scanner._tags_count.get("beta", 0) == 1
assert scanner._tags_count.get("gamma", 0) == 1
@pytest.mark.asyncio
async def test_sync_cache_not_in_cache_delegates(tmp_path: Path):
"""When the file_path is not in the cache at all, fall back to full update."""
scanner = DummyScanner(tmp_path)
scanner._cache = ModelCache(raw_data=[], folders=[], name_display_mode="model_name")
await scanner._cache.resort()
metadata_dict = {
"file_path": "/m/b.safetensors",
"model_name": "BrandNew",
"file_name": "b",
"folder": "",
"size": 200,
"modified": 20.0,
"sha256": "def456",
"base_model": "SDXL",
"preview_url": "",
"preview_nsfw_level": 0,
"from_civitai": True,
"favorite": False,
"notes": "",
"usage_tips": "{}",
"tags": [],
"civitai": {},
"hf_url": "",
}
changed = await scanner.sync_cache_from_metadata(
"/m/b.safetensors", metadata_dict
)
assert changed is True
cached = await scanner.get_cached_data()
assert len(cached.raw_data) == 1
assert cached.raw_data[0]["model_name"] == "BrandNew"
@pytest.mark.asyncio
async def test_sync_cache_conditional_resort_skipped(tmp_path: Path, monkeypatch):
"""When only non-sort-key fields change, resort() is NOT called."""
scanner = DummyScanner(tmp_path)
entry = _make_cache_entry(notes="old note", model_name="SameName")
scanner._cache = ModelCache(
raw_data=[dict(entry)], folders=[], name_display_mode="model_name"
)
await scanner._cache.resort()
scanner._cache._last_sort = ("name", "asc") # name sort is active
scanner._tags_count = {"alpha": 1}
scanner._hash_index.add_entry("abc123", "/m/a.safetensors")
# Track resort calls
resort_called = False
original_resort = scanner._cache.resort
async def tracking_resort():
nonlocal resort_called
resort_called = True
await original_resort()
monkeypatch.setattr(scanner._cache, "resort", tracking_resort)
metadata_dict = {
"file_path": "/m/a.safetensors",
"model_name": "SameName", # unchanged — no resort needed
"file_name": "a",
"folder": "",
"size": 100,
"modified": 10.0,
"sha256": "abc123",
"base_model": "SD1.5",
"preview_url": "",
"preview_nsfw_level": 0,
"from_civitai": True,
"favorite": False,
"notes": "updated note", # changed, but not sort-relevant
"usage_tips": "{}",
"tags": ["alpha"],
"civitai": {"id": 111, "modelId": 222, "name": "v1"},
"hf_url": "",
}
changed = await scanner.sync_cache_from_metadata(
"/m/a.safetensors", metadata_dict
)
assert changed is True
assert resort_called is False
@pytest.mark.asyncio
async def test_sync_cache_conditional_resort_triggered(tmp_path: Path, monkeypatch):
"""When the sort-key field changes, resort() IS called."""
scanner = DummyScanner(tmp_path)
entry = _make_cache_entry(model_name="OldName")
scanner._cache = ModelCache(
raw_data=[dict(entry)], folders=[], name_display_mode="model_name"
)
await scanner._cache.resort()
scanner._cache._last_sort = ("name", "asc")
scanner._tags_count = {"alpha": 1}
scanner._hash_index.add_entry("abc123", "/m/a.safetensors")
resort_calls = 0
original_resort = scanner._cache.resort
async def tracking_resort():
nonlocal resort_calls
resort_calls += 1
await original_resort()
monkeypatch.setattr(scanner._cache, "resort", tracking_resort)
metadata_dict = {
"file_path": "/m/a.safetensors",
"model_name": "NewName", # changed — should trigger resort
"file_name": "a",
"folder": "",
"size": 100,
"modified": 10.0,
"sha256": "abc123",
"base_model": "SD1.5",
"preview_url": "",
"preview_nsfw_level": 0,
"from_civitai": True,
"favorite": False,
"notes": "old note",
"usage_tips": "{}",
"tags": ["alpha"],
"civitai": {"id": 111, "modelId": 222, "name": "v1"},
"hf_url": "",
}
changed = await scanner.sync_cache_from_metadata(
"/m/a.safetensors", metadata_dict
)
assert changed is True
assert resort_calls == 1
@@ -225,3 +225,119 @@ def test_incremental_updates_only_touch_changed_rows(tmp_path: Path, monkeypatch
assert second['metadata_source'] == 'archive_db' assert second['metadata_source'] == 'archive_db'
assert second['civitai_deleted'] is True assert second['civitai_deleted'] is True
assert second['civitai']['creator']['username'] == 'builder_v2' assert second['civitai']['creator']['username'] == 'builder_v2'
# ── update_single_model ───────────────────────────────────────────────
def test_update_single_model_insert(tmp_path: Path, monkeypatch):
"""Insert a brand-new model row via update_single_model."""
monkeypatch.setenv('LORA_MANAGER_DISABLE_PERSISTENT_CACHE', '0')
db_path = tmp_path / 'cache.sqlite'
store = PersistentModelCache(db_path=str(db_path))
file_path = (tmp_path / 'x.safetensors').as_posix()
new_item = {
'file_path': file_path,
'file_name': 'x',
'model_name': 'Model X',
'folder': '',
'size': 42,
'modified': 1.0,
'sha256': 'sha-x',
'base_model': 'SDXL',
'preview_url': '',
'preview_nsfw_level': 0,
'from_civitai': True,
'favorite': True,
'notes': 'test note',
'usage_tips': '{}',
'metadata_source': None,
'exclude': False,
'db_checked': False,
'last_checked_at': 0.0,
'tags': ['test', 'new'],
'civitai': None,
'civitai_deleted': False,
'skip_metadata_refresh': False,
'license_flags': DEFAULT_LICENSE_FLAGS,
'hash_status': 'completed',
'hf_url': '',
}
store.update_single_model('dummy', new_item)
persisted = store.load_cache('dummy')
assert persisted is not None
items = {item['file_path']: item for item in persisted.raw_data}
assert file_path in items
assert items[file_path]['model_name'] == 'Model X'
assert items[file_path]['favorite'] is True
assert sorted(items[file_path]['tags']) == ['new', 'test']
def test_update_single_model_update_tags(tmp_path: Path, monkeypatch):
"""Tags are updated incrementally: old tags removed, new tags added."""
monkeypatch.setenv('LORA_MANAGER_DISABLE_PERSISTENT_CACHE', '0')
db_path = tmp_path / 'cache.sqlite'
store = PersistentModelCache(db_path=str(db_path))
file_path = (tmp_path / 'y.safetensors').as_posix()
base = {
'file_path': file_path, 'file_name': 'y', 'model_name': 'Y',
'folder': '', 'size': 1, 'modified': 1.0, 'sha256': 'sha-y',
'base_model': '', 'preview_url': '', 'preview_nsfw_level': 0,
'from_civitai': True, 'favorite': False, 'notes': '', 'usage_tips': '{}',
'metadata_source': None, 'exclude': False, 'db_checked': False,
'last_checked_at': 0.0, 'civitai': None, 'civitai_deleted': False,
'skip_metadata_refresh': False, 'license_flags': DEFAULT_LICENSE_FLAGS,
'hash_status': 'completed', 'hf_url': '',
}
# First insert with tags [alpha, beta]
store.update_single_model('dummy', {**base, 'tags': ['alpha', 'beta']})
# Now update: replace with [beta, gamma]
old_item = {'file_path': file_path, 'tags': ['alpha', 'beta'], 'sha256': 'sha-y'}
new_item = {**base, 'tags': ['beta', 'gamma']}
store.update_single_model('dummy', new_item, old_item=old_item)
persisted = store.load_cache('dummy')
assert persisted is not None
items = {item['file_path']: item for item in persisted.raw_data}
assert sorted(items[file_path]['tags']) == ['beta', 'gamma']
def test_update_single_model_update_hash(tmp_path: Path, monkeypatch):
"""When sha256 changes, the hash_index is updated incrementally."""
monkeypatch.setenv('LORA_MANAGER_DISABLE_PERSISTENT_CACHE', '0')
db_path = tmp_path / 'cache.sqlite'
store = PersistentModelCache(db_path=str(db_path))
file_path = (tmp_path / 'z.safetensors').as_posix()
base = {
'file_path': file_path, 'file_name': 'z', 'model_name': 'Z',
'folder': '', 'size': 1, 'modified': 1.0, 'base_model': '',
'preview_url': '', 'preview_nsfw_level': 0, 'from_civitai': True,
'favorite': False, 'notes': '', 'usage_tips': '{}',
'metadata_source': None, 'exclude': False, 'db_checked': False,
'last_checked_at': 0.0, 'tags': [], 'civitai': None,
'civitai_deleted': False, 'skip_metadata_refresh': False,
'license_flags': DEFAULT_LICENSE_FLAGS, 'hash_status': 'completed', 'hf_url': '',
}
store.update_single_model('dummy', {**base, 'sha256': 'old-hash'})
old_item = {'file_path': file_path, 'tags': [], 'sha256': 'old-hash'}
new_item = {**base, 'sha256': 'new-hash'}
store.update_single_model('dummy', new_item, old_item=old_item)
persisted = store.load_cache('dummy')
assert persisted is not None
# old hash should be gone from hash_index
old_hash_pairs = [p for p in persisted.hash_rows if p[0] == 'old-hash']
assert len(old_hash_pairs) == 0
# new hash should be present
new_hash_pairs = [p for p in persisted.hash_rows if p[0] == 'new-hash']
assert len(new_hash_pairs) == 1
assert new_hash_pairs[0][1] == file_path
+32
View File
@@ -114,6 +114,38 @@ def test_calculate_relative_path_sanitizes_model_and_version_names(isolated_sett
assert relative_path == "Fancy_Model/Version_One" assert relative_path == "Fancy_Model/Version_One"
def test_calculate_relative_path_sanitizes_leading_slash(isolated_settings):
"""Test that empty base_model does NOT produce a leading slash in the path."""
isolated_settings["download_path_templates"]["lora"] = "{base_model}/{first_tag}"
model_data = {
"base_model": "",
"tags": [],
"civitai": {"id": 1, "creator": {"username": "Author"}},
}
relative_path = calculate_relative_path_for_model(model_data, "lora")
assert not relative_path.startswith("/")
assert relative_path == "no tags"
def test_calculate_relative_path_sanitizes_double_slashes(isolated_settings):
"""Test that empty substitutions don't produce double slashes."""
isolated_settings["download_path_templates"]["lora"] = "{base_model}/{first_tag}/{author}"
model_data = {
"base_model": "",
"tags": [],
"civitai": {"id": 1, "creator": {"username": "Author"}},
}
relative_path = calculate_relative_path_for_model(model_data, "lora")
assert "//" not in relative_path
assert relative_path == "no tags/Author"
def test_calculate_recipe_fingerprint_filters_and_sorts(): def test_calculate_recipe_fingerprint_filters_and_sorts():
loras = [ loras = [
{"hash": "ABC", "strength": 0.1234}, {"hash": "ABC", "strength": 0.1234},
+1
View File
@@ -16,6 +16,7 @@ export const LORA_PROVIDER_NODE_TYPES = [
"Lora Stacker (LoraManager)", "Lora Stacker (LoraManager)",
"Lora Randomizer (LoraManager)", "Lora Randomizer (LoraManager)",
"Lora Cycler (LoraManager)", "Lora Cycler (LoraManager)",
"Create Hook LoRA (LoraManager)",
] as const; ] as const;
/** /**
@@ -30,6 +30,7 @@ function createMockToast() {
function createMockWidget(value?: unknown) { function createMockWidget(value?: unknown) {
type PendingInfo = { name: string; notes: string; filePath: string; activeTab?: string } | null type PendingInfo = { name: string; notes: string; filePath: string; activeTab?: string } | null
const widget = { const widget = {
options: {} as { getValue?: () => unknown; setValue?: (v: unknown) => void },
serializeValue: (async () => null) as () => Promise<unknown>, serializeValue: (async () => null) as () => Promise<unknown>,
value: (value ?? undefined) as unknown, value: (value ?? undefined) as unknown,
onSetValue: undefined as unknown as ((v: unknown) => void), onSetValue: undefined as unknown as ((v: unknown) => void),
+143
View File
@@ -0,0 +1,143 @@
import { app } from "../../scripts/app.js";
import {
getActiveLorasFromNode,
updateConnectedTriggerWords,
chainCallback,
mergeLoras,
getWidgetByName,
getWidgetSerializedValue,
} from "./utils.js";
import { addLorasWidget } from "./loras_widget.js";
import { applyLoraValuesToText, debounce } from "./lora_syntax_utils.js";
import { applySelectionHighlight } from "./trigger_word_highlight.js";
import { updateConnectedLoraInfoNodes } from "./lora_info.js";
app.registerExtension({
name: "LoraManager.CreateHookLora",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeType.comfyClass === "Create Hook LoRA (LoraManager)") {
chainCallback(nodeType.prototype, "onNodeCreated", function () {
// Enable widget serialization so loras widget state is persisted
this.serialize_widgets = true;
this.addInput("prev_hooks", "HOOKS", {
shape: 7,
});
// Flags to prevent callback loops between text widget ↔ loras widget
let isUpdating = false;
let isSyncingInput = false;
// Get the text input widget (AUTOCOMPLETE_TEXT_LORAS type, created by Vue widgets)
const inputWidget = getWidgetByName(this, "text");
if (!inputWidget) {
console.warn(
"LoRA Manager: text widget not found for Create Hook LoRA"
);
return;
}
this.inputWidget = inputWidget;
const scheduleInputSync = debounce((lorasValue) => {
if (isSyncingInput) {
return;
}
isSyncingInput = true;
isUpdating = true;
try {
const nextText = applyLoraValuesToText(
inputWidget.value,
lorasValue
);
if (inputWidget.value !== nextText) {
inputWidget.value = nextText;
}
} finally {
isUpdating = false;
isSyncingInput = false;
}
});
// Create the LoRA list widget
const result = addLorasWidget(
this,
"loras",
{
onSelectionChange: (selection) => {
applySelectionHighlight(this, selection);
updateConnectedLoraInfoNodes(this, selection);
},
},
(value) => {
// Prevent recursive calls
if (isUpdating) return;
isUpdating = true;
try {
// Update connected trigger word toggles with active LoRA names
const activeLoraNames = new Set();
value.forEach((lora) => {
if (lora.active) {
activeLoraNames.add(lora.name);
}
});
updateConnectedTriggerWords(this, activeLoraNames);
} finally {
isUpdating = false;
}
scheduleInputSync(value);
}
);
this.lorasWidget = result.widget;
// Set up callback for the text input widget to trigger merge logic
inputWidget.callback = (value) => {
if (isUpdating) return;
isUpdating = true;
try {
const currentLoras = this.lorasWidget?.value || [];
const mergedLoras = mergeLoras(value, currentLoras);
if (this.lorasWidget) {
this.lorasWidget.value = mergedLoras;
}
// Update connected trigger word toggles
const activeLoraNames = getActiveLorasFromNode(this);
updateConnectedTriggerWords(this, activeLoraNames);
} finally {
isUpdating = false;
}
};
});
}
},
async loadedGraphNode(node) {
if (node.comfyClass === "Create Hook LoRA (LoraManager)") {
// Restore saved loras widget values on workflow load
let existingLoras = [];
if (node.widgets_values && node.widgets_values.length > 0) {
const savedValue = getWidgetSerializedValue(node, "loras");
existingLoras = savedValue || [];
}
// Merge the loras data from text widget with saved values
const inputWidget =
node.inputWidget || getWidgetByName(node, "text");
if (!inputWidget) {
console.warn(
"LoRA Manager: text widget not found while restoring Create Hook LoRA"
);
return;
}
const mergedLoras = mergeLoras(inputWidget.value, existingLoras);
node.lorasWidget.value = mergedLoras;
}
},
});
+5 -3
View File
@@ -438,7 +438,9 @@ export function initReorderDrag(dragHandle, loraName, widget, renderFunction) {
if (firstEntry) { if (firstEntry) {
const rect = firstEntry.getBoundingClientRect(); const rect = firstEntry.getBoundingClientRect();
const containerRect = container.getBoundingClientRect(); const containerRect = container.getBoundingClientRect();
dropIndicator.style.top = `${(rect.top - containerRect.top - 2) / scale}px`; // Convert GBCR visual offset to container-local space (rect/containerRect are post-scale,
// scrollTop is pre-scale), so only the visual-diff portion is divided by scale
dropIndicator.style.top = `${(rect.top - containerRect.top) / scale + container.scrollTop - 2}px`;
dropIndicator.style.opacity = '1'; dropIndicator.style.opacity = '1';
} }
} else if (targetIndex < entries.length) { } else if (targetIndex < entries.length) {
@@ -447,7 +449,7 @@ export function initReorderDrag(dragHandle, loraName, widget, renderFunction) {
if (targetEntry) { if (targetEntry) {
const rect = targetEntry.getBoundingClientRect(); const rect = targetEntry.getBoundingClientRect();
const containerRect = container.getBoundingClientRect(); const containerRect = container.getBoundingClientRect();
dropIndicator.style.top = `${(rect.top - containerRect.top - 2) / scale}px`; dropIndicator.style.top = `${(rect.top - containerRect.top) / scale + container.scrollTop - 2}px`;
dropIndicator.style.opacity = '1'; dropIndicator.style.opacity = '1';
} }
} else { } else {
@@ -456,7 +458,7 @@ export function initReorderDrag(dragHandle, loraName, widget, renderFunction) {
if (lastEntry) { if (lastEntry) {
const rect = lastEntry.getBoundingClientRect(); const rect = lastEntry.getBoundingClientRect();
const containerRect = container.getBoundingClientRect(); const containerRect = container.getBoundingClientRect();
dropIndicator.style.top = `${(rect.bottom - containerRect.top + 2) / scale}px`; dropIndicator.style.top = `${(rect.bottom - containerRect.top) / scale + container.scrollTop + 2}px`;
dropIndicator.style.opacity = '1'; dropIndicator.style.opacity = '1';
} }
} }
+1
View File
@@ -12,6 +12,7 @@ const LORA_NODE_CLASSES = new Set([
"Lora Loader (LoraManager)", "Lora Loader (LoraManager)",
"Lora Stacker (LoraManager)", "Lora Stacker (LoraManager)",
"WanVideo Lora Select (LoraManager)", "WanVideo Lora Select (LoraManager)",
"Create Hook LoRA (LoraManager)",
]); ]);
function normalizeTriggerWordList(triggerWords) { function normalizeTriggerWordList(triggerWords) {
+1
View File
@@ -8,6 +8,7 @@ export const LORA_PROVIDER_NODE_TYPES = [
"Lora Stacker (LoraManager)", "Lora Stacker (LoraManager)",
"Lora Randomizer (LoraManager)", "Lora Randomizer (LoraManager)",
"Lora Cycler (LoraManager)", "Lora Cycler (LoraManager)",
"Create Hook LoRA (LoraManager)",
]; ];
export const LORA_STACK_AGGREGATOR_NODE_TYPES = [ export const LORA_STACK_AGGREGATOR_NODE_TYPES = [
@@ -15656,7 +15656,8 @@ function createVueWidgetCleanup(vueApp, onCleanup) {
const LORA_PROVIDER_NODE_TYPES$1 = [ const LORA_PROVIDER_NODE_TYPES$1 = [
"Lora Stacker (LoraManager)", "Lora Stacker (LoraManager)",
"Lora Randomizer (LoraManager)", "Lora Randomizer (LoraManager)",
"Lora Cycler (LoraManager)" "Lora Cycler (LoraManager)",
"Create Hook LoRA (LoraManager)"
]; ];
const LORA_STACK_AGGREGATOR_NODE_TYPES$1 = [ const LORA_STACK_AGGREGATOR_NODE_TYPES$1 = [
"Lora Stack Combiner (LoraManager)" "Lora Stack Combiner (LoraManager)"
@@ -15781,7 +15782,8 @@ const ROOT_GRAPH_ID = "root";
const LORA_PROVIDER_NODE_TYPES = [ const LORA_PROVIDER_NODE_TYPES = [
"Lora Stacker (LoraManager)", "Lora Stacker (LoraManager)",
"Lora Randomizer (LoraManager)", "Lora Randomizer (LoraManager)",
"Lora Cycler (LoraManager)" "Lora Cycler (LoraManager)",
"Create Hook LoRA (LoraManager)"
]; ];
const LORA_STACK_AGGREGATOR_NODE_TYPES = [ const LORA_STACK_AGGREGATOR_NODE_TYPES = [
"Lora Stack Combiner (LoraManager)" "Lora Stack Combiner (LoraManager)"
File diff suppressed because one or more lines are too long
+134 -1
View File
@@ -1,6 +1,6 @@
import { app } from "../../scripts/app.js"; import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js"; import { api } from "../../scripts/api.js";
import { getAllGraphNodes, getNodeReference, getNodeFromGraph, chainCallback } from "./utils.js"; import { getAllGraphNodes, getNodeReference, getNodeFromGraph, chainCallback, getLinkFromGraph } from "./utils.js";
import { ensureLmStyles } from "./lm_styles_loader.js"; import { ensureLmStyles } from "./lm_styles_loader.js";
const DEBOUNCE_DELAY = 500; const DEBOUNCE_DELAY = 500;
@@ -9,6 +9,7 @@ const LORA_NODE_CLASSES = new Set([
"Lora Loader (LoraManager)", "Lora Loader (LoraManager)",
"Lora Stacker (LoraManager)", "Lora Stacker (LoraManager)",
"WanVideo Lora Select (LoraManager)", "WanVideo Lora Select (LoraManager)",
"Create Hook LoRA (LoraManager)",
]); ]);
const TARGET_WIDGET_NAMES = new Set(["ckpt_name", "unet_name"]); const TARGET_WIDGET_NAMES = new Set(["ckpt_name", "unet_name"]);
@@ -76,6 +77,84 @@ function fadeWidgetTextColor(widget, fromColor, toColor, duration) {
return () => { if (rafId) cancelAnimationFrame(rafId); }; return () => { if (rafId) cancelAnimationFrame(rafId); };
} }
// ---------------------------------------------------------------------------
// Primitive node helpers
// ---------------------------------------------------------------------------
/**
* Set of node type names that represent Primitive value nodes.
* Includes both the dynamic PrimitiveNode (created by double-clicking
* a widget input) and the static typed primitives from the node library.
*/
const PRIMITIVE_NODE_TYPES = new Set([
"PrimitiveNode", // dynamic (double-click a widget input)
"PrimitiveInt",
"PrimitiveFloat",
"PrimitiveString",
"PrimitiveBoolean",
"PrimitiveStringMultiline",
]);
/**
* Return true when `node` is any flavour of Primitive node.
* @param {Object} node - LiteGraph node instance
* @returns {boolean}
*/
function isPrimitiveNodeType(node) {
return PRIMITIVE_NODE_TYPES.has(node?.type);
}
/**
* Find the 0-based input slot index whose widget name matches `widgetName`.
* Returns -1 when no matching input is found.
*
* Matching strategy (in order):
* 1. `input.widget?.name === widgetName` direct widget ref (preferred)
* 2. `input.name === widgetName` fallback by slot name
*
* @param {Object} node - LiteGraph node instance
* @param {string} widgetName
* @returns {number}
*/
function findInputSlotForWidget(node, widgetName) {
if (!node || !Array.isArray(node.inputs)) {
return -1;
}
return node.inputs.findIndex(
(inp) => inp?.widget?.name === widgetName || inp?.name === widgetName
);
}
/**
* If the input slot that backs `widgetName` on `node` is connected to a
* Primitive node, return that Primitive node. Otherwise return null.
*
* This is the key bridge for the "send gen params → Primitive" flow:
* when a KSampler widget (e.g. "steps") has an incoming wire from a
* Primitive node, we want to update the Primitive's value instead of the
* KSampler widget, because ComfyUI's execution engine reads from the
* connected input, not the widget.
*
* @param {Object} node - the target node (e.g. KSampler)
* @param {string} widgetName - e.g. "steps", "cfg", "seed"
* @returns {Object|null} - the connected Primitive node, or null
*/
function tryResolvePrimitiveConnection(node, widgetName) {
const slotIndex = findInputSlotForWidget(node, widgetName);
if (slotIndex === -1) return null;
const input = node.inputs[slotIndex];
if (input?.link == null) return null;
const link = getLinkFromGraph(node.graph, input.link);
if (!link) return null;
const originNode = node.graph?.getNodeById?.(link.origin_id);
if (!originNode) return null;
return isPrimitiveNodeType(originNode) ? originNode : null;
}
app.registerExtension({ app.registerExtension({
name: "LoraManager.WorkflowRegistry", name: "LoraManager.WorkflowRegistry",
@@ -309,6 +388,60 @@ app.registerExtension({
return; return;
} }
// ---- Redirect to connected Primitive node when present ----
// When a widget input (e.g. "steps", "cfg", "seed" on KSampler)
// is wired to a Primitive node, the Primitive's value overrides
// the widget value during execution. Update the Primitive
// directly so the change actually takes effect.
if (widgetName) {
const primitiveNode = tryResolvePrimitiveConnection(node, widgetName);
if (primitiveNode) {
const primWidget = primitiveNode.widgets?.[0];
if (primWidget) {
let primNewValue = value;
if (mode === "append") {
const sep =
primWidget.value && primWidget.value.length > 0
? " "
: "";
primNewValue = primWidget.value + sep + value;
}
primWidget.value = primNewValue;
if (
Array.isArray(primitiveNode.widgets_values) &&
primitiveNode.widgets_values.length > 0
) {
primitiveNode.widgets_values[0] = primNewValue;
}
if (typeof primWidget.callback === "function") {
try {
primWidget.callback(primNewValue);
} catch (callbackError) {
console.error(
"LoRA Manager: primitive widget callback failed",
callbackError
);
}
}
if (typeof primitiveNode.setDirtyCanvas === "function") {
primitiveNode.setDirtyCanvas(true);
}
if (typeof app.graph?.setDirtyCanvas === "function") {
app.graph.setDirtyCanvas(true, true);
}
this.flashWidget(primitiveNode, primWidget);
console.debug(
"LoRA Manager: redirected widget update to Primitive node %s (id=%d) ← %s = %o",
primitiveNode.type,
primitiveNode.id,
widgetName,
primNewValue
);
return;
}
}
}
// ---- Update widget value ---- // ---- Update widget value ----
const widgetIndex = node.widgets.indexOf(targetWidget); const widgetIndex = node.widgets.indexOf(targetWidget);
let newValue = value; let newValue = value;