Compare commits

..

14 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
47 changed files with 899 additions and 588 deletions

136
README.md

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

@@ -687,6 +687,9 @@
"autoOrganize": "Automatisch organisieren", "autoOrganize": "Automatisch organisieren",
"skipMetadataRefresh": "Metadaten-Aktualisierung für ausgewählte Modelle überspringen", "skipMetadataRefresh": "Metadaten-Aktualisierung für ausgewählte Modelle überspringen",
"resumeMetadataRefresh": "Metadaten-Aktualisierung für ausgewählte Modelle fortsetzen", "resumeMetadataRefresh": "Metadaten-Aktualisierung für ausgewählte Modelle fortsetzen",
"setFavorite": "Als Favorit setzen",
"setFavoriteCount": "Als Favorit setzen ({favorited}/{total})",
"unfavorite": "Aus Favoriten entfernen",
"deleteAll": "Ausgewählte löschen", "deleteAll": "Ausgewählte löschen",
"downloadMissingLoras": "Fehlende LoRAs herunterladen", "downloadMissingLoras": "Fehlende LoRAs herunterladen",
"clear": "Auswahl löschen", "clear": "Auswahl löschen",
@@ -1699,6 +1702,11 @@
"bulkContentRatingSet": "Inhaltsbewertung auf {level} für {count} Modell(e) gesetzt", "bulkContentRatingSet": "Inhaltsbewertung auf {level} für {count} Modell(e) gesetzt",
"bulkContentRatingPartial": "Inhaltsbewertung auf {level} für {success} Modell(e) gesetzt, {failed} fehlgeschlagen", "bulkContentRatingPartial": "Inhaltsbewertung auf {level} für {success} Modell(e) gesetzt, {failed} fehlgeschlagen",
"bulkContentRatingFailed": "Inhaltsbewertung für ausgewählte Modelle konnte nicht aktualisiert werden", "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...", "bulkUpdatesChecking": "Ausgewählte {type}-Modelle werden auf Updates geprüft...",
"bulkUpdatesSuccess": "Updates für {count} ausgewählte {type}-Modelle verfügbar", "bulkUpdatesSuccess": "Updates für {count} ausgewählte {type}-Modelle verfügbar",
"bulkUpdatesNone": "Keine Updates für ausgewählte {type}-Modelle gefunden", "bulkUpdatesNone": "Keine Updates für ausgewählte {type}-Modelle gefunden",

View File

@@ -687,6 +687,9 @@
"autoOrganize": "Auto-Organize Selected", "autoOrganize": "Auto-Organize Selected",
"skipMetadataRefresh": "Skip Metadata Refresh for Selected", "skipMetadataRefresh": "Skip Metadata Refresh for Selected",
"resumeMetadataRefresh": "Resume 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", "deleteAll": "Delete Selected",
"downloadMissingLoras": "Download Missing LoRAs", "downloadMissingLoras": "Download Missing LoRAs",
"clear": "Clear Selection", "clear": "Clear Selection",
@@ -1699,6 +1702,11 @@
"bulkContentRatingSet": "Set content rating to {level} for {count} model(s)", "bulkContentRatingSet": "Set content rating to {level} for {count} model(s)",
"bulkContentRatingPartial": "Set content rating to {level} for {success} model(s), {failed} failed", "bulkContentRatingPartial": "Set content rating to {level} for {success} model(s), {failed} failed",
"bulkContentRatingFailed": "Failed to update content rating for selected models", "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...", "bulkUpdatesChecking": "Checking selected {type}(s) for updates...",
"bulkUpdatesSuccess": "Updates available for {count} selected {type}(s)", "bulkUpdatesSuccess": "Updates available for {count} selected {type}(s)",
"bulkUpdatesNone": "No updates found for selected {type}(s)", "bulkUpdatesNone": "No updates found for selected {type}(s)",

View File

@@ -687,6 +687,9 @@
"autoOrganize": "Auto-organizar seleccionados", "autoOrganize": "Auto-organizar seleccionados",
"skipMetadataRefresh": "Omitir actualización de metadatos para seleccionados", "skipMetadataRefresh": "Omitir actualización de metadatos para seleccionados",
"resumeMetadataRefresh": "Reanudar actualización de metadatos para seleccionados", "resumeMetadataRefresh": "Reanudar actualización de metadatos para seleccionados",
"setFavorite": "Marcar como favorito",
"setFavoriteCount": "Marcar como favorito ({favorited}/{total})",
"unfavorite": "Quitar de favoritos",
"deleteAll": "Eliminar seleccionados", "deleteAll": "Eliminar seleccionados",
"downloadMissingLoras": "Descargar LoRAs faltantes", "downloadMissingLoras": "Descargar LoRAs faltantes",
"clear": "Limpiar selección", "clear": "Limpiar selección",
@@ -1699,6 +1702,11 @@
"bulkContentRatingSet": "Clasificación de contenido establecida en {level} para {count} modelo(s)", "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", "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", "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...", "bulkUpdatesChecking": "Comprobando actualizaciones para {type} seleccionados...",
"bulkUpdatesSuccess": "Actualizaciones disponibles para {count} {type} seleccionados", "bulkUpdatesSuccess": "Actualizaciones disponibles para {count} {type} seleccionados",
"bulkUpdatesNone": "No se encontraron actualizaciones para los {type} seleccionados", "bulkUpdatesNone": "No se encontraron actualizaciones para los {type} seleccionados",

View File

@@ -687,6 +687,9 @@
"autoOrganize": "Auto-organiser la sélection", "autoOrganize": "Auto-organiser la sélection",
"skipMetadataRefresh": "Ignorer l'actualisation des métadonnées pour la sélection", "skipMetadataRefresh": "Ignorer l'actualisation des métadonnées pour la sélection",
"resumeMetadataRefresh": "Reprendre l'actualisation des métadonnées pour la sélection", "resumeMetadataRefresh": "Reprendre l'actualisation des métadonnées pour la sélection",
"setFavorite": "Définir comme favori",
"setFavoriteCount": "Définir comme favori ({favorited}/{total})",
"unfavorite": "Retirer des favoris",
"deleteAll": "Supprimer la sélection", "deleteAll": "Supprimer la sélection",
"downloadMissingLoras": "Télécharger les LoRAs manquants", "downloadMissingLoras": "Télécharger les LoRAs manquants",
"clear": "Effacer la sélection", "clear": "Effacer la sélection",
@@ -1699,6 +1702,11 @@
"bulkContentRatingSet": "Classification du contenu définie sur {level} pour {count} modèle(s)", "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)", "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", "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...", "bulkUpdatesChecking": "Vérification des mises à jour pour les {type} sélectionnés...",
"bulkUpdatesSuccess": "Mises à jour disponibles pour {count} {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", "bulkUpdatesNone": "Aucune mise à jour trouvée pour les {type} sélectionnés",

View File

@@ -687,6 +687,9 @@
"autoOrganize": "ארגן אוטומטית נבחרים", "autoOrganize": "ארגן אוטומטית נבחרים",
"skipMetadataRefresh": "דילוג על רענון מטא-נתונים לנבחרים", "skipMetadataRefresh": "דילוג על רענון מטא-נתונים לנבחרים",
"resumeMetadataRefresh": "המשך רענון מטא-נתונים לנבחרים", "resumeMetadataRefresh": "המשך רענון מטא-נתונים לנבחרים",
"setFavorite": "הגדר כמועדף",
"setFavoriteCount": "הגדר כמועדף ({favorited}/{total})",
"unfavorite": "הסר ממועדפים",
"deleteAll": "מחק נבחרים", "deleteAll": "מחק נבחרים",
"downloadMissingLoras": "הורדת LoRAs חסרים", "downloadMissingLoras": "הורדת LoRAs חסרים",
"clear": "נקה בחירה", "clear": "נקה בחירה",
@@ -1699,6 +1702,11 @@
"bulkContentRatingSet": "דירוג התוכן הוגדר ל-{level} עבור {count} מודלים", "bulkContentRatingSet": "דירוג התוכן הוגדר ל-{level} עבור {count} מודלים",
"bulkContentRatingPartial": "דירוג התוכן הוגדר ל-{level} עבור {success} מודלים, {failed} נכשלו", "bulkContentRatingPartial": "דירוג התוכן הוגדר ל-{level} עבור {success} מודלים, {failed} נכשלו",
"bulkContentRatingFailed": "עדכון דירוג התוכן עבור המודלים שנבחרו נכשל", "bulkContentRatingFailed": "עדכון דירוג התוכן עבור המודלים שנבחרו נכשל",
"bulkFavoriteUpdating": "מוסיף {count} דגמים למועדפים...",
"bulkUnfavoriteUpdating": "מסיר {count} דגמים ממועדפים...",
"bulkFavoritePartialAdded": "{success} דגמים נוספו למועדפים, {failed} נכשלו",
"bulkFavoritePartialRemoved": "{success} דגמים הוסרו ממועדפים, {failed} נכשלו",
"bulkFavoriteFailed": "עדכון סטטוס מועדפים נכשל",
"bulkUpdatesChecking": "בודק עדכונים עבור {type} שנבחרו...", "bulkUpdatesChecking": "בודק עדכונים עבור {type} שנבחרו...",
"bulkUpdatesSuccess": "יש עדכונים עבור {count} {type} שנבחרו", "bulkUpdatesSuccess": "יש עדכונים עבור {count} {type} שנבחרו",
"bulkUpdatesNone": "לא נמצאו עדכונים עבור {type} שנבחרו", "bulkUpdatesNone": "לא נמצאו עדכונים עבור {type} שנבחרו",

View File

@@ -687,6 +687,9 @@
"autoOrganize": "自動整理を実行", "autoOrganize": "自動整理を実行",
"skipMetadataRefresh": "選択したモデルのメタデータ更新をスキップ", "skipMetadataRefresh": "選択したモデルのメタデータ更新をスキップ",
"resumeMetadataRefresh": "選択したモデルのメタデータ更新を再開", "resumeMetadataRefresh": "選択したモデルのメタデータ更新を再開",
"setFavorite": "お気に入りに設定",
"setFavoriteCount": "お気に入りに設定 ({favorited}/{total})",
"unfavorite": "お気に入りから削除",
"deleteAll": "選択したものを削除", "deleteAll": "選択したものを削除",
"downloadMissingLoras": "不足している LoRA をダウンロード", "downloadMissingLoras": "不足している LoRA をダウンロード",
"clear": "選択をクリア", "clear": "選択をクリア",
@@ -1699,6 +1702,11 @@
"bulkContentRatingSet": "{count} 件のモデルのコンテンツレーティングを {level} に設定しました", "bulkContentRatingSet": "{count} 件のモデルのコンテンツレーティングを {level} に設定しました",
"bulkContentRatingPartial": "{success} 件のモデルのコンテンツレーティングを {level} に設定、{failed} 件は失敗しました", "bulkContentRatingPartial": "{success} 件のモデルのコンテンツレーティングを {level} に設定、{failed} 件は失敗しました",
"bulkContentRatingFailed": "選択したモデルのコンテンツレーティングを更新できませんでした", "bulkContentRatingFailed": "選択したモデルのコンテンツレーティングを更新できませんでした",
"bulkFavoriteUpdating": "{count} 個のモデルをお気に入りに追加中...",
"bulkUnfavoriteUpdating": "{count} 個のモデルをお気に入りから削除中...",
"bulkFavoritePartialAdded": "{success} 個のモデルをお気に入りに追加、{failed} 個失敗",
"bulkFavoritePartialRemoved": "{success} 個のモデルをお気に入りから削除、{failed} 個失敗",
"bulkFavoriteFailed": "お気に入り状態の更新に失敗しました",
"bulkUpdatesChecking": "選択された{type}の更新を確認しています...", "bulkUpdatesChecking": "選択された{type}の更新を確認しています...",
"bulkUpdatesSuccess": "{count} 件の選択された{type}に利用可能な更新があります", "bulkUpdatesSuccess": "{count} 件の選択された{type}に利用可能な更新があります",
"bulkUpdatesNone": "選択された{type}には更新が見つかりませんでした", "bulkUpdatesNone": "選択された{type}には更新が見つかりませんでした",

View File

@@ -687,6 +687,9 @@
"autoOrganize": "자동 정리 선택", "autoOrganize": "자동 정리 선택",
"skipMetadataRefresh": "선택한 모델의 메타데이터 새로고침 건너뛰기", "skipMetadataRefresh": "선택한 모델의 메타데이터 새로고침 건너뛰기",
"resumeMetadataRefresh": "선택한 모델의 메타데이터 새로고침 재개", "resumeMetadataRefresh": "선택한 모델의 메타데이터 새로고침 재개",
"setFavorite": "즐겨찾기로 설정",
"setFavoriteCount": "즐겨찾기로 설정 ({favorited}/{total})",
"unfavorite": "즐겨찾기 해제",
"deleteAll": "선택된 항목 삭제", "deleteAll": "선택된 항목 삭제",
"downloadMissingLoras": "누락된 LoRA 다운로드", "downloadMissingLoras": "누락된 LoRA 다운로드",
"clear": "선택 지우기", "clear": "선택 지우기",
@@ -1699,6 +1702,11 @@
"bulkContentRatingSet": "{count}개 모델의 콘텐츠 등급을 {level}(으)로 설정했습니다", "bulkContentRatingSet": "{count}개 모델의 콘텐츠 등급을 {level}(으)로 설정했습니다",
"bulkContentRatingPartial": "{success}개 모델의 콘텐츠 등급을 {level}(으)로 설정했고, {failed}개는 실패했습니다", "bulkContentRatingPartial": "{success}개 모델의 콘텐츠 등급을 {level}(으)로 설정했고, {failed}개는 실패했습니다",
"bulkContentRatingFailed": "선택한 모델의 콘텐츠 등급을 업데이트하지 못했습니다", "bulkContentRatingFailed": "선택한 모델의 콘텐츠 등급을 업데이트하지 못했습니다",
"bulkFavoriteUpdating": "{count}개 모델을 즐겨찾기에 추가 중...",
"bulkUnfavoriteUpdating": "{count}개 모델을 즐겨찾기에서 제거 중...",
"bulkFavoritePartialAdded": "{success}개 모델을 즐겨찾기에 추가, {failed}개 실패",
"bulkFavoritePartialRemoved": "{success}개 모델을 즐겨찾기에서 제거, {failed}개 실패",
"bulkFavoriteFailed": "즐겨찾기 상태 업데이트 실패",
"bulkUpdatesChecking": "선택한 {type}의 업데이트를 확인하는 중...", "bulkUpdatesChecking": "선택한 {type}의 업데이트를 확인하는 중...",
"bulkUpdatesSuccess": "선택한 {count}개의 {type}에 사용할 수 있는 업데이트가 있습니다", "bulkUpdatesSuccess": "선택한 {count}개의 {type}에 사용할 수 있는 업데이트가 있습니다",
"bulkUpdatesNone": "선택한 {type}에 대한 업데이트가 없습니다", "bulkUpdatesNone": "선택한 {type}에 대한 업데이트가 없습니다",

View File

@@ -687,6 +687,9 @@
"autoOrganize": "Автоматически организовать выбранные", "autoOrganize": "Автоматически организовать выбранные",
"skipMetadataRefresh": "Пропустить обновление метаданных для выбранных", "skipMetadataRefresh": "Пропустить обновление метаданных для выбранных",
"resumeMetadataRefresh": "Возобновить обновление метаданных для выбранных", "resumeMetadataRefresh": "Возобновить обновление метаданных для выбранных",
"setFavorite": "Добавить в избранное",
"setFavoriteCount": "Добавить в избранное ({favorited}/{total})",
"unfavorite": "Удалить из избранного",
"deleteAll": "Удалить выбранные", "deleteAll": "Удалить выбранные",
"downloadMissingLoras": "Скачать отсутствующие LoRAs", "downloadMissingLoras": "Скачать отсутствующие LoRAs",
"clear": "Очистить выбор", "clear": "Очистить выбор",
@@ -1699,6 +1702,11 @@
"bulkContentRatingSet": "Рейтинг контента установлен на {level} для {count} модель(ей)", "bulkContentRatingSet": "Рейтинг контента установлен на {level} для {count} модель(ей)",
"bulkContentRatingPartial": "Рейтинг контента {level} установлен для {success} модель(ей), {failed} не удалось", "bulkContentRatingPartial": "Рейтинг контента {level} установлен для {success} модель(ей), {failed} не удалось",
"bulkContentRatingFailed": "Не удалось обновить рейтинг контента для выбранных моделей", "bulkContentRatingFailed": "Не удалось обновить рейтинг контента для выбранных моделей",
"bulkFavoriteUpdating": "Добавление {count} моделей в избранное...",
"bulkUnfavoriteUpdating": "Удаление {count} моделей из избранного...",
"bulkFavoritePartialAdded": "{success} моделей добавлено в избранное, {failed} не удалось",
"bulkFavoritePartialRemoved": "{success} моделей удалено из избранного, {failed} не удалось",
"bulkFavoriteFailed": "Не удалось обновить статус избранного",
"bulkUpdatesChecking": "Проверка обновлений для выбранных {type}...", "bulkUpdatesChecking": "Проверка обновлений для выбранных {type}...",
"bulkUpdatesSuccess": "Доступны обновления для {count} выбранных {type}", "bulkUpdatesSuccess": "Доступны обновления для {count} выбранных {type}",
"bulkUpdatesNone": "Обновления для выбранных {type} не найдены", "bulkUpdatesNone": "Обновления для выбранных {type} не найдены",

View File

@@ -687,6 +687,9 @@
"autoOrganize": "自动整理所选模型", "autoOrganize": "自动整理所选模型",
"skipMetadataRefresh": "跳过所选模型的元数据刷新", "skipMetadataRefresh": "跳过所选模型的元数据刷新",
"resumeMetadataRefresh": "恢复所选模型的元数据刷新", "resumeMetadataRefresh": "恢复所选模型的元数据刷新",
"setFavorite": "设为收藏",
"setFavoriteCount": "设为收藏 ({favorited}/{total})",
"unfavorite": "取消收藏",
"deleteAll": "删除已选", "deleteAll": "删除已选",
"downloadMissingLoras": "下载缺失的 LoRAs", "downloadMissingLoras": "下载缺失的 LoRAs",
"clear": "清除选择", "clear": "清除选择",
@@ -1699,6 +1702,11 @@
"bulkContentRatingSet": "已将 {count} 个模型的内容评级设置为 {level}", "bulkContentRatingSet": "已将 {count} 个模型的内容评级设置为 {level}",
"bulkContentRatingPartial": "已将 {success} 个模型的内容评级设置为 {level}{failed} 个失败", "bulkContentRatingPartial": "已将 {success} 个模型的内容评级设置为 {level}{failed} 个失败",
"bulkContentRatingFailed": "未能更新所选模型的内容评级", "bulkContentRatingFailed": "未能更新所选模型的内容评级",
"bulkFavoriteUpdating": "正在将 {count} 个模型添加到收藏...",
"bulkUnfavoriteUpdating": "正在将 {count} 个模型从收藏移除...",
"bulkFavoritePartialAdded": "已将 {success} 个模型添加到收藏,{failed} 个失败",
"bulkFavoritePartialRemoved": "已将 {success} 个模型从收藏移除,{failed} 个失败",
"bulkFavoriteFailed": "更新收藏状态失败",
"bulkUpdatesChecking": "正在检查所选 {type} 的更新...", "bulkUpdatesChecking": "正在检查所选 {type} 的更新...",
"bulkUpdatesSuccess": "{count} 个所选 {type} 有可用更新", "bulkUpdatesSuccess": "{count} 个所选 {type} 有可用更新",
"bulkUpdatesNone": "所选 {type} 未发现更新", "bulkUpdatesNone": "所选 {type} 未发现更新",

View File

@@ -687,6 +687,9 @@
"autoOrganize": "自動整理所選模型", "autoOrganize": "自動整理所選模型",
"skipMetadataRefresh": "跳過所選模型的元數據更新", "skipMetadataRefresh": "跳過所選模型的元數據更新",
"resumeMetadataRefresh": "恢復所選模型的元數據更新", "resumeMetadataRefresh": "恢復所選模型的元數據更新",
"setFavorite": "設為收藏",
"setFavoriteCount": "設為收藏 ({favorited}/{total})",
"unfavorite": "取消收藏",
"deleteAll": "刪除所選", "deleteAll": "刪除所選",
"downloadMissingLoras": "下載缺失的 LoRAs", "downloadMissingLoras": "下載缺失的 LoRAs",
"clear": "清除選取", "clear": "清除選取",
@@ -1699,6 +1702,11 @@
"bulkContentRatingSet": "已將 {count} 個模型的內容分級設定為 {level}", "bulkContentRatingSet": "已將 {count} 個模型的內容分級設定為 {level}",
"bulkContentRatingPartial": "已將 {success} 個模型的內容分級設定為 {level}{failed} 個失敗", "bulkContentRatingPartial": "已將 {success} 個模型的內容分級設定為 {level}{failed} 個失敗",
"bulkContentRatingFailed": "無法更新所選模型的內容分級", "bulkContentRatingFailed": "無法更新所選模型的內容分級",
"bulkFavoriteUpdating": "正在將 {count} 個模型加入收藏...",
"bulkUnfavoriteUpdating": "正在將 {count} 個模型從收藏移除...",
"bulkFavoritePartialAdded": "已將 {success} 個模型加入收藏,{failed} 個失敗",
"bulkFavoritePartialRemoved": "已將 {success} 個模型從收藏移除,{failed} 個失敗",
"bulkFavoriteFailed": "更新收藏狀態失敗",
"bulkUpdatesChecking": "正在檢查所選 {type} 的更新...", "bulkUpdatesChecking": "正在檢查所選 {type} 的更新...",
"bulkUpdatesSuccess": "{count} 個所選 {type} 有可用更新", "bulkUpdatesSuccess": "{count} 個所選 {type} 有可用更新",
"bulkUpdatesNone": "所選 {type} 未找到更新", "bulkUpdatesNone": "所選 {type} 未找到更新",

View File

@@ -33,6 +33,7 @@ from ...services.metadata_service import (
update_metadata_providers, update_metadata_providers,
) )
from ...services.service_registry import ServiceRegistry from ...services.service_registry import ServiceRegistry
from ...services.model_lifecycle_service import delete_model_artifacts
from ...services.settings_manager import get_settings_manager from ...services.settings_manager import get_settings_manager
from ...services.websocket_manager import ws_manager from ...services.websocket_manager import ws_manager
from ...services.downloader import get_downloader from ...services.downloader import get_downloader
@@ -2082,6 +2083,78 @@ class ModelLibraryHandler:
) )
return web.json_response({"success": False, "error": str(exc)}, status=500) return web.json_response({"success": False, "error": str(exc)}, status=500)
async def delete_model_version(self, request: web.Request) -> web.Response:
try:
model_version_id_str = request.query.get("modelVersionId")
if not model_version_id_str:
return web.json_response(
{"success": False, "error": "Missing required parameter: modelVersionId"},
status=400,
)
try:
model_version_id = int(model_version_id_str)
except ValueError:
return web.json_response(
{"success": False, "error": "Parameter modelVersionId must be an integer"},
status=400,
)
lora_scanner = await self._service_registry.get_lora_scanner()
checkpoint_scanner = await self._service_registry.get_checkpoint_scanner()
embedding_scanner = await self._service_registry.get_embedding_scanner()
found_type = None
file_path = None
found_cache = None
for model_type, scanner in (
("lora", lora_scanner),
("checkpoint", checkpoint_scanner),
("embedding", embedding_scanner),
):
cache = await scanner.get_cached_data()
if cache and model_version_id in cache.version_index:
found_type = model_type
found_cache = cache
entry = cache.version_index[model_version_id]
file_path = entry.get("file_path")
break
if not file_path:
return web.json_response(
{"success": False, "error": "Model version not found in any scanner cache"},
status=404,
)
target_dir = os.path.dirname(file_path)
base_name = os.path.basename(file_path)
file_name, extension = os.path.splitext(base_name)
await delete_model_artifacts(target_dir, file_name, main_extension=extension)
if found_cache:
found_cache.raw_data = [
item
for item in found_cache.raw_data
if item.get("file_path") != file_path
]
await found_cache.resort()
history_service = await self._get_download_history_service()
await history_service.mark_not_downloaded(found_type, model_version_id)
return web.json_response(
{
"success": True,
"modelType": found_type,
"modelVersionId": model_version_id,
}
)
except Exception as exc:
logger.error(
"Failed to delete model version: %s", exc, exc_info=True
)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def get_model_versions_status(self, request: web.Request) -> web.Response: async def get_model_versions_status(self, request: web.Request) -> web.Response:
try: try:
model_id_str = request.query.get("modelId") model_id_str = request.query.get("modelId")
@@ -3118,6 +3191,7 @@ class MiscHandlerSet:
"check_models_exist": self.model_library.check_models_exist, "check_models_exist": self.model_library.check_models_exist,
"get_model_version_download_status": self.model_library.get_model_version_download_status, "get_model_version_download_status": self.model_library.get_model_version_download_status,
"set_model_version_download_status": self.model_library.set_model_version_download_status, "set_model_version_download_status": self.model_library.set_model_version_download_status,
"delete_model_version": self.model_library.delete_model_version,
"get_civitai_user_models": self.model_library.get_civitai_user_models, "get_civitai_user_models": self.model_library.get_civitai_user_models,
"download_metadata_archive": self.metadata_archive.download_metadata_archive, "download_metadata_archive": self.metadata_archive.download_metadata_archive,
"remove_metadata_archive": self.metadata_archive.remove_metadata_archive, "remove_metadata_archive": self.metadata_archive.remove_metadata_archive,

View File

@@ -91,6 +91,9 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition( RouteDefinition(
"GET", "/api/lm/base-models/cache-status", "get_base_model_cache_status" "GET", "/api/lm/base-models/cache-status", "get_base_model_cache_status"
), ),
RouteDefinition(
"GET", "/api/lm/delete-model-version", "delete_model_version"
),
) )

View File

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

View File

@@ -577,6 +577,59 @@ class CivitaiClient:
logger.error(error_msg) logger.error(error_msg)
return None 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]]: async def get_user_models(self, username: str) -> Optional[List[Dict]]:
"""Fetch all models for a specific Civitai user.""" """Fetch all models for a specific Civitai user."""
if not username: if not username:

View File

@@ -109,6 +109,18 @@ class ModelMetadataProvider(ABC):
"""Fetch model versions for multiple model ids when supported.""" """Fetch model versions for multiple model ids when supported."""
raise NotImplementedError 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 @abstractmethod
async def get_model_version(self, model_id: int = None, version_id: int = None) -> Optional[Dict]: async def get_model_version(self, model_id: int = None, version_id: int = None) -> Optional[Dict]:
"""Get specific model version with additional metadata""" """Get specific model version with additional metadata"""
@@ -141,6 +153,11 @@ class CivitaiModelMetadataProvider(ModelMetadataProvider):
) -> Optional[Dict[int, Dict]]: ) -> Optional[Dict[int, Dict]]:
return await self.client.get_model_versions_bulk(model_ids) 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]: 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) return await self.client.get_model_version(model_id, version_id)
@@ -519,6 +536,32 @@ class FallbackMetadataProvider(ModelMetadataProvider):
continue continue
return None, "No provider could retrieve the data" 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]]: async def get_user_models(self, username: str) -> Optional[List[Dict]]:
for provider, label in self._iter_providers(): for provider, label in self._iter_providers():
try: try:
@@ -593,6 +636,15 @@ class RateLimitRetryingProvider(ModelMetadataProvider):
model_ids, 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]: async def get_model_version(self, model_id: int = None, version_id: int = None) -> Optional[Dict]:
return await self._rate_limit_helper.run( return await self._rate_limit_helper.run(
self._label, self._label,
@@ -669,6 +721,17 @@ class ModelMetadataProviderManager:
provider = self._get_provider(provider_name) provider = self._get_provider(provider_name)
return await provider.get_model_version_info(version_id) 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]]: async def get_user_models(self, username: str, provider_name: str = None) -> Optional[List[Dict]]:
"""Fetch models owned by the specified user""" """Fetch models owned by the specified user"""
provider = self._get_provider(provider_name) provider = self._get_provider(provider_name)

View File

@@ -989,6 +989,11 @@ class ModelUpdateService:
fallback_attempted = True fallback_attempted = True
try: try:
response = await metadata_provider.get_model_versions(model_id) 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: except RateLimitError:
raise raise
except ResourceNotFoundError as exc: except ResourceNotFoundError as exc:
@@ -1083,6 +1088,136 @@ class ModelUpdateService:
self._upsert_record(record) self._upsert_record(record)
return 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( async def _fetch_model_versions_bulk(
self, self,
metadata_provider, metadata_provider,
@@ -1134,6 +1269,7 @@ class ModelUpdateService:
len(aggregated), len(aggregated),
provider_name, provider_name,
) )
await self._enrich_version_entries(metadata_provider, aggregated)
return aggregated return aggregated
async def _collect_local_versions( async def _collect_local_versions(
@@ -1261,6 +1397,7 @@ class ModelUpdateService:
sort_index=sort_map.get(version_id, index), sort_index=sort_map.get(version_id, index),
early_access_ends_at=remote_version.early_access_ends_at, early_access_ends_at=remote_version.early_access_ends_at,
is_early_access=remote_version.is_early_access, is_early_access=remote_version.is_early_access,
usage_control=remote_version.usage_control,
) )
) )

View File

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

View File

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

View File

@@ -329,7 +329,6 @@
} }
.card-actions i { .card-actions i {
margin-left: var(--space-1);
cursor: pointer; cursor: pointer;
color: white; color: white;
transition: opacity 0.2s, transform 0.15s ease; transition: opacity 0.2s, transform 0.15s ease;

View File

@@ -141,8 +141,7 @@
.header-search .search-container:focus-within { .header-search .search-container:focus-within {
border-color: var(--lora-accent); border-color: var(--lora-accent);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12), 0 0 0 1px var(--lora-accent); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08), 0 0 0 1px var(--lora-accent);
transform: translateY(-1px);
} }
.header-search input { .header-search input {

View File

@@ -387,6 +387,10 @@
cursor: not-allowed; cursor: not-allowed;
} }
.version-action-disabled-wrapper {
display: inline-flex;
}
.versions-loading-state, .versions-loading-state,
.versions-empty, .versions-empty,
.versions-error { .versions-error {

View File

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

View File

@@ -74,6 +74,34 @@ export class BulkContextMenu extends BaseContextMenu {
if (setContentRatingItem) { if (setContentRatingItem) {
setContentRatingItem.style.display = config.setContentRating ? 'flex' : 'none'; 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) { if (downloadMissingLorasItem) {
// Only show for recipes page // Only show for recipes page
downloadMissingLorasItem.style.display = currentModelType === 'recipes' ? 'flex' : 'none'; downloadMissingLorasItem.style.display = currentModelType === 'recipes' ? 'flex' : 'none';
@@ -138,6 +166,20 @@ export class BulkContextMenu extends BaseContextMenu {
return count; 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) { showMenu(x, y, card) {
this.updateMenuItemsForModelType(); this.updateMenuItemsForModelType();
this.updateSelectedCountHeader(); this.updateSelectedCountHeader();
@@ -185,6 +227,11 @@ export class BulkContextMenu extends BaseContextMenu {
case 'delete-all': case 'delete-all':
bulkManager.showBulkDeleteModal(); bulkManager.showBulkDeleteModal();
break; break;
case 'set-favorite': {
const allFavorited = this.countFavoritedInSelection() === state.selectedModels.size;
bulkManager.setBulkFavorites(!allFavorited);
break;
}
case 'download-missing-loras': case 'download-missing-loras':
this.handleDownloadMissingLoras(); this.handleDownloadMissingLoras();
break; break;

View File

@@ -241,7 +241,7 @@ function buildActionButton(label, variant, action, options = {}) {
if (action) { if (action) {
attributes.push(`data-version-action="${escapeHtml(action)}"`); attributes.push(`data-version-action="${escapeHtml(action)}"`);
} }
if (options.title) { if (!options.disabled && options.title) {
attributes.push(`title="${escapeHtml(options.title)}"`); attributes.push(`title="${escapeHtml(options.title)}"`);
attributes.push(`aria-label="${escapeHtml(options.title)}"`); attributes.push(`aria-label="${escapeHtml(options.title)}"`);
} }
@@ -251,7 +251,11 @@ function buildActionButton(label, variant, action, options = {}) {
if (options.extraAttributes) { if (options.extraAttributes) {
attributes.push(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({ const DISPLAY_FILTER_MODES = Object.freeze({

View File

@@ -3,7 +3,7 @@ import { showToast, copyToClipboard, sendLoraToWorkflow, buildLoraSyntax, getNSF
import { updateCardsForBulkMode } from '../components/shared/ModelCard.js'; import { updateCardsForBulkMode } from '../components/shared/ModelCard.js';
import { modalManager } from './ModalManager.js'; import { modalManager } from './ModalManager.js';
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.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 { MODEL_TYPES, MODEL_CONFIG } from '../api/apiConfig.js';
import { BASE_MODEL_CATEGORIES } from '../utils/constants.js'; import { BASE_MODEL_CATEGORIES } from '../utils/constants.js';
import { getPriorityTagSuggestions } from '../utils/priorityTagHelpers.js'; import { getPriorityTagSuggestions } from '../utils/priorityTagHelpers.js';
@@ -41,7 +41,9 @@ export class BulkManager {
autoOrganize: true, autoOrganize: true,
deleteAll: true, deleteAll: true,
setContentRating: true, setContentRating: true,
skipMetadataRefresh: true skipMetadataRefresh: true,
setFavorite: true,
unfavorite: true
}, },
[MODEL_TYPES.EMBEDDING]: { [MODEL_TYPES.EMBEDDING]: {
addTags: true, addTags: true,
@@ -53,7 +55,9 @@ export class BulkManager {
autoOrganize: true, autoOrganize: true,
deleteAll: true, deleteAll: true,
setContentRating: false, setContentRating: false,
skipMetadataRefresh: true skipMetadataRefresh: true,
setFavorite: true,
unfavorite: true
}, },
[MODEL_TYPES.CHECKPOINT]: { [MODEL_TYPES.CHECKPOINT]: {
addTags: true, addTags: true,
@@ -65,7 +69,9 @@ export class BulkManager {
autoOrganize: true, autoOrganize: true,
deleteAll: true, deleteAll: true,
setContentRating: true, setContentRating: true,
skipMetadataRefresh: true skipMetadataRefresh: true,
setFavorite: true,
unfavorite: true
}, },
recipes: { recipes: {
addTags: false, addTags: false,
@@ -77,7 +83,9 @@ export class BulkManager {
autoOrganize: false, autoOrganize: false,
deleteAll: true, deleteAll: true,
setContentRating: false, 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 * Show bulk base model modal
*/ */

View File

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

View File

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

View File

@@ -114,7 +114,8 @@ describe('LoRA widget drag interactions', () => {
dragEl.dispatchEvent(new PointerEvent('pointerup', { pointerId: 1 })); dragEl.dispatchEvent(new PointerEvent('pointerup', { pointerId: 1 }));
expect(document.body.classList.contains('lm-lora-strength-dragging')).toBe(false); expect(document.body.classList.contains('lm-lora-strength-dragging')).toBe(false);
expect(onDragEnd).toHaveBeenCalledTimes(1); 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 () => { it('deletes the selected LoRA when backspace is pressed outside of strength inputs', async () => {

View File

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

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