Compare commits

...

22 Commits

Author SHA1 Message Date
willmiao
0ffee3a854 docs: auto-update supporters list in README 2026-05-06 10:29:43 +00:00
Will Miao
8aa9739c44 data: refresh supporters from license server (739 supporters, includes Patreon data) 2026-05-06 18:29:21 +08:00
Will Miao
50739bbb43 fix(css): remove dead CSS properties causing Biome errors
- batch-import-modal.css: add generic font family fallback to Font Awesome
- card.css: remove dead margin-left overridden by shorthand margin: 0
- shared.css: remove duplicate position: absolute overridden by position: fixed
2026-05-06 09:33:15 +08:00
Will Miao
e849303763 fix(header): eliminate search input focus layout shift and reduce focus ring size
- Remove transform: translateY(-1px) that caused layout shift on focus
- Reduce box-shadow focus ring from 2px to 1px for subtler appearance
- Tone down drop-shadow from 4px/16px to 2px/8px (matches base state)
2026-05-06 09:33:04 +08:00
Will Miao
241b2e15d2 docs: update extension image URL 2026-05-05 22:26:40 +08:00
Will Miao
88da754504 docs: migrate wiki-images to wiki repo, remove stale docs
Moved wiki-images to the wiki repo (willmiao/ComfyUI-Lora-Manager.wiki). Updated README.md image reference to use wiki raw URL. Removed docs/LM-Extension-Wiki.md (superseded by wiki pages).
2026-05-05 22:20:19 +08:00
Will Miao
b4a706651f feat(delete-model-version): add GET endpoint to delete a model version by version ID 2026-05-05 21:25:08 +08:00
pixelpaws
ff7cc6d9bb Merge pull request #921 from 1756141021/fix/drag-strength-notify-setValue
fix: commit dragged strength through options.setValue at drag end
2026-05-05 16:20:48 +08:00
hein
454210a47c fix: commit dragged strength through options.setValue at drag end
During drag, handleStrengthDrag is called with updateWidget=false, which
mutates widgetValue in-place via parseLoraValue's direct array reference,
bypassing widget.value setter and options.setValue entirely.

endDrag only called renderFunction for a DOM refresh, but never flushed the
mutation through options.setValue. Any external observer that wraps
options.setValue (e.g. ComfyUI Mirror Panel's bidirectional sync) would
therefore never see the dragged value and would treat the widget as unchanged.

Fix: replace the explicit renderFunction call with widget.value = widget.value.
This flushes the in-place mutation through the setter (options.setValue), which
re-renders the DOM internally AND notifies all setValue wrappers. Also fire
widget.callback for parity with the updateWidget=true path in handleStrengthDrag.

Applies the same fix to initHeaderDrag (proportional all-LoRA header drag).
2026-05-04 22:40:30 +08:00
Will Miao
2d7c404ebb fix(recipes): preserve scroll position on filter, search, and folder-driven reloads
Five entry points that trigger recipe page reloads were not passing
preserveScroll: true, causing the page to snap back to top after
filtering, searching, or navigating folders — especially painful with
hundreds of recipes.

- RecipePageControls.resetAndReload() → refreshVirtualScroll() now
  passes { preserveScroll: true } (sidebar folder clicks/drag moves)
- FilterManager applyFilters/clearAllFilters → loadRecipes(true)
  changed to loadRecipes({ preserveScroll: true })
- SearchManager performSearch → loadRecipes(true) changed to
  loadRecipes({ preserveScroll: true })
- SettingsManager reloadContent → loadRecipes() changed to
  loadRecipes({ preserveScroll: true })

The normalizeLoadRecipesOptions boolean path always forces
preserveScroll: false — the object form is required to pass it.
2026-05-04 20:26:13 +08:00
Will Miao
e23d803ecf fix(layout): ensure refresh split-button dropdown renders above breadcrumb nav 2026-05-03 18:14:54 +08:00
Will Miao
0cc640cfaa fix(recipe): support ComfyUI-Easy-Use nodes in runtime metadata extraction (#920)
- Add EasyComfyLoaderExtractor for comfyLoader (easy comfyLoader):
  extracts checkpoint, optional_lora_stack as LoRA apply node,
  prompt text, clip_skip, and latent dimensions
- Add EasyPreSamplingExtractor for samplerSettings (easy preSampling):
  extracts steps, cfg, sampler_name, scheduler, denoise, seed
- Add EasySeedExtractor for easySeed
- Fix clip_skip hardcoded to '1' — now searched from SAMPLING metadata
- Lora Stacker nodes intentionally excluded from extraction to
  prevent double-counting; LoRAs only recorded at apply nodes
2026-05-02 23:21:51 +08:00
Will Miao
2ac0eb0f9d fix(wanvideo): resolve lora path resolution and name truncation for extra folder paths
- Use get_lora_info_absolute to obtain correct absolute paths for loras
  in LM extra folder paths, instead of folder_paths.get_full_path which
  only searches ComfyUI's standard loras directories (returned None)
- Fix name field truncation: str.split('.')[0] stopped at the first dot,
  replaced with os.path.splitext to only strip the file extension
- Add _relpath_within_loras helper to preserve subdirectory info in the
  name field, matching WanVideoWrapper's os.path.splitext(lora)[0] format
2026-05-02 14:55:12 +08:00
Will Miao
f028625ce9 feat(check-models-exist): add batch endpoint for checking multiple model IDs
New endpoint: GET /api/lm/check-models-exist?modelIds=1,2,3,...

Accepts comma-separated modelIds, returns a results array with one
entry per modelId. Uses a single scanner lookup batch - three
service-registry calls total, regardless of model count. Skips
history checks entirely (same rationale as the singleton endpoint:
when models exist locally, history is redundant).

Expected: reduces 231 HTTP round-trips to 1 for the browser
extension's model-card indicator flow. Combined with the prior
SQLite-connection and history-skip fixes, total wall-clock time
for a 175K-lora user's page load drops from ~9.4s to <10ms.
2026-05-02 13:43:53 +08:00
Will Miao
06acc7f576 fix(trigger-word-toggle): default group children to active regardless of default_active 2026-05-02 13:33:42 +08:00
Will Miao
d324b57274 perf(check-model-exists): eliminate SQLite connection-per-query overhead and skip redundant history checks
Root cause: 231 concurrent /check-model-exists requests on 175K-lora library
caused ~9.4s wall clock time. The bottleneck was two-fold:

1. DownloadedVersionHistoryService opened a new sqlite3.connect() for every
   query under asyncio.Lock. With a large WAL from 175K entries, each
   connect() took ~8ms. Serialized by the lock across 231 requests, the
   230th request waited ~1848ms just for lock acquisition.

2. check_model_exists always queried download history even when the model
   was found locally. The history result (hasBeenDownloaded /
   downloadedVersionIds) is only used by the UI when the model is NOT
   found locally; when found, the 'in library' indicator takes priority.

Changes:
- downloaded_version_history_service.py: added persistent _get_conn() that
  creates the SQLite connection once and reuses it across all queries
- misc_handlers.py: early-return from check_model_exists when the model
  exists locally, bypassing the history service entirely (lock skipped)

Expected: per-request wait time drops from ~1912ms to <3ms, wall clock
from ~9.4s to <0.3s for the 175K-lora user's 231-card page.
2026-05-02 13:31:20 +08:00
Will Miao
502b7eab31 fix(layout): correct breadcrumb sticky behavior and controls wrapping overflow
- Extract breadcrumb from controls template into sibling component
- Fix breadcrumb sticky positioning (top: 0, z-index: calc(--z-header - 1))
- Add 1500px breakpoint to wrap controls-right and prevent overflow
- Adjust breadcrumb padding-bottom to cover controls-right area when sticky
2026-05-01 22:53:40 +08:00
Will Miao
be75ad930e feat(layout): implement responsive edge-to-edge card grid with density-aware column calculation
- Add dynamic column calculation based on container width and min card width
- Prevent tiny cards on narrow windows by respecting density-based minimums:
  - Default: 240px, Medium: 200px, Compact: 170px
- Fix edge-to-edge layout with proper CSS selector (.virtual-scroll-item.model-card)
- Add hamburger menu for mobile/small screens with proper translations
- Update all locale files with 'common.actions.menu' key

Fixes: Cards becoming too small/overlapping on narrow window widths (e.g., 1156px)
Changes: 15 files, +569/-114 lines
2026-05-01 21:34:31 +08:00
Will Miao
763c4f4dad feat(usage-control): add support for Civitai usageControl field
Handle models that are only available for on-site generation (usageControl:
"Generation" or "InternalGeneration") rather than downloadable.

Backend changes:
- Add usage_control field to ModelVersionRecord dataclass
- Extract usageControl from Civitai API responses
- Filter non-downloadable versions from update availability checks
- Add database schema migration for usage_control column
- Include usageControl in version response JSON

Frontend changes:
- Add isDownloadAllowed() helper function
- Show disabled download button for non-downloadable versions
- Add "On-Site Only" badge for restricted versions
- Update resolveUpdateAvailability() to filter non-downloadable versions
- Add CSS styling for disabled action button

Internationalization:
- Add translations for onSiteOnly badge and downloadNotAllowedTooltip
- Complete translations for all 10 supported languages
2026-05-01 13:10:15 +08:00
Will Miao
d32c492bdb feat(scripts): add legacy metadata migration tool
Add script to migrate metadata from legacy sidecar JSON files to
LoRA Manager's metadata.json format.

Features:
- Auto-discovers model folders from settings.json
- Supports LoRA and Checkpoint model types
- Migrates activation text, preferred weight (LoRA only), and notes
- Dry-run mode for safe preview
- Idempotent migration (won't duplicate existing data)
2026-05-01 08:56:00 +08:00
Will Miao
5dcfde36ea feat(doctor): add duplicate filename conflict detection and one-click resolution
Detects when multiple model files share the same basename (causing
ambiguity in LoRA resolution), logs warnings during scanning, and
provides a "Resolve Conflicts" button in the Doctor panel. Resolution
renames duplicates with hash-prefixed unique filenames, migrates all
sidecar and preview files, and updates the cache and frontend scroller
in-place so the model modal immediately reflects the new filename.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 15:21:26 +08:00
Will Miao
1d035361a4 fix(download): accept Diffusion Model file type when selecting primary file from CivitAI metadata
CivitAI returns file type "Diffusion Model" for checkpoint files (e.g., Anima
models), but the file selection logic only accepted "Model" and "Negative",
causing "No suitable file found in metadata" errors.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 11:54:14 +08:00
68 changed files with 2534 additions and 786 deletions

File diff suppressed because one or more lines are too long

View File

@@ -12,33 +12,39 @@
"2018cfh", "2018cfh",
"W+K+White", "W+K+White",
"wackop", "wackop",
"Takkan", "Phil",
"Carl G.", "Carl G.",
"Arlecchino Shion",
"stone9k",
"$MetaSamsara", "$MetaSamsara",
"itismyelement", "itismyelement",
"Gingko Biloba",
"onesecondinosaur", "onesecondinosaur",
"stone9k", "Takkan",
"Charles Blakemore",
"Rob Williams",
"Rosenthal", "Rosenthal",
"Francisco Tatis", "Francisco Tatis",
"Tobi_Swagg",
"Andrew Wilson", "Andrew Wilson",
"Greybush", "Greybush",
"Gooohokrbe",
"Ricky Carter", "Ricky Carter",
"JongWon Han", "JongWon Han",
"OldBones",
"VantAI", "VantAI",
"runte3221", "runte3221",
"Illrigger",
"FreelancerZ", "FreelancerZ",
"Edgar Tejeda", "Edgar Tejeda",
"Jorge Hussni",
"Liam MacDougal", "Liam MacDougal",
"Fraser Cross", "Fraser Cross",
"Polymorphic Indeterminate", "Polymorphic Indeterminate",
"Birdy",
"Marc Whiffen", "Marc Whiffen",
"Jorge Hussni", "Birdy",
"Kiba",
"Skalabananen", "Skalabananen",
"Kiba",
"Reno Lam", "Reno Lam",
"Mozzel",
"sig", "sig",
"Christian Byrne", "Christian Byrne",
"DM", "DM",
@@ -46,39 +52,41 @@
"Estragon", "Estragon",
"J\\B/ 8r0wns0n", "J\\B/ 8r0wns0n",
"Snaggwort", "Snaggwort",
"Arlecchino Shion",
"Charles Blakemore",
"Rob Williams",
"ClockDaemon", "ClockDaemon",
"Jonathan Ross",
"KD", "KD",
"Omnidex", "Omnidex",
"Nazono_hito",
"Tyler Trebuchon", "Tyler Trebuchon",
"Release Cabrakan", "Release Cabrakan",
"Tobi_Swagg", "contrite831",
"SG", "SG",
"carozzz", "carozzz",
"James Dooley", "James Dooley",
"zenbound", "zenbound",
"Buzzard", "Buzzard",
"jmack", "jmack",
"Adam Shaw",
"Mark Corneglio", "Mark Corneglio",
"SarcasticHashtag", "SarcasticHashtag",
"Cosmosis", "Anthony Rizzo",
"iamresist", "iamresist",
"Gooohokrbe",
"RedrockVP", "RedrockVP",
"Wolffen", "Wolffen",
"FloPro4Sho",
"James Todd", "James Todd",
"OldBones",
"Steven Pfeiffer", "Steven Pfeiffer",
"Tim", "Tim",
"Timmy",
"Johnny",
"Lisster", "Lisster",
"Michael Wong", "Michael Wong",
"Illrigger", "whudunit",
"Tom Corrigan", "Tom Corrigan",
"dl0901dm",
"JackieWang", "JackieWang",
"fnkylove", "fnkylove",
"Julian V",
"Steven Owens",
"Yushio", "Yushio",
"Vik71it", "Vik71it",
"Echo", "Echo",
@@ -86,147 +94,137 @@
"Robert Stacey", "Robert Stacey",
"PM", "PM",
"Todd Keck", "Todd Keck",
"Mozzel", "Briton Heilbrun",
"Gingko Biloba", "Aleksander Wujczyk",
"Sterilized",
"BadassArabianMofo", "BadassArabianMofo",
"Sterilized",
"Pascal Dahle", "Pascal Dahle",
"quarz", "quarz",
"Greg",
"Penfore", "Penfore",
"Greg",
"JSST", "JSST",
"esthe",
"lmsupporter", "lmsupporter",
"IamAyam", "zounic",
"wfpearl", "wfpearl",
"Baekdoosixt", "Baekdoosixt",
"Jonathan Ross",
"Jack B Nimble", "Jack B Nimble",
"Nazono_hito",
"Melville Parrish", "Melville Parrish",
"daniel dove", "daniel dove",
"Lustre", "Lustre",
"JW Sin", "JW Sin",
"contrite831",
"Alex", "Alex",
"bh", "bh",
"confiscated Zyra",
"Marlon Daniels", "Marlon Daniels",
"Starkselle", "Starkselle",
"Aaron Bleuer", "Aaron Bleuer",
"LacesOut!", "LacesOut!",
"greebles", "greebles",
"Adam Shaw", "Cosmosis",
"Tee Gee",
"Anthony Rizzo",
"tarek helmi",
"M Postkasse", "M Postkasse",
"FloPro4Sho",
"ASLPro3D", "ASLPro3D",
"Jacob Hoehler", "Jacob Hoehler",
"FinalyFree", "FinalyFree",
"Weasyl", "Weasyl",
"Timmy", "Lex Song",
"Johnny",
"Cory Paza", "Cory Paza",
"Tak", "Tak",
"Gonzalo Andre Allendes Lopez", "Gonzalo Andre Allendes Lopez",
"Zach Gonser", "Zach Gonser",
"Big Red", "Big Red",
"whudunit", "Jimmy Ledbetter",
"Luc Job", "Luc Job",
"dl0901dm",
"Philip Hempel", "Philip Hempel",
"corde", "corde",
"Nick Walker", "Nick Walker",
"lh qwe", "Julian V",
"Steven Owens",
"Bishoujoker", "Bishoujoker",
"conner",
"aai", "aai",
"Briton Heilbrun",
"Tori", "Tori",
"wildnut", "wildnut",
"Princess Bright Eyes",
"AbstractAss",
"Felipe dos Santos",
"ViperC",
"jean jahren", "jean jahren",
"Aleksander Wujczyk",
"AM Kuro", "AM Kuro",
"Markus", "ViperC",
"S Sang", "Ran C",
"Sangheili460",
"MagnaInsomnia",
"Karl P.", "Karl P.",
"Akira_HentAI", "Akira_HentAI",
"MagnaInsomnia",
"Gordon Cole", "Gordon Cole",
"yuxz69", "yuxz69",
"Douglas Gaspar", "esthe",
"AlexDuKaNa",
"George",
"andrew.tappan", "andrew.tappan",
"dw",
"N/A", "N/A",
"The Spawn", "The Spawn",
"Phil",
"graysock", "graysock",
"Pozadine1",
"Greenmoustache", "Greenmoustache",
"zounic",
"fancypants", "fancypants",
"IamAyam",
"Eldithor",
"Joboshy",
"Digital", "Digital",
"JaxMax", "JaxMax",
"takyamtom", "takyamtom",
"奚明 刘", "Bohemian Corporal",
"Dan",
"confiscated Zyra",
"Jwk0205", "Jwk0205",
"Bro Xie", "Bro Xie",
"준희 김", "yer fey",
"batblue", "batblue",
"carey6409", "carey6409",
"Olive", "Olive",
"太郎 ゲーム", "太郎 ゲーム",
"Tee Gee",
"Some Guy Named Barry", "Some Guy Named Barry",
"jinxedx",
"tarek helmi",
"Max Marklund", "Max Marklund",
"Tomohiro Baba",
"David Ortega",
"AELOX", "AELOX",
"Dankin",
"Nicfit23", "Nicfit23",
"Noora",
"wamekukyouzin", "wamekukyouzin",
"drum matthieu", "drum matthieu",
"Dogmaster", "Dogmaster",
"Matt Wenzel", "Matt Wenzel",
"Mattssn", "Frank Nitty",
"Lex Song", "Pronredn",
"John Saveas",
"Christopher Michel", "Christopher Michel",
"Serge Bekenkamp", "Serge Bekenkamp",
"Jimmy Ledbetter", "DougPeterson",
"LeoZero", "LeoZero",
"Antonio Pontes", "Antonio Pontes",
"ApathyJones", "ApathyJones",
"nahinahi9", "nahinahi9",
"lh qwe",
"Kevin John Duck",
"conner",
"Dustin Chen", "Dustin Chen",
"dan", "dan",
"Yaboi", "Blackfish95",
"Mouthlessman", "Mouthlessman",
"Steam Steam", "Princess Bright Eyes",
"Damon Cunliffe", "Paul Kroll",
"CryptoTraderJK", "AbstractAss",
"Davaitamin",
"otaku fra", "otaku fra",
"Ran C", "Felipe dos Santos",
"tedcor", "Bas Imagineer",
"Fotek Design", "Markus",
"MiraiKuriyamaSy",
"Adam Taylor", "Adam Taylor",
"Douglas Gaspar",
"Weird_With_A_Beard", "Weird_With_A_Beard",
"MadSpin", "AlexDuKaNa",
"Pozadine1", "George",
"dw",
"Qarob", "Qarob",
"AIGooner", "AIGooner",
"inbijiburu",
"Luc", "Luc",
"ProtonPrince", "ProtonPrince",
"DiffDuck", "DiffDuck",
"elu3199", "elu3199",
"Nick “Loadstone” D",
"Hasturkun", "Hasturkun",
"Jon Sandman", "Jon Sandman",
"Ubivis", "Ubivis",
@@ -234,54 +232,45 @@
"thesoftwaredruid", "thesoftwaredruid",
"wundershark", "wundershark",
"mr_dinosaur", "mr_dinosaur",
"Tyrswood",
"linnfrey", "linnfrey",
"Gamalonia",
"Vir",
"Pkrsky", "Pkrsky",
"Joboshy", "奚明 刘",
"Bohemian Corporal",
"Dan",
"Josef Lanzl", "Josef Lanzl",
"Seth Christensen", "Nerezza",
"Griffin Dahlberg", "Griffin Dahlberg",
"Draven T", "준희 김",
"yer fey",
"Error_Rule34_Not_found", "Error_Rule34_Not_found",
"Gerald Welly", "Gerald Welly",
"Roslynd", "Roslynd",
"Geolog", "Geolog",
"jinxedx",
"Neco28", "Neco28",
"Aquatic Coffee", "Tomohiro Baba",
"Dankin", "David Ortega",
"ethanfel", "Noora",
"Cristian Vazquez", "Cristian Vazquez",
"Frank Nitty", "Mattssn",
"Magic Noob", "Magic Noob",
"Focuschannel",
"DougPeterson",
"Jeff", "Jeff",
"Bruce", "Bruce",
"Kevin John Duck",
"Anthony Faxlandez",
"Kevin Christopher", "Kevin Christopher",
"Ouro Boros", "Ouro Boros",
"Blackfish95", "Chad Idk",
"Yaboi",
"dd", "dd",
"Paul Kroll", "Steam Steam",
"MiraiKuriyamaSy", "CryptoTraderJK",
"semicolon drainpipe", "Davaitamin",
"Thesharingbrother", "Dušan Ryban",
"Bas Imagineer", "tedcor",
"Pat Hen", "Fotek Design",
"sjon kreutz",
"John Statham", "John Statham",
"ResidentDeviant", "MadSpin",
"Nihongasuki", "Metryman55",
"JC", "inbijiburu",
"Prompt Pirate",
"uwutismxd",
"decoy", "decoy",
"Tyrswood", "Nick “Loadstone” D",
"Ray Wing", "Ray Wing",
"Ranzitho", "Ranzitho",
"Gus", "Gus",
@@ -290,6 +279,7 @@
"David LaVallee", "David LaVallee",
"ae", "ae",
"Tr4shP4nda", "Tr4shP4nda",
"Gamalonia",
"WRL_SPR", "WRL_SPR",
"capn", "capn",
"Joseph", "Joseph",
@@ -302,77 +292,60 @@
"Moon Knight", "Moon Knight",
"몽타주", "몽타주",
"Kland", "Kland",
"zenobeus", "Hailshem",
"Jackthemind", "kudari",
"ryoma", "Naomi Hale Danchi",
"Stryker", "dc7431",
"raf8osz", "Vir",
"ElitaSSJ4",
"blikkies",
"Chris",
"Brian M", "Brian M",
"Nerezza",
"sanborondon", "sanborondon",
"Seth Christensen",
"Draven T",
"Taylor Funk", "Taylor Funk",
"aezin", "aezin",
"Thought2Form", "Thought2Form",
"jcay015", "jcay015",
"Kevin Picco", "Kevin Picco",
"Erik Lopez", "Erik Lopez",
"Shock Shockor",
"Mateo Curić", "Mateo Curić",
"Goldwaters", "Aquatic Coffee",
"Zude",
"Eris3D", "Eris3D",
"m", "m",
"ethanfel",
"Pierce McBride", "Pierce McBride",
"Joshua Gray", "Joshua Gray",
"Kyler", "Focuschannel",
"Mikko Hemilä", "Mikko Hemilä",
"aRtFuL_DodGeR",
"Jamie Ogletree", "Jamie Ogletree",
"a _", "a _",
"James Coleman", "James Coleman",
"CrimsonDX",
"Martial", "Martial",
"Anthony Faxlandez",
"battu", "battu",
"Emil Andersson", "Emil Andersson",
"Chad Idk",
"DarkSunset",
"Billy Gladky",
"Yuji Kaneko", "Yuji Kaneko",
"Probis", "Pat Hen",
"Dušan Ryban", "semicolon drainpipe",
"ItsGeneralButtNaked",
"Jordan Shaw", "Jordan Shaw",
"Rops Alot", "Rops Alot",
"Thesharingbrother",
"Sam", "Sam",
"sjon kreutz",
"Nimess",
"SRDB",
"Ace Ventura", "Ace Ventura",
"g unit", "ResidentDeviant",
"Youguang", "Nihongasuki",
"Metryman55", "JC",
"andrewzpong", "Prompt Pirate",
"FrxzenSnxw", "uwutismxd",
"BossGame",
"lrdchs",
"momokai", "momokai",
"Hailshem", "zenobeus",
"kudari",
"Naomi Hale Danchi",
"dc7431",
"ken", "ken",
"Inversity",
"AIVORY3D",
"epicgamer0020690", "epicgamer0020690",
"Joshua Porrata", "Joshua Porrata",
"keemun", "keemun",
"SuBu", "SuBu",
"RedPIXel", "RedPIXel",
"Kevinj",
"Wind", "Wind",
"Jackthemind",
"Nexus", "Nexus",
"Ramneek“Guy”Ashok", "Ramneek“Guy”Ashok",
"squid_actually", "squid_actually",
@@ -385,80 +358,81 @@
"emyth", "emyth",
"chriphost", "chriphost",
"KitKatM", "KitKatM",
"ryoma",
"socrasteeze", "socrasteeze",
"ResidentDeviant", "OrganicArtifact",
"Stryker",
"MudkipMedkitz",
"gzmzmvp", "gzmzmvp",
"Welkor", "raf8osz",
"John Martin", "ElitaSSJ4",
"Richard", "Richard",
"blikkies",
"Andrew", "Andrew",
"Chris",
"Robert Wegemund", "Robert Wegemund",
"Littlehuggy", "Littlehuggy",
"moranqianlong",
"Gregory Kozhemiak", "Gregory Kozhemiak",
"mrjuan", "mrjuan",
"Brian Buie", "Brian Buie",
"Shock Shockor",
"Sadlip", "Sadlip",
"Haru Yotu", "Goldwaters",
"Eric Whitney", "Eric Whitney",
"Joey Callahan", "Joey Callahan",
"Zude",
"Ivan Tadic", "Ivan Tadic",
"Mike Simone", "Mike Simone",
"John J Linehan",
"Kyler",
"Elliot E",
"Morgandel", "Morgandel",
"Kyron Mahan", "Theerat Jiramate",
"Matura Arbeit", "aRtFuL_DodGeR",
"Noah", "Noah",
"Jacob McDaniel", "Jacob McDaniel",
"X", "X",
"Sloan Steddy", "Sloan Steddy",
"TBitz33",
"Anonym dkjglfleeoeldldldlkf",
"Temikus", "Temikus",
"Artokun", "Artokun",
"Michael Taylor", "Michael Taylor",
"SendingRavens",
"Derek Baker", "Derek Baker",
"CrimsonDX",
"Michael Anthony Scott", "Michael Anthony Scott",
"DarkSunset",
"Atilla Berke Pekduyar", "Atilla Berke Pekduyar",
"Michael Docherty",
"Nathan", "Nathan",
"Billy Gladky",
"NICHOLAS BAXLEY",
"Decx _", "Decx _",
"Paul Hartsuyker", "Probis",
"elitassj", "Ed Wang",
"Jacob Winter", "ItsGeneralButtNaked",
"Nimess",
"SRDB",
"g unit",
"Distortik", "Distortik",
"David", "Youguang",
"Meilo",
"Pen Bouryoung",
"四糸凜音", "四糸凜音",
"shinonomeiro", "Saya",
"Snille", "andrewzpong",
"MaartenAlbers", "FrxzenSnxw",
"khanh duy", "BossGame",
"xybrightsummer", "lrdchs",
"jreedatchison",
"PhilW",
"Tree Tagger", "Tree Tagger",
"Janik", "Inversity",
"Crocket", "Crocket",
"Cruel", "AIVORY3D",
"MRBlack", "Kevinj",
"Mitchell Robson", "Mitchell Robson",
"Kiyoe",
"humptynutz",
"michael.isaza",
"Kalnei",
"Whitepinetrader", "Whitepinetrader",
"OrganicArtifact", "ResidentDeviant",
"Scott",
"MudkipMedkitz",
"deanbrian", "deanbrian",
"POPPIN", "POPPIN",
"Alex Wortman", "Alex Wortman",
"Cody", "Cody",
"Raku", "Raku",
"smart.edge5178", "smart.edge5178",
"emadsultan",
"InformedViewz", "InformedViewz",
"CHKeeho80", "CHKeeho80",
"Bubbafett", "Bubbafett",
@@ -466,76 +440,152 @@
"Menard", "Menard",
"Skyfire83", "Skyfire83",
"Adam Rinehart", "Adam Rinehart",
"D",
"Pitpe11", "Pitpe11",
"TheD1rtyD03", "TheD1rtyD03",
"moonpetal", "moonpetal",
"SomeDude", "SomeDude",
"g9p0o", "g9p0o",
"nanana",
"TheHolySheep", "TheHolySheep",
"Monte Won", "Monte Won",
"SpringBootisTrash", "SpringBootisTrash",
"carsten", "carsten",
"ikok", "ikok",
"Nathen+Choi",
"T",
"LarsesFPC",
"cocona",
"sfasdfasfdsa",
"Buecyb99", "Buecyb99",
"4IXplr0r3r", "Welkor",
"dfklsjfkljslfjd", "David Schenck",
"hayden", "John Martin",
"ahoystan",
"Leland Saunders",
"Wolfe7D1", "Wolfe7D1",
"Ink Temptation", "Ink Temptation",
"Bob Barker", "moranqianlong",
"edk",
"Kalli Core", "Kalli Core",
"Aeternyx",
"elleshar666", "elleshar666",
"YOU SINWOO", "ACTUALLY_the_Real_Willem_Dafoe",
"ja s", "Haru Yotu",
"Doug Mason",
"Kauffy", "Kauffy",
"Jeremy Townsend",
"EpicElric", "EpicElric",
"Sean voets", "Kyron Mahan",
"Owen Gwosdz",
"John J Linehan",
"Elliot E",
"Thomas Wanner",
"Theerat Jiramate",
"Edward Kennedy", "Edward Kennedy",
"Justin Blaylock", "Justin Blaylock",
"Devil Lude", "Matura Arbeit",
"Nick Kage", "Nick Kage",
"kevin stoddard", "TBitz33",
"Jack Dole", "Anonym dkjglfleeoeldldldlkf",
"Vane Holzer", "Vane Holzer",
"psytrax", "psytrax",
"Cyrus Fett",
"Ezokewn", "Ezokewn",
"SendingRavens",
"hexxish", "hexxish",
"CptNeo",
"notedfakes", "notedfakes",
"Maso", "Michael Docherty",
"Eric Ketchum",
"NICHOLAS BAXLEY",
"Michael Scott", "Michael Scott",
"Kevin Wallace", "Paul Hartsuyker",
"Matheus Couto", "elitassj",
"Saya", "Jacob Winter",
"ChicRic",
"mercur",
"J C",
"Ed Wang",
"Ryan Presley Ng", "Ryan Presley Ng",
"Wes Sims", "Wes Sims",
"Donor4115", "Donor4115",
"Lyavph",
"David",
"Meilo",
"Filippo Ferrari",
"Pen Bouryoung",
"shinonomeiro",
"Snille",
"MaartenAlbers",
"khanh duy",
"xybrightsummer",
"jreedatchison",
"PhilW",
"Janik",
"Cruel",
"MRBlack",
"Kiyoe",
"humptynutz",
"michael.isaza",
"Kalnei",
"Scott",
"Muratoraccio",
"Ginnie",
"emadsultan",
"D",
"nanana",
"Fthehappy",
"rsamerica",
"Alan+Cano",
"FeralOpticsAI",
"Pavlaki",
"generic404",
"Doug+Rintoul",
"Noor",
"Yorunai",
"quantenmecha",
"abattoirblues",
"Jason+Nash",
"BillyBoy84",
"zounik",
"DarkRoast",
"letzte",
"Nasty+Hobbit",
"Sora+Yori",
"lrdchs2",
"Duk3+Rand0m",
"4IXplr0r3r",
"hayden",
"ahoystan",
"Leland Saunders",
"Bob Barker",
"edk",
"JBsuede",
"Time Valentine",
"Aeternyx",
"YOU SINWOO",
"りん あめ",
"ja s",
"Михал Михалыч",
"Matt",
"Doug Mason",
"Jeremy Townsend",
"Frogmilk",
"Sean voets",
"Owen Gwosdz",
"SPJ",
"Thomas Wanner",
"Bryan Rutkowski",
"Devil Lude",
"David Murcko",
"kevin stoddard",
"Jack Dole",
"max blo",
"Xenon Xue",
"CptNeo",
"JackJohnnyJim",
"Dmitry Ryzhov",
"Maso",
"Edward Ten Eyck",
"Eric Ketchum",
"Kevin Wallace",
"Matheus Couto",
"ChicRic",
"Henrique Faiolli",
"mercur",
"Solixer",
"J C",
"jinksta187",
"Andrew Wilkinson",
"Manu Thetug",
"Karlanx",
"Yves Poezevara", "Yves Poezevara",
"operationancut",
"Teriak47", "Teriak47",
"Just me", "Just me",
"Raf Stahelin", "Raf Stahelin",
"Вячеслав Маринин", "Вячеслав Маринин",
"Lyavph",
"Filippo Ferrari",
"Cola Matthew", "Cola Matthew",
"OniNoKen", "OniNoKen",
"Iain Wisely", "Iain Wisely",
@@ -576,98 +626,121 @@
"dg", "dg",
"Maarten Harms", "Maarten Harms",
"Israel", "Israel",
"Muratoraccio",
"SelfishMedic", "SelfishMedic",
"Ginnie",
"adderleighn", "adderleighn",
"EnragedAntelope", "EnragedAntelope",
"Alan+Cano", "lighthawke",
"FeralOpticsAI", "Terraformer",
"Pavlaki", "GDS+DEV",
"generic404", "4rt+r3d",
"low9",
"Winged",
"you+halo9",
"YassineKhaled",
"YK12",
"MatteKey",
"Flob",
"ShiroSenpai",
"Somebody",
"Inkognito",
"Somebody",
"Gramer+Gumbyte",
"Crescent~San",
"Tan+Huynh",
"AiGirlTS",
"D",
"datasl4ve",
"Somebody",
"Dark_Pest",
"Aza",
"Jacky+Ho",
"koopa990",
"Karru",
"ChaChanoKo",
"null",
"bo",
"The+Forgetful+Dev",
"redcarrot",
"powerbot99",
"Mateusz+Kosela", "Mateusz+Kosela",
"Doug+Rintoul",
"Noor",
"Yorunai",
"Bula", "Bula",
"quantenmecha",
"abattoirblues",
"Jason+Nash",
"BillyBoy84",
"DarkRoast",
"zounik",
"letzte",
"Nasty+Hobbit",
"SgtFluffles",
"lrdchs2",
"Duk3+Rand0m",
"KUJYAKU", "KUJYAKU",
"NathenChoi",
"Thomas+Reck",
"Larses",
"cocona",
"Coeur+de+cochon", "Coeur+de+cochon",
"David Schenck",
"han b", "han b",
"Nico", "Nico",
"Banana Joe", "Banana Joe",
"_ G3n", "_ G3n",
"Donovan Jenkins", "Donovan Jenkins",
"JBsuede", "Tú Nguyễn Lý Hoàng",
"Michael Eid", "Michael Eid",
"beersandbacon", "beersandbacon",
"Maximilian Pyko", "Maximilian Pyko",
"Invis", "Invis",
"Justin Houston", "Bob barker",
"Time Valentine", "Ben D",
"Garrett Wood",
"Ronan Delevacq",
"james", "james",
"Christian Schäfer",
"OrochiNights", "OrochiNights",
"Michael Zhu", "Michael Zhu",
"ACTUALLY_the_Real_Willem_Dafoe",
"gonzalo", "gonzalo",
"Seraphy", "Seraphy",
"Михал Михалыч",
"雨の心 落", "雨の心 落",
"Matt",
"AllTimeNoobie", "AllTimeNoobie",
"jumpd", "jumpd",
"John C", "John C",
"Rim", "Rim",
"Dave Abraham",
"Joaquin Hierrezuelo",
"Dismem", "Dismem",
"Frogmilk", "Locrospiel",
"SPJ", "Jairus Knudsen",
"Jarrid Lee",
"Xan Dionysus", "Xan Dionysus",
"Nathan lee", "Nathan lee",
"Kor",
"Joseph Hanson",
"Mewtora", "Mewtora",
"Middo", "Middo",
"Forbidden Atelier", "Forbidden Atelier",
"Bryan Rutkowski", "John Rednoulf",
"Spire",
"Adictedtohumping", "Adictedtohumping",
"Boba Smith",
"Towelie", "Towelie",
"Cyrus Fett", "MR.Bear",
"dsffsdfsdfsdfsdfsdf",
"Jean-françois SEMA", "Jean-françois SEMA",
"Kurt", "Kurt",
"max blo", "ivistorm",
"Xenon Xue", "Sauv",
"JackJohnnyJim", "Steven",
"Edward Ten Eyck", "TenaciousD",
"Khánh Đặng",
"Chase Kwon", "Chase Kwon",
"Ted Cart",
"Inyoshu", "Inyoshu",
"Goober719", "Goober719",
"Chad Barnes", "Chad Barnes",
"Person Y",
"David Spearing",
"James Ming", "James Ming",
"vanditking", "vanditking",
"kripitonga", "kripitonga",
"Rizzi", "Rizzi",
"nimin", "nimin",
"OMAR LUCIANO", "OMAR LUCIANO",
"Ken+Suzuki",
"hannibal", "hannibal",
"Jo+Example", "Jo+Example",
"BrentBertram", "BrentBertram",
"Tigon",
"eumelzocker", "eumelzocker",
"dxjaymz", "dxjaymz",
"L C", "L C",
"Dude" "Dude",
"CK"
], ],
"totalCount": 666 "totalCount": 739
} }

View File

@@ -1,183 +0,0 @@
## Overview
The **LoRA Manager Civitai Extension** is a Browser extension designed to work seamlessly with [LoRA Manager](https://github.com/willmiao/ComfyUI-Lora-Manager) to significantly enhance your browsing experience on [Civitai](https://civitai.com). With this extension, you can:
✅ Instantly see which models are already present in your local library
✅ Download new models with a single click
✅ Manage downloads efficiently with queue and parallel download support
✅ Keep your downloaded models automatically organized according to your custom settings
![Civitai Models page](https://github.com/willmiao/ComfyUI-Lora-Manager/blob/main/wiki-images/civitai-models-page.png)
**Update:** It now also supports browsing on [CivArchive](https://civarchive.com/) (formerly CivitaiArchive).
![CivArchive Models page](https://github.com/willmiao/ComfyUI-Lora-Manager/blob/main/wiki-images/civarchive-models-page.png)
---
## Why Supporter Access?
LoRA Manager is built with love for the Stable Diffusion and ComfyUI communities. Your support makes it possible for me to keep improving and maintaining the tool full-time.
Supporter-exclusive features help ensure the long-term sustainability of LoRA Manager, allowing continuous updates, new features, and better performance for everyone.
Every contribution directly fuels development and keeps the core LoRA Manager free and open-source. In addition to monthly supporters, one-time donation supporters will also receive a license key, with the duration scaling according to the contribution amount. Thank you for helping keep this project alive and growing. ❤️
---
## Installation
### Supported Browsers & Installation Methods
| Browser | Installation Method |
|--------------------|-------------------------------------------------------------------------------------|
| **Google Chrome** | [Chrome Web Store link](https://chromewebstore.google.com/detail/capigligggeijgmocnaflanlbghnamgm?utm_source=item-share-cb) |
| **Microsoft Edge** | Install via Chrome Web Store (compatible) |
| **Brave Browser** | Install via Chrome Web Store (compatible) |
| **Opera** | Install via Chrome Web Store (compatible) |
| **Firefox** | <div id="firefox-install" class="install-ok"><a href="https://github.com/willmiao/lm-civitai-extension-firefox/releases/latest/download/extension.xpi">📦 Install Firefox Extension (reviewed and verified by Mozilla)</a></div> |
For non-Chrome browsers (e.g., Microsoft Edge), you can typically install extensions from the Chrome Web Store by following these steps: open the extensions Chrome Web Store page, click 'Get extension', then click 'Allow' when prompted to enable installations from other stores, and finally click 'Add extension' to complete the installation.
---
## Privacy & Security
I understand concerns around browser extensions and privacy, and I want to be fully transparent about how the **LM Civitai Extension** works:
- **Reviewed and Verified**
This extension has been **manually reviewed and approved by the Chrome Web Store**. The Firefox version uses the **exact same code** (only the packaging format differs) and has passed **Mozillas Add-on review**.
- **Minimal Network Access**
The only external server this extension connects to is:
**`https://willmiao.shop`** — used solely for **license validation**.
It does **not collect, transmit, or store any personal or usage data**.
No browsing history, no user IDs, no analytics, no hidden trackers.
- **Local-Only Model Detection**
Model detection and LoRA Manager communication all happen **locally** within your browser, directly interacting with your local LoRA Manager backend.
I value your trust and are committed to keeping your local setup private and secure. If you have any questions, feel free to reach out!
---
## How to Use
After installing the extension, you'll automatically receive a **7-day trial** to explore all features.
When the extension is correctly installed and your license is valid:
- Open **Civitai**, and you'll see visual indicators added by the extension on model cards, showing:
- ✅ Models already present in your local library
- ⬇️ A download button for models not in your library
Clicking the download button adds the corresponding model version to the download queue, waiting to be downloaded. You can set up to **5 models to download simultaneously**.
### Visual Indicators Appear On:
- **Home Page** — Featured models
- **Models Page**
- **Creator Profiles** — If the creator has set their models to be visible
- **Recommended Resources** — On individual model pages
### Version Buttons on Model Pages
On a specific model page, visual indicators also appear on version buttons, showing which versions are already in your local library.
**Starting from v0.4.8**, model pages use a dedicated download button for better compatibility. When switching to a specific version by clicking a version button:
- The new **dedicated download button** directly triggers download via **LoRA Manager**
- The **original download button** remains unchanged for standard browser downloads
![Civitai Model Page](https://github.com/willmiao/ComfyUI-Lora-Manager/blob/main/wiki-images/civitai-model-page.png)
### Hide Models Already in Library (Beta)
**New in v0.4.8**: A new **Hide models already in library (Beta)** option makes it easier to focus on models you haven't added yet. It can be enabled from Settings, or toggled quickly using **Ctrl + Shift + H** (macOS: **Command + Shift + H**).
### Resources on Image Pages — now shows in-library indicators for image resources plus one-click recipe import
- **One-Click Import Civitai Image as Recipe** — Import any Civitai image as a recipe with a single click in the Resources Used panel.
- **Auto-Queue Missing Assets** — In Settings you can decide if LoRAs or checkpoints referenced by that image should automatically be added to your download queue.
- **More Accurate Metadata** — Importing directly from the page is faster than copying inside LM and keeps on-site tags and other metadata perfectly aligned.
![Civitai Image Page](https://github.com/willmiao/ComfyUI-Lora-Manager/blob/main/wiki-images/civitai-image-page.jpg)
[![alt](url)](https://github.com/user-attachments/assets/41fd4240-c949-4f83-bde7-8f3124c09494)
---
## Model Download Location & LoRA Manager Settings
To use the **one-click download function**, you must first set:
- Your **Default LoRAs Root**
- Your **Default Checkpoints Root**
These are set within LoRA Manager's settings.
When everything is configured, downloaded model files will be placed in:
`<Default_Models_Root>/<Base_Model_of_the_Model>/<First_Tag_of_the_Model>`
### Update: Default Path Customization (2025-07-21)
A new setting to customize the default download path has been added in the nightly version. You can now personalize where models are saved when downloading via the LM Civitai Extension.
![Default Path Customization](https://github.com/willmiao/ComfyUI-Lora-Manager/blob/main/wiki-images/default-path-customization.png)
The previous YAML path mapping file will be deprecated—settings will now be unified in settings.json to simplify configuration.
---
## Backend Port Configuration
If your **ComfyUI** or **LoRA Manager** backend is running on a port **other than the default 8188**, you must configure the backend port in the extension's settings.
After correctly setting and saving the port, you'll see in the extension's header area:
- A **Healthy** status with the tooltip: `Connected to LoRA Manager on port xxxx`
---
## Advanced Usage
### Connecting to a Remote LoRA Manager
If your LoRA Manager is running on another computer, you can still connect from your browser using port forwarding.
> **Why can't you set a remote IP directly?**
>
> For privacy and security, the extension only requests access to `http://127.0.0.1/*`. Supporting remote IPs would require much broader permissions, which may be rejected by browser stores and could raise user concerns.
**Solution: Port Forwarding with `socat`**
On your browser computer, run:
`socat TCP-LISTEN:8188,bind=127.0.0.1,fork TCP:REMOTE.IP.ADDRESS.HERE:8188`
- Replace `REMOTE.IP.ADDRESS.HERE` with the IP of the machine running LoRA Manager.
- Adjust the port if needed.
This lets the extension connect to `127.0.0.1:8188` as usual, with traffic forwarded to your remote server.
_Thanks to user **Temikus** for sharing this solution!_
---
## Roadmap
The extension will evolve alongside **LoRA Manager** improvements. Planned features include:
- [x] Support for **additional model types** (e.g., embeddings)
- [x] One-click **Recipe Import**
- [x] Display of in-library status for all resources in the **Resources Used** section of the image page
- [x] One-click **Auto-organize Models**
- [x] **Hide models already in library (Beta)** - Focus on models you haven't added yet
**Stay tuned — and thank you for your support!**
---

View File

@@ -15,7 +15,8 @@
"settings": "Einstellungen", "settings": "Einstellungen",
"help": "Hilfe", "help": "Hilfe",
"add": "Hinzufügen", "add": "Hinzufügen",
"close": "Schließen" "close": "Schließen",
"menu": "Menü"
}, },
"status": { "status": {
"loading": "Wird geladen...", "loading": "Wird geladen...",
@@ -1292,12 +1293,15 @@
"earlyAccess": "Früher Zugriff", "earlyAccess": "Früher Zugriff",
"earlyAccessTooltip": "Für diese Version ist derzeit Civitai Early Access erforderlich", "earlyAccessTooltip": "Für diese Version ist derzeit Civitai Early Access erforderlich",
"ignored": "Ignoriert", "ignored": "Ignoriert",
"ignoredTooltip": "Für diese Version sind Update-Benachrichtigungen deaktiviert" "ignoredTooltip": "Für diese Version sind Update-Benachrichtigungen deaktiviert",
"onSiteOnly": "Nur On-Site",
"onSiteOnlyTooltip": "Diese Version ist nur für die On-Site-Generierung auf Civitai verfügbar"
}, },
"actions": { "actions": {
"download": "Herunterladen", "download": "Herunterladen",
"downloadTooltip": "Diese Version herunterladen", "downloadTooltip": "Diese Version herunterladen",
"downloadEarlyAccessTooltip": "Diese Early-Access-Version von Civitai herunterladen", "downloadEarlyAccessTooltip": "Diese Early-Access-Version von Civitai herunterladen",
"downloadNotAllowedTooltip": "Diese Version ist nur für die On-Site-Generierung auf Civitai verfügbar",
"delete": "Löschen", "delete": "Löschen",
"deleteTooltip": "Diese lokale Version löschen", "deleteTooltip": "Diese lokale Version löschen",
"ignore": "Ignorieren", "ignore": "Ignorieren",
@@ -1906,7 +1910,9 @@
"repairSuccess": "Cache-Neuaufbau abgeschlossen.", "repairSuccess": "Cache-Neuaufbau abgeschlossen.",
"repairFailed": "Cache-Neuaufbau fehlgeschlagen: {message}", "repairFailed": "Cache-Neuaufbau fehlgeschlagen: {message}",
"exportSuccess": "Diagnosepaket exportiert.", "exportSuccess": "Diagnosepaket exportiert.",
"exportFailed": "Export des Diagnosepakets fehlgeschlagen: {message}" "exportFailed": "Export des Diagnosepakets fehlgeschlagen: {message}",
"conflictsResolved": "{count} Dateinamenskonflikt(e) gelöst.",
"conflictsResolveFailed": "Auflösung der Dateinamenskonflikte fehlgeschlagen: {message}"
} }
}, },
"banners": { "banners": {

View File

@@ -15,7 +15,8 @@
"settings": "Settings", "settings": "Settings",
"help": "Help", "help": "Help",
"add": "Add", "add": "Add",
"close": "Close" "close": "Close",
"menu": "Menu"
}, },
"status": { "status": {
"loading": "Loading...", "loading": "Loading...",
@@ -1292,12 +1293,15 @@
"earlyAccess": "Early Access", "earlyAccess": "Early Access",
"earlyAccessTooltip": "This version currently requires Civitai early access", "earlyAccessTooltip": "This version currently requires Civitai early access",
"ignored": "Ignored", "ignored": "Ignored",
"ignoredTooltip": "Update notifications are disabled for this version" "ignoredTooltip": "Update notifications are disabled for this version",
"onSiteOnly": "On-Site Only",
"onSiteOnlyTooltip": "This version is only available for on-site generation on Civitai"
}, },
"actions": { "actions": {
"download": "Download", "download": "Download",
"downloadTooltip": "Download this version", "downloadTooltip": "Download this version",
"downloadEarlyAccessTooltip": "Download this early access version from Civitai", "downloadEarlyAccessTooltip": "Download this early access version from Civitai",
"downloadNotAllowedTooltip": "This version is only available for on-site generation on Civitai",
"delete": "Delete", "delete": "Delete",
"deleteTooltip": "Delete this local version", "deleteTooltip": "Delete this local version",
"ignore": "Ignore", "ignore": "Ignore",
@@ -1906,7 +1910,9 @@
"repairSuccess": "Cache rebuild completed.", "repairSuccess": "Cache rebuild completed.",
"repairFailed": "Cache rebuild failed: {message}", "repairFailed": "Cache rebuild failed: {message}",
"exportSuccess": "Diagnostics bundle exported.", "exportSuccess": "Diagnostics bundle exported.",
"exportFailed": "Failed to export diagnostics bundle: {message}" "exportFailed": "Failed to export diagnostics bundle: {message}",
"conflictsResolved": "{count} filename conflict(s) resolved.",
"conflictsResolveFailed": "Failed to resolve filename conflicts: {message}"
} }
}, },
"banners": { "banners": {

View File

@@ -15,7 +15,8 @@
"settings": "Configuración", "settings": "Configuración",
"help": "Ayuda", "help": "Ayuda",
"add": "Añadir", "add": "Añadir",
"close": "Cerrar" "close": "Cerrar",
"menu": "Menú"
}, },
"status": { "status": {
"loading": "Cargando...", "loading": "Cargando...",
@@ -1292,12 +1293,15 @@
"earlyAccess": "Acceso temprano", "earlyAccess": "Acceso temprano",
"earlyAccessTooltip": "Esta versión requiere actualmente acceso temprano de Civitai", "earlyAccessTooltip": "Esta versión requiere actualmente acceso temprano de Civitai",
"ignored": "Ignorada", "ignored": "Ignorada",
"ignoredTooltip": "Las notificaciones de actualización están desactivadas para esta versión" "ignoredTooltip": "Las notificaciones de actualización están desactivadas para esta versión",
"onSiteOnly": "Solo en Sitio",
"onSiteOnlyTooltip": "Esta versión solo está disponible para generación en el sitio de Civitai"
}, },
"actions": { "actions": {
"download": "Descargar", "download": "Descargar",
"downloadTooltip": "Descargar esta versión", "downloadTooltip": "Descargar esta versión",
"downloadEarlyAccessTooltip": "Descargar esta versión de acceso temprano desde Civitai", "downloadEarlyAccessTooltip": "Descargar esta versión de acceso temprano desde Civitai",
"downloadNotAllowedTooltip": "Esta versión solo está disponible para generación en el sitio de Civitai",
"delete": "Eliminar", "delete": "Eliminar",
"deleteTooltip": "Eliminar esta versión local", "deleteTooltip": "Eliminar esta versión local",
"ignore": "Ignorar", "ignore": "Ignorar",
@@ -1906,7 +1910,9 @@
"repairSuccess": "Reconstrucción de caché completada.", "repairSuccess": "Reconstrucción de caché completada.",
"repairFailed": "Error al reconstruir la caché: {message}", "repairFailed": "Error al reconstruir la caché: {message}",
"exportSuccess": "Paquete de diagnósticos exportado.", "exportSuccess": "Paquete de diagnósticos exportado.",
"exportFailed": "Error al exportar el paquete de diagnósticos: {message}" "exportFailed": "Error al exportar el paquete de diagnósticos: {message}",
"conflictsResolved": "{count} conflicto(s) de nombre de archivo resuelto(s).",
"conflictsResolveFailed": "Error al resolver conflictos de nombre de archivo: {message}"
} }
}, },
"banners": { "banners": {

View File

@@ -15,7 +15,8 @@
"settings": "Paramètres", "settings": "Paramètres",
"help": "Aide", "help": "Aide",
"add": "Ajouter", "add": "Ajouter",
"close": "Fermer" "close": "Fermer",
"menu": "Menu"
}, },
"status": { "status": {
"loading": "Chargement...", "loading": "Chargement...",
@@ -1292,12 +1293,15 @@
"earlyAccess": "Accès anticipé", "earlyAccess": "Accès anticipé",
"earlyAccessTooltip": "Cette version nécessite actuellement l'accès anticipé Civitai", "earlyAccessTooltip": "Cette version nécessite actuellement l'accès anticipé Civitai",
"ignored": "Ignorée", "ignored": "Ignorée",
"ignoredTooltip": "Les notifications de mise à jour sont désactivées pour cette version" "ignoredTooltip": "Les notifications de mise à jour sont désactivées pour cette version",
"onSiteOnly": "Uniquement sur Site",
"onSiteOnlyTooltip": "Cette version n'est disponible que pour la génération sur le site Civitai"
}, },
"actions": { "actions": {
"download": "Télécharger", "download": "Télécharger",
"downloadTooltip": "Télécharger cette version", "downloadTooltip": "Télécharger cette version",
"downloadEarlyAccessTooltip": "Télécharger cette version en accès anticipé depuis Civitai", "downloadEarlyAccessTooltip": "Télécharger cette version en accès anticipé depuis Civitai",
"downloadNotAllowedTooltip": "Cette version n'est disponible que pour la génération sur le site Civitai",
"delete": "Supprimer", "delete": "Supprimer",
"deleteTooltip": "Supprimer cette version locale", "deleteTooltip": "Supprimer cette version locale",
"ignore": "Ignorer", "ignore": "Ignorer",
@@ -1906,7 +1910,9 @@
"repairSuccess": "Reconstruction du cache terminée.", "repairSuccess": "Reconstruction du cache terminée.",
"repairFailed": "Échec de la reconstruction du cache : {message}", "repairFailed": "Échec de la reconstruction du cache : {message}",
"exportSuccess": "Lot de diagnostics exporté.", "exportSuccess": "Lot de diagnostics exporté.",
"exportFailed": "Échec de l'export du lot de diagnostics : {message}" "exportFailed": "Échec de l'export du lot de diagnostics : {message}",
"conflictsResolved": "{count} conflit(s) de nom de fichier résolu(s).",
"conflictsResolveFailed": "Échec de la résolution des conflits de nom de fichier : {message}"
} }
}, },
"banners": { "banners": {

View File

@@ -15,7 +15,8 @@
"settings": "הגדרות", "settings": "הגדרות",
"help": "עזרה", "help": "עזרה",
"add": "הוספה", "add": "הוספה",
"close": "סגור" "close": "סגור",
"menu": "תפריט"
}, },
"status": { "status": {
"loading": "טוען...", "loading": "טוען...",
@@ -1292,12 +1293,15 @@
"earlyAccess": "גישה מוקדמת", "earlyAccess": "גישה מוקדמת",
"earlyAccessTooltip": "גרסה זו דורשת כרגע גישת Early Access של Civitai", "earlyAccessTooltip": "גרסה זו דורשת כרגע גישת Early Access של Civitai",
"ignored": "התעלם", "ignored": "התעלם",
"ignoredTooltip": "התראות העדכון מושבתות עבור גרסה זו" "ignoredTooltip": "התראות העדכון מושבתות עבור גרסה זו",
"onSiteOnly": "רק באתר",
"onSiteOnlyTooltip": "גרסה זו זמינה רק ליצירה באתר Civitai"
}, },
"actions": { "actions": {
"download": "הורדה", "download": "הורדה",
"downloadTooltip": "הורד את הגרסה הזו", "downloadTooltip": "הורד את הגרסה הזו",
"downloadEarlyAccessTooltip": "הורד את גרסת ה-Early Access הזו מ-Civitai", "downloadEarlyAccessTooltip": "הורד את גרסת ה-Early Access הזו מ-Civitai",
"downloadNotAllowedTooltip": "גרסה זו זמינה רק ליצירה באתר Civitai",
"delete": "מחיקה", "delete": "מחיקה",
"deleteTooltip": "מחק את הגרסה המקומית הזו", "deleteTooltip": "מחק את הגרסה המקומית הזו",
"ignore": "התעלם", "ignore": "התעלם",
@@ -1906,7 +1910,9 @@
"repairSuccess": "בניית המטמון מחדש הושלמה.", "repairSuccess": "בניית המטמון מחדש הושלמה.",
"repairFailed": "בניית המטמון מחדש נכשלה: {message}", "repairFailed": "בניית המטמון מחדש נכשלה: {message}",
"exportSuccess": "חבילת האבחון יוצאה.", "exportSuccess": "חבילת האבחון יוצאה.",
"exportFailed": "ייצוא חבילת האבחון נכשל: {message}" "exportFailed": "ייצוא חבילת האבחון נכשל: {message}",
"conflictsResolved": "נפתרו {count} התנגשויות בשמות קבצים.",
"conflictsResolveFailed": "פתרון התנגשויות שמות קבצים נכשל: {message}"
} }
}, },
"banners": { "banners": {

View File

@@ -15,7 +15,8 @@
"settings": "設定", "settings": "設定",
"help": "ヘルプ", "help": "ヘルプ",
"add": "追加", "add": "追加",
"close": "閉じる" "close": "閉じる",
"menu": "メニュー"
}, },
"status": { "status": {
"loading": "読み込み中...", "loading": "読み込み中...",
@@ -1292,12 +1293,15 @@
"earlyAccess": "早期アクセス", "earlyAccess": "早期アクセス",
"earlyAccessTooltip": "このバージョンは現在 Civitai の早期アクセスが必要です", "earlyAccessTooltip": "このバージョンは現在 Civitai の早期アクセスが必要です",
"ignored": "無視中", "ignored": "無視中",
"ignoredTooltip": "このバージョンの更新通知は無効です" "ignoredTooltip": "このバージョンの更新通知は無効です",
"onSiteOnly": "サイト内のみ",
"onSiteOnlyTooltip": "このバージョンはCivitaiサイト内でのみ利用可能で、ダウンロードはできません"
}, },
"actions": { "actions": {
"download": "ダウンロード", "download": "ダウンロード",
"downloadTooltip": "このバージョンをダウンロード", "downloadTooltip": "このバージョンをダウンロード",
"downloadEarlyAccessTooltip": "Civitai からこの早期アクセス版をダウンロード", "downloadEarlyAccessTooltip": "Civitai からこの早期アクセス版をダウンロード",
"downloadNotAllowedTooltip": "このバージョンはCivitaiサイト内でのみ利用可能で、ダウンロードはできません",
"delete": "削除", "delete": "削除",
"deleteTooltip": "このローカルバージョンを削除", "deleteTooltip": "このローカルバージョンを削除",
"ignore": "無視", "ignore": "無視",
@@ -1906,7 +1910,9 @@
"repairSuccess": "キャッシュの再構築が完了しました。", "repairSuccess": "キャッシュの再構築が完了しました。",
"repairFailed": "キャッシュの再構築に失敗しました: {message}", "repairFailed": "キャッシュの再構築に失敗しました: {message}",
"exportSuccess": "診断パッケージをエクスポートしました。", "exportSuccess": "診断パッケージをエクスポートしました。",
"exportFailed": "診断パッケージのエクスポートに失敗しました: {message}" "exportFailed": "診断パッケージのエクスポートに失敗しました: {message}",
"conflictsResolved": "{count} 件のファイル名競合が解決されました。",
"conflictsResolveFailed": "ファイル名競合の解決に失敗しました: {message}"
} }
}, },
"banners": { "banners": {

View File

@@ -15,7 +15,8 @@
"settings": "설정", "settings": "설정",
"help": "도움말", "help": "도움말",
"add": "추가", "add": "추가",
"close": "닫기" "close": "닫기",
"menu": "메뉴"
}, },
"status": { "status": {
"loading": "로딩 중...", "loading": "로딩 중...",
@@ -1292,12 +1293,15 @@
"earlyAccess": "얼리 액세스", "earlyAccess": "얼리 액세스",
"earlyAccessTooltip": "이 버전은 현재 Civitai 얼리 액세스가 필요합니다", "earlyAccessTooltip": "이 버전은 현재 Civitai 얼리 액세스가 필요합니다",
"ignored": "무시됨", "ignored": "무시됨",
"ignoredTooltip": "이 버전은 업데이트 알림이 비활성화되어 있습니다" "ignoredTooltip": "이 버전은 업데이트 알림이 비활성화되어 있습니다",
"onSiteOnly": "사이트 내 전용",
"onSiteOnlyTooltip": "이 버전은 Civitai 사이트 내에서만 사용 가능하며 다운로드할 수 없습니다"
}, },
"actions": { "actions": {
"download": "다운로드", "download": "다운로드",
"downloadTooltip": "이 버전 다운로드", "downloadTooltip": "이 버전 다운로드",
"downloadEarlyAccessTooltip": "Civitai에서 이 얼리 액세스 버전 다운로드", "downloadEarlyAccessTooltip": "Civitai에서 이 얼리 액세스 버전 다운로드",
"downloadNotAllowedTooltip": "이 버전은 Civitai 사이트 내에서만 사용 가능하며 다운로드할 수 없습니다",
"delete": "삭제", "delete": "삭제",
"deleteTooltip": "이 로컬 버전 삭제", "deleteTooltip": "이 로컬 버전 삭제",
"ignore": "무시", "ignore": "무시",
@@ -1906,7 +1910,9 @@
"repairSuccess": "캐시 재구성이 완료되었습니다.", "repairSuccess": "캐시 재구성이 완료되었습니다.",
"repairFailed": "캐시 재구성 실패: {message}", "repairFailed": "캐시 재구성 실패: {message}",
"exportSuccess": "진단 번들이 내보내졌습니다.", "exportSuccess": "진단 번들이 내보내졌습니다.",
"exportFailed": "진단 번들 내보내기 실패: {message}" "exportFailed": "진단 번들 내보내기 실패: {message}",
"conflictsResolved": "{count}개 파일명 충돌이 해결되었습니다.",
"conflictsResolveFailed": "파일명 충돌 해결 실패: {message}"
} }
}, },
"banners": { "banners": {

View File

@@ -15,7 +15,8 @@
"settings": "Настройки", "settings": "Настройки",
"help": "Справка", "help": "Справка",
"add": "Добавить", "add": "Добавить",
"close": "Закрыть" "close": "Закрыть",
"menu": "Меню"
}, },
"status": { "status": {
"loading": "Загрузка...", "loading": "Загрузка...",
@@ -1292,12 +1293,15 @@
"earlyAccess": "Ранний доступ", "earlyAccess": "Ранний доступ",
"earlyAccessTooltip": "Для этой версии сейчас требуется ранний доступ Civitai", "earlyAccessTooltip": "Для этой версии сейчас требуется ранний доступ Civitai",
"ignored": "Игнорируется", "ignored": "Игнорируется",
"ignoredTooltip": "Уведомления об обновлениях для этой версии отключены" "ignoredTooltip": "Уведомления об обновлениях для этой версии отключены",
"onSiteOnly": "Только на Сайте",
"onSiteOnlyTooltip": "Эта версия доступна только для генерации на сайте Civitai"
}, },
"actions": { "actions": {
"download": "Скачать", "download": "Скачать",
"downloadTooltip": "Скачать эту версию", "downloadTooltip": "Скачать эту версию",
"downloadEarlyAccessTooltip": "Скачать эту версию раннего доступа с Civitai", "downloadEarlyAccessTooltip": "Скачать эту версию раннего доступа с Civitai",
"downloadNotAllowedTooltip": "Эта версия доступна только для генерации на сайте Civitai",
"delete": "Удалить", "delete": "Удалить",
"deleteTooltip": "Удалить эту локальную версию", "deleteTooltip": "Удалить эту локальную версию",
"ignore": "Игнорировать", "ignore": "Игнорировать",
@@ -1906,7 +1910,9 @@
"repairSuccess": "Перестройка кэша завершена.", "repairSuccess": "Перестройка кэша завершена.",
"repairFailed": "Не удалось перестроить кэш: {message}", "repairFailed": "Не удалось перестроить кэш: {message}",
"exportSuccess": "Диагностический пакет экспортирован.", "exportSuccess": "Диагностический пакет экспортирован.",
"exportFailed": "Не удалось экспортировать диагностический пакет: {message}" "exportFailed": "Не удалось экспортировать диагностический пакет: {message}",
"conflictsResolved": "Разрешено конфликтов имён файлов: {count}.",
"conflictsResolveFailed": "Не удалось разрешить конфликты имён файлов: {message}"
} }
}, },
"banners": { "banners": {

View File

@@ -15,7 +15,8 @@
"settings": "设置", "settings": "设置",
"help": "帮助", "help": "帮助",
"add": "添加", "add": "添加",
"close": "关闭" "close": "关闭",
"menu": "菜单"
}, },
"status": { "status": {
"loading": "加载中...", "loading": "加载中...",
@@ -1292,12 +1293,15 @@
"earlyAccess": "抢先体验", "earlyAccess": "抢先体验",
"earlyAccessTooltip": "此版本当前需要 Civitai 抢先体验权限", "earlyAccessTooltip": "此版本当前需要 Civitai 抢先体验权限",
"ignored": "已忽略", "ignored": "已忽略",
"ignoredTooltip": "此版本已关闭更新通知" "ignoredTooltip": "此版本已关闭更新通知",
"onSiteOnly": "仅站内生成",
"onSiteOnlyTooltip": "此版本仅在 Civitai 站内可用,无法下载"
}, },
"actions": { "actions": {
"download": "下载", "download": "下载",
"downloadTooltip": "下载此版本", "downloadTooltip": "下载此版本",
"downloadEarlyAccessTooltip": "从 Civitai 下载此抢先体验版本", "downloadEarlyAccessTooltip": "从 Civitai 下载此抢先体验版本",
"downloadNotAllowedTooltip": "此版本仅在 Civitai 站内可用,无法下载",
"delete": "删除", "delete": "删除",
"deleteTooltip": "删除此本地版本", "deleteTooltip": "删除此本地版本",
"ignore": "忽略", "ignore": "忽略",
@@ -1906,7 +1910,9 @@
"repairSuccess": "缓存重建完成。", "repairSuccess": "缓存重建完成。",
"repairFailed": "缓存重建失败:{message}", "repairFailed": "缓存重建失败:{message}",
"exportSuccess": "诊断包已导出。", "exportSuccess": "诊断包已导出。",
"exportFailed": "导出诊断包失败:{message}" "exportFailed": "导出诊断包失败:{message}",
"conflictsResolved": "已解决 {count} 个文件名冲突。",
"conflictsResolveFailed": "解决文件名冲突失败:{message}"
} }
}, },
"banners": { "banners": {

View File

@@ -15,7 +15,8 @@
"settings": "設定", "settings": "設定",
"help": "說明", "help": "說明",
"add": "新增", "add": "新增",
"close": "關閉" "close": "關閉",
"menu": "選單"
}, },
"status": { "status": {
"loading": "載入中...", "loading": "載入中...",
@@ -1292,12 +1293,15 @@
"earlyAccess": "搶先體驗", "earlyAccess": "搶先體驗",
"earlyAccessTooltip": "此版本目前需要 Civitai 搶先體驗權限", "earlyAccessTooltip": "此版本目前需要 Civitai 搶先體驗權限",
"ignored": "已忽略", "ignored": "已忽略",
"ignoredTooltip": "此版本已關閉更新通知" "ignoredTooltip": "此版本已關閉更新通知",
"onSiteOnly": "僅站內生成",
"onSiteOnlyTooltip": "此版本僅在 Civitai 站內可用,無法下載"
}, },
"actions": { "actions": {
"download": "下載", "download": "下載",
"downloadTooltip": "下載此版本", "downloadTooltip": "下載此版本",
"downloadEarlyAccessTooltip": "從 Civitai 下載此搶先體驗版本", "downloadEarlyAccessTooltip": "從 Civitai 下載此搶先體驗版本",
"downloadNotAllowedTooltip": "此版本僅在 Civitai 站內可用,無法下載",
"delete": "刪除", "delete": "刪除",
"deleteTooltip": "刪除此本地版本", "deleteTooltip": "刪除此本地版本",
"ignore": "忽略", "ignore": "忽略",
@@ -1906,7 +1910,9 @@
"repairSuccess": "快取重建完成。", "repairSuccess": "快取重建完成。",
"repairFailed": "快取重建失敗:{message}", "repairFailed": "快取重建失敗:{message}",
"exportSuccess": "診斷套件已匯出。", "exportSuccess": "診斷套件已匯出。",
"exportFailed": "匯出診斷套件失敗:{message}" "exportFailed": "匯出診斷套件失敗:{message}",
"conflictsResolved": "已解決 {count} 個檔案名稱衝突。",
"conflictsResolveFailed": "解決檔案名稱衝突失敗:{message}"
} }
}, },
"banners": { "banners": {

View File

@@ -560,8 +560,14 @@ class MetadataProcessor:
params["loras"] = " ".join(lora_parts) params["loras"] = " ".join(lora_parts)
# Set default clip_skip value # Extract clip_skip from any SAMPLING node that provides it
params["clip_skip"] = "1" # Common default for sampler_info in metadata.get(SAMPLING, {}).values():
clip_skip = sampler_info.get("parameters", {}).get("clip_skip")
if clip_skip is not None:
params["clip_skip"] = clip_skip
break
if params["clip_skip"] is None:
params["clip_skip"] = "1"
return params return params

View File

@@ -144,6 +144,118 @@ class TSCCheckpointLoaderExtractor(NodeMetadataExtractor):
metadata[PROMPTS][node_id]["positive_encoded"] = positive_conditioning metadata[PROMPTS][node_id]["positive_encoded"] = positive_conditioning
metadata[PROMPTS][node_id]["negative_encoded"] = negative_conditioning metadata[PROMPTS][node_id]["negative_encoded"] = negative_conditioning
class EasyComfyLoaderExtractor(NodeMetadataExtractor):
@staticmethod
def extract(node_id, inputs, outputs, metadata):
if not inputs:
return
if "ckpt_name" in inputs:
_store_checkpoint_metadata(metadata, node_id, inputs["ckpt_name"])
# Only extract from optional_lora_stack — skip the single lora_name to
# avoid double-counting LoRAs that come through the LORA_STACK path.
active_loras = []
optional_lora_stack = inputs.get("optional_lora_stack")
if optional_lora_stack is not None and isinstance(optional_lora_stack, (list, tuple)):
for item in optional_lora_stack:
if isinstance(item, (list, tuple)) and len(item) >= 2:
lora_path = item[0]
model_strength = item[1]
lora_name = os.path.splitext(os.path.basename(lora_path))[0]
active_loras.append({
"name": lora_name,
"strength": model_strength
})
if active_loras:
metadata[LORAS][node_id] = {
"lora_list": active_loras,
"node_id": node_id
}
positive_text = inputs.get("positive", "")
negative_text = inputs.get("negative", "")
if positive_text or negative_text:
if node_id not in metadata[PROMPTS]:
metadata[PROMPTS][node_id] = {"node_id": node_id}
metadata[PROMPTS][node_id]["positive_text"] = positive_text
metadata[PROMPTS][node_id]["negative_text"] = negative_text
if "clip_skip" in inputs:
clip_skip = inputs["clip_skip"]
if node_id not in metadata[SAMPLING]:
metadata[SAMPLING][node_id] = {"parameters": {}, "node_id": node_id}
metadata[SAMPLING][node_id]["parameters"]["clip_skip"] = clip_skip
width = inputs.get("empty_latent_width")
height = inputs.get("empty_latent_height")
if width is not None and height is not None:
if SIZE not in metadata:
metadata[SIZE] = {}
metadata[SIZE][node_id] = {
"width": int(width),
"height": int(height),
"node_id": node_id
}
@staticmethod
def update(node_id, outputs, metadata):
# outputs: [(pipe_dict, model, vae), ...]
if not outputs or not isinstance(outputs, list) or len(outputs) == 0:
return
first_output = outputs[0]
if not isinstance(first_output, tuple) or len(first_output) < 1:
return
pipe = first_output[0]
if not isinstance(pipe, dict):
return
positive_conditioning = pipe.get("positive")
negative_conditioning = pipe.get("negative")
if positive_conditioning is not None or negative_conditioning is not None:
if node_id not in metadata[PROMPTS]:
metadata[PROMPTS][node_id] = {"node_id": node_id}
if positive_conditioning is not None:
metadata[PROMPTS][node_id]["positive_encoded"] = positive_conditioning
if negative_conditioning is not None:
metadata[PROMPTS][node_id]["negative_encoded"] = negative_conditioning
class EasyPreSamplingExtractor(NodeMetadataExtractor):
@staticmethod
def extract(node_id, inputs, outputs, metadata):
if not inputs:
return
sampling_params = {}
for key in ("steps", "cfg", "sampler_name", "scheduler", "denoise", "seed"):
if key in inputs:
sampling_params[key] = inputs[key]
metadata[SAMPLING][node_id] = {
"parameters": sampling_params,
"node_id": node_id,
IS_SAMPLER: True
}
class EasySeedExtractor(NodeMetadataExtractor):
@staticmethod
def extract(node_id, inputs, outputs, metadata):
if not inputs or "seed" not in inputs:
return
metadata[SAMPLING][node_id] = {
"parameters": {"seed": inputs["seed"]},
"node_id": node_id,
IS_SAMPLER: False
}
class CLIPTextEncodeExtractor(NodeMetadataExtractor): class CLIPTextEncodeExtractor(NodeMetadataExtractor):
@staticmethod @staticmethod
def extract(node_id, inputs, outputs, metadata): def extract(node_id, inputs, outputs, metadata):
@@ -1013,9 +1125,12 @@ NODE_EXTRACTORS = {
"KSamplerSelect": KSamplerSelectExtractor, # Add KSamplerSelect "KSamplerSelect": KSamplerSelectExtractor, # Add KSamplerSelect
"BasicScheduler": BasicSchedulerExtractor, # Add BasicScheduler "BasicScheduler": BasicSchedulerExtractor, # Add BasicScheduler
"AlignYourStepsScheduler": BasicSchedulerExtractor, # Add AlignYourStepsScheduler "AlignYourStepsScheduler": BasicSchedulerExtractor, # Add AlignYourStepsScheduler
# ComfyUI-Easy-Use pre-sampling / seed
"samplerSettings": EasyPreSamplingExtractor, # easy preSampling
"easySeed": EasySeedExtractor, # easy seed
# Loaders # Loaders
"CheckpointLoaderSimple": CheckpointLoaderExtractor, "CheckpointLoaderSimple": CheckpointLoaderExtractor,
"comfyLoader": CheckpointLoaderExtractor, # easy comfyLoader "comfyLoader": EasyComfyLoaderExtractor, # ComfyUI-Easy-Use easy comfyLoader
"CheckpointLoaderSimpleWithImages": CheckpointLoaderExtractor, # CheckpointLoader|pysssss "CheckpointLoaderSimpleWithImages": CheckpointLoaderExtractor, # CheckpointLoader|pysssss
"TSC_EfficientLoader": TSCCheckpointLoaderExtractor, # Efficient Nodes "TSC_EfficientLoader": TSCCheckpointLoaderExtractor, # Efficient Nodes
"NunchakuFluxDiTLoader": NunchakuFluxDiTLoaderExtractor, # ComfyUI-Nunchaku "NunchakuFluxDiTLoader": NunchakuFluxDiTLoaderExtractor, # ComfyUI-Nunchaku

View File

@@ -1,10 +1,22 @@
import folder_paths # type: ignore import os
from ..utils.utils import get_lora_info from ..utils.utils import get_lora_info_absolute
from ..config import config
from .utils import FlexibleOptionalInputType, any_type, get_loras_list from .utils import FlexibleOptionalInputType, any_type, get_loras_list
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _relpath_within_loras(abs_path):
"""Return abs_path relative to the first matching lora root, or basename as fallback."""
all_roots = list(config.loras_roots or []) + list(config.extra_loras_roots or [])
for root in all_roots:
try:
return os.path.relpath(abs_path, root)
except ValueError:
continue
return os.path.basename(abs_path)
class WanVideoLoraSelectLM: class WanVideoLoraSelectLM:
NAME = "WanVideo Lora Select (LoraManager)" NAME = "WanVideo Lora Select (LoraManager)"
CATEGORY = "Lora Manager/stackers" CATEGORY = "Lora Manager/stackers"
@@ -56,13 +68,13 @@ class WanVideoLoraSelectLM:
clip_strength = float(lora.get('clipStrength', model_strength)) clip_strength = float(lora.get('clipStrength', model_strength))
# Get lora path and trigger words # Get lora path and trigger words
lora_path, trigger_words = get_lora_info(lora_name) lora_path, trigger_words = get_lora_info_absolute(lora_name)
# Create lora item for WanVideo format # Create lora item for WanVideo format
lora_item = { lora_item = {
"path": folder_paths.get_full_path("loras", lora_path), "path": lora_path,
"strength": model_strength, "strength": model_strength,
"name": lora_path.split(".")[0], "name": os.path.splitext(_relpath_within_loras(lora_path))[0],
"blocks": selected_blocks, "blocks": selected_blocks,
"layer_filter": layer_filter, "layer_filter": layer_filter,
"low_mem_load": low_mem_load, "low_mem_load": low_mem_load,

View File

@@ -1,11 +1,23 @@
import folder_paths # type: ignore import os
from ..utils.utils import get_lora_info from ..utils.utils import get_lora_info_absolute
from ..config import config
from .utils import any_type from .utils import any_type
import logging import logging
# 初始化日志记录器 # 初始化日志记录器
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _relpath_within_loras(abs_path):
"""Return abs_path relative to the first matching lora root, or basename as fallback."""
all_roots = list(config.loras_roots or []) + list(config.extra_loras_roots or [])
for root in all_roots:
try:
return os.path.relpath(abs_path, root)
except ValueError:
continue
return os.path.basename(abs_path)
# 定义新节点的类 # 定义新节点的类
class WanVideoLoraTextSelectLM: class WanVideoLoraTextSelectLM:
# 节点在UI中显示的名称 # 节点在UI中显示的名称
@@ -87,12 +99,12 @@ class WanVideoLoraTextSelectLM:
else: else:
continue continue
lora_path, trigger_words = get_lora_info(lora_name_raw) lora_path, trigger_words = get_lora_info_absolute(lora_name_raw)
lora_item = { lora_item = {
"path": folder_paths.get_full_path("loras", lora_path), "path": lora_path,
"strength": model_strength, "strength": model_strength,
"name": lora_path.split(".")[0], "name": os.path.splitext(_relpath_within_loras(lora_path))[0],
"blocks": selected_blocks, "blocks": selected_blocks,
"layer_filter": layer_filter, "layer_filter": layer_filter,
"low_mem_load": low_mem_load, "low_mem_load": low_mem_load,

View File

@@ -251,7 +251,7 @@ class BaseModelRoutes(ABC):
def _find_model_file(self, files): def _find_model_file(self, files):
"""Find the appropriate model file from the files list - can be overridden by subclasses.""" """Find the appropriate model file from the files list - can be overridden by subclasses."""
return next((file for file in files if file.get("type") == "Model" and file.get("primary") is True), None) return next((file for file in files if file.get("type") in ("Model", "Diffusion Model") and file.get("primary") is True), None)
def get_handler(self, name: str) -> Callable[[web.Request], web.StreamResponse]: def get_handler(self, name: str) -> Callable[[web.Request], web.StreamResponse]:
"""Expose handlers for subclasses or tests.""" """Expose handlers for subclasses or tests."""

View File

@@ -33,15 +33,18 @@ from ...services.metadata_service import (
update_metadata_providers, update_metadata_providers,
) )
from ...services.service_registry import ServiceRegistry from ...services.service_registry import ServiceRegistry
from ...services.model_lifecycle_service import delete_model_artifacts
from ...services.settings_manager import get_settings_manager from ...services.settings_manager import get_settings_manager
from ...services.websocket_manager import ws_manager from ...services.websocket_manager import ws_manager
from ...services.downloader import get_downloader from ...services.downloader import get_downloader
from ...services.errors import ResourceNotFoundError from ...services.errors import ResourceNotFoundError
from ...services.cache_health_monitor import CacheHealthMonitor, CacheHealthStatus from ...services.cache_health_monitor import CacheHealthMonitor, CacheHealthStatus
from ...utils.models import BaseModelMetadata
from ...utils.constants import ( from ...utils.constants import (
CIVITAI_USER_MODEL_TYPES, CIVITAI_USER_MODEL_TYPES,
DEFAULT_NODE_COLOR, DEFAULT_NODE_COLOR,
NODE_TYPES, NODE_TYPES,
PREVIEW_EXTENSIONS,
SUPPORTED_MEDIA_EXTENSIONS, SUPPORTED_MEDIA_EXTENSIONS,
VALID_LORA_TYPES, VALID_LORA_TYPES,
) )
@@ -617,6 +620,7 @@ class DoctorHandler:
diagnostics = [ diagnostics = [
await self._check_civitai_api_key(), await self._check_civitai_api_key(),
await self._check_cache_health(), await self._check_cache_health(),
await self._check_filename_conflicts(),
self._check_ui_version(client_version, app_version), self._check_ui_version(client_version, app_version),
] ]
@@ -681,6 +685,145 @@ class DoctorHandler:
status=status, status=status,
) )
async def resolve_filename_conflicts(self, request: web.Request) -> web.Response:
renamed: list[dict[str, Any]] = []
try:
for model_type, label, factory in self._scanner_factories:
try:
scanner = await factory()
hash_index = getattr(scanner, "_hash_index", None)
if hash_index is None:
continue
duplicates = {
filename: list(paths)
for filename, paths in hash_index.get_duplicate_filenames().items()
}
if not duplicates:
continue
cache = await scanner.get_cached_data()
path_to_model = {m["file_path"]: m for m in cache.raw_data}
used_basenames: set[str] = set()
for paths in duplicates.values():
if paths:
used_basenames.add(
os.path.splitext(os.path.basename(paths[0]))[0]
)
for filename, paths in duplicates.items():
for idx, path in enumerate(paths):
if idx == 0:
continue
dirname = os.path.dirname(path)
base_name = os.path.splitext(os.path.basename(path))[0]
ext = os.path.splitext(path)[1]
if not ext:
continue
model_data = path_to_model.get(path)
sha256 = (
model_data.get("sha256", "") if model_data else ""
)
hash_provider = (
lambda s=sha256: s if s else "0000"
)
new_filename = (
BaseModelMetadata.generate_unique_filename(
dirname,
base_name,
ext,
hash_provider=hash_provider,
)
)
candidate_base = os.path.splitext(new_filename)[0]
counter = 1
original_base = candidate_base
while candidate_base in used_basenames:
candidate_base = f"{original_base}-{counter}"
new_filename = f"{candidate_base}{ext}"
counter += 1
used_basenames.add(candidate_base)
new_path = os.path.join(dirname, new_filename)
if new_filename == os.path.basename(path):
continue
if not os.path.exists(path):
continue
old_base_no_ext = os.path.splitext(path)[0]
new_base_no_ext = (
os.path.splitext(new_path)[0]
)
os.rename(path, new_path)
for suffix in (".metadata.json", ".civitai.info"):
old_sidecar = old_base_no_ext + suffix
new_sidecar = new_base_no_ext + suffix
if os.path.exists(old_sidecar):
os.rename(old_sidecar, new_sidecar)
for preview_ext in PREVIEW_EXTENSIONS:
old_preview = old_base_no_ext + preview_ext
new_preview = new_base_no_ext + preview_ext
if os.path.exists(old_preview):
os.rename(old_preview, new_preview)
entry = path_to_model.get(path)
if entry:
entry = dict(entry)
entry["file_name"] = os.path.splitext(new_filename)[0]
if entry.get("preview_url"):
old_preview_url = entry["preview_url"].replace("\\", "/")
preview_ext = os.path.splitext(old_preview_url)[1]
if preview_ext:
entry["preview_url"] = (new_base_no_ext + preview_ext).replace(os.sep, "/")
await scanner.update_single_model_cache(
path, new_path, entry
)
logger.info(
"Resolved duplicate filename '%s': "
"renamed '%s' to '%s'",
filename,
path,
new_path,
)
renamed.append({
"model_type": model_type,
"label": label,
"filename": filename,
"old_path": path,
"new_path": new_path,
"new_filename": new_filename,
})
except Exception as exc: # pragma: no cover - defensive
logger.error(
"Failed to resolve filename conflicts for %s: %s",
model_type,
exc,
exc_info=True,
)
return web.json_response({
"success": True,
"renamed": renamed,
"count": len(renamed),
})
except Exception as exc:
logger.error(
"Error resolving filename conflicts: %s", exc, exc_info=True
)
return web.json_response(
{"success": False, "error": str(exc)}, status=500
)
async def export_doctor_bundle(self, request: web.Request) -> web.Response: async def export_doctor_bundle(self, request: web.Request) -> web.Response:
try: try:
payload = await request.json() payload = await request.json()
@@ -846,6 +989,79 @@ class DoctorHandler:
"actions": [{"id": "repair-cache", "label": "Rebuild Cache"}], "actions": [{"id": "repair-cache", "label": "Rebuild Cache"}],
} }
async def _check_filename_conflicts(self) -> dict[str, Any]:
all_conflicts: list[dict[str, Any]] = []
total_conflict_groups = 0
total_conflict_files = 0
for model_type, label, factory in self._scanner_factories:
try:
scanner = await factory()
hash_index = getattr(scanner, "_hash_index", None)
if hash_index is None:
continue
duplicates = hash_index.get_duplicate_filenames()
if not duplicates:
continue
total_conflict_groups += len(duplicates)
for filename, paths in duplicates.items():
total_conflict_files += len(paths)
all_conflicts.append({
"model_type": model_type,
"label": label,
"filename": filename,
"paths": paths,
})
except Exception as exc: # pragma: no cover - defensive
logger.error(
"Doctor filename conflict check failed for %s: %s",
model_type,
exc,
exc_info=True,
)
if not all_conflicts:
return {
"id": "filename_conflicts",
"title": "Duplicate Filename Conflicts",
"status": "ok",
"summary": "No duplicate filenames found across model directories.",
"details": [],
"actions": [],
}
summary = (
f"{total_conflict_groups} filename(s) shared by "
f"{total_conflict_files} files across your library. "
f"This causes ambiguity when loading LoRAs by name."
)
details: list[str | dict[str, Any]] = [
{
"conflict_groups": total_conflict_groups,
"total_conflict_files": total_conflict_files,
}
]
for conflict in all_conflicts:
details.append(
f"[{conflict['label']}] '{conflict['filename']}' "
f"found in {len(conflict['paths'])} locations"
)
return {
"id": "filename_conflicts",
"title": "Duplicate Filename Conflicts",
"status": "warning",
"summary": summary,
"details": details,
"actions": [
{
"id": "resolve-filename-conflicts",
"label": "Resolve Conflicts",
}
],
}
def _check_ui_version(self, client_version: str, app_version: str) -> dict[str, Any]: def _check_ui_version(self, client_version: str, app_version: str) -> dict[str, Any]:
if client_version and client_version != app_version: if client_version and client_version != app_version:
return { return {
@@ -1576,29 +1792,33 @@ class ModelLibraryHandler:
exists = True exists = True
model_type = "embedding" model_type = "embedding"
if exists:
return web.json_response(
{
"success": True,
"exists": True,
"modelType": model_type,
"hasBeenDownloaded": False,
}
)
history_service = await self._get_download_history_service() history_service = await self._get_download_history_service()
has_been_downloaded = False has_been_downloaded = False
history_type = model_type history_type = None
if history_type: for candidate_type in ("lora", "checkpoint", "embedding"):
has_been_downloaded = await history_service.has_been_downloaded( if await history_service.has_been_downloaded(
history_type, candidate_type,
model_version_id, model_version_id,
) ):
else: has_been_downloaded = True
for candidate_type in ("lora", "checkpoint", "embedding"): history_type = candidate_type
if await history_service.has_been_downloaded( break
candidate_type,
model_version_id,
):
has_been_downloaded = True
history_type = candidate_type
break
return web.json_response( return web.json_response(
{ {
"success": True, "success": True,
"exists": exists, "exists": False,
"modelType": model_type if exists else history_type, "modelType": history_type,
"hasBeenDownloaded": has_been_downloaded, "hasBeenDownloaded": has_been_downloaded,
} }
) )
@@ -1618,40 +1838,46 @@ class ModelLibraryHandler:
model_type = None model_type = None
versions = [] versions = []
downloaded_version_ids = [] downloaded_version_ids = []
history_service = await self._get_download_history_service()
if lora_versions: if lora_versions:
model_type = "lora" return web.json_response(
versions = self._with_downloaded_flag(lora_versions) {
downloaded_version_ids = await history_service.get_downloaded_version_ids( "success": True,
model_type, "modelType": "lora",
model_id, "versions": self._with_downloaded_flag(lora_versions),
"downloadedVersionIds": [],
}
) )
elif checkpoint_versions: if checkpoint_versions:
model_type = "checkpoint" return web.json_response(
versions = self._with_downloaded_flag(checkpoint_versions) {
downloaded_version_ids = await history_service.get_downloaded_version_ids( "success": True,
model_type, "modelType": "checkpoint",
model_id, "versions": self._with_downloaded_flag(checkpoint_versions),
"downloadedVersionIds": [],
}
) )
elif embedding_versions: if embedding_versions:
model_type = "embedding" return web.json_response(
versions = self._with_downloaded_flag(embedding_versions) {
downloaded_version_ids = await history_service.get_downloaded_version_ids( "success": True,
model_type, "modelType": "embedding",
model_id, "versions": self._with_downloaded_flag(embedding_versions),
"downloadedVersionIds": [],
}
) )
else:
for candidate_type in ("lora", "checkpoint", "embedding"): history_service = await self._get_download_history_service()
candidate_downloaded_version_ids = ( for candidate_type in ("lora", "checkpoint", "embedding"):
await history_service.get_downloaded_version_ids( candidate_downloaded_version_ids = (
candidate_type, await history_service.get_downloaded_version_ids(
model_id, candidate_type,
) model_id,
) )
if candidate_downloaded_version_ids: )
model_type = candidate_type if candidate_downloaded_version_ids:
downloaded_version_ids = candidate_downloaded_version_ids model_type = candidate_type
break downloaded_version_ids = candidate_downloaded_version_ids
break
return web.json_response( return web.json_response(
{ {
@@ -1665,6 +1891,86 @@ class ModelLibraryHandler:
logger.error("Failed to check model existence: %s", exc, exc_info=True) logger.error("Failed to check model existence: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500) return web.json_response({"success": False, "error": str(exc)}, status=500)
async def check_models_exist(self, request: web.Request) -> web.Response:
try:
model_ids_raw = request.query.get("modelIds", "")
if not model_ids_raw:
return web.json_response(
{"success": True, "results": []}
)
raw_ids = model_ids_raw.split(",")
seen: set[int] = set()
model_ids: list[int] = []
for raw in raw_ids:
stripped = raw.strip()
if not stripped:
continue
try:
mid = int(stripped)
except ValueError:
continue
if mid not in seen:
seen.add(mid)
model_ids.append(mid)
if not model_ids:
return web.json_response(
{"success": True, "results": []}
)
lora_scanner = await self._service_registry.get_lora_scanner()
checkpoint_scanner = await self._service_registry.get_checkpoint_scanner()
embedding_scanner = await self._service_registry.get_embedding_scanner()
results: list[dict] = []
for model_id in model_ids:
lora_versions = await lora_scanner.get_model_versions_by_id(model_id)
if lora_versions:
results.append({
"modelId": model_id,
"modelType": "lora",
"versions": self._with_downloaded_flag(lora_versions),
"downloadedVersionIds": [],
})
continue
if checkpoint_scanner:
checkpoint_versions = await checkpoint_scanner.get_model_versions_by_id(model_id)
if checkpoint_versions:
results.append({
"modelId": model_id,
"modelType": "checkpoint",
"versions": self._with_downloaded_flag(checkpoint_versions),
"downloadedVersionIds": [],
})
continue
if embedding_scanner:
embedding_versions = await embedding_scanner.get_model_versions_by_id(model_id)
if embedding_versions:
results.append({
"modelId": model_id,
"modelType": "embedding",
"versions": self._with_downloaded_flag(embedding_versions),
"downloadedVersionIds": [],
})
continue
results.append({
"modelId": model_id,
"modelType": None,
"versions": [],
"downloadedVersionIds": [],
})
return web.json_response(
{"success": True, "results": results}
)
except Exception as exc:
logger.error("Failed to check models existence: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def get_model_version_download_status( async def get_model_version_download_status(
self, request: web.Request self, request: web.Request
) -> web.Response: ) -> web.Response:
@@ -1777,6 +2083,78 @@ class ModelLibraryHandler:
) )
return web.json_response({"success": False, "error": str(exc)}, status=500) return web.json_response({"success": False, "error": str(exc)}, status=500)
async def delete_model_version(self, request: web.Request) -> web.Response:
try:
model_version_id_str = request.query.get("modelVersionId")
if not model_version_id_str:
return web.json_response(
{"success": False, "error": "Missing required parameter: modelVersionId"},
status=400,
)
try:
model_version_id = int(model_version_id_str)
except ValueError:
return web.json_response(
{"success": False, "error": "Parameter modelVersionId must be an integer"},
status=400,
)
lora_scanner = await self._service_registry.get_lora_scanner()
checkpoint_scanner = await self._service_registry.get_checkpoint_scanner()
embedding_scanner = await self._service_registry.get_embedding_scanner()
found_type = None
file_path = None
found_cache = None
for model_type, scanner in (
("lora", lora_scanner),
("checkpoint", checkpoint_scanner),
("embedding", embedding_scanner),
):
cache = await scanner.get_cached_data()
if cache and model_version_id in cache.version_index:
found_type = model_type
found_cache = cache
entry = cache.version_index[model_version_id]
file_path = entry.get("file_path")
break
if not file_path:
return web.json_response(
{"success": False, "error": "Model version not found in any scanner cache"},
status=404,
)
target_dir = os.path.dirname(file_path)
base_name = os.path.basename(file_path)
file_name, extension = os.path.splitext(base_name)
await delete_model_artifacts(target_dir, file_name, main_extension=extension)
if found_cache:
found_cache.raw_data = [
item
for item in found_cache.raw_data
if item.get("file_path") != file_path
]
await found_cache.resort()
history_service = await self._get_download_history_service()
await history_service.mark_not_downloaded(found_type, model_version_id)
return web.json_response(
{
"success": True,
"modelType": found_type,
"modelVersionId": model_version_id,
}
)
except Exception as exc:
logger.error(
"Failed to delete model version: %s", exc, exc_info=True
)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def get_model_versions_status(self, request: web.Request) -> web.Response: async def get_model_versions_status(self, request: web.Request) -> web.Response:
try: try:
model_id_str = request.query.get("modelId") model_id_str = request.query.get("modelId")
@@ -2796,6 +3174,7 @@ class MiscHandlerSet:
"update_settings": self.settings.update_settings, "update_settings": self.settings.update_settings,
"get_doctor_diagnostics": self.doctor.get_doctor_diagnostics, "get_doctor_diagnostics": self.doctor.get_doctor_diagnostics,
"repair_doctor_cache": self.doctor.repair_doctor_cache, "repair_doctor_cache": self.doctor.repair_doctor_cache,
"resolve_doctor_filename_conflicts": self.doctor.resolve_filename_conflicts,
"export_doctor_bundle": self.doctor.export_doctor_bundle, "export_doctor_bundle": self.doctor.export_doctor_bundle,
"get_priority_tags": self.settings.get_priority_tags, "get_priority_tags": self.settings.get_priority_tags,
"get_settings_libraries": self.settings.get_libraries, "get_settings_libraries": self.settings.get_libraries,
@@ -2809,8 +3188,10 @@ class MiscHandlerSet:
"update_node_widget": self.node_registry.update_node_widget, "update_node_widget": self.node_registry.update_node_widget,
"get_registry": self.node_registry.get_registry, "get_registry": self.node_registry.get_registry,
"check_model_exists": self.model_library.check_model_exists, "check_model_exists": self.model_library.check_model_exists,
"check_models_exist": self.model_library.check_models_exist,
"get_model_version_download_status": self.model_library.get_model_version_download_status, "get_model_version_download_status": self.model_library.get_model_version_download_status,
"set_model_version_download_status": self.model_library.set_model_version_download_status, "set_model_version_download_status": self.model_library.set_model_version_download_status,
"delete_model_version": self.model_library.delete_model_version,
"get_civitai_user_models": self.model_library.get_civitai_user_models, "get_civitai_user_models": self.model_library.get_civitai_user_models,
"download_metadata_archive": self.metadata_archive.download_metadata_archive, "download_metadata_archive": self.metadata_archive.download_metadata_archive,
"remove_metadata_archive": self.metadata_archive.remove_metadata_archive, "remove_metadata_archive": self.metadata_archive.remove_metadata_archive,

View File

@@ -2423,6 +2423,7 @@ class ModelUpdateHandler:
"shouldIgnore": version.should_ignore, "shouldIgnore": version.should_ignore,
"earlyAccessEndsAt": version.early_access_ends_at, "earlyAccessEndsAt": version.early_access_ends_at,
"isEarlyAccess": is_early_access, "isEarlyAccess": is_early_access,
"usageControl": version.usage_control,
"filePath": context.get("file_path"), "filePath": context.get("file_path"),
"fileName": context.get("file_name"), "fileName": context.get("file_name"),
} }

View File

@@ -24,6 +24,7 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("POST", "/api/lm/settings", "update_settings"), RouteDefinition("POST", "/api/lm/settings", "update_settings"),
RouteDefinition("GET", "/api/lm/doctor/diagnostics", "get_doctor_diagnostics"), RouteDefinition("GET", "/api/lm/doctor/diagnostics", "get_doctor_diagnostics"),
RouteDefinition("POST", "/api/lm/doctor/repair-cache", "repair_doctor_cache"), RouteDefinition("POST", "/api/lm/doctor/repair-cache", "repair_doctor_cache"),
RouteDefinition("POST", "/api/lm/doctor/resolve-filename-conflicts", "resolve_doctor_filename_conflicts"),
RouteDefinition("POST", "/api/lm/doctor/export-bundle", "export_doctor_bundle"), RouteDefinition("POST", "/api/lm/doctor/export-bundle", "export_doctor_bundle"),
RouteDefinition("GET", "/api/lm/priority-tags", "get_priority_tags"), RouteDefinition("GET", "/api/lm/priority-tags", "get_priority_tags"),
RouteDefinition("GET", "/api/lm/settings/libraries", "get_settings_libraries"), RouteDefinition("GET", "/api/lm/settings/libraries", "get_settings_libraries"),
@@ -42,6 +43,7 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("POST", "/api/lm/update-node-widget", "update_node_widget"), RouteDefinition("POST", "/api/lm/update-node-widget", "update_node_widget"),
RouteDefinition("GET", "/api/lm/get-registry", "get_registry"), RouteDefinition("GET", "/api/lm/get-registry", "get_registry"),
RouteDefinition("GET", "/api/lm/check-model-exists", "check_model_exists"), RouteDefinition("GET", "/api/lm/check-model-exists", "check_model_exists"),
RouteDefinition("GET", "/api/lm/check-models-exist", "check_models_exist"),
RouteDefinition( RouteDefinition(
"GET", "GET",
"/api/lm/model-version-download-status", "/api/lm/model-version-download-status",
@@ -89,6 +91,9 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition( RouteDefinition(
"GET", "/api/lm/base-models/cache-status", "get_base_model_cache_status" "GET", "/api/lm/base-models/cache-status", "get_base_model_cache_status"
), ),
RouteDefinition(
"GET", "/api/lm/delete-model-version", "delete_model_version"
),
) )

View File

@@ -1364,7 +1364,7 @@ class DownloadManager:
f f
for f in files for f in files
if f.get("primary") if f.get("primary")
and f.get("type") in ("Model", "Negative") and f.get("type") in ("Model", "Negative", "Diffusion Model")
), ),
None, None,
) )
@@ -1395,7 +1395,7 @@ class DownloadManager:
( (
f f
for f in files for f in files
if f.get("primary") and f.get("type") in ("Model", "Negative") if f.get("primary") and f.get("type") in ("Model", "Negative", "Diffusion Model")
), ),
None, None,
) )

View File

@@ -64,6 +64,7 @@ class DownloadedVersionHistoryService:
self._db_path = db_path or _resolve_database_path() self._db_path = db_path or _resolve_database_path()
self._settings = settings_manager or get_settings_manager() self._settings = settings_manager or get_settings_manager()
self._lock = asyncio.Lock() self._lock = asyncio.Lock()
self._conn: sqlite3.Connection | None = None
self._schema_initialized = False self._schema_initialized = False
self._ensure_directory() self._ensure_directory()
self._initialize_schema() self._initialize_schema()
@@ -78,6 +79,12 @@ class DownloadedVersionHistoryService:
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
return conn return conn
def _get_conn(self) -> sqlite3.Connection:
if self._conn is None:
self._conn = sqlite3.connect(self._db_path, check_same_thread=False)
self._conn.row_factory = sqlite3.Row
return self._conn
def _initialize_schema(self) -> None: def _initialize_schema(self) -> None:
if self._schema_initialized: if self._schema_initialized:
return return
@@ -116,33 +123,33 @@ class DownloadedVersionHistoryService:
timestamp = time.time() timestamp = time.time()
async with self._lock: async with self._lock:
with self._connect() as conn: conn = self._get_conn()
conn.execute( conn.execute(
""" """
INSERT INTO downloaded_model_versions ( INSERT INTO downloaded_model_versions (
model_type, version_id, model_id, first_seen_at, last_seen_at, model_type, version_id, model_id, first_seen_at, last_seen_at,
source, last_file_path, last_library_name, is_deleted_override source, last_file_path, last_library_name, is_deleted_override
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)
ON CONFLICT(model_type, version_id) DO UPDATE SET ON CONFLICT(model_type, version_id) DO UPDATE SET
model_id = COALESCE(excluded.model_id, downloaded_model_versions.model_id), model_id = COALESCE(excluded.model_id, downloaded_model_versions.model_id),
last_seen_at = excluded.last_seen_at, last_seen_at = excluded.last_seen_at,
source = excluded.source, source = excluded.source,
last_file_path = COALESCE(excluded.last_file_path, downloaded_model_versions.last_file_path), last_file_path = COALESCE(excluded.last_file_path, downloaded_model_versions.last_file_path),
last_library_name = COALESCE(excluded.last_library_name, downloaded_model_versions.last_library_name), last_library_name = COALESCE(excluded.last_library_name, downloaded_model_versions.last_library_name),
is_deleted_override = 0 is_deleted_override = 0
""", """,
( (
normalized_type, normalized_type,
normalized_version_id, normalized_version_id,
normalized_model_id, normalized_model_id,
timestamp, timestamp,
timestamp, timestamp,
source, source,
file_path, file_path,
active_library_name, active_library_name,
), ),
) )
conn.commit() conn.commit()
async def mark_downloaded_bulk( async def mark_downloaded_bulk(
self, self,
@@ -180,24 +187,24 @@ class DownloadedVersionHistoryService:
return return
async with self._lock: async with self._lock:
with self._connect() as conn: conn = self._get_conn()
conn.executemany( conn.executemany(
""" """
INSERT INTO downloaded_model_versions ( INSERT INTO downloaded_model_versions (
model_type, version_id, model_id, first_seen_at, last_seen_at, model_type, version_id, model_id, first_seen_at, last_seen_at,
source, last_file_path, last_library_name, is_deleted_override source, last_file_path, last_library_name, is_deleted_override
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)
ON CONFLICT(model_type, version_id) DO UPDATE SET ON CONFLICT(model_type, version_id) DO UPDATE SET
model_id = COALESCE(excluded.model_id, downloaded_model_versions.model_id), model_id = COALESCE(excluded.model_id, downloaded_model_versions.model_id),
last_seen_at = excluded.last_seen_at, last_seen_at = excluded.last_seen_at,
source = excluded.source, source = excluded.source,
last_file_path = COALESCE(excluded.last_file_path, downloaded_model_versions.last_file_path), last_file_path = COALESCE(excluded.last_file_path, downloaded_model_versions.last_file_path),
last_library_name = COALESCE(excluded.last_library_name, downloaded_model_versions.last_library_name), last_library_name = COALESCE(excluded.last_library_name, downloaded_model_versions.last_library_name),
is_deleted_override = 0 is_deleted_override = 0
""", """,
payload, payload,
) )
conn.commit() conn.commit()
async def mark_not_downloaded(self, model_type: str, version_id: int) -> None: async def mark_not_downloaded(self, model_type: str, version_id: int) -> None:
normalized_type = _normalize_model_type(model_type) normalized_type = _normalize_model_type(model_type)
@@ -208,28 +215,28 @@ class DownloadedVersionHistoryService:
timestamp = time.time() timestamp = time.time()
async with self._lock: async with self._lock:
with self._connect() as conn: conn = self._get_conn()
conn.execute( conn.execute(
""" """
INSERT INTO downloaded_model_versions ( INSERT INTO downloaded_model_versions (
model_type, version_id, model_id, first_seen_at, last_seen_at, model_type, version_id, model_id, first_seen_at, last_seen_at,
source, last_file_path, last_library_name, is_deleted_override source, last_file_path, last_library_name, is_deleted_override
) VALUES (?, ?, NULL, ?, ?, 'manual', NULL, ?, 1) ) VALUES (?, ?, NULL, ?, ?, 'manual', NULL, ?, 1)
ON CONFLICT(model_type, version_id) DO UPDATE SET ON CONFLICT(model_type, version_id) DO UPDATE SET
last_seen_at = excluded.last_seen_at, last_seen_at = excluded.last_seen_at,
source = excluded.source, source = excluded.source,
last_library_name = COALESCE(excluded.last_library_name, downloaded_model_versions.last_library_name), last_library_name = COALESCE(excluded.last_library_name, downloaded_model_versions.last_library_name),
is_deleted_override = 1 is_deleted_override = 1
""", """,
( (
normalized_type, normalized_type,
normalized_version_id, normalized_version_id,
timestamp, timestamp,
timestamp, timestamp,
self._get_active_library_name(), self._get_active_library_name(),
), ),
) )
conn.commit() conn.commit()
async def has_been_downloaded(self, model_type: str, version_id: int) -> bool: async def has_been_downloaded(self, model_type: str, version_id: int) -> bool:
normalized_type = _normalize_model_type(model_type) normalized_type = _normalize_model_type(model_type)
@@ -238,15 +245,15 @@ class DownloadedVersionHistoryService:
return False return False
async with self._lock: async with self._lock:
with self._connect() as conn: conn = self._get_conn()
row = conn.execute( row = conn.execute(
""" """
SELECT is_deleted_override SELECT is_deleted_override
FROM downloaded_model_versions FROM downloaded_model_versions
WHERE model_type = ? AND version_id = ? WHERE model_type = ? AND version_id = ?
""", """,
(normalized_type, normalized_version_id), (normalized_type, normalized_version_id),
).fetchone() ).fetchone()
return bool(row) and not bool(row["is_deleted_override"]) return bool(row) and not bool(row["is_deleted_override"])
async def get_downloaded_version_ids( async def get_downloaded_version_ids(
@@ -258,16 +265,16 @@ class DownloadedVersionHistoryService:
return [] return []
async with self._lock: async with self._lock:
with self._connect() as conn: conn = self._get_conn()
rows = conn.execute( rows = conn.execute(
""" """
SELECT version_id SELECT version_id
FROM downloaded_model_versions FROM downloaded_model_versions
WHERE model_type = ? AND model_id = ? AND is_deleted_override = 0 WHERE model_type = ? AND model_id = ? AND is_deleted_override = 0
ORDER BY version_id ASC ORDER BY version_id ASC
""", """,
(normalized_type, normalized_model_id), (normalized_type, normalized_model_id),
).fetchall() ).fetchall()
return [int(row["version_id"]) for row in rows] return [int(row["version_id"]) for row in rows]
async def get_downloaded_version_ids_bulk( async def get_downloaded_version_ids_bulk(
@@ -291,17 +298,17 @@ class DownloadedVersionHistoryService:
params: list[object] = [normalized_type, *normalized_model_ids] params: list[object] = [normalized_type, *normalized_model_ids]
async with self._lock: async with self._lock:
with self._connect() as conn: conn = self._get_conn()
rows = conn.execute( rows = conn.execute(
f""" f"""
SELECT model_id, version_id SELECT model_id, version_id
FROM downloaded_model_versions FROM downloaded_model_versions
WHERE model_type = ? WHERE model_type = ?
AND model_id IN ({placeholders}) AND model_id IN ({placeholders})
AND is_deleted_override = 0 AND is_deleted_override = 0
""", """,
params, params,
).fetchall() ).fetchall()
result: dict[int, set[int]] = {} result: dict[int, set[int]] = {}
for row in rows: for row in rows:

View File

@@ -79,6 +79,12 @@ class ModelHashIndex:
hash_val = h hash_val = h
break break
if hash_val is None:
for h, paths in self._duplicate_hashes.items():
if file_path in paths:
hash_val = h
break
# If we didn't find a hash, nothing to do # If we didn't find a hash, nothing to do
if not hash_val: if not hash_val:
return return

View File

@@ -1072,14 +1072,6 @@ class ModelScanner:
excluded_models.append(model_data['file_path']) excluded_models.append(model_data['file_path'])
return None return None
# Check for duplicate filename before adding to hash index
# filename = os.path.splitext(os.path.basename(file_path))[0]
# existing_hash = hash_index.get_hash_by_filename(filename)
# if existing_hash and existing_hash != model_data.get('sha256', '').lower():
# existing_path = hash_index.get_path(existing_hash)
# if existing_path and existing_path != file_path:
# logger.warning(f"Duplicate filename detected: '{filename}' - files: '{existing_path}' and '{file_path}'")
return model_data return model_data
async def _apply_scan_result(self, scan_result: CacheBuildResult) -> None: async def _apply_scan_result(self, scan_result: CacheBuildResult) -> None:
@@ -1105,6 +1097,31 @@ class ModelScanner:
await self._cache.resort() await self._cache.resort()
self._log_duplicate_filename_summary()
def _log_duplicate_filename_summary(self) -> None:
"""Log a batched summary of duplicate filename conflicts once per scan."""
if self._hash_index is None:
return
duplicates = self._hash_index.get_duplicate_filenames()
if not duplicates:
return
total_files = sum(len(paths) for paths in duplicates.values())
conflict_count = len(duplicates)
model_type_label = self.model_type or "model"
logger.warning(
"Duplicate filename conflict detected: %d %s filename(s) "
"are shared by %d files total, causing ambiguity in %s resolution. "
"Open the Doctor panel to resolve one-click.",
conflict_count,
model_type_label,
total_files,
model_type_label.capitalize(),
)
async def _sync_download_history( async def _sync_download_history(
self, self,
raw_data: List[Mapping[str, Any]], raw_data: List[Mapping[str, Any]],

View File

@@ -69,6 +69,7 @@ class ModelVersionRecord:
early_access_ends_at: Optional[str] = None early_access_ends_at: Optional[str] = None
sort_index: int = 0 sort_index: int = 0
is_early_access: bool = False is_early_access: bool = False
usage_control: Optional[str] = None # "Download", "Generation", "InternalGeneration"
@dataclass @dataclass
@@ -101,11 +102,14 @@ class ModelUpdateRecord:
return [version.version_id for version in self.versions if version.is_in_library] return [version.version_id for version in self.versions if version.is_in_library]
def has_update(self, hide_early_access: bool = False) -> bool: def has_update(
self, hide_early_access: bool = False, hide_non_downloadable: bool = True
) -> bool:
"""Return True when a non-ignored remote version newer than the newest local copy is available. """Return True when a non-ignored remote version newer than the newest local copy is available.
Args: Args:
hide_early_access: If True, exclude early access versions from update check. hide_early_access: If True, exclude early access versions from update check.
hide_non_downloadable: If True, exclude versions that don't allow downloads.
""" """
if self.should_ignore_model: if self.should_ignore_model:
@@ -121,6 +125,7 @@ class ModelUpdateRecord:
not version.is_in_library not version.is_in_library
and not version.should_ignore and not version.should_ignore
and not (hide_early_access and ModelUpdateRecord._is_early_access_active(version)) and not (hide_early_access and ModelUpdateRecord._is_early_access_active(version))
and not (hide_non_downloadable and not ModelUpdateRecord._is_downloadable(version))
for version in self.versions for version in self.versions
) )
@@ -129,6 +134,8 @@ class ModelUpdateRecord:
continue continue
if hide_early_access and ModelUpdateRecord._is_early_access_active(version): if hide_early_access and ModelUpdateRecord._is_early_access_active(version):
continue continue
if hide_non_downloadable and not ModelUpdateRecord._is_downloadable(version):
continue
if version.version_id > max_in_library: if version.version_id > max_in_library:
return True return True
return False return False
@@ -155,11 +162,18 @@ class ModelUpdateRecord:
# Phase 1: Basic EA flag from bulk API # Phase 1: Basic EA flag from bulk API
return version.is_early_access return version.is_early_access
@staticmethod
def _is_downloadable(version: ModelVersionRecord) -> bool:
if version.usage_control is None:
return True
return version.usage_control == "Download"
def has_update_for_base( def has_update_for_base(
self, self,
local_version_id: Optional[int], local_version_id: Optional[int],
local_base_model: Optional[str], local_base_model: Optional[str],
hide_early_access: bool = False, hide_early_access: bool = False,
hide_non_downloadable: bool = True,
) -> bool: ) -> bool:
"""Return True when a newer remote version with the same base model exists. """Return True when a newer remote version with the same base model exists.
@@ -167,6 +181,7 @@ class ModelUpdateRecord:
local_version_id: The current local version id. local_version_id: The current local version id.
local_base_model: The base model to filter by. local_base_model: The base model to filter by.
hide_early_access: If True, exclude early access versions from update check. hide_early_access: If True, exclude early access versions from update check.
hide_non_downloadable: If True, exclude versions that don't allow downloads.
""" """
if self.should_ignore_model: if self.should_ignore_model:
@@ -197,6 +212,8 @@ class ModelUpdateRecord:
continue continue
if hide_early_access and ModelUpdateRecord._is_early_access_active(version): if hide_early_access and ModelUpdateRecord._is_early_access_active(version):
continue continue
if hide_non_downloadable and not ModelUpdateRecord._is_downloadable(version):
continue
version_base = _normalize_base_model(version.base_model) version_base = _normalize_base_model(version.base_model)
if version_base != normalized_base: if version_base != normalized_base:
continue continue
@@ -230,6 +247,7 @@ class ModelUpdateService:
preview_url TEXT, preview_url TEXT,
is_in_library INTEGER NOT NULL DEFAULT 0, is_in_library INTEGER NOT NULL DEFAULT 0,
should_ignore INTEGER NOT NULL DEFAULT 0, should_ignore INTEGER NOT NULL DEFAULT 0,
usage_control TEXT,
PRIMARY KEY (model_id, version_id), PRIMARY KEY (model_id, version_id),
FOREIGN KEY(model_id) REFERENCES model_update_status(model_id) ON DELETE CASCADE FOREIGN KEY(model_id) REFERENCES model_update_status(model_id) ON DELETE CASCADE
); );
@@ -465,6 +483,10 @@ class ModelUpdateService:
"ALTER TABLE model_update_versions " "ALTER TABLE model_update_versions "
"ADD COLUMN is_early_access INTEGER NOT NULL DEFAULT 0" "ADD COLUMN is_early_access INTEGER NOT NULL DEFAULT 0"
), ),
"usage_control": (
"ALTER TABLE model_update_versions "
"ADD COLUMN usage_control TEXT"
),
} }
for column, statement in migrations.items(): for column, statement in migrations.items():
@@ -1337,6 +1359,7 @@ class ModelUpdateService:
# Check availability field from bulk API for basic EA detection # Check availability field from bulk API for basic EA detection
availability = _normalize_string(entry.get("availability")) availability = _normalize_string(entry.get("availability"))
is_early_access = availability == "EarlyAccess" is_early_access = availability == "EarlyAccess"
usage_control = _normalize_string(entry.get("usageControl"))
return ModelVersionRecord( return ModelVersionRecord(
version_id=version_id, version_id=version_id,
@@ -1350,6 +1373,7 @@ class ModelUpdateService:
early_access_ends_at=early_access_ends_at, early_access_ends_at=early_access_ends_at,
sort_index=index, sort_index=index,
is_early_access=is_early_access, is_early_access=is_early_access,
usage_control=usage_control,
) )
def _extract_size_bytes(self, files) -> Optional[int]: def _extract_size_bytes(self, files) -> Optional[int]:
@@ -1464,7 +1488,7 @@ class ModelUpdateService:
f""" f"""
SELECT model_id, version_id, sort_index, name, base_model, released_at, SELECT model_id, version_id, sort_index, name, base_model, released_at,
size_bytes, preview_url, is_in_library, should_ignore, early_access_ends_at, size_bytes, preview_url, is_in_library, should_ignore, early_access_ends_at,
is_early_access is_early_access, usage_control
FROM model_update_versions FROM model_update_versions
WHERE model_id IN ({placeholders}) WHERE model_id IN ({placeholders})
ORDER BY model_id ASC, sort_index ASC, version_id ASC ORDER BY model_id ASC, sort_index ASC, version_id ASC
@@ -1492,6 +1516,7 @@ class ModelUpdateService:
early_access_ends_at=row["early_access_ends_at"], early_access_ends_at=row["early_access_ends_at"],
sort_index=_normalize_int(row["sort_index"]) or 0, sort_index=_normalize_int(row["sort_index"]) or 0,
is_early_access=bool(row["is_early_access"]), is_early_access=bool(row["is_early_access"]),
usage_control=row["usage_control"],
) )
) )
@@ -1548,8 +1573,8 @@ class ModelUpdateService:
INSERT INTO model_update_versions ( INSERT INTO model_update_versions (
version_id, model_id, sort_index, name, base_model, released_at, version_id, model_id, sort_index, name, base_model, released_at,
size_bytes, preview_url, is_in_library, should_ignore, early_access_ends_at, size_bytes, preview_url, is_in_library, should_ignore, early_access_ends_at,
is_early_access is_early_access, usage_control
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
version.version_id, version.version_id,
@@ -1564,6 +1589,7 @@ class ModelUpdateService:
1 if version.should_ignore else 0, 1 if version.should_ignore else 0,
version.early_access_ends_at, version.early_access_ends_at,
1 if version.is_early_access else 0, 1 if version.is_early_access else 0,
version.usage_control,
), ),
) )
conn.commit() conn.commit()

View File

@@ -0,0 +1,354 @@
#!/usr/bin/env python3
"""
Migrate metadata from old sidecar JSON format to LoRA Manager's metadata.json format.
This script automatically discovers model folders from LoRA Manager's settings.json,
finds JSON files with the same basename as model files (e.g., `model.json` for
`model.safetensors`), and migrates their content to the corresponding `.metadata.json` files.
Fields migrated:
- "activation text" → civitai.trainedWords (array of trigger words)
- "preferred weight" → usage_tips.strength (LoRA only, skipped for Checkpoint)
- "notes" → notes (user-defined notes)
Supported model types: LoRA, Checkpoint
Usage:
python scripts/migrate_legacy_metadata.py [--dry-run] [--verbose]
The script will:
1. Read settings.json to find all configured model folders
2. Recursively scan for model files (.safetensors, .ckpt, .pt, .pth, .bin)
3. Find corresponding legacy metadata JSON files
4. Migrate data to .metadata.json files
"""
from __future__ import annotations
import argparse
import json
import logging
import os
import re
import sys
from pathlib import Path
from typing import Any
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s",
)
logger = logging.getLogger(__name__)
APP_NAME = "ComfyUI-LoRA-Manager"
MODEL_EXTENSIONS = {".safetensors", ".ckpt", ".pt", ".pth", ".bin"}
SECRET_PATTERN = re.compile(r"(key|token|secret|password|auth|credential)", re.IGNORECASE)
def resolve_settings_path() -> Path:
repo_root = Path(__file__).parent.parent.resolve()
portable = repo_root / "settings.json"
if portable.exists():
payload = load_json(portable)
if isinstance(payload, dict) and payload.get("use_portable_settings") is True:
return portable
config_home = os.environ.get("XDG_CONFIG_HOME")
if config_home:
return Path(config_home).expanduser() / APP_NAME / "settings.json"
return Path.home() / ".config" / APP_NAME / "settings.json"
def load_json(path: Path) -> dict[str, Any]:
try:
with path.open("r", encoding="utf-8") as f:
return json.load(f)
except FileNotFoundError:
return {}
except json.JSONDecodeError as exc:
logger.error(f"Invalid JSON in {path}: {exc}")
return {}
except OSError as exc:
logger.error(f"Cannot read {path}: {exc}")
return {}
def expand_path(value: str) -> str:
return str(Path(value).expanduser().resolve(strict=False))
def normalize_path_list(value: Any) -> list[str]:
if isinstance(value, str):
return [expand_path(value)] if value else []
if isinstance(value, list):
return [expand_path(item) for item in value if isinstance(item, str) and item]
return []
def dedupe(values: list[str]) -> list[str]:
seen: set[str] = set()
result: list[str] = []
for value in values:
if value not in seen:
result.append(value)
seen.add(value)
return result
def get_model_roots(settings: dict[str, Any]) -> dict[str, list[str]]:
roots: dict[str, list[str]] = {}
active_library = settings.get("active_library") or "default"
sources = [settings]
library = settings.get("libraries", {}).get(active_library)
if isinstance(library, dict):
sources.insert(0, library)
for source in sources:
folder_paths = source.get("folder_paths")
if isinstance(folder_paths, dict):
for key, value in folder_paths.items():
roots.setdefault(key, []).extend(normalize_path_list(value))
for default_key, folder_key in (
("default_lora_root", "loras"),
("default_checkpoint_root", "checkpoints"),
("default_embedding_root", "embeddings"),
("default_unet_root", "unet"),
):
value = settings.get(default_key)
if isinstance(value, str) and value:
roots.setdefault(folder_key, []).append(expand_path(value))
return {key: dedupe(values) for key, values in roots.items()}
def find_model_files(directory: Path) -> list[Path]:
model_files = []
for ext in MODEL_EXTENSIONS:
model_files.extend(directory.rglob(f"*{ext}"))
return model_files
def find_legacy_metadata(model_path: Path) -> Path | None:
base_name = model_path.stem
legacy_path = model_path.with_name(f"{base_name}.json")
if legacy_path.exists() and legacy_path.is_file():
return legacy_path
return None
def load_legacy_metadata(legacy_path: Path) -> dict[str, Any] | None:
try:
with open(legacy_path, "r", encoding="utf-8") as f:
return json.load(f)
except json.JSONDecodeError as e:
logger.error(f"Invalid JSON in legacy file {legacy_path}: {e}")
return None
except Exception as e:
logger.error(f"Error reading legacy file {legacy_path}: {e}")
return None
def load_metadata(metadata_path: Path) -> dict[str, Any]:
if not metadata_path.exists():
return {}
try:
with open(metadata_path, "r", encoding="utf-8") as f:
return json.load(f)
except json.JSONDecodeError as e:
logger.warning(f"Invalid JSON in metadata file {metadata_path}: {e}. Starting fresh.")
return {}
except Exception as e:
logger.error(f"Error reading metadata file {metadata_path}: {e}")
return {}
def save_metadata(metadata_path: Path, data: dict[str, Any], dry_run: bool = False) -> bool:
if dry_run:
logger.info(f"[DRY RUN] Would save metadata to: {metadata_path}")
return True
temp_path = metadata_path.with_suffix(".tmp")
try:
with open(temp_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
os.replace(temp_path, metadata_path)
return True
except Exception as e:
logger.error(f"Error saving metadata to {metadata_path}: {e}")
if temp_path.exists():
try:
temp_path.unlink()
except:
pass
return False
def migrate_metadata(
legacy_data: dict[str, Any],
existing_metadata: dict[str, Any],
model_type: str
) -> dict[str, Any] | None:
metadata = existing_metadata.copy()
changes_made = False
if "civitai" not in metadata:
metadata["civitai"] = {}
activation_text = legacy_data.get("activation text")
if activation_text and isinstance(activation_text, str):
trigger_words = [
word.strip()
for word in activation_text.replace("\n", ",").split(",")
if word.strip()
]
if trigger_words:
existing_trained = metadata["civitai"].get("trainedWords", [])
if not isinstance(existing_trained, list):
existing_trained = []
merged = list(dict.fromkeys(existing_trained + trigger_words))
if merged != existing_trained:
metadata["civitai"]["trainedWords"] = merged
changes_made = True
logger.debug(f" Migrated activation text: {trigger_words}")
if model_type == "lora":
preferred_weight = legacy_data.get("preferred weight")
if preferred_weight is not None:
try:
weight_value = float(preferred_weight)
usage_tips_str = metadata.get("usage_tips", "{}")
if isinstance(usage_tips_str, str):
try:
usage_tips = json.loads(usage_tips_str)
except json.JSONDecodeError:
usage_tips = {}
elif isinstance(usage_tips_str, dict):
usage_tips = usage_tips_str
else:
usage_tips = {}
if "strength" not in usage_tips:
usage_tips["strength"] = weight_value
metadata["usage_tips"] = json.dumps(usage_tips, ensure_ascii=False)
changes_made = True
logger.debug(f" Migrated preferred weight: {weight_value}")
except (ValueError, TypeError) as e:
logger.warning(f" Could not parse preferred weight '{preferred_weight}': {e}")
else:
if legacy_data.get("preferred weight") is not None:
logger.debug(" Skipping 'preferred weight' for non-LoRA model")
notes = legacy_data.get("notes")
if notes and isinstance(notes, str) and notes.strip():
existing_notes = metadata.get("notes", "")
if not existing_notes:
metadata["notes"] = notes.strip()
changes_made = True
logger.debug(" Migrated notes")
elif notes.strip() not in existing_notes:
metadata["notes"] = f"{existing_notes}\n\n{notes.strip()}".strip()
changes_made = True
logger.debug(" Appended notes")
return metadata if changes_made else None
def process_model(model_path: Path, model_type: str, dry_run: bool = False) -> bool:
legacy_path = find_legacy_metadata(model_path)
if not legacy_path:
return True
logger.info(f"Processing: {model_path.name} ({model_type})")
logger.info(f" Found legacy metadata: {legacy_path.name}")
legacy_data = load_legacy_metadata(legacy_path)
if legacy_data is None:
return False
metadata_path = model_path.with_suffix(".metadata.json")
existing_metadata = load_metadata(metadata_path)
migrated = migrate_metadata(legacy_data, existing_metadata, model_type)
if migrated is None:
logger.info(" No changes needed (fields already exist or no migratable data)")
return True
if save_metadata(metadata_path, migrated, dry_run):
logger.info(f" ✓ Successfully migrated metadata to: {metadata_path.name}")
return True
else:
logger.error(" ✗ Failed to save metadata")
return False
def main() -> int:
parser = argparse.ArgumentParser(
description="Migrate legacy metadata JSON files to LoRA Manager's metadata.json format.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
python scripts/migrate_legacy_metadata.py
python scripts/migrate_legacy_metadata.py --dry-run
python scripts/migrate_legacy_metadata.py --verbose
"""
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Preview changes without modifying any files"
)
parser.add_argument(
"-v", "--verbose",
action="store_true",
help="Enable verbose output"
)
args = parser.parse_args()
if args.verbose:
logging.getLogger().setLevel(logging.DEBUG)
settings_path = resolve_settings_path()
logger.info(f"Using settings: {settings_path}")
settings = load_json(settings_path)
if not settings:
logger.error("Could not load settings.json. Please ensure LoRA Manager is configured.")
return 1
roots = get_model_roots(settings)
if not roots:
logger.error("No model folders configured in settings.json.")
return 1
lora_roots = roots.get("loras", [])
checkpoint_roots = roots.get("checkpoints", []) + roots.get("unet", [])
all_roots = []
for root_list in [lora_roots, checkpoint_roots]:
for root in root_list:
path = Path(root)
if path.exists() and path.is_dir():
all_roots.append((path, "lora" if root in lora_roots else "checkpoint"))
if not all_roots:
logger.error("No valid model folders found.")
return 1
logger.info(f"Found {len(lora_roots)} LoRA root(s), {len(checkpoint_roots)} Checkpoint root(s)")
processed = 0
migrated = 0
errors = 0
skipped = 0
lora_count = 0
checkpoint_count = 0
for root_path, model_type in all_roots:
logger.info(f"Scanning: {root_path} ({model_type})")
model_files = find_model_files(root_path)
logger.debug(f" Found {len(model_files)} model files")
for model_path in model_files:
legacy_path = find_legacy_metadata(model_path)
if not legacy_path:
skipped += 1
continue
processed += 1
if process_model(model_path, model_type, dry_run=args.dry_run):
migrated += 1
if model_type == "lora":
lora_count += 1
else:
checkpoint_count += 1
else:
errors += 1
logger.info("\n" + "=" * 50)
logger.info("Migration Summary:")
logger.info(f" Models with legacy metadata: {processed}")
logger.info(f" Successfully migrated: {migrated}")
logger.info(f" - LoRA models: {lora_count}")
logger.info(f" - Checkpoint models: {checkpoint_count}")
logger.info(f" Errors: {errors}")
logger.info(f" Skipped (no legacy file): {skipped}")
if args.dry_run:
logger.info("\n [DRY RUN MODE - No files were modified]")
return 0 if errors == 0 else 1
if __name__ == "__main__":
sys.exit(main())

View File

@@ -87,7 +87,7 @@
.checkbox-label input[type="checkbox"]:checked + .checkmark::after { .checkbox-label input[type="checkbox"]:checked + .checkmark::after {
content: '\f00c'; content: '\f00c';
font-family: 'Font Awesome 6 Free'; font-family: 'Font Awesome 6 Free', sans-serif;
font-weight: 900; font-weight: 900;
color: var(--lora-text); color: var(--lora-text);
font-size: 12px; font-size: 12px;

View File

@@ -22,6 +22,7 @@
transition: transform 160ms ease-out; transition: transform 160ms ease-out;
aspect-ratio: 896/1152; /* Preserve aspect ratio */ aspect-ratio: 896/1152; /* Preserve aspect ratio */
max-width: 260px; /* Base size */ max-width: 260px; /* Base size */
min-width: 200px; /* Prevent cards from becoming too narrow */
width: 100%; width: 100%;
margin: 0 auto; margin: 0 auto;
cursor: pointer; cursor: pointer;
@@ -328,7 +329,6 @@
} }
.card-actions i { .card-actions i {
margin-left: var(--space-1);
cursor: pointer; cursor: pointer;
color: white; color: white;
transition: opacity 0.2s, transform 0.15s ease; transition: opacity 0.2s, transform 0.15s ease;
@@ -370,7 +370,16 @@
text-shadow: 0 0 5px rgba(255, 193, 7, 0.5); text-shadow: 0 0 5px rgba(255, 193, 7, 0.5);
} }
/* 响应式设计 */ @media (max-width: 1200px) {
.card-grid {
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
}
.model-card {
max-width: 240px;
min-width: 180px;
}
}
@media (max-width: 768px) { @media (max-width: 768px) {
.card-grid { .card-grid {
grid-template-columns: minmax(260px, 1fr); /* Adjusted minimum size for mobile */ grid-template-columns: minmax(260px, 1fr); /* Adjusted minimum size for mobile */
@@ -378,6 +387,7 @@
.model-card { .model-card {
max-width: 100%; /* Allow cards to fill available space on mobile */ max-width: 100%; /* Allow cards to fill available space on mobile */
min-width: 200px;
} }
} }
@@ -563,8 +573,13 @@ body.hide-card-version .civitai-version {
position: absolute; position: absolute;
box-sizing: border-box; box-sizing: border-box;
transition: transform 160ms ease-out; transition: transform 160ms ease-out;
margin: 0; /* Remove margins, positioning is handled by VirtualScroller */ margin: 0;
width: 100%; /* Allow width to be set by the VirtualScroller */ width: 100%;
}
/* Allow cards to grow beyond 260px in virtual scroll mode */
.virtual-scroll-item.model-card {
max-width: none;
} }
.virtual-scroll-item:hover { .virtual-scroll-item:hover {
@@ -576,11 +591,11 @@ body.hide-card-version .civitai-version {
.card-grid.virtual-scroll { .card-grid.virtual-scroll {
display: block; display: block;
position: relative; position: relative;
margin: 0 auto; margin: 0; /* Remove auto margins - positioning handled by VirtualScroller leftOffset */
padding: 4px 0; /* Add top/bottom padding equivalent to card padding */ padding: 4px 0; /* Add top/bottom padding equivalent to card padding */
height: auto; height: auto;
width: 100%; width: 100%;
max-width: 1400px; /* Keep the max-width from original grid */ max-width: none; /* Remove max-width constraint - handled by VirtualScroller */
box-sizing: border-box; /* Include padding in width calculation */ box-sizing: border-box; /* Include padding in width calculation */
overflow-x: hidden; /* Prevent horizontal overflow */ overflow-x: hidden; /* Prevent horizontal overflow */
} }

View File

@@ -22,6 +22,22 @@
gap: 1rem; gap: 1rem;
} }
/* Left section: Logo + Navigation */
.header-left {
display: flex;
align-items: center;
gap: 1rem;
flex-shrink: 0;
}
/* Right section: Controls */
.header-right {
display: flex;
align-items: center;
gap: 1rem;
flex-shrink: 0;
}
/* Responsive header container for larger screens */ /* Responsive header container for larger screens */
@media (min-width: 2150px) { @media (min-width: 2150px) {
.header-container { .header-container {
@@ -77,6 +93,7 @@
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
font-size: 0.9rem; font-size: 0.9rem;
white-space: nowrap;
} }
.nav-item:hover, .nav-item:hover,
@@ -97,13 +114,99 @@
color: white; color: white;
} }
/* Header search */ /* Header search - Centered with VS Code command palette style */
.header-search { .header-search {
flex: 1; flex: 1;
max-width: 400px; display: flex;
justify-content: center;
max-width: 600px;
margin: 0 auto;
transition: opacity 0.2s ease; transition: opacity 0.2s ease;
} }
/* VS Code command palette style search container */
.header-search .search-container {
width: 100%;
max-width: 600px;
position: relative;
display: flex;
align-items: center;
background: var(--input-bg, var(--card-bg));
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm, 6px);
transition: all 0.2s ease;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
overflow: hidden;
}
.header-search .search-container:focus-within {
border-color: var(--lora-accent);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08), 0 0 0 1px var(--lora-accent);
}
.header-search input {
flex: 1;
width: 100%;
padding: 0.5rem 0.75rem;
padding-left: 2.25rem !important;
padding-right: 5rem !important;
border: none;
background: transparent;
color: var(--text-color);
font-size: 0.95rem;
outline: none;
}
.header-search input::placeholder {
color: var(--text-muted);
}
.header-search .search-icon {
position: absolute;
left: 0.75rem;
color: var(--text-muted);
font-size: 0.9rem;
pointer-events: none;
}
.header-search .search-options-toggle,
.header-search .search-filter-toggle {
position: absolute;
right: 0.5rem;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
color: var(--text-muted);
cursor: pointer;
border-radius: var(--border-radius-xs, 4px);
transition: all 0.2s ease;
}
.header-search .search-options-toggle {
right: 2.25rem;
}
.header-search .search-options-toggle:hover,
.header-search .search-filter-toggle:hover {
background: var(--lora-surface-hover, oklch(95% 0.02 256));
color: var(--lora-accent);
}
.header-search .filter-badge {
position: absolute;
top: 2px;
right: 2px;
width: 8px;
height: 8px;
background: var(--lora-accent);
border-radius: 50%;
font-size: 0;
}
/* Disabled state for header search */ /* Disabled state for header search */
.header-search.disabled { .header-search.disabled {
opacity: 0.5; opacity: 0.5;
@@ -247,44 +350,207 @@
opacity: 1; opacity: 1;
} }
/* Mobile adjustments */ /* Hamburger menu button - hidden by default */
@media (max-width: 768px) { .hamburger-menu-btn {
.app-title { display: none;
display: none; width: 32px;
/* Hide text title on mobile */ height: 32px;
border-radius: 50%;
background: var(--card-bg);
border: 1px solid var(--border-color);
color: var(--text-color);
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
flex-shrink: 0;
}
.hamburger-menu-btn:hover {
background: var(--lora-accent);
color: white;
}
/* Hamburger dropdown menu */
.hamburger-dropdown {
display: none;
position: absolute;
top: 100%;
right: 0;
margin-top: 8px;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm, 6px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
padding: 0.5rem;
min-width: 160px;
z-index: var(--z-dropdown, 200);
}
.hamburger-dropdown.active {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.hamburger-dropdown .dropdown-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0.75rem;
border-radius: var(--border-radius-xs, 4px);
color: var(--text-color);
cursor: pointer;
transition: all 0.2s ease;
font-size: 0.9rem;
white-space: nowrap;
}
.hamburger-dropdown .dropdown-item:hover {
background: var(--lora-surface-hover, oklch(95% 0.02 256));
color: var(--lora-accent);
}
.hamburger-dropdown .dropdown-item i {
width: 20px;
text-align: center;
}
.hamburger-dropdown .dropdown-divider {
height: 1px;
background: var(--border-color);
margin: 0.25rem 0;
}
/* Responsive: Early optimization at 1200px - reduce gaps and padding */
@media (max-width: 1200px) {
.header-container {
gap: 0.75rem;
padding: 0 12px;
}
.main-nav {
gap: 0.25rem;
}
.nav-item {
padding: 0.25rem 0.5rem;
font-size: 0.85rem;
} }
.header-controls { .header-controls {
gap: 4px; gap: 6px;
} }
.header-controls>div { .header-controls > div {
width: 28px; width: 30px;
height: 28px; height: 30px;
}
}
/* Responsive: Hide nav icons at 1100px to save space */
@media (max-width: 1100px) {
.nav-item {
gap: 0;
padding: 0.25rem 0.4rem;
}
.nav-item i {
display: none;
}
.header-search {
max-width: 450px;
}
}
@media (max-width: 950px) {
.app-title {
display: none !important;
}
.header-container {
padding: 0 10px;
gap: 0.5rem;
}
.header-controls {
display: none !important;
}
.hamburger-menu-btn {
display: flex !important;
}
.hamburger-dropdown {
display: none;
}
.hamburger-dropdown.active {
display: flex;
} }
.header-search { .header-search {
max-width: none; max-width: none;
margin: 0 0.5rem; margin: 0;
flex: 1;
min-width: 200px;
} }
.main-nav { .main-nav {
margin-right: 0.5rem; gap: 0.25rem;
margin-right: 0;
}
.nav-item {
padding: 0.25rem 0.35rem;
font-size: 0.8rem;
} }
} }
/* For very small screens */ /* Responsive: Compact mode at 768px */
@media (max-width: 768px) {
.header-search input {
padding: 0.4rem 0.6rem;
padding-left: 2rem !important;
padding-right: 4.5rem !important;
font-size: 0.9rem;
}
.header-search .search-container {
border-radius: var(--border-radius-xs, 4px);
}
}
/* For very small screens - switch nav to icons only */
@media (max-width: 600px) { @media (max-width: 600px) {
.header-container { .header-container {
padding: 0 8px; padding: 0 8px;
gap: 0.4rem;
} }
.main-nav { .main-nav {
display: none; display: flex;
/* Hide navigation on very small screens */ gap: 0.15rem;
margin-right: 0;
} }
.header-search { .nav-item {
flex: 1; padding: 0.25rem;
font-size: 0.75rem;
}
.nav-item span {
display: none;
}
.nav-item i {
display: block;
font-size: 1rem;
} }
} }
/* Position relative for hamburger menu positioning */
.header-right {
position: relative;
}

View File

@@ -374,6 +374,14 @@
background: color-mix(in oklch, var(--lora-surface) 35%, transparent); background: color-mix(in oklch, var(--lora-surface) 35%, transparent);
} }
.version-action-disabled {
background: transparent;
border-color: var(--border-color);
color: var(--text-muted);
opacity: 0.6;
cursor: not-allowed;
}
.version-action:disabled { .version-action:disabled {
opacity: 0.6; opacity: 0.6;
cursor: not-allowed; cursor: not-allowed;

View File

@@ -67,7 +67,6 @@
.early-access-info { .early-access-info {
display: none; display: none;
position: absolute;
top: 100%; top: 100%;
right: 0; right: 0;
background: var(--card-bg); background: var(--card-bg);
@@ -97,7 +96,6 @@
.local-path { .local-path {
display: none; display: none;
position: absolute;
top: 100%; top: 100%;
right: 0; right: 0;
background: var(--card-bg); background: var(--card-bg);

View File

@@ -271,11 +271,16 @@
/* Enhanced Sidebar Breadcrumb Styles */ /* Enhanced Sidebar Breadcrumb Styles */
.sidebar-breadcrumb-container { .sidebar-breadcrumb-container {
margin-top: 8px;
padding: 8px 0; padding: 8px 0;
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
background: var(--bg-color); background: var(--bg-color);
border-radius: var(--border-radius-xs); border-radius: var(--border-radius-xs);
/* Sticky positioning to stick below header when scrolling
top: 0 means stick at the top of the scroll container (page-content)
which is at header height (48px) from the viewport */
position: sticky;
top: 0;
z-index: calc(var(--z-header) - 1);
} }
.sidebar-breadcrumb-nav { .sidebar-breadcrumb-nav {
@@ -284,7 +289,6 @@
flex-wrap: wrap; flex-wrap: wrap;
gap: 4px; gap: 4px;
font-size: 0.85em; font-size: 0.85em;
padding: 0 8px;
} }
.sidebar-breadcrumb-item { .sidebar-breadcrumb-item {

View File

@@ -21,7 +21,7 @@
top: -54px; top: -54px;
z-index: calc(var(--z-header) - 1); z-index: calc(var(--z-header) - 1);
background: var(--bg-color); background: var(--bg-color);
padding: var(--space-2) 0; padding: var(--space-1) 0;
box-shadow: 0 1px 3px rgba(0,0,0,0.05); box-shadow: 0 1px 3px rgba(0,0,0,0.05);
} }
@@ -371,6 +371,14 @@
display: block; display: block;
} }
/* Elevate the controls stacking context above breadcrumb nav when a dropdown is open,
so the dropdown menu isn't obscured. Only active when dropdown is shown to avoid
the entire controls bar (which can wrap to 2 rows on narrow viewports) covering
the sticky breadcrumb. */
.controls:has(.dropdown-group.active) {
z-index: var(--z-header);
}
.dropdown-item { .dropdown-item {
display: block; display: block;
padding: 6px 15px; padding: 6px 15px;
@@ -397,6 +405,33 @@
text-align: center; text-align: center;
} }
/* Intermediate breakpoint: wrap controls-right to prevent overflow */
@media (max-width: 1500px) {
.actions {
flex-wrap: wrap;
gap: var(--space-2);
}
.action-buttons {
flex-wrap: wrap;
gap: var(--space-1);
}
.controls-right {
width: 100%;
justify-content: flex-end;
margin-top: 8px;
padding-left: 0;
}
/* Reduce button sizes to fit better */
.control-group button {
min-width: 80px;
padding: 4px 8px;
font-size: 0.8em;
}
}
@media (max-width: 768px) { @media (max-width: 768px) {
.actions { .actions {
flex-wrap: wrap; flex-wrap: wrap;

View File

@@ -129,6 +129,126 @@ export class HeaderManager {
// Hide search functionality on Statistics page // Hide search functionality on Statistics page
this.updateHeaderForPage(); this.updateHeaderForPage();
// Initialize hamburger menu for mobile
this.initializeHamburgerMenu();
}
initializeHamburgerMenu() {
const hamburgerBtn = document.getElementById('hamburgerMenuBtn');
const hamburgerDropdown = document.getElementById('hamburgerDropdown');
if (!hamburgerBtn || !hamburgerDropdown) return;
// Toggle dropdown on hamburger button click
hamburgerBtn.addEventListener('click', (e) => {
e.stopPropagation();
hamburgerDropdown.classList.toggle('active');
const icon = hamburgerBtn.querySelector('i');
if (hamburgerDropdown.classList.contains('active')) {
icon.classList.remove('fa-bars');
icon.classList.add('fa-times');
} else {
icon.classList.remove('fa-times');
icon.classList.add('fa-bars');
}
});
// Handle dropdown item clicks
const dropdownItems = hamburgerDropdown.querySelectorAll('.dropdown-item');
dropdownItems.forEach(item => {
item.addEventListener('click', (e) => {
const action = item.dataset.action;
this.handleHamburgerAction(action);
hamburgerDropdown.classList.remove('active');
const icon = hamburgerBtn.querySelector('i');
icon.classList.remove('fa-times');
icon.classList.add('fa-bars');
});
});
// Close dropdown when clicking outside
document.addEventListener('click', (e) => {
if (!hamburgerDropdown.contains(e.target) && !hamburgerBtn.contains(e.target)) {
hamburgerDropdown.classList.remove('active');
const icon = hamburgerBtn.querySelector('i');
if (icon) {
icon.classList.remove('fa-times');
icon.classList.add('fa-bars');
}
}
});
// Update theme icon in hamburger menu based on current theme
this.updateHamburgerThemeIcon();
}
handleHamburgerAction(action) {
switch (action) {
case 'theme':
if (typeof toggleTheme === 'function') {
const newTheme = toggleTheme();
// Update theme toggle in header if it exists
const themeToggle = document.querySelector('.theme-toggle');
if (themeToggle) {
themeToggle.classList.remove('theme-light', 'theme-dark', 'theme-auto');
themeToggle.classList.add(`theme-${newTheme}`);
this.updateThemeTooltip(themeToggle, newTheme);
}
this.updateHamburgerThemeIcon();
}
break;
case 'settings':
if (window.settingsManager) {
window.settingsManager.toggleSettings();
}
break;
case 'help':
const helpToggle = document.getElementById('helpToggleBtn');
if (helpToggle) {
helpToggle.click();
}
break;
case 'notifications':
updateService.toggleUpdateModal();
break;
case 'support':
if (window.modalManager) {
window.modalManager.toggleModal('supportModal');
renderSupporters().catch(error => {
console.error('Error loading supporters:', error);
});
}
break;
}
}
updateHamburgerThemeIcon() {
const themeItem = document.querySelector('.dropdown-item[data-action="theme"]');
if (!themeItem) return;
const currentTheme = getStorageItem('theme') || 'auto';
const icon = themeItem.querySelector('i');
const text = themeItem.querySelector('span');
if (icon) {
icon.classList.remove('fa-moon', 'fa-sun', 'fa-adjust');
if (currentTheme === 'light') {
icon.classList.add('fa-sun');
} else if (currentTheme === 'dark') {
icon.classList.add('fa-moon');
} else {
icon.classList.add('fa-adjust');
}
}
// Update text based on current theme
if (text) {
const key = currentTheme === 'light' ? 'header.theme.switchToDark' :
currentTheme === 'dark' ? 'header.theme.switchToLight' :
'header.theme.toggle';
updateElementAttribute(themeItem, 'aria-label', key, {}, '');
}
} }
updateHeaderForPage() { updateHeaderForPage() {

View File

@@ -181,6 +181,13 @@ function isEarlyAccessActive(version) {
} }
} }
function isDownloadAllowed(version) {
if (!version.usageControl) {
return true;
}
return version.usageControl === 'Download';
}
function buildMetaMarkup(version, options = {}) { function buildMetaMarkup(version, options = {}) {
const segments = []; const segments = [];
if (version.baseModel) { if (version.baseModel) {
@@ -230,12 +237,17 @@ function buildBadge(label, tone, options = {}) {
function buildActionButton(label, variant, action, options = {}) { function buildActionButton(label, variant, action, options = {}) {
const attributes = [ const attributes = [
`class="version-action ${variant}"`, `class="version-action ${variant}"`,
`data-version-action="${escapeHtml(action)}"`,
]; ];
if (action) {
attributes.push(`data-version-action="${escapeHtml(action)}"`);
}
if (options.title) { if (options.title) {
attributes.push(`title="${escapeHtml(options.title)}"`); attributes.push(`title="${escapeHtml(options.title)}"`);
attributes.push(`aria-label="${escapeHtml(options.title)}"`); attributes.push(`aria-label="${escapeHtml(options.title)}"`);
} }
if (options.disabled) {
attributes.push('disabled');
}
if (options.extraAttributes) { if (options.extraAttributes) {
attributes.push(options.extraAttributes); attributes.push(options.extraAttributes);
} }
@@ -371,6 +383,9 @@ function resolveUpdateAvailability(record, baseModel, currentVersionId) {
if (hideEarlyAccess && isEarlyAccessActive(version)) { if (hideEarlyAccess && isEarlyAccessActive(version)) {
return false; return false;
} }
if (!isDownloadAllowed(version)) {
return false;
}
const versionBase = normalizeBaseModelName(version.baseModel); const versionBase = normalizeBaseModelName(version.baseModel);
if (versionBase !== normalizedBase) { if (versionBase !== normalizedBase) {
return false; return false;
@@ -502,6 +517,17 @@ function renderRow(version, options) {
})); }));
} }
if (!isDownloadAllowed(version)) {
const onSiteOnlyBadgeLabel = translate('modals.model.versions.badges.onSiteOnly', {}, 'On-Site Only');
badges.push(buildBadge(onSiteOnlyBadgeLabel, 'info', {
title: translate(
'modals.model.versions.badges.onSiteOnlyTooltip',
{},
'This version is only available for on-site generation on Civitai'
),
}));
}
if (version.shouldIgnore) { if (version.shouldIgnore) {
badges.push(buildBadge(ignoredBadgeLabel, 'muted', { badges.push(buildBadge(ignoredBadgeLabel, 'muted', {
title: translate( title: translate(
@@ -524,25 +550,36 @@ function renderRow(version, options) {
const actions = []; const actions = [];
if (!version.isInLibrary) { if (!version.isInLibrary) {
// Download button with optional EA bolt icon const canDownload = isDownloadAllowed(version);
const downloadIcon = isEarlyAccess ? '<i class="fas fa-bolt"></i> ' : ''; const downloadIcon = isEarlyAccess ? '<i class="fas fa-bolt"></i> ' : '';
let downloadTitle;
if (!canDownload) {
downloadTitle = translate(
'modals.model.versions.actions.downloadNotAllowedTooltip',
{},
'This version is only available for on-site generation on Civitai'
);
} else if (isEarlyAccess) {
downloadTitle = translate(
'modals.model.versions.actions.downloadEarlyAccessTooltip',
{},
'Download this early access version from Civitai'
);
} else {
downloadTitle = translate(
'modals.model.versions.actions.downloadTooltip',
{},
'Download this version'
);
}
actions.push(buildActionButton( actions.push(buildActionButton(
downloadLabel, downloadLabel,
'version-action-primary', canDownload ? 'version-action-primary' : 'version-action-disabled',
'download', canDownload ? 'download' : '',
{ {
title: isEarlyAccess title: downloadTitle,
? translate(
'modals.model.versions.actions.downloadEarlyAccessTooltip',
{},
'Download this early access version from Civitai'
)
: translate(
'modals.model.versions.actions.downloadTooltip',
{},
'Download this version'
),
iconMarkup: downloadIcon, iconMarkup: downloadIcon,
disabled: !canDownload,
} }
)); ));
} else if (version.filePath) { } else if (version.filePath) {

View File

@@ -2,6 +2,7 @@ import { modalManager } from './ModalManager.js';
import { showToast } from '../utils/uiHelpers.js'; import { showToast } from '../utils/uiHelpers.js';
import { translate } from '../utils/i18nHelpers.js'; import { translate } from '../utils/i18nHelpers.js';
import { escapeHtml } from '../components/shared/utils.js'; import { escapeHtml } from '../components/shared/utils.js';
import { state } from '../state/index.js';
const MAX_CONSOLE_ENTRIES = 200; const MAX_CONSOLE_ENTRIES = 200;
@@ -258,6 +259,15 @@ export class DoctorManager {
} }
renderInlineDetail(detail) { renderInlineDetail(detail) {
if (detail.conflict_groups || detail.total_conflict_files) {
return `
<div class="doctor-inline-detail">
<strong>${escapeHtml(translate('doctor.status.warning', {}, 'Conflicts'))}</strong>
<div>${escapeHtml(`${detail.conflict_groups || 0} filenames, ${detail.total_conflict_files || 0} files`)}</div>
</div>
`;
}
if (detail.client_version || detail.server_version) { if (detail.client_version || detail.server_version) {
return ` return `
<div class="doctor-inline-detail"> <div class="doctor-inline-detail">
@@ -317,6 +327,9 @@ export class DoctorManager {
case 'repair-cache': case 'repair-cache':
await this.repairCache(); await this.repairCache();
break; break;
case 'resolve-filename-conflicts':
await this.resolveFilenameConflicts();
break;
case 'reload-page': case 'reload-page':
this.reloadUi(); this.reloadUi();
break; break;
@@ -345,6 +358,47 @@ export class DoctorManager {
} }
} }
async resolveFilenameConflicts() {
try {
this.setLoading(true);
const response = await fetch('/api/lm/doctor/resolve-filename-conflicts', { method: 'POST' });
const payload = await response.json();
if (!response.ok || payload.success === false) {
throw new Error(payload.error || 'Failed to resolve filename conflicts.');
}
const renamedCount = payload.count || 0;
showToast(
'doctor.toast.conflictsResolved',
{ count: renamedCount },
'success'
);
// Update scroller items so model cards reflect new filenames immediately
if (state.virtualScroller && payload.renamed) {
for (const renamed of payload.renamed) {
const baseName = renamed.new_filename.replace(/\.[^.]+$/, '');
state.virtualScroller.updateSingleItem(renamed.old_path, {
file_name: baseName,
file_path: renamed.new_path,
});
}
}
await this.refreshDiagnostics({ silent: true });
} catch (error) {
console.error('Doctor filename conflict resolution failed:', error);
showToast(
'doctor.toast.conflictsResolveFailed',
{ message: error.message },
'error'
);
} finally {
this.setLoading(false);
}
}
async exportBundle() { async exportBundle() {
try { try {
this.setLoading(true); this.setLoading(true);

View File

@@ -599,7 +599,7 @@ export class FilterManager {
// Call the appropriate manager's load method based on page type // Call the appropriate manager's load method based on page type
if (this.currentPage === 'recipes' && window.recipeManager) { if (this.currentPage === 'recipes' && window.recipeManager) {
await window.recipeManager.loadRecipes(true); await window.recipeManager.loadRecipes({ preserveScroll: true });
} else if (this.currentPage === 'loras' || this.currentPage === 'embeddings' || this.currentPage === 'checkpoints') { } else if (this.currentPage === 'loras' || this.currentPage === 'embeddings' || this.currentPage === 'checkpoints') {
// For models page, reset the page and reload // For models page, reset the page and reload
await getModelApiClient().loadMoreWithVirtualScroll(true, false); await getModelApiClient().loadMoreWithVirtualScroll(true, false);
@@ -682,7 +682,7 @@ export class FilterManager {
// Reload data using the appropriate method for the current page // Reload data using the appropriate method for the current page
if (this.currentPage === 'recipes' && window.recipeManager) { if (this.currentPage === 'recipes' && window.recipeManager) {
await window.recipeManager.loadRecipes(true); await window.recipeManager.loadRecipes({ preserveScroll: true });
} else if (this.currentPage === 'loras' || this.currentPage === 'checkpoints' || this.currentPage === 'embeddings') { } else if (this.currentPage === 'loras' || this.currentPage === 'checkpoints' || this.currentPage === 'embeddings') {
await getModelApiClient().loadMoreWithVirtualScroll(true, true); await getModelApiClient().loadMoreWithVirtualScroll(true, true);
} }

View File

@@ -301,7 +301,7 @@ export class SearchManager {
// Call the appropriate manager's load method based on page type // Call the appropriate manager's load method based on page type
if (this.currentPage === 'recipes' && window.recipeManager) { if (this.currentPage === 'recipes' && window.recipeManager) {
window.recipeManager.loadRecipes(true); // true to reset pagination window.recipeManager.loadRecipes({ preserveScroll: true });
} else if (this.currentPage === 'loras' || this.currentPage === 'embeddings' || this.currentPage === 'checkpoints') { } else if (this.currentPage === 'loras' || this.currentPage === 'embeddings' || this.currentPage === 'checkpoints') {
// For models page, reset the page and reload // For models page, reset the page and reload
getModelApiClient().loadMoreWithVirtualScroll(true, false); getModelApiClient().loadMoreWithVirtualScroll(true, false);

View File

@@ -2863,7 +2863,7 @@ export class SettingsManager {
await resetAndReload(false); await resetAndReload(false);
} else if (this.currentPage === 'recipes') { } else if (this.currentPage === 'recipes') {
// Reload the recipes without updating folders // Reload the recipes without updating folders
await window.recipeManager.loadRecipes(); await window.recipeManager.loadRecipes({ preserveScroll: true });
} else if (this.currentPage === 'checkpoints') { } else if (this.currentPage === 'checkpoints') {
// Reload the checkpoints without updating folders // Reload the checkpoints without updating folders
await resetAndReload(false); await resetAndReload(false);

View File

@@ -19,7 +19,7 @@ class RecipePageControls {
} }
async resetAndReload() { async resetAndReload() {
refreshVirtualScroll(); await refreshVirtualScroll({ preserveScroll: true });
} }
async refreshModels(fullRebuild = false) { async refreshModels(fullRebuild = false) {

View File

@@ -104,69 +104,74 @@ export class VirtualScroller {
// Get display density setting // Get display density setting
const displayDensity = state.global.settings?.display_density || 'default'; const displayDensity = state.global.settings?.display_density || 'default';
// Set exact column counts and grid widths to match CSS container widths // Base gap between cards
let maxColumns, maxGridWidth; const baseGap = 12;
this.columnGap = baseGap;
// Match exact column counts and CSS container width values based on density // Define minimum card width based on density setting to ensure usability
// Cards smaller than this become hard to interact with and view
const minCardWidths = {
'default': 240, // Default: comfortable minimum
'medium': 200, // Medium: slightly smaller
'compact': 170 // Compact: smallest usable size
};
const minCardWidth = minCardWidths[displayDensity] || 240;
// Calculate maximum possible columns that fit in available width
// Formula: maxColumns = floor((availableWidth + gap) / (minCardWidth + gap))
const maxPossibleColumns = Math.floor((availableContentWidth + this.columnGap) / (minCardWidth + this.columnGap));
// Ensure at least 1 column
const maxColumns = Math.max(1, maxPossibleColumns);
// Define preferred maximum columns based on display density and screen size
// These are upper limits to prevent too many columns on ultra-wide screens
let preferredMaxColumns;
if (window.innerWidth >= 3000) { // 4K if (window.innerWidth >= 3000) { // 4K
if (displayDensity === 'default') { if (displayDensity === 'default') {
maxColumns = 8; preferredMaxColumns = 8;
} else if (displayDensity === 'medium') { } else if (displayDensity === 'medium') {
maxColumns = 9; preferredMaxColumns = 10;
} else { // compact } else { // compact
maxColumns = 10; preferredMaxColumns = 12;
} }
maxGridWidth = 2400; // Match exact CSS container width for 4K
} else if (window.innerWidth >= 2150) { // 2K/1440p } else if (window.innerWidth >= 2150) { // 2K/1440p
if (displayDensity === 'default') { if (displayDensity === 'default') {
maxColumns = 6; preferredMaxColumns = 6;
} else if (displayDensity === 'medium') { } else if (displayDensity === 'medium') {
maxColumns = 7; preferredMaxColumns = 8;
} else { // compact } else { // compact
maxColumns = 8; preferredMaxColumns = 10;
} }
maxGridWidth = 1800; // Match exact CSS container width for 2K } else { // 1080p and smaller
} else {
// 1080p
if (displayDensity === 'default') { if (displayDensity === 'default') {
maxColumns = 5; preferredMaxColumns = 5;
} else if (displayDensity === 'medium') { } else if (displayDensity === 'medium') {
maxColumns = 6; preferredMaxColumns = 6;
} else { // compact } else { // compact
maxColumns = 7; preferredMaxColumns = 8;
} }
maxGridWidth = 1400; // Match exact CSS container width for 1080p
} }
// Calculate baseCardWidth based on desired column count and available space // Use the smaller of: max columns that fit, or preferred max
// Formula: (maxGridWidth - (columns-1)*gap) / columns // This ensures cards are never smaller than minCardWidth
const baseCardWidth = (maxGridWidth - ((maxColumns - 1) * this.columnGap)) / maxColumns; this.columnsCount = Math.min(maxColumns, preferredMaxColumns);
// Use the smaller of available content width or max grid width // Calculate card width to perfectly fill available space
const actualGridWidth = Math.min(availableContentWidth, maxGridWidth); // Formula: (availableWidth - totalGap) / columns
const totalGap = (this.columnsCount - 1) * this.columnGap;
this.itemWidth = (availableContentWidth - totalGap) / this.columnsCount;
// Set exact column count based on screen size and mode // Calculate height based on aspect ratio (896/1152)
this.columnsCount = maxColumns;
// When available width is smaller than maxGridWidth, recalculate columns
if (availableContentWidth < maxGridWidth) {
// Calculate how many columns can fit in the available space
this.columnsCount = Math.max(1, Math.floor(
(availableContentWidth + this.columnGap) / (baseCardWidth + this.columnGap)
));
}
// Calculate actual item width
this.itemWidth = (actualGridWidth - (this.columnsCount - 1) * this.columnGap) / this.columnsCount;
// Calculate height based on aspect ratio
this.itemHeight = this.itemWidth / this.itemAspectRatio; this.itemHeight = this.itemWidth / this.itemAspectRatio;
// Calculate the left offset to center the grid within the content area // Edge-to-edge layout: no offset, grid fills container
this.leftOffset = Math.max(0, (availableContentWidth - actualGridWidth) / 2); this.leftOffset = 0;
const actualGridWidth = this.itemWidth * this.columnsCount + totalGap;
// Update grid element max-width to match available width // Update grid element to fill available width
this.gridElement.style.maxWidth = `${actualGridWidth}px`; this.gridElement.style.maxWidth = `${actualGridWidth}px`;
this.gridElement.style.width = `${actualGridWidth}px`;
// Add or remove density classes for style adjustments // Add or remove density classes for style adjustments
this.gridElement.classList.remove('default-density', 'medium-density', 'compact-density'); this.gridElement.classList.remove('default-density', 'medium-density', 'compact-density');
@@ -478,6 +483,12 @@ export class VirtualScroller {
element.style.width = `${this.itemWidth}px`; element.style.width = `${this.itemWidth}px`;
element.style.height = `${this.itemHeight}px`; element.style.height = `${this.itemHeight}px`;
// Remove max-width constraint from model-card to allow dynamic sizing
const modelCard = element.querySelector('.model-card');
if (modelCard) {
modelCard.style.maxWidth = 'none';
}
return element; return element;
} }

View File

@@ -28,6 +28,7 @@
{% block content %} {% block content %}
{% include 'components/controls.html' %} {% include 'components/controls.html' %}
{% include 'components/breadcrumb.html' %}
{% include 'components/duplicates_banner.html' %} {% include 'components/duplicates_banner.html' %}
{% include 'components/folder_sidebar.html' %} {% include 'components/folder_sidebar.html' %}

View File

@@ -0,0 +1,5 @@
<div id="breadcrumbContainer" class="sidebar-breadcrumb-container">
<nav class="sidebar-breadcrumb-nav" id="sidebarBreadcrumbNav">
<!-- Breadcrumbs will be populated by JavaScript -->
</nav>
</div>

View File

@@ -129,11 +129,4 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Breadcrumb Navigation -->
<div id="breadcrumbContainer" class="sidebar-breadcrumb-container">
<nav class="sidebar-breadcrumb-nav" id="sidebarBreadcrumbNav">
<!-- Breadcrumbs will be populated by JavaScript -->
</nav>
</div>
</div> </div>

View File

@@ -1,50 +1,53 @@
<header class="app-header"> <header class="app-header">
<div class="header-container"> <div class="header-container">
<div class="header-branding"> <!-- Left section: Logo + Navigation -->
<a href="/loras" class="logo-link"> <div class="header-left">
<img src="/loras_static/images/favicon-32x32.png" alt="LoRA Manager" class="app-logo"> <div class="header-branding">
<span class="app-title">{{ t('header.appTitle') }}</span> <a href="/loras" class="logo-link">
</a> <img src="/loras_static/images/favicon-32x32.png" alt="LoRA Manager" class="app-logo">
<span class="app-title">{{ t('header.appTitle') }}</span>
</a>
</div>
{% set current_path = request.path %}
{% if current_path.startswith('/loras/recipes') %}
{% set current_page = 'recipes' %}
{% elif current_path.startswith('/checkpoints') %}
{% set current_page = 'checkpoints' %}
{% elif current_path.startswith('/embeddings') %}
{% set current_page = 'embeddings' %}
{% elif current_path.startswith('/statistics') %}
{% set current_page = 'statistics' %}
{% else %}
{% set current_page = 'loras' %}
{% endif %}
<nav class="main-nav">
<a href="/loras" class="nav-item{% if current_path == '/loras' %} active{% endif %}" id="lorasNavItem">
<i class="fas fa-layer-group"></i> <span>{{ t('header.navigation.loras') }}</span>
</a>
<a href="/loras/recipes" class="nav-item{% if current_path.startswith('/loras/recipes') %} active{% endif %}"
id="recipesNavItem">
<i class="fas fa-book-open"></i> <span>{{ t('header.navigation.recipes') }}</span>
</a>
<a href="/checkpoints" class="nav-item{% if current_path.startswith('/checkpoints') %} active{% endif %}"
id="checkpointsNavItem">
<i class="fas fa-check-circle"></i> <span>{{ t('header.navigation.checkpoints') }}</span>
</a>
<a href="/embeddings" class="nav-item{% if current_path.startswith('/embeddings') %} active{% endif %}"
id="embeddingsNavItem">
<i class="fas fa-code"></i> <span>{{ t('header.navigation.embeddings') }}</span>
</a>
<a href="/statistics" class="nav-item{% if current_path.startswith('/statistics') %} active{% endif %}"
id="statisticsNavItem">
<i class="fas fa-chart-bar"></i> <span>{{ t('header.navigation.statistics') }}</span>
</a>
</nav>
</div> </div>
{% set current_path = request.path %}
{% if current_path.startswith('/loras/recipes') %} <!-- Center section: Search -->
{% set current_page = 'recipes' %}
{% elif current_path.startswith('/checkpoints') %}
{% set current_page = 'checkpoints' %}
{% elif current_path.startswith('/embeddings') %}
{% set current_page = 'embeddings' %}
{% elif current_path.startswith('/statistics') %}
{% set current_page = 'statistics' %}
{% else %}
{% set current_page = 'loras' %}
{% endif %}
{% set search_disabled = current_page == 'statistics' %} {% set search_disabled = current_page == 'statistics' %}
{% set search_placeholder_key = 'header.search.notAvailable' if search_disabled else 'header.search.placeholders.' ~ {% set search_placeholder_key = 'header.search.notAvailable' if search_disabled else 'header.search.placeholders.' ~
current_page %} current_page %}
{% set header_search_class = 'header-search disabled' if search_disabled else 'header-search' %} {% set header_search_class = 'header-search disabled' if search_disabled else 'header-search' %}
<nav class="main-nav">
<a href="/loras" class="nav-item{% if current_path == '/loras' %} active{% endif %}" id="lorasNavItem">
<i class="fas fa-layer-group"></i> <span>{{ t('header.navigation.loras') }}</span>
</a>
<a href="/loras/recipes" class="nav-item{% if current_path.startswith('/loras/recipes') %} active{% endif %}"
id="recipesNavItem">
<i class="fas fa-book-open"></i> <span>{{ t('header.navigation.recipes') }}</span>
</a>
<a href="/checkpoints" class="nav-item{% if current_path.startswith('/checkpoints') %} active{% endif %}"
id="checkpointsNavItem">
<i class="fas fa-check-circle"></i> <span>{{ t('header.navigation.checkpoints') }}</span>
</a>
<a href="/embeddings" class="nav-item{% if current_path.startswith('/embeddings') %} active{% endif %}"
id="embeddingsNavItem">
<i class="fas fa-code"></i> <span>{{ t('header.navigation.embeddings') }}</span>
</a>
<a href="/statistics" class="nav-item{% if current_path.startswith('/statistics') %} active{% endif %}"
id="statisticsNavItem">
<i class="fas fa-chart-bar"></i> <span>{{ t('header.navigation.statistics') }}</span>
</a>
</nav>
<!-- Context-aware search container -->
<div class="{{ header_search_class }}" id="headerSearch"> <div class="{{ header_search_class }}" id="headerSearch">
<div class="search-container"> <div class="search-container">
<input type="text" id="searchInput" placeholder="{{ t(search_placeholder_key) }}" {% if search_disabled %} <input type="text" id="searchInput" placeholder="{{ t(search_placeholder_key) }}" {% if search_disabled %}
@@ -62,9 +65,9 @@
</div> </div>
</div> </div>
<div class="header-actions"> <!-- Right section: Controls -->
<!-- Integrated corner controls --> <div class="header-right">
<div class="header-controls"> <div class="header-controls" id="headerControls">
<div class="theme-toggle" title="{{ t('header.theme.toggle') }}"> <div class="theme-toggle" title="{{ t('header.theme.toggle') }}">
<i class="fas fa-moon dark-icon"></i> <i class="fas fa-moon dark-icon"></i>
<i class="fas fa-sun light-icon"></i> <i class="fas fa-sun light-icon"></i>
@@ -85,6 +88,34 @@
<i class="fas fa-heart"></i> <i class="fas fa-heart"></i>
</div> </div>
</div> </div>
<!-- Hamburger menu button (visible on mobile) -->
<button class="hamburger-menu-btn" id="hamburgerMenuBtn" title="{{ t('common.actions.menu') }}">
<i class="fas fa-bars"></i>
</button>
<!-- Hamburger dropdown menu -->
<div class="hamburger-dropdown" id="hamburgerDropdown">
<div class="dropdown-item theme-toggle-item" data-action="theme">
<i class="fas fa-moon"></i>
<span>{{ t('header.theme.toggle') }}</span>
</div>
<div class="dropdown-item" data-action="settings">
<i class="fas fa-cog"></i>
<span>{{ t('common.actions.settings') }}</span>
</div>
<div class="dropdown-item" data-action="help">
<i class="fas fa-question-circle"></i>
<span>{{ t('common.actions.help') }}</span>
</div>
<div class="dropdown-item" data-action="notifications">
<i class="fas fa-bell"></i>
<span>{{ t('header.actions.notifications') }}</span>
</div>
<div class="dropdown-divider"></div>
<div class="dropdown-item" data-action="support">
<i class="fas fa-heart"></i>
<span>{{ t('header.actions.support') }}</span>
</div>
</div>
</div> </div>
</div> </div>
</header> </header>

View File

@@ -26,6 +26,7 @@
{% block content %} {% block content %}
{% include 'components/controls.html' %} {% include 'components/controls.html' %}
{% include 'components/breadcrumb.html' %}
{% include 'components/duplicates_banner.html' %} {% include 'components/duplicates_banner.html' %}
{% include 'components/folder_sidebar.html' %} {% include 'components/folder_sidebar.html' %}

View File

@@ -9,6 +9,7 @@
{% block content %} {% block content %}
{% include 'components/controls.html' %} {% include 'components/controls.html' %}
{% include 'components/breadcrumb.html' %}
{% include 'components/duplicates_banner.html' %} {% include 'components/duplicates_banner.html' %}
{% include 'components/folder_sidebar.html' %} {% include 'components/folder_sidebar.html' %}

View File

@@ -10,6 +10,7 @@ from unittest.mock import patch, MagicMock
import pytest import pytest
from aiohttp import web from aiohttp import web
from py.services.model_hash_index import ModelHashIndex
from py.routes.handlers.misc_handlers import ( from py.routes.handlers.misc_handlers import (
BackupHandler, BackupHandler,
DoctorHandler, DoctorHandler,
@@ -78,10 +79,11 @@ async def dummy_downloader_factory():
class DummyDoctorScanner: class DummyDoctorScanner:
def __init__(self, *, model_type='lora', raw_data=None, rebuild_error=None): def __init__(self, *, model_type='lora', raw_data=None, rebuild_error=None, hash_index=None):
self.model_type = model_type self.model_type = model_type
self._raw_data = list(raw_data or []) self._raw_data = list(raw_data or [])
self._rebuild_error = rebuild_error self._rebuild_error = rebuild_error
self._hash_index = hash_index
self._persistent_cache = SimpleNamespace( self._persistent_cache = SimpleNamespace(
load_cache=lambda _model_type: SimpleNamespace(raw_data=list(self._raw_data)) load_cache=lambda _model_type: SimpleNamespace(raw_data=list(self._raw_data))
) )
@@ -91,6 +93,16 @@ class DummyDoctorScanner:
raise self._rebuild_error raise self._rebuild_error
return SimpleNamespace(raw_data=list(self._raw_data)) return SimpleNamespace(raw_data=list(self._raw_data))
async def update_single_model_cache(self, original_path, new_path, metadata):
for item in self._raw_data:
if item.get("file_path") == original_path:
item["file_path"] = new_path
item["file_name"] = metadata.get("file_name", item.get("file_name", ""))
if metadata.get("preview_url"):
item["preview_url"] = metadata["preview_url"]
break
return True
class DummyCivitaiClient: class DummyCivitaiClient:
def __init__(self, *, success=True, result=None): def __init__(self, *, success=True, result=None):
@@ -1582,3 +1594,107 @@ def test_wsl_to_windows_path_returns_none_on_subprocess_error(tmp_path):
): ):
result = _wsl_to_windows_path("/mnt/c/test") result = _wsl_to_windows_path("/mnt/c/test")
assert result is None assert result is None
# ── DoctorHandler filename conflict tests ──────────────────────────────
@pytest.mark.asyncio
async def test_check_filename_conflicts_returns_ok_when_no_duplicates():
hash_index = ModelHashIndex()
async def scanner_factory():
return DummyDoctorScanner(
model_type="lora", raw_data=[], hash_index=hash_index
)
handler = DoctorHandler(
settings_service=DummySettings({"civitai_api_key": "token"}),
scanner_factories=(("lora", "LoRAs", scanner_factory),),
)
response = await handler.get_doctor_diagnostics(FakeRequest(method="GET"))
payload = json.loads(response.text)
diagnostic_map = {item["id"]: item for item in payload["diagnostics"]}
assert diagnostic_map["filename_conflicts"]["status"] == "ok"
assert "No duplicate filenames" in diagnostic_map["filename_conflicts"]["summary"]
@pytest.mark.asyncio
async def test_check_filename_conflicts_detects_duplicates():
hash_index = ModelHashIndex()
hash_index.add_entry("abc123", "/a/lora.safetensors")
hash_index.add_entry("def456", "/b/lora.safetensors")
async def scanner_factory():
return DummyDoctorScanner(
model_type="lora",
raw_data=[
{"file_path": "/a/lora.safetensors"},
{"file_path": "/b/lora.safetensors"},
],
hash_index=hash_index,
)
handler = DoctorHandler(
settings_service=DummySettings({"civitai_api_key": "token"}),
scanner_factories=(("lora", "LoRAs", scanner_factory),),
)
response = await handler.get_doctor_diagnostics(FakeRequest(method="GET"))
payload = json.loads(response.text)
diagnostic_map = {item["id"]: item for item in payload["diagnostics"]}
conflict_diag = diagnostic_map["filename_conflicts"]
assert conflict_diag["status"] == "warning"
assert "1 filename(s)" in conflict_diag["summary"]
assert any("resolve-filename-conflicts" in str(a) for a in conflict_diag.get("actions", []))
@pytest.mark.asyncio
async def test_resolve_filename_conflicts_returns_renamed_list():
hash_index = ModelHashIndex()
hash_index.add_entry("abc123", "lora.safetensors")
hash_index.add_entry("def456", "lora.safetensors")
async def scanner_factory():
return DummyDoctorScanner(
model_type="lora",
raw_data=[],
hash_index=hash_index,
)
handler = DoctorHandler(
settings_service=DummySettings({"civitai_api_key": "token"}),
scanner_factories=(("lora", "LoRAs", scanner_factory),),
)
response = await handler.resolve_filename_conflicts(FakeRequest(method="POST"))
payload = json.loads(response.text)
assert payload["success"] is True
# Files don't exist on disk, so nothing gets renamed
assert payload["count"] == 0
@pytest.mark.asyncio
async def test_resolve_filename_conflicts_handles_scanner_error_gracefully():
class ErrorScanner:
model_type = "lora"
async def get_cached_data(self):
raise RuntimeError("scanner unavailable")
async def scanner_factory():
return ErrorScanner()
handler = DoctorHandler(
settings_service=DummySettings({"civitai_api_key": "token"}),
scanner_factories=(("lora", "LoRAs", scanner_factory),),
)
response = await handler.resolve_filename_conflicts(FakeRequest(method="POST"))
payload = json.loads(response.text)
assert payload["success"] is True
assert payload["count"] == 0

View File

@@ -0,0 +1,113 @@
import pytest
from py.services.model_hash_index import ModelHashIndex
class TestModelHashIndexRemoveByPath:
def test_remove_by_path_finds_hash_in_hash_to_path(self):
index = ModelHashIndex()
index.add_entry("abc123", "/models/lora.safetensors")
index.remove_by_path("/models/lora.safetensors")
assert len(index) == 0
assert not index.get_duplicate_filenames()
def test_remove_by_path_falls_back_to_duplicate_hashes(self):
"""When a path is only tracked in _duplicate_hashes, remove_by_path
should still find and remove it."""
index = ModelHashIndex()
index.add_entry("abc123", "/models/lora_v1.safetensors")
index.add_entry("abc123", "/models/lora_v2.safetensors")
# lora_v1 is the primary (_hash_to_path), lora_v2 is in _duplicate_hashes
index.remove_by_path("/models/lora_v2.safetensors")
assert len(index) == 1
assert index._hash_to_path.get("abc123") == "/models/lora_v1.safetensors"
assert "abc123" not in index._duplicate_hashes
def test_remove_by_path_cleans_up_duplicate_filenames(self):
"""After removing a path, _duplicate_filenames should be updated."""
index = ModelHashIndex()
index.add_entry("abc123", "/models/mylora.safetensors")
index.add_entry("def456", "/other/mylora.safetensors")
assert "mylora" in index.get_duplicate_filenames()
assert len(index.get_duplicate_filenames()["mylora"]) == 2
index.remove_by_path("/other/mylora.safetensors")
# After removing one duplicate, only one path remains — no longer a duplicate
assert "mylora" not in index.get_duplicate_filenames()
def test_remove_by_path_keeps_duplicate_filenames_with_three_entries(self):
"""With 3 entries for the same filename, removing one should leave 2."""
index = ModelHashIndex()
index.add_entry("abc123", "/models/mylora.safetensors")
index.add_entry("def456", "/other/mylora.safetensors")
index.add_entry("ghi789", "/third/mylora.safetensors")
index.remove_by_path("/other/mylora.safetensors")
assert "mylora" in index.get_duplicate_filenames()
paths = index.get_duplicate_filenames()["mylora"]
assert len(paths) == 2
assert "/other/mylora.safetensors" not in paths
def test_remove_by_path_noop_on_unknown_path(self):
index = ModelHashIndex()
index.add_entry("abc123", "/models/lora.safetensors")
# Should not raise
index.remove_by_path("/nonexistent/lora.safetensors")
assert len(index) == 1
def test_remove_by_path_handles_hash_from_duplicate_hashes_only(self):
"""Remove a path whose hash exists ONLY in _duplicate_hashes,
not in _hash_to_path (edge case from index rebuilds)."""
index = ModelHashIndex()
index.add_entry("abc123", "/a/model.safetensors")
index.add_entry("abc123", "/b/model.safetensors")
# Manually remove the primary entry to simulate edge case
del index._hash_to_path["abc123"]
# Now the path is only referenced in _duplicate_hashes
assert "abc123" in index._duplicate_hashes
index.remove_by_path("/b/model.safetensors")
# The remaining path is promoted to _hash_to_path, duplicates cleared
assert "abc123" not in index._duplicate_hashes
assert index._hash_to_path.get("abc123") == "/a/model.safetensors"
class TestModelHashIndexGetDuplicateFilenames:
def test_empty_index_returns_empty_dict(self):
index = ModelHashIndex()
assert index.get_duplicate_filenames() == {}
def test_no_duplicates_returns_empty_dict(self):
index = ModelHashIndex()
index.add_entry("abc123", "/models/lora.safetensors")
index.add_entry("def456", "/models/other.safetensors")
assert index.get_duplicate_filenames() == {}
def test_duplicate_filenames_detected(self):
index = ModelHashIndex()
index.add_entry("abc123", "/a/mylora.safetensors")
index.add_entry("def456", "/b/mylora.safetensors")
dupes = index.get_duplicate_filenames()
assert "mylora" in dupes
assert len(dupes["mylora"]) == 2
def test_same_hash_same_name_not_a_filename_duplicate(self):
"""Same hash with same filename = hash duplicate, not filename conflict."""
index = ModelHashIndex()
index.add_entry("abc123", "/a/lora.safetensors")
# Same hash, same filename — this is a true duplicate (hash collision)
# but the filename index only tracks different files with same name
# Currently add_entry for same hash+path would update, not create duplicate
# This is correct behavior — filename dupes are for different files
def test_add_entry_idempotent_for_same_path_and_hash(self):
index = ModelHashIndex()
index.add_entry("abc123", "/a/lora.safetensors")
index.add_entry("abc123", "/a/lora.safetensors")
assert len(index) == 1
assert index.get_duplicate_filenames() == {}

View File

@@ -624,3 +624,42 @@ async def test_reconcile_cache_removes_duplicate_alias_when_same_real_file_seen_
cache = await scanner.get_cached_data() cache = await scanner.get_cached_data()
cached_paths = {item["file_path"] for item in cache.raw_data} cached_paths = {item["file_path"] for item in cache.raw_data}
assert cached_paths == {_normalize_path(loras_root / "link" / "one.txt")} assert cached_paths == {_normalize_path(loras_root / "link" / "one.txt")}
@pytest.mark.asyncio
async def test_log_duplicate_filename_summary_logs_warning(tmp_path: Path, caplog):
"""When duplicate filenames exist, _log_duplicate_filename_summary should emit
a single warning log with the conflict count and total file count."""
import logging
caplog.set_level(logging.WARNING)
root = tmp_path / "loras"
root.mkdir()
scanner = DummyScanner(root)
# Simulate duplicate filenames in the hash index
scanner._hash_index.add_entry("aaa111", str(root / "model.safetensors"))
scanner._hash_index.add_entry("bbb222", str(root / "dir" / "model.safetensors"))
scanner._log_duplicate_filename_summary()
assert len(caplog.records) >= 1
log_msg = caplog.records[-1].message
assert "Duplicate filename conflict detected" in log_msg
assert "1 dummy filename(s)" in log_msg
assert "2 files total" in log_msg
@pytest.mark.asyncio
async def test_log_duplicate_filename_summary_silent_when_no_duplicates(tmp_path: Path, caplog):
import logging
caplog.set_level(logging.WARNING)
root = tmp_path / "loras"
root.mkdir()
scanner = DummyScanner(root)
scanner._log_duplicate_filename_summary()
# No warning should be logged when there are no duplicates
for record in caplog.records:
assert "Duplicate filename conflict detected" not in record.message

View File

@@ -232,9 +232,13 @@ export function initDrag(
onDragEnd(); onDragEnd();
} }
// Now do the re-render after drag is complete // Commit final value through options.setValue so external observers are notified.
if (renderFunction) { // During drag, handleStrengthDrag mutates widgetValue in-place (updateWidget=false),
renderFunction(widget.value, widget); // bypassing widget.value setter and options.setValue entirely. This assignment
// flushes the in-place mutation through the setter so any setValue wrappers fire.
widget.value = widget.value;
if (typeof widget.callback === 'function') {
widget.callback(widget.value);
} }
} }
}; };
@@ -349,8 +353,12 @@ export function initHeaderDrag(headerEl, widget, renderFunction) {
document.body.classList.remove('lm-lora-strength-dragging'); document.body.classList.remove('lm-lora-strength-dragging');
// Only re-render if we actually dragged // Only re-render if we actually dragged
if (wasDragging && renderFunction) { if (wasDragging) {
renderFunction(widget.value, widget); // Commit final value through options.setValue so external observers are notified.
widget.value = widget.value;
if (typeof widget.callback === 'function') {
widget.callback(widget.value);
}
} }
}; };

View File

@@ -413,7 +413,7 @@ app.registerExtension({
const savedItem = consumeQueuedState(itemState, itemText); const savedItem = consumeQueuedState(itemState, itemText);
return { return {
text: itemText, text: itemText,
active: savedItem ? savedItem.active : defaultActive, active: savedItem ? savedItem.active : true,
highlighted: false, highlighted: false,
strength: null, strength: null,
}; };

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 597 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 872 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 362 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 400 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 639 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 529 KiB