Compare commits

..

25 Commits

Author SHA1 Message Date
Will Miao
6d5b4b7312 fix(test): update drag interaction test to match 454210a4's renderFunction→setValue change
Commit 454210a4 replaced renderFunction() with widget.value setter +
widget.callback() in endDrag, so the test assertion should verify
callback invocation instead of the removed renderSpy call.
2026-05-07 11:03:38 +08:00
Will Miao
7803bd542d feat(base-models): add Ernie, Ernie Turbo, Nucleus base model types (#922)
- Ernie & Anima: auto-fetched via CivitaiBaseModelService from Civitai API
- Ernie Turbo & Nucleus: pre-added as hardcoded constants (not yet in Civitai API)
- Added abbreviations (ERNI, ETRB, NUCL) and category entries across all layers
2026-05-07 10:49:01 +08:00
Will Miao
f0a86dbbc0 feat(bulk): add bulk favorite/unfavorite toggle with context-sensitive single menu item
Replaces two separate menu items with a single smart item that dynamically
switches between 'Set as Favorite' and 'Remove from Favorites' based on
whether all selected items are already favorited. Shows a count badge
'(3/5)' when only some items are favorited in a mixed selection.

Supports all model types (LoRA, Checkpoint, Embedding) and recipes via
existing per-item save/update API — no backend changes needed.
2026-05-07 09:51:23 +08:00
Will Miao
682e964f89 fix(usage-control): enrich usageControl from CivitAI by-hash API for all model types
The model-level API (GET /api/v1/models/{id}) does not include usageControl
on version entries, causing generation-only models to show as downloadable.

Backend changes:
- Add get_model_versions_by_hashes() to CivitaiClient (POST by-hash batch)
- Propagate through all provider classes including RateLimitRetryingProvider
- Add _enrich_version_entries() pipeline: extract SHA256 from files[].hashes,
  batch-call by-hash endpoint, inject usageControl+earlyAccessEndsAt in-place
- Wire enrichment into both bulk (_fetch_model_versions_bulk) and individual
  (_refresh_single_model) refresh paths
- Fix _build_record_from_remote dropping usage_control field
- Fix POST by-hash request format (plain JSON array, not {hashes:[...]} object)

Frontend changes:
- Fix disabled download button tooltip: wrap in <span> since HTML title
  attribute does not fire on disabled elements
2026-05-07 08:56:19 +08:00
Will Miao
908464bc0a docs: remove inline release notes from README (now maintained via GitHub Releases) 2026-05-06 22:40:06 +08:00
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
69 changed files with 2408 additions and 902 deletions

136
README.md

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -15,7 +15,8 @@
"settings": "Settings",
"help": "Help",
"add": "Add",
"close": "Close"
"close": "Close",
"menu": "Menu"
},
"status": {
"loading": "Loading...",
@@ -686,6 +687,9 @@
"autoOrganize": "Auto-Organize Selected",
"skipMetadataRefresh": "Skip Metadata Refresh for Selected",
"resumeMetadataRefresh": "Resume Metadata Refresh for Selected",
"setFavorite": "Set as Favorite",
"setFavoriteCount": "Set as Favorite ({favorited}/{total})",
"unfavorite": "Remove from Favorites",
"deleteAll": "Delete Selected",
"downloadMissingLoras": "Download Missing LoRAs",
"clear": "Clear Selection",
@@ -1292,12 +1296,15 @@
"earlyAccess": "Early Access",
"earlyAccessTooltip": "This version currently requires Civitai early access",
"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": {
"download": "Download",
"downloadTooltip": "Download this version",
"downloadEarlyAccessTooltip": "Download this early access version from Civitai",
"downloadNotAllowedTooltip": "This version is only available for on-site generation on Civitai",
"delete": "Delete",
"deleteTooltip": "Delete this local version",
"ignore": "Ignore",
@@ -1695,6 +1702,11 @@
"bulkContentRatingSet": "Set content rating to {level} for {count} model(s)",
"bulkContentRatingPartial": "Set content rating to {level} for {success} model(s), {failed} failed",
"bulkContentRatingFailed": "Failed to update content rating for selected models",
"bulkFavoriteUpdating": "Adding {count} model(s) to favorites...",
"bulkUnfavoriteUpdating": "Removing {count} model(s) from favorites...",
"bulkFavoritePartialAdded": "Added {success} model(s) to favorites, {failed} failed",
"bulkFavoritePartialRemoved": "Removed {success} model(s) from favorites, {failed} failed",
"bulkFavoriteFailed": "Failed to update favorite status for selected models",
"bulkUpdatesChecking": "Checking selected {type}(s) for updates...",
"bulkUpdatesSuccess": "Updates available for {count} selected {type}(s)",
"bulkUpdatesNone": "No updates found for selected {type}(s)",

View File

@@ -15,7 +15,8 @@
"settings": "Configuración",
"help": "Ayuda",
"add": "Añadir",
"close": "Cerrar"
"close": "Cerrar",
"menu": "Menú"
},
"status": {
"loading": "Cargando...",
@@ -686,6 +687,9 @@
"autoOrganize": "Auto-organizar seleccionados",
"skipMetadataRefresh": "Omitir actualización de metadatos para seleccionados",
"resumeMetadataRefresh": "Reanudar actualización de metadatos para seleccionados",
"setFavorite": "Marcar como favorito",
"setFavoriteCount": "Marcar como favorito ({favorited}/{total})",
"unfavorite": "Quitar de favoritos",
"deleteAll": "Eliminar seleccionados",
"downloadMissingLoras": "Descargar LoRAs faltantes",
"clear": "Limpiar selección",
@@ -1292,12 +1296,15 @@
"earlyAccess": "Acceso temprano",
"earlyAccessTooltip": "Esta versión requiere actualmente acceso temprano de Civitai",
"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": {
"download": "Descargar",
"downloadTooltip": "Descargar esta versión",
"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",
"deleteTooltip": "Eliminar esta versión local",
"ignore": "Ignorar",
@@ -1695,6 +1702,11 @@
"bulkContentRatingSet": "Clasificación de contenido establecida en {level} para {count} modelo(s)",
"bulkContentRatingPartial": "Clasificación de contenido establecida en {level} para {success} modelo(s), {failed} fallaron",
"bulkContentRatingFailed": "No se pudo actualizar la clasificación de contenido para los modelos seleccionados",
"bulkFavoriteUpdating": "Añadiendo {count} modelo(s) a favoritos...",
"bulkUnfavoriteUpdating": "Eliminando {count} modelo(s) de favoritos...",
"bulkFavoritePartialAdded": "{success} modelo(s) añadido(s) a favoritos, {failed} fallido(s)",
"bulkFavoritePartialRemoved": "{success} modelo(s) eliminado(s) de favoritos, {failed} fallido(s)",
"bulkFavoriteFailed": "Error al actualizar el estado de favorito",
"bulkUpdatesChecking": "Comprobando actualizaciones para {type} seleccionados...",
"bulkUpdatesSuccess": "Actualizaciones disponibles para {count} {type} seleccionados",
"bulkUpdatesNone": "No se encontraron actualizaciones para los {type} seleccionados",

View File

@@ -15,7 +15,8 @@
"settings": "Paramètres",
"help": "Aide",
"add": "Ajouter",
"close": "Fermer"
"close": "Fermer",
"menu": "Menu"
},
"status": {
"loading": "Chargement...",
@@ -686,6 +687,9 @@
"autoOrganize": "Auto-organiser la sélection",
"skipMetadataRefresh": "Ignorer l'actualisation des métadonnées pour la sélection",
"resumeMetadataRefresh": "Reprendre l'actualisation des métadonnées pour la sélection",
"setFavorite": "Définir comme favori",
"setFavoriteCount": "Définir comme favori ({favorited}/{total})",
"unfavorite": "Retirer des favoris",
"deleteAll": "Supprimer la sélection",
"downloadMissingLoras": "Télécharger les LoRAs manquants",
"clear": "Effacer la sélection",
@@ -1292,12 +1296,15 @@
"earlyAccess": "Accès anticipé",
"earlyAccessTooltip": "Cette version nécessite actuellement l'accès anticipé Civitai",
"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": {
"download": "Télécharger",
"downloadTooltip": "Télécharger cette version",
"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",
"deleteTooltip": "Supprimer cette version locale",
"ignore": "Ignorer",
@@ -1695,6 +1702,11 @@
"bulkContentRatingSet": "Classification du contenu définie sur {level} pour {count} modèle(s)",
"bulkContentRatingPartial": "Classification du contenu définie sur {level} pour {success} modèle(s), {failed} échec(s)",
"bulkContentRatingFailed": "Impossible de mettre à jour la classification du contenu pour les modèles sélectionnés",
"bulkFavoriteUpdating": "Ajout de {count} modèle(s) aux favoris...",
"bulkUnfavoriteUpdating": "Suppression de {count} modèle(s) des favoris...",
"bulkFavoritePartialAdded": "{success} modèle(s) ajouté(s) aux favoris, {failed} échec(s)",
"bulkFavoritePartialRemoved": "{success} modèle(s) retiré(s) des favoris, {failed} échec(s)",
"bulkFavoriteFailed": "Échec de la mise à jour du statut de favori",
"bulkUpdatesChecking": "Vérification des mises à jour pour les {type} sélectionnés...",
"bulkUpdatesSuccess": "Mises à jour disponibles pour {count} {type} sélectionnés",
"bulkUpdatesNone": "Aucune mise à jour trouvée pour les {type} sélectionnés",

View File

@@ -15,7 +15,8 @@
"settings": "הגדרות",
"help": "עזרה",
"add": "הוספה",
"close": "סגור"
"close": "סגור",
"menu": "תפריט"
},
"status": {
"loading": "טוען...",
@@ -686,6 +687,9 @@
"autoOrganize": "ארגן אוטומטית נבחרים",
"skipMetadataRefresh": "דילוג על רענון מטא-נתונים לנבחרים",
"resumeMetadataRefresh": "המשך רענון מטא-נתונים לנבחרים",
"setFavorite": "הגדר כמועדף",
"setFavoriteCount": "הגדר כמועדף ({favorited}/{total})",
"unfavorite": "הסר ממועדפים",
"deleteAll": "מחק נבחרים",
"downloadMissingLoras": "הורדת LoRAs חסרים",
"clear": "נקה בחירה",
@@ -1292,12 +1296,15 @@
"earlyAccess": "גישה מוקדמת",
"earlyAccessTooltip": "גרסה זו דורשת כרגע גישת Early Access של Civitai",
"ignored": "התעלם",
"ignoredTooltip": "התראות העדכון מושבתות עבור גרסה זו"
"ignoredTooltip": "התראות העדכון מושבתות עבור גרסה זו",
"onSiteOnly": "רק באתר",
"onSiteOnlyTooltip": "גרסה זו זמינה רק ליצירה באתר Civitai"
},
"actions": {
"download": "הורדה",
"downloadTooltip": "הורד את הגרסה הזו",
"downloadEarlyAccessTooltip": "הורד את גרסת ה-Early Access הזו מ-Civitai",
"downloadNotAllowedTooltip": "גרסה זו זמינה רק ליצירה באתר Civitai",
"delete": "מחיקה",
"deleteTooltip": "מחק את הגרסה המקומית הזו",
"ignore": "התעלם",
@@ -1695,6 +1702,11 @@
"bulkContentRatingSet": "דירוג התוכן הוגדר ל-{level} עבור {count} מודלים",
"bulkContentRatingPartial": "דירוג התוכן הוגדר ל-{level} עבור {success} מודלים, {failed} נכשלו",
"bulkContentRatingFailed": "עדכון דירוג התוכן עבור המודלים שנבחרו נכשל",
"bulkFavoriteUpdating": "מוסיף {count} דגמים למועדפים...",
"bulkUnfavoriteUpdating": "מסיר {count} דגמים ממועדפים...",
"bulkFavoritePartialAdded": "{success} דגמים נוספו למועדפים, {failed} נכשלו",
"bulkFavoritePartialRemoved": "{success} דגמים הוסרו ממועדפים, {failed} נכשלו",
"bulkFavoriteFailed": "עדכון סטטוס מועדפים נכשל",
"bulkUpdatesChecking": "בודק עדכונים עבור {type} שנבחרו...",
"bulkUpdatesSuccess": "יש עדכונים עבור {count} {type} שנבחרו",
"bulkUpdatesNone": "לא נמצאו עדכונים עבור {type} שנבחרו",

View File

@@ -15,7 +15,8 @@
"settings": "設定",
"help": "ヘルプ",
"add": "追加",
"close": "閉じる"
"close": "閉じる",
"menu": "メニュー"
},
"status": {
"loading": "読み込み中...",
@@ -686,6 +687,9 @@
"autoOrganize": "自動整理を実行",
"skipMetadataRefresh": "選択したモデルのメタデータ更新をスキップ",
"resumeMetadataRefresh": "選択したモデルのメタデータ更新を再開",
"setFavorite": "お気に入りに設定",
"setFavoriteCount": "お気に入りに設定 ({favorited}/{total})",
"unfavorite": "お気に入りから削除",
"deleteAll": "選択したものを削除",
"downloadMissingLoras": "不足している LoRA をダウンロード",
"clear": "選択をクリア",
@@ -1292,12 +1296,15 @@
"earlyAccess": "早期アクセス",
"earlyAccessTooltip": "このバージョンは現在 Civitai の早期アクセスが必要です",
"ignored": "無視中",
"ignoredTooltip": "このバージョンの更新通知は無効です"
"ignoredTooltip": "このバージョンの更新通知は無効です",
"onSiteOnly": "サイト内のみ",
"onSiteOnlyTooltip": "このバージョンはCivitaiサイト内でのみ利用可能で、ダウンロードはできません"
},
"actions": {
"download": "ダウンロード",
"downloadTooltip": "このバージョンをダウンロード",
"downloadEarlyAccessTooltip": "Civitai からこの早期アクセス版をダウンロード",
"downloadNotAllowedTooltip": "このバージョンはCivitaiサイト内でのみ利用可能で、ダウンロードはできません",
"delete": "削除",
"deleteTooltip": "このローカルバージョンを削除",
"ignore": "無視",
@@ -1695,6 +1702,11 @@
"bulkContentRatingSet": "{count} 件のモデルのコンテンツレーティングを {level} に設定しました",
"bulkContentRatingPartial": "{success} 件のモデルのコンテンツレーティングを {level} に設定、{failed} 件は失敗しました",
"bulkContentRatingFailed": "選択したモデルのコンテンツレーティングを更新できませんでした",
"bulkFavoriteUpdating": "{count} 個のモデルをお気に入りに追加中...",
"bulkUnfavoriteUpdating": "{count} 個のモデルをお気に入りから削除中...",
"bulkFavoritePartialAdded": "{success} 個のモデルをお気に入りに追加、{failed} 個失敗",
"bulkFavoritePartialRemoved": "{success} 個のモデルをお気に入りから削除、{failed} 個失敗",
"bulkFavoriteFailed": "お気に入り状態の更新に失敗しました",
"bulkUpdatesChecking": "選択された{type}の更新を確認しています...",
"bulkUpdatesSuccess": "{count} 件の選択された{type}に利用可能な更新があります",
"bulkUpdatesNone": "選択された{type}には更新が見つかりませんでした",

View File

@@ -15,7 +15,8 @@
"settings": "설정",
"help": "도움말",
"add": "추가",
"close": "닫기"
"close": "닫기",
"menu": "메뉴"
},
"status": {
"loading": "로딩 중...",
@@ -686,6 +687,9 @@
"autoOrganize": "자동 정리 선택",
"skipMetadataRefresh": "선택한 모델의 메타데이터 새로고침 건너뛰기",
"resumeMetadataRefresh": "선택한 모델의 메타데이터 새로고침 재개",
"setFavorite": "즐겨찾기로 설정",
"setFavoriteCount": "즐겨찾기로 설정 ({favorited}/{total})",
"unfavorite": "즐겨찾기 해제",
"deleteAll": "선택된 항목 삭제",
"downloadMissingLoras": "누락된 LoRA 다운로드",
"clear": "선택 지우기",
@@ -1292,12 +1296,15 @@
"earlyAccess": "얼리 액세스",
"earlyAccessTooltip": "이 버전은 현재 Civitai 얼리 액세스가 필요합니다",
"ignored": "무시됨",
"ignoredTooltip": "이 버전은 업데이트 알림이 비활성화되어 있습니다"
"ignoredTooltip": "이 버전은 업데이트 알림이 비활성화되어 있습니다",
"onSiteOnly": "사이트 내 전용",
"onSiteOnlyTooltip": "이 버전은 Civitai 사이트 내에서만 사용 가능하며 다운로드할 수 없습니다"
},
"actions": {
"download": "다운로드",
"downloadTooltip": "이 버전 다운로드",
"downloadEarlyAccessTooltip": "Civitai에서 이 얼리 액세스 버전 다운로드",
"downloadNotAllowedTooltip": "이 버전은 Civitai 사이트 내에서만 사용 가능하며 다운로드할 수 없습니다",
"delete": "삭제",
"deleteTooltip": "이 로컬 버전 삭제",
"ignore": "무시",
@@ -1695,6 +1702,11 @@
"bulkContentRatingSet": "{count}개 모델의 콘텐츠 등급을 {level}(으)로 설정했습니다",
"bulkContentRatingPartial": "{success}개 모델의 콘텐츠 등급을 {level}(으)로 설정했고, {failed}개는 실패했습니다",
"bulkContentRatingFailed": "선택한 모델의 콘텐츠 등급을 업데이트하지 못했습니다",
"bulkFavoriteUpdating": "{count}개 모델을 즐겨찾기에 추가 중...",
"bulkUnfavoriteUpdating": "{count}개 모델을 즐겨찾기에서 제거 중...",
"bulkFavoritePartialAdded": "{success}개 모델을 즐겨찾기에 추가, {failed}개 실패",
"bulkFavoritePartialRemoved": "{success}개 모델을 즐겨찾기에서 제거, {failed}개 실패",
"bulkFavoriteFailed": "즐겨찾기 상태 업데이트 실패",
"bulkUpdatesChecking": "선택한 {type}의 업데이트를 확인하는 중...",
"bulkUpdatesSuccess": "선택한 {count}개의 {type}에 사용할 수 있는 업데이트가 있습니다",
"bulkUpdatesNone": "선택한 {type}에 대한 업데이트가 없습니다",

View File

@@ -15,7 +15,8 @@
"settings": "Настройки",
"help": "Справка",
"add": "Добавить",
"close": "Закрыть"
"close": "Закрыть",
"menu": "Меню"
},
"status": {
"loading": "Загрузка...",
@@ -686,6 +687,9 @@
"autoOrganize": "Автоматически организовать выбранные",
"skipMetadataRefresh": "Пропустить обновление метаданных для выбранных",
"resumeMetadataRefresh": "Возобновить обновление метаданных для выбранных",
"setFavorite": "Добавить в избранное",
"setFavoriteCount": "Добавить в избранное ({favorited}/{total})",
"unfavorite": "Удалить из избранного",
"deleteAll": "Удалить выбранные",
"downloadMissingLoras": "Скачать отсутствующие LoRAs",
"clear": "Очистить выбор",
@@ -1292,12 +1296,15 @@
"earlyAccess": "Ранний доступ",
"earlyAccessTooltip": "Для этой версии сейчас требуется ранний доступ Civitai",
"ignored": "Игнорируется",
"ignoredTooltip": "Уведомления об обновлениях для этой версии отключены"
"ignoredTooltip": "Уведомления об обновлениях для этой версии отключены",
"onSiteOnly": "Только на Сайте",
"onSiteOnlyTooltip": "Эта версия доступна только для генерации на сайте Civitai"
},
"actions": {
"download": "Скачать",
"downloadTooltip": "Скачать эту версию",
"downloadEarlyAccessTooltip": "Скачать эту версию раннего доступа с Civitai",
"downloadNotAllowedTooltip": "Эта версия доступна только для генерации на сайте Civitai",
"delete": "Удалить",
"deleteTooltip": "Удалить эту локальную версию",
"ignore": "Игнорировать",
@@ -1695,6 +1702,11 @@
"bulkContentRatingSet": "Рейтинг контента установлен на {level} для {count} модель(ей)",
"bulkContentRatingPartial": "Рейтинг контента {level} установлен для {success} модель(ей), {failed} не удалось",
"bulkContentRatingFailed": "Не удалось обновить рейтинг контента для выбранных моделей",
"bulkFavoriteUpdating": "Добавление {count} моделей в избранное...",
"bulkUnfavoriteUpdating": "Удаление {count} моделей из избранного...",
"bulkFavoritePartialAdded": "{success} моделей добавлено в избранное, {failed} не удалось",
"bulkFavoritePartialRemoved": "{success} моделей удалено из избранного, {failed} не удалось",
"bulkFavoriteFailed": "Не удалось обновить статус избранного",
"bulkUpdatesChecking": "Проверка обновлений для выбранных {type}...",
"bulkUpdatesSuccess": "Доступны обновления для {count} выбранных {type}",
"bulkUpdatesNone": "Обновления для выбранных {type} не найдены",

View File

@@ -15,7 +15,8 @@
"settings": "设置",
"help": "帮助",
"add": "添加",
"close": "关闭"
"close": "关闭",
"menu": "菜单"
},
"status": {
"loading": "加载中...",
@@ -686,6 +687,9 @@
"autoOrganize": "自动整理所选模型",
"skipMetadataRefresh": "跳过所选模型的元数据刷新",
"resumeMetadataRefresh": "恢复所选模型的元数据刷新",
"setFavorite": "设为收藏",
"setFavoriteCount": "设为收藏 ({favorited}/{total})",
"unfavorite": "取消收藏",
"deleteAll": "删除已选",
"downloadMissingLoras": "下载缺失的 LoRAs",
"clear": "清除选择",
@@ -1292,12 +1296,15 @@
"earlyAccess": "抢先体验",
"earlyAccessTooltip": "此版本当前需要 Civitai 抢先体验权限",
"ignored": "已忽略",
"ignoredTooltip": "此版本已关闭更新通知"
"ignoredTooltip": "此版本已关闭更新通知",
"onSiteOnly": "仅站内生成",
"onSiteOnlyTooltip": "此版本仅在 Civitai 站内可用,无法下载"
},
"actions": {
"download": "下载",
"downloadTooltip": "下载此版本",
"downloadEarlyAccessTooltip": "从 Civitai 下载此抢先体验版本",
"downloadNotAllowedTooltip": "此版本仅在 Civitai 站内可用,无法下载",
"delete": "删除",
"deleteTooltip": "删除此本地版本",
"ignore": "忽略",
@@ -1695,6 +1702,11 @@
"bulkContentRatingSet": "已将 {count} 个模型的内容评级设置为 {level}",
"bulkContentRatingPartial": "已将 {success} 个模型的内容评级设置为 {level}{failed} 个失败",
"bulkContentRatingFailed": "未能更新所选模型的内容评级",
"bulkFavoriteUpdating": "正在将 {count} 个模型添加到收藏...",
"bulkUnfavoriteUpdating": "正在将 {count} 个模型从收藏移除...",
"bulkFavoritePartialAdded": "已将 {success} 个模型添加到收藏,{failed} 个失败",
"bulkFavoritePartialRemoved": "已将 {success} 个模型从收藏移除,{failed} 个失败",
"bulkFavoriteFailed": "更新收藏状态失败",
"bulkUpdatesChecking": "正在检查所选 {type} 的更新...",
"bulkUpdatesSuccess": "{count} 个所选 {type} 有可用更新",
"bulkUpdatesNone": "所选 {type} 未发现更新",

View File

@@ -15,7 +15,8 @@
"settings": "設定",
"help": "說明",
"add": "新增",
"close": "關閉"
"close": "關閉",
"menu": "選單"
},
"status": {
"loading": "載入中...",
@@ -686,6 +687,9 @@
"autoOrganize": "自動整理所選模型",
"skipMetadataRefresh": "跳過所選模型的元數據更新",
"resumeMetadataRefresh": "恢復所選模型的元數據更新",
"setFavorite": "設為收藏",
"setFavoriteCount": "設為收藏 ({favorited}/{total})",
"unfavorite": "取消收藏",
"deleteAll": "刪除所選",
"downloadMissingLoras": "下載缺失的 LoRAs",
"clear": "清除選取",
@@ -1292,12 +1296,15 @@
"earlyAccess": "搶先體驗",
"earlyAccessTooltip": "此版本目前需要 Civitai 搶先體驗權限",
"ignored": "已忽略",
"ignoredTooltip": "此版本已關閉更新通知"
"ignoredTooltip": "此版本已關閉更新通知",
"onSiteOnly": "僅站內生成",
"onSiteOnlyTooltip": "此版本僅在 Civitai 站內可用,無法下載"
},
"actions": {
"download": "下載",
"downloadTooltip": "下載此版本",
"downloadEarlyAccessTooltip": "從 Civitai 下載此搶先體驗版本",
"downloadNotAllowedTooltip": "此版本僅在 Civitai 站內可用,無法下載",
"delete": "刪除",
"deleteTooltip": "刪除此本地版本",
"ignore": "忽略",
@@ -1695,6 +1702,11 @@
"bulkContentRatingSet": "已將 {count} 個模型的內容分級設定為 {level}",
"bulkContentRatingPartial": "已將 {success} 個模型的內容分級設定為 {level}{failed} 個失敗",
"bulkContentRatingFailed": "無法更新所選模型的內容分級",
"bulkFavoriteUpdating": "正在將 {count} 個模型加入收藏...",
"bulkUnfavoriteUpdating": "正在將 {count} 個模型從收藏移除...",
"bulkFavoritePartialAdded": "已將 {success} 個模型加入收藏,{failed} 個失敗",
"bulkFavoritePartialRemoved": "已將 {success} 個模型從收藏移除,{failed} 個失敗",
"bulkFavoriteFailed": "更新收藏狀態失敗",
"bulkUpdatesChecking": "正在檢查所選 {type} 的更新...",
"bulkUpdatesSuccess": "{count} 個所選 {type} 有可用更新",
"bulkUpdatesNone": "所選 {type} 未找到更新",

View File

@@ -560,8 +560,14 @@ class MetadataProcessor:
params["loras"] = " ".join(lora_parts)
# Set default clip_skip value
params["clip_skip"] = "1" # Common default
# Extract clip_skip from any SAMPLING node that provides it
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

View File

@@ -144,6 +144,118 @@ class TSCCheckpointLoaderExtractor(NodeMetadataExtractor):
metadata[PROMPTS][node_id]["positive_encoded"] = positive_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):
@staticmethod
def extract(node_id, inputs, outputs, metadata):
@@ -1013,9 +1125,12 @@ NODE_EXTRACTORS = {
"KSamplerSelect": KSamplerSelectExtractor, # Add KSamplerSelect
"BasicScheduler": BasicSchedulerExtractor, # Add BasicScheduler
"AlignYourStepsScheduler": BasicSchedulerExtractor, # Add AlignYourStepsScheduler
# ComfyUI-Easy-Use pre-sampling / seed
"samplerSettings": EasyPreSamplingExtractor, # easy preSampling
"easySeed": EasySeedExtractor, # easy seed
# Loaders
"CheckpointLoaderSimple": CheckpointLoaderExtractor,
"comfyLoader": CheckpointLoaderExtractor, # easy comfyLoader
"comfyLoader": EasyComfyLoaderExtractor, # ComfyUI-Easy-Use easy comfyLoader
"CheckpointLoaderSimpleWithImages": CheckpointLoaderExtractor, # CheckpointLoader|pysssss
"TSC_EfficientLoader": TSCCheckpointLoaderExtractor, # Efficient Nodes
"NunchakuFluxDiTLoader": NunchakuFluxDiTLoaderExtractor, # ComfyUI-Nunchaku

View File

@@ -1,10 +1,22 @@
import folder_paths # type: ignore
from ..utils.utils import get_lora_info
import os
from ..utils.utils import get_lora_info_absolute
from ..config import config
from .utils import FlexibleOptionalInputType, any_type, get_loras_list
import logging
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:
NAME = "WanVideo Lora Select (LoraManager)"
CATEGORY = "Lora Manager/stackers"
@@ -56,13 +68,13 @@ class WanVideoLoraSelectLM:
clip_strength = float(lora.get('clipStrength', model_strength))
# 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
lora_item = {
"path": folder_paths.get_full_path("loras", lora_path),
"path": lora_path,
"strength": model_strength,
"name": lora_path.split(".")[0],
"name": os.path.splitext(_relpath_within_loras(lora_path))[0],
"blocks": selected_blocks,
"layer_filter": layer_filter,
"low_mem_load": low_mem_load,

View File

@@ -1,11 +1,23 @@
import folder_paths # type: ignore
from ..utils.utils import get_lora_info
import os
from ..utils.utils import get_lora_info_absolute
from ..config import config
from .utils import any_type
import logging
# 初始化日志记录器
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:
# 节点在UI中显示的名称
@@ -87,12 +99,12 @@ class WanVideoLoraTextSelectLM:
else:
continue
lora_path, trigger_words = get_lora_info(lora_name_raw)
lora_path, trigger_words = get_lora_info_absolute(lora_name_raw)
lora_item = {
"path": folder_paths.get_full_path("loras", lora_path),
"path": lora_path,
"strength": model_strength,
"name": lora_path.split(".")[0],
"name": os.path.splitext(_relpath_within_loras(lora_path))[0],
"blocks": selected_blocks,
"layer_filter": layer_filter,
"low_mem_load": low_mem_load,

View File

@@ -33,6 +33,7 @@ from ...services.metadata_service import (
update_metadata_providers,
)
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.websocket_manager import ws_manager
from ...services.downloader import get_downloader
@@ -1791,29 +1792,33 @@ class ModelLibraryHandler:
exists = True
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()
has_been_downloaded = False
history_type = model_type
if history_type:
has_been_downloaded = await history_service.has_been_downloaded(
history_type,
history_type = None
for candidate_type in ("lora", "checkpoint", "embedding"):
if await history_service.has_been_downloaded(
candidate_type,
model_version_id,
)
else:
for candidate_type in ("lora", "checkpoint", "embedding"):
if await history_service.has_been_downloaded(
candidate_type,
model_version_id,
):
has_been_downloaded = True
history_type = candidate_type
break
):
has_been_downloaded = True
history_type = candidate_type
break
return web.json_response(
{
"success": True,
"exists": exists,
"modelType": model_type if exists else history_type,
"exists": False,
"modelType": history_type,
"hasBeenDownloaded": has_been_downloaded,
}
)
@@ -1833,40 +1838,46 @@ class ModelLibraryHandler:
model_type = None
versions = []
downloaded_version_ids = []
history_service = await self._get_download_history_service()
if lora_versions:
model_type = "lora"
versions = self._with_downloaded_flag(lora_versions)
downloaded_version_ids = await history_service.get_downloaded_version_ids(
model_type,
model_id,
return web.json_response(
{
"success": True,
"modelType": "lora",
"versions": self._with_downloaded_flag(lora_versions),
"downloadedVersionIds": [],
}
)
elif checkpoint_versions:
model_type = "checkpoint"
versions = self._with_downloaded_flag(checkpoint_versions)
downloaded_version_ids = await history_service.get_downloaded_version_ids(
model_type,
model_id,
if checkpoint_versions:
return web.json_response(
{
"success": True,
"modelType": "checkpoint",
"versions": self._with_downloaded_flag(checkpoint_versions),
"downloadedVersionIds": [],
}
)
elif embedding_versions:
model_type = "embedding"
versions = self._with_downloaded_flag(embedding_versions)
downloaded_version_ids = await history_service.get_downloaded_version_ids(
model_type,
model_id,
if embedding_versions:
return web.json_response(
{
"success": True,
"modelType": "embedding",
"versions": self._with_downloaded_flag(embedding_versions),
"downloadedVersionIds": [],
}
)
else:
for candidate_type in ("lora", "checkpoint", "embedding"):
candidate_downloaded_version_ids = (
await history_service.get_downloaded_version_ids(
candidate_type,
model_id,
)
history_service = await self._get_download_history_service()
for candidate_type in ("lora", "checkpoint", "embedding"):
candidate_downloaded_version_ids = (
await history_service.get_downloaded_version_ids(
candidate_type,
model_id,
)
if candidate_downloaded_version_ids:
model_type = candidate_type
downloaded_version_ids = candidate_downloaded_version_ids
break
)
if candidate_downloaded_version_ids:
model_type = candidate_type
downloaded_version_ids = candidate_downloaded_version_ids
break
return web.json_response(
{
@@ -1880,6 +1891,86 @@ class ModelLibraryHandler:
logger.error("Failed to check model existence: %s", exc, exc_info=True)
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(
self, request: web.Request
) -> web.Response:
@@ -1992,6 +2083,78 @@ class ModelLibraryHandler:
)
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:
try:
model_id_str = request.query.get("modelId")
@@ -3025,8 +3188,10 @@ class MiscHandlerSet:
"update_node_widget": self.node_registry.update_node_widget,
"get_registry": self.node_registry.get_registry,
"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,
"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,
"download_metadata_archive": self.metadata_archive.download_metadata_archive,
"remove_metadata_archive": self.metadata_archive.remove_metadata_archive,

View File

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

View File

@@ -43,6 +43,7 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("POST", "/api/lm/update-node-widget", "update_node_widget"),
RouteDefinition("GET", "/api/lm/get-registry", "get_registry"),
RouteDefinition("GET", "/api/lm/check-model-exists", "check_model_exists"),
RouteDefinition("GET", "/api/lm/check-models-exist", "check_models_exist"),
RouteDefinition(
"GET",
"/api/lm/model-version-download-status",
@@ -90,6 +91,9 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition(
"GET", "/api/lm/base-models/cache-status", "get_base_model_cache_status"
),
RouteDefinition(
"GET", "/api/lm/delete-model-version", "delete_model_version"
),
)

View File

@@ -193,6 +193,9 @@ class CivitaiBaseModelService:
"zimageturbo": "ZIT",
"zimagebase": "ZIB",
"anima": "ANI",
"ernie": "ERNI",
"ernie turbo": "ETRB",
"nucleus": "NUCL",
"svd": "SVD",
"ltxv": "LTXV",
"ltxv2": "LTV2",
@@ -418,6 +421,9 @@ class CivitaiBaseModelService:
"Kolors",
"NoobAI",
"Anima",
"Ernie",
"Ernie Turbo",
"Nucleus",
],
}

View File

@@ -577,6 +577,59 @@ class CivitaiClient:
logger.error(error_msg)
return None
async def get_model_versions_by_hashes(
self, hashes: List[str]
) -> Optional[List[Dict]]:
"""Fetch full version details for up to 100 SHA256 hashes via the batch endpoint.
Uses POST /api/v1/model-versions/by-hash which returns full version
details including ``usageControl`` and ``earlyAccessEndsAt`` that are
not available from the model-level API.
Args:
hashes: List of SHA256 hashes (max 100 per batch; auto-split).
Returns:
List of version dicts or None on failure.
"""
if not hashes:
return []
BATCH_SIZE = 100
all_versions: List[Dict] = []
for start in range(0, len(hashes), BATCH_SIZE):
batch = hashes[start : start + BATCH_SIZE]
try:
success, result = await self._make_request(
"POST",
f"{self.base_url}/model-versions/by-hash",
use_auth=True,
json=batch,
)
if not success:
logger.warning(
"Batch by-hash request failed for %d hashes: %s",
len(batch),
result,
)
continue
if isinstance(result, list):
all_versions.extend(result)
else:
logger.debug(
"Unexpected by-hash response type: %s", type(result)
)
except RateLimitError:
raise
except Exception as exc: # pragma: no cover - defensive logging
logger.error(
"Error fetching model versions by hashes: %s", exc
)
return all_versions if all_versions else None
async def get_user_models(self, username: str) -> Optional[List[Dict]]:
"""Fetch all models for a specific Civitai user."""
if not username:

View File

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

View File

@@ -109,6 +109,18 @@ class ModelMetadataProvider(ABC):
"""Fetch model versions for multiple model ids when supported."""
raise NotImplementedError
async def get_model_versions_by_hashes(
self, hashes: List[str]
) -> Optional[List[Dict]]:
"""Fetch full version details for multiple SHA256 hashes.
Used specifically to retrieve ``usageControl`` which is only
available from the per-version / by-hash API, not from model-level
responses. Providers that cannot resolve hashes should let the
default ``NotImplementedError`` propagate.
"""
raise NotImplementedError
@abstractmethod
async def get_model_version(self, model_id: int = None, version_id: int = None) -> Optional[Dict]:
"""Get specific model version with additional metadata"""
@@ -141,6 +153,11 @@ class CivitaiModelMetadataProvider(ModelMetadataProvider):
) -> Optional[Dict[int, Dict]]:
return await self.client.get_model_versions_bulk(model_ids)
async def get_model_versions_by_hashes(
self, hashes: List[str]
) -> Optional[List[Dict]]:
return await self.client.get_model_versions_by_hashes(hashes)
async def get_model_version(self, model_id: int = None, version_id: int = None) -> Optional[Dict]:
return await self.client.get_model_version(model_id, version_id)
@@ -519,6 +536,32 @@ class FallbackMetadataProvider(ModelMetadataProvider):
continue
return None, "No provider could retrieve the data"
async def get_model_versions_by_hashes(
self, hashes: List[str]
) -> Optional[List[Dict]]:
for provider, label in self._iter_providers():
try:
result = await self._call_with_rate_limit(
label,
provider.get_model_versions_by_hashes,
hashes,
)
if result is not None:
return result
except NotImplementedError:
continue
except RateLimitError as exc:
exc.provider = exc.provider or label
raise exc
except Exception as e:
logger.debug(
"Provider %s failed for get_model_versions_by_hashes: %s",
label,
e,
)
continue
return None
async def get_user_models(self, username: str) -> Optional[List[Dict]]:
for provider, label in self._iter_providers():
try:
@@ -593,6 +636,15 @@ class RateLimitRetryingProvider(ModelMetadataProvider):
model_ids,
)
async def get_model_versions_by_hashes(
self, hashes: List[str]
) -> Optional[List[Dict]]:
return await self._rate_limit_helper.run(
self._label,
self._provider.get_model_versions_by_hashes,
hashes,
)
async def get_model_version(self, model_id: int = None, version_id: int = None) -> Optional[Dict]:
return await self._rate_limit_helper.run(
self._label,
@@ -669,6 +721,17 @@ class ModelMetadataProviderManager:
provider = self._get_provider(provider_name)
return await provider.get_model_version_info(version_id)
async def get_model_versions_by_hashes(
self,
hashes: List[str],
provider_name: str = None,
) -> Optional[List[Dict]]:
provider = self._get_provider(provider_name)
try:
return await provider.get_model_versions_by_hashes(hashes)
except NotImplementedError:
return None
async def get_user_models(self, username: str, provider_name: str = None) -> Optional[List[Dict]]:
"""Fetch models owned by the specified user"""
provider = self._get_provider(provider_name)

View File

@@ -69,6 +69,7 @@ class ModelVersionRecord:
early_access_ends_at: Optional[str] = None
sort_index: int = 0
is_early_access: bool = False
usage_control: Optional[str] = None # "Download", "Generation", "InternalGeneration"
@dataclass
@@ -101,11 +102,14 @@ class ModelUpdateRecord:
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.
Args:
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:
@@ -121,6 +125,7 @@ class ModelUpdateRecord:
not version.is_in_library
and not version.should_ignore
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
)
@@ -129,6 +134,8 @@ class ModelUpdateRecord:
continue
if hide_early_access and ModelUpdateRecord._is_early_access_active(version):
continue
if hide_non_downloadable and not ModelUpdateRecord._is_downloadable(version):
continue
if version.version_id > max_in_library:
return True
return False
@@ -155,11 +162,18 @@ class ModelUpdateRecord:
# Phase 1: Basic EA flag from bulk API
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(
self,
local_version_id: Optional[int],
local_base_model: Optional[str],
hide_early_access: bool = False,
hide_non_downloadable: bool = True,
) -> bool:
"""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_base_model: The base model to filter by.
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:
@@ -197,6 +212,8 @@ class ModelUpdateRecord:
continue
if hide_early_access and ModelUpdateRecord._is_early_access_active(version):
continue
if hide_non_downloadable and not ModelUpdateRecord._is_downloadable(version):
continue
version_base = _normalize_base_model(version.base_model)
if version_base != normalized_base:
continue
@@ -230,6 +247,7 @@ class ModelUpdateService:
preview_url TEXT,
is_in_library INTEGER NOT NULL DEFAULT 0,
should_ignore INTEGER NOT NULL DEFAULT 0,
usage_control TEXT,
PRIMARY KEY (model_id, version_id),
FOREIGN KEY(model_id) REFERENCES model_update_status(model_id) ON DELETE CASCADE
);
@@ -465,6 +483,10 @@ class ModelUpdateService:
"ALTER TABLE model_update_versions "
"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():
@@ -967,6 +989,11 @@ class ModelUpdateService:
fallback_attempted = True
try:
response = await metadata_provider.get_model_versions(model_id)
if response is not None:
await self._enrich_version_entries(
metadata_provider,
{model_id: response},
)
except RateLimitError:
raise
except ResourceNotFoundError as exc:
@@ -1061,6 +1088,136 @@ class ModelUpdateService:
self._upsert_record(record)
return record
async def _enrich_version_entries(
self,
metadata_provider,
responses_by_model_id: Dict[int, Mapping],
) -> None:
"""Enrich version entries with ``usageControl`` via batch hash endpoint.
The model-level API does not include ``usageControl`` on version
entries. This method collects SHA256 hashes from every version's
primary model file, calls ``POST /api/v1/model-versions/by-hash``
(up to 100 hashes per request), and injects ``usageControl`` +
``earlyAccessEndsAt`` into each version entry dict in-place.
"""
if not metadata_provider or not responses_by_model_id:
return
hashes_by_version: Dict[int, str] = {}
for response in responses_by_model_id.values():
hashes_by_version.update(
self._collect_hashes_from_response(response)
)
if not hashes_by_version:
return
version_ids_by_hash: Dict[str, List[int]] = {}
for version_id, sha256 in hashes_by_version.items():
version_ids_by_hash.setdefault(sha256, []).append(version_id)
all_hashes = list(version_ids_by_hash.keys())
BATCH_SIZE = 100
enrichment: Dict[int, Dict] = {}
try:
for start in range(0, len(all_hashes), BATCH_SIZE):
batch = all_hashes[start : start + BATCH_SIZE]
try:
enriched = await metadata_provider.get_model_versions_by_hashes(
batch
)
except NotImplementedError:
return
except RateLimitError:
raise
except Exception:
continue
if not enriched:
continue
for entry in enriched:
if not isinstance(entry, dict):
continue
version_id = entry.get("id")
if version_id is None:
continue
enrichment[version_id] = {
"usageControl": _normalize_string(
entry.get("usageControl")
),
"earlyAccessEndsAt": _normalize_string(
entry.get("earlyAccessEndsAt")
),
}
except RateLimitError:
raise
if not enrichment:
return
for response in responses_by_model_id.values():
versions = response.get("modelVersions")
if not isinstance(versions, list):
continue
for version in versions:
if not isinstance(version, dict):
continue
version_id = version.get("id")
if version_id not in enrichment:
continue
extra = enrichment[version_id]
if extra.get("usageControl") and not version.get("usageControl"):
version["usageControl"] = extra["usageControl"]
if extra.get("earlyAccessEndsAt") and not version.get(
"earlyAccessEndsAt"
):
version["earlyAccessEndsAt"] = extra["earlyAccessEndsAt"]
@staticmethod
def _collect_hashes_from_response(response: Mapping) -> Dict[int, str]:
"""Extract ``{version_id: sha256}`` from a model-level API response.
Returns an empty dict if the response structure is unexpected.
"""
result: Dict[int, str] = {}
versions = response.get("modelVersions")
if not isinstance(versions, list):
return result
for entry in versions:
if not isinstance(entry, dict):
continue
version_id = _normalize_int(entry.get("id"))
if version_id is None:
continue
sha256 = ModelUpdateService._extract_sha256_from_version_entry(entry)
if sha256:
result[version_id] = sha256
return result
@staticmethod
def _extract_sha256_from_version_entry(entry: Mapping) -> Optional[str]:
"""Return the SHA256 hash from the primary model file of a version entry."""
files = entry.get("files")
if not isinstance(files, list):
return None
for file_info in files:
if not isinstance(file_info, dict):
continue
if file_info.get("type") != "Model":
continue
primary = file_info.get("primary")
if primary is not True and str(primary).strip().lower() != "true":
continue
hashes = file_info.get("hashes")
if isinstance(hashes, dict):
sha256 = hashes.get("SHA256")
if sha256:
return sha256
return None
async def _fetch_model_versions_bulk(
self,
metadata_provider,
@@ -1112,6 +1269,7 @@ class ModelUpdateService:
len(aggregated),
provider_name,
)
await self._enrich_version_entries(metadata_provider, aggregated)
return aggregated
async def _collect_local_versions(
@@ -1239,6 +1397,7 @@ class ModelUpdateService:
sort_index=sort_map.get(version_id, index),
early_access_ends_at=remote_version.early_access_ends_at,
is_early_access=remote_version.is_early_access,
usage_control=remote_version.usage_control,
)
)
@@ -1337,6 +1496,7 @@ class ModelUpdateService:
# Check availability field from bulk API for basic EA detection
availability = _normalize_string(entry.get("availability"))
is_early_access = availability == "EarlyAccess"
usage_control = _normalize_string(entry.get("usageControl"))
return ModelVersionRecord(
version_id=version_id,
@@ -1350,6 +1510,7 @@ class ModelUpdateService:
early_access_ends_at=early_access_ends_at,
sort_index=index,
is_early_access=is_early_access,
usage_control=usage_control,
)
def _extract_size_bytes(self, files) -> Optional[int]:
@@ -1464,7 +1625,7 @@ class ModelUpdateService:
f"""
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,
is_early_access
is_early_access, usage_control
FROM model_update_versions
WHERE model_id IN ({placeholders})
ORDER BY model_id ASC, sort_index ASC, version_id ASC
@@ -1492,6 +1653,7 @@ class ModelUpdateService:
early_access_ends_at=row["early_access_ends_at"],
sort_index=_normalize_int(row["sort_index"]) or 0,
is_early_access=bool(row["is_early_access"]),
usage_control=row["usage_control"],
)
)
@@ -1548,8 +1710,8 @@ class ModelUpdateService:
INSERT INTO model_update_versions (
version_id, model_id, sort_index, name, base_model, released_at,
size_bytes, preview_url, is_in_library, should_ignore, early_access_ends_at,
is_early_access
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
is_early_access, usage_control
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
version.version_id,
@@ -1564,6 +1726,7 @@ class ModelUpdateService:
1 if version.should_ignore else 0,
version.early_access_ends_at,
1 if version.is_early_access else 0,
version.usage_control,
),
)
conn.commit()

View File

@@ -178,5 +178,8 @@ SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS = frozenset(
"Wan Video 2.5 I2V",
"Hunyuan Video",
"Anima",
"Ernie",
"Ernie Turbo",
"Nucleus",
]
)

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 {
content: '\f00c';
font-family: 'Font Awesome 6 Free';
font-family: 'Font Awesome 6 Free', sans-serif;
font-weight: 900;
color: var(--lora-text);
font-size: 12px;

View File

@@ -22,6 +22,7 @@
transition: transform 160ms ease-out;
aspect-ratio: 896/1152; /* Preserve aspect ratio */
max-width: 260px; /* Base size */
min-width: 200px; /* Prevent cards from becoming too narrow */
width: 100%;
margin: 0 auto;
cursor: pointer;
@@ -328,7 +329,6 @@
}
.card-actions i {
margin-left: var(--space-1);
cursor: pointer;
color: white;
transition: opacity 0.2s, transform 0.15s ease;
@@ -370,7 +370,16 @@
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) {
.card-grid {
grid-template-columns: minmax(260px, 1fr); /* Adjusted minimum size for mobile */
@@ -378,6 +387,7 @@
.model-card {
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;
box-sizing: border-box;
transition: transform 160ms ease-out;
margin: 0; /* Remove margins, positioning is handled by VirtualScroller */
width: 100%; /* Allow width to be set by the VirtualScroller */
margin: 0;
width: 100%;
}
/* Allow cards to grow beyond 260px in virtual scroll mode */
.virtual-scroll-item.model-card {
max-width: none;
}
.virtual-scroll-item:hover {
@@ -576,11 +591,11 @@ body.hide-card-version .civitai-version {
.card-grid.virtual-scroll {
display: block;
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 */
height: auto;
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 */
overflow-x: hidden; /* Prevent horizontal overflow */
}

View File

@@ -22,6 +22,22 @@
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 */
@media (min-width: 2150px) {
.header-container {
@@ -77,6 +93,7 @@
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
white-space: nowrap;
}
.nav-item:hover,
@@ -97,13 +114,99 @@
color: white;
}
/* Header search */
/* Header search - Centered with VS Code command palette style */
.header-search {
flex: 1;
max-width: 400px;
display: flex;
justify-content: center;
max-width: 600px;
margin: 0 auto;
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 */
.header-search.disabled {
opacity: 0.5;
@@ -247,44 +350,207 @@
opacity: 1;
}
/* Mobile adjustments */
@media (max-width: 768px) {
.app-title {
display: none;
/* Hide text title on mobile */
/* Hamburger menu button - hidden by default */
.hamburger-menu-btn {
display: none;
width: 32px;
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 {
gap: 4px;
gap: 6px;
}
.header-controls>div {
width: 28px;
height: 28px;
.header-controls > div {
width: 30px;
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 {
max-width: none;
margin: 0 0.5rem;
margin: 0;
flex: 1;
min-width: 200px;
}
.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) {
.header-container {
padding: 0 8px;
gap: 0.4rem;
}
.main-nav {
display: none;
/* Hide navigation on very small screens */
display: flex;
gap: 0.15rem;
margin-right: 0;
}
.header-search {
flex: 1;
.nav-item {
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,11 +374,23 @@
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 {
opacity: 0.6;
cursor: not-allowed;
}
.version-action-disabled-wrapper {
display: inline-flex;
}
.versions-loading-state,
.versions-empty,
.versions-error {

View File

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

View File

@@ -271,11 +271,16 @@
/* Enhanced Sidebar Breadcrumb Styles */
.sidebar-breadcrumb-container {
margin-top: 8px;
padding: 8px 0;
border-bottom: 1px solid var(--border-color);
background: var(--bg-color);
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 {
@@ -284,7 +289,6 @@
flex-wrap: wrap;
gap: 4px;
font-size: 0.85em;
padding: 0 8px;
}
.sidebar-breadcrumb-item {

View File

@@ -21,7 +21,7 @@
top: -54px;
z-index: calc(var(--z-header) - 1);
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);
}
@@ -371,6 +371,14 @@
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 {
display: block;
padding: 6px 15px;
@@ -397,6 +405,33 @@
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) {
.actions {
flex-wrap: wrap;

View File

@@ -74,6 +74,34 @@ export class BulkContextMenu extends BaseContextMenu {
if (setContentRatingItem) {
setContentRatingItem.style.display = config.setContentRating ? 'flex' : 'none';
}
const setFavoriteItem = this.menu.querySelector('[data-action="set-favorite"]');
if (setFavoriteItem && config.setFavorite) {
setFavoriteItem.style.display = 'flex';
const total = state.selectedModels.size;
const favoritedCount = this.countFavoritedInSelection();
const allFavorited = total > 0 && favoritedCount === total;
const icon = setFavoriteItem.querySelector('i');
const label = setFavoriteItem.querySelector('span');
if (allFavorited) {
if (icon) { icon.className = 'far fa-star'; }
if (label) { label.textContent = translate('loras.bulkOperations.unfavorite'); }
} else {
if (icon) { icon.className = 'fas fa-star'; }
if (label) {
label.textContent = favoritedCount > 0
? translate('loras.bulkOperations.setFavoriteCount', { favorited: favoritedCount, total })
: translate('loras.bulkOperations.setFavorite');
}
}
} else if (setFavoriteItem) {
setFavoriteItem.style.display = 'none';
}
if (downloadMissingLorasItem) {
// Only show for recipes page
downloadMissingLorasItem.style.display = currentModelType === 'recipes' ? 'flex' : 'none';
@@ -138,6 +166,20 @@ export class BulkContextMenu extends BaseContextMenu {
return count;
}
countFavoritedInSelection() {
let count = 0;
for (const filePath of state.selectedModels) {
const escapedPath = window.CSS && typeof window.CSS.escape === 'function'
? window.CSS.escape(filePath)
: filePath.replace(/["\\]/g, '\\$&');
const card = document.querySelector(`.model-card[data-filepath="${escapedPath}"]`);
if (card && card.dataset.favorite === 'true') {
count++;
}
}
return count;
}
showMenu(x, y, card) {
this.updateMenuItemsForModelType();
this.updateSelectedCountHeader();
@@ -185,6 +227,11 @@ export class BulkContextMenu extends BaseContextMenu {
case 'delete-all':
bulkManager.showBulkDeleteModal();
break;
case 'set-favorite': {
const allFavorited = this.countFavoritedInSelection() === state.selectedModels.size;
bulkManager.setBulkFavorites(!allFavorited);
break;
}
case 'download-missing-loras':
this.handleDownloadMissingLoras();
break;

View File

@@ -129,6 +129,126 @@ export class HeaderManager {
// Hide search functionality on Statistics page
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() {

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 = {}) {
const segments = [];
if (version.baseModel) {
@@ -230,16 +237,25 @@ function buildBadge(label, tone, options = {}) {
function buildActionButton(label, variant, action, options = {}) {
const attributes = [
`class="version-action ${variant}"`,
`data-version-action="${escapeHtml(action)}"`,
];
if (options.title) {
if (action) {
attributes.push(`data-version-action="${escapeHtml(action)}"`);
}
if (!options.disabled && options.title) {
attributes.push(`title="${escapeHtml(options.title)}"`);
attributes.push(`aria-label="${escapeHtml(options.title)}"`);
}
if (options.disabled) {
attributes.push('disabled');
}
if (options.extraAttributes) {
attributes.push(options.extraAttributes);
}
return `<button ${attributes.join(' ')}>${options.iconMarkup || ''}${escapeHtml(label)}</button>`;
const buttonHtml = `<button ${attributes.join(' ')}>${options.iconMarkup || ''}${escapeHtml(label)}</button>`;
if (options.disabled && options.title) {
return `<span class="version-action-disabled-wrapper" title="${escapeHtml(options.title)}" aria-label="${escapeHtml(options.title)}">${buttonHtml}</span>`;
}
return buttonHtml;
}
const DISPLAY_FILTER_MODES = Object.freeze({
@@ -371,6 +387,9 @@ function resolveUpdateAvailability(record, baseModel, currentVersionId) {
if (hideEarlyAccess && isEarlyAccessActive(version)) {
return false;
}
if (!isDownloadAllowed(version)) {
return false;
}
const versionBase = normalizeBaseModelName(version.baseModel);
if (versionBase !== normalizedBase) {
return false;
@@ -502,6 +521,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) {
badges.push(buildBadge(ignoredBadgeLabel, 'muted', {
title: translate(
@@ -524,25 +554,36 @@ function renderRow(version, options) {
const actions = [];
if (!version.isInLibrary) {
// Download button with optional EA bolt icon
const canDownload = isDownloadAllowed(version);
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(
downloadLabel,
'version-action-primary',
'download',
canDownload ? 'version-action-primary' : 'version-action-disabled',
canDownload ? 'download' : '',
{
title: isEarlyAccess
? translate(
'modals.model.versions.actions.downloadEarlyAccessTooltip',
{},
'Download this early access version from Civitai'
)
: translate(
'modals.model.versions.actions.downloadTooltip',
{},
'Download this version'
),
title: downloadTitle,
iconMarkup: downloadIcon,
disabled: !canDownload,
}
));
} else if (version.filePath) {

View File

@@ -3,7 +3,7 @@ import { showToast, copyToClipboard, sendLoraToWorkflow, buildLoraSyntax, getNSF
import { updateCardsForBulkMode } from '../components/shared/ModelCard.js';
import { modalManager } from './ModalManager.js';
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
import { RecipeSidebarApiClient } from '../api/recipeApi.js';
import { RecipeSidebarApiClient, updateRecipeMetadata } from '../api/recipeApi.js';
import { MODEL_TYPES, MODEL_CONFIG } from '../api/apiConfig.js';
import { BASE_MODEL_CATEGORIES } from '../utils/constants.js';
import { getPriorityTagSuggestions } from '../utils/priorityTagHelpers.js';
@@ -41,7 +41,9 @@ export class BulkManager {
autoOrganize: true,
deleteAll: true,
setContentRating: true,
skipMetadataRefresh: true
skipMetadataRefresh: true,
setFavorite: true,
unfavorite: true
},
[MODEL_TYPES.EMBEDDING]: {
addTags: true,
@@ -53,7 +55,9 @@ export class BulkManager {
autoOrganize: true,
deleteAll: true,
setContentRating: false,
skipMetadataRefresh: true
skipMetadataRefresh: true,
setFavorite: true,
unfavorite: true
},
[MODEL_TYPES.CHECKPOINT]: {
addTags: true,
@@ -65,7 +69,9 @@ export class BulkManager {
autoOrganize: true,
deleteAll: true,
setContentRating: true,
skipMetadataRefresh: true
skipMetadataRefresh: true,
setFavorite: true,
unfavorite: true
},
recipes: {
addTags: false,
@@ -77,7 +83,9 @@ export class BulkManager {
autoOrganize: false,
deleteAll: true,
setContentRating: false,
skipMetadataRefresh: false
skipMetadataRefresh: false,
setFavorite: true,
unfavorite: true
}
};
@@ -1090,6 +1098,60 @@ export class BulkManager {
}
}
async setBulkFavorites(value) {
if (state.selectedModels.size === 0) {
showToast('toast.models.noModelsSelected', {}, 'warning');
return;
}
const totalCount = state.selectedModels.size;
const isRecipesPage = state.currentPageType === 'recipes';
state.loadingManager.showSimpleLoading(
translate(value ? 'toast.models.bulkFavoriteUpdating' : 'toast.models.bulkUnfavoriteUpdating', { count: totalCount })
);
let cancelled = false;
state.loadingManager.showCancelButton(() => {
cancelled = true;
});
let successCount = 0;
let failureCount = 0;
try {
for (const filePath of state.selectedModels) {
if (cancelled) {
showToast('toast.api.operationCancelled', {}, 'info');
break;
}
try {
if (isRecipesPage) {
await updateRecipeMetadata(filePath, { favorite: value });
} else {
const apiClient = getModelApiClient();
await apiClient.saveModelMetadata(filePath, { favorite: value });
}
successCount++;
} catch (error) {
failureCount++;
console.error(`Failed to set favorite=${value} for ${filePath}:`, error);
}
}
} finally {
state.loadingManager?.hide?.();
}
if (successCount === totalCount) {
const toastKey = value ? 'modelCard.favorites.added' : 'modelCard.favorites.removed';
showToast(toastKey, {}, 'success');
} else if (successCount > 0) {
const toastKey = value ? 'toast.models.bulkFavoritePartialAdded' : 'toast.models.bulkFavoritePartialRemoved';
showToast(toastKey, { success: successCount, failed: failureCount }, 'warning');
} else {
showToast('toast.models.bulkFavoriteFailed', {}, 'error');
}
}
/**
* Show bulk base model modal
*/

View File

@@ -599,7 +599,7 @@ export class FilterManager {
// Call the appropriate manager's load method based on page type
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') {
// For models page, reset the page and reload
await getModelApiClient().loadMoreWithVirtualScroll(true, false);
@@ -682,7 +682,7 @@ export class FilterManager {
// Reload data using the appropriate method for the current page
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') {
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
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') {
// For models page, reset the page and reload
getModelApiClient().loadMoreWithVirtualScroll(true, false);

View File

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

View File

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

View File

@@ -104,69 +104,74 @@ export class VirtualScroller {
// Get display density setting
const displayDensity = state.global.settings?.display_density || 'default';
// Set exact column counts and grid widths to match CSS container widths
let maxColumns, maxGridWidth;
// Base gap between cards
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 (displayDensity === 'default') {
maxColumns = 8;
preferredMaxColumns = 8;
} else if (displayDensity === 'medium') {
maxColumns = 9;
preferredMaxColumns = 10;
} else { // compact
maxColumns = 10;
preferredMaxColumns = 12;
}
maxGridWidth = 2400; // Match exact CSS container width for 4K
} else if (window.innerWidth >= 2150) { // 2K/1440p
if (displayDensity === 'default') {
maxColumns = 6;
preferredMaxColumns = 6;
} else if (displayDensity === 'medium') {
maxColumns = 7;
preferredMaxColumns = 8;
} else { // compact
maxColumns = 8;
preferredMaxColumns = 10;
}
maxGridWidth = 1800; // Match exact CSS container width for 2K
} else {
// 1080p
} else { // 1080p and smaller
if (displayDensity === 'default') {
maxColumns = 5;
preferredMaxColumns = 5;
} else if (displayDensity === 'medium') {
maxColumns = 6;
preferredMaxColumns = 6;
} 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
// Formula: (maxGridWidth - (columns-1)*gap) / columns
const baseCardWidth = (maxGridWidth - ((maxColumns - 1) * this.columnGap)) / maxColumns;
// Use the smaller of: max columns that fit, or preferred max
// This ensures cards are never smaller than minCardWidth
this.columnsCount = Math.min(maxColumns, preferredMaxColumns);
// Use the smaller of available content width or max grid width
const actualGridWidth = Math.min(availableContentWidth, maxGridWidth);
// Calculate card width to perfectly fill available space
// 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
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
// Calculate height based on aspect ratio (896/1152)
this.itemHeight = this.itemWidth / this.itemAspectRatio;
// Calculate the left offset to center the grid within the content area
this.leftOffset = Math.max(0, (availableContentWidth - actualGridWidth) / 2);
// Edge-to-edge layout: no offset, grid fills container
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.width = `${actualGridWidth}px`;
// Add or remove density classes for style adjustments
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.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;
}

View File

@@ -66,6 +66,9 @@ export const BASE_MODELS = {
HUNYUAN_VIDEO: "Hunyuan Video",
// Other models
ANIMA: "Anima",
ERNIE: "Ernie",
ERNIE_TURBO: "Ernie Turbo",
NUCLEUS: "Nucleus",
PONY_V7: "Pony V7",
// Default
UNKNOWN: "Other"
@@ -191,6 +194,9 @@ export const BASE_MODEL_ABBREVIATIONS = {
[BASE_MODELS.ZIMAGE_TURBO]: 'ZIT',
[BASE_MODELS.ZIMAGE_BASE]: 'ZIB',
[BASE_MODELS.ANIMA]: 'ANI',
[BASE_MODELS.ERNIE]: 'ERNI',
[BASE_MODELS.ERNIE_TURBO]: 'ETRB',
[BASE_MODELS.NUCLEUS]: 'NUCL',
// Default
[BASE_MODELS.UNKNOWN]: 'OTH'
@@ -394,6 +400,7 @@ export const BASE_MODEL_CATEGORIES = {
BASE_MODELS.QWEN, BASE_MODELS.AURAFLOW, BASE_MODELS.CHROMA, BASE_MODELS.ZIMAGE_TURBO, BASE_MODELS.ZIMAGE_BASE,
BASE_MODELS.PIXART_A, BASE_MODELS.PIXART_E, BASE_MODELS.HUNYUAN_1,
BASE_MODELS.LUMINA, BASE_MODELS.KOLORS, BASE_MODELS.NOOBAI, BASE_MODELS.ANIMA,
BASE_MODELS.ERNIE, BASE_MODELS.ERNIE_TURBO, BASE_MODELS.NUCLEUS,
BASE_MODELS.UNKNOWN
]
};

View File

@@ -28,6 +28,7 @@
{% block content %}
{% include 'components/controls.html' %}
{% include 'components/breadcrumb.html' %}
{% include 'components/duplicates_banner.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

@@ -77,6 +77,9 @@
<div class="context-menu-item" data-action="set-base-model">
<i class="fas fa-layer-group"></i> <span>{{ t('loras.bulkOperations.setBaseModel') }}</span>
</div>
<div class="context-menu-item" data-action="set-favorite">
<i class="fas fa-star"></i> <span>{{ t('loras.bulkOperations.setFavorite') }}</span>
</div>
<div class="context-menu-item" data-action="set-content-rating">
<i class="fas fa-exclamation-triangle"></i> <span>{{ t('loras.bulkOperations.setContentRating') }}</span>
</div>

View File

@@ -129,11 +129,4 @@
</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>

View File

@@ -1,50 +1,53 @@
<header class="app-header">
<div class="header-container">
<div class="header-branding">
<a href="/loras" class="logo-link">
<img src="/loras_static/images/favicon-32x32.png" alt="LoRA Manager" class="app-logo">
<span class="app-title">{{ t('header.appTitle') }}</span>
</a>
<!-- Left section: Logo + Navigation -->
<div class="header-left">
<div class="header-branding">
<a href="/loras" class="logo-link">
<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>
{% 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 %}
<!-- Center section: Search -->
{% set search_disabled = current_page == 'statistics' %}
{% set search_placeholder_key = 'header.search.notAvailable' if search_disabled else 'header.search.placeholders.' ~
current_page %}
{% 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="search-container">
<input type="text" id="searchInput" placeholder="{{ t(search_placeholder_key) }}" {% if search_disabled %}
@@ -62,9 +65,9 @@
</div>
</div>
<div class="header-actions">
<!-- Integrated corner controls -->
<div class="header-controls">
<!-- Right section: Controls -->
<div class="header-right">
<div class="header-controls" id="headerControls">
<div class="theme-toggle" title="{{ t('header.theme.toggle') }}">
<i class="fas fa-moon dark-icon"></i>
<i class="fas fa-sun light-icon"></i>
@@ -85,6 +88,34 @@
<i class="fas fa-heart"></i>
</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>
</header>

View File

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

View File

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

View File

@@ -114,7 +114,8 @@ describe('LoRA widget drag interactions', () => {
dragEl.dispatchEvent(new PointerEvent('pointerup', { pointerId: 1 }));
expect(document.body.classList.contains('lm-lora-strength-dragging')).toBe(false);
expect(onDragEnd).toHaveBeenCalledTimes(1);
expect(renderSpy).toHaveBeenCalledWith(widget.value, widget);
// 454210a4 replaced renderFunction() with widget.value setter + widget.callback()
expect(widget.callback).toHaveBeenCalledWith(widget.value);
});
it('deletes the selected LoRA when backspace is pressed outside of strength inputs', async () => {

View File

@@ -232,9 +232,13 @@ export function initDrag(
onDragEnd();
}
// Now do the re-render after drag is complete
if (renderFunction) {
renderFunction(widget.value, widget);
// Commit final value through options.setValue so external observers are notified.
// During drag, handleStrengthDrag mutates widgetValue in-place (updateWidget=false),
// 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');
// Only re-render if we actually dragged
if (wasDragging && renderFunction) {
renderFunction(widget.value, widget);
if (wasDragging) {
// 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);
return {
text: itemText,
active: savedItem ? savedItem.active : defaultActive,
active: savedItem ? savedItem.active : true,
highlighted: false,
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