Compare commits

...

7 Commits

Author SHA1 Message Date
Will Miao
94e1a8ac7b chore(release): bump version to v1.0.7 2026-05-17 20:40:13 +08:00
Will Miao
cc20d3b992 feat(ui): auto-detect HIGH/LOW badges and auto-tag filters (#918)
- Backend auto-tag extraction service: detect HIGH/LOW (Wan-only), I2V/T2V/TI2V,
  Lightning/Turbo from filename, base_model, and CivitAI version name
- HIGH/LOW badge in card footer (inline before version name), color-coded:
  blue for HIGH, teal for LOW; abbreviated to H/L in medium/compact density
- Auto-tag filter panel (I2V, T2V, TI2V, Lightning, Turbo) with tri-state
  include/exclude filtering
- Full filter pipeline: FilterCriteria → ModelFilterSet → baseModelApi params
- AUTO_TAG_GROUPS exported for frontend use
- 19 unit tests for auto-tag extraction edge cases
2026-05-17 17:45:12 +08:00
Will Miao
a74cbe7aa2 fix(test): sync civitai bulk test with nsfw param 2026-05-16 22:15:55 +08:00
Will Miao
94edfaa190 fix(import): discover all resources from CivitAI modelVersionIds
CivitAI image API returns modelVersionIds at the root level of the
response (not inside meta), containing ALL model version IDs across
all resources (checkpoint + LoRAs). Two bugs prevented LoRAs from
being discovered:

1. _download_remote_media only extracted the first modelVersionId for
   enrichment, dropping the rest.
2. CivitAI API meta parsing only ran as an EXIF fallback, but most
   images have embedded EXIF metadata (prompt, steps, etc.), so the
   fallback was never triggered.
3. When civitai_meta_raw itself has a nested 'meta' key, unwrapping
   it stripped the injected modelVersionIds.

Also fixed gen_params merge: API gen_params now overlays EXIF at the
field level instead of full replacement, preserving EXIF-only fields
like detailed generation parameters.
2026-05-16 22:12:30 +08:00
Will Miao
31c54ff068 fix(civitai): add nsfw param to user-models and batch-ids queries (#930)
The CivitAI /api/v1/models endpoint defaults to filtering out NSFW
content when the nsfw query parameter is omitted. Both get_user_models()
and get_model_versions_bulk() hit this endpoint without passing nsfw=true,
causing models whose nsfwLevel doesn't include the PG bit to be silently
dropped from results.

Add nsfw=true to both call sites so all browsing levels are returned.
2026-05-16 20:15:03 +08:00
Will Miao
21872a8e9e fix(ui): default_active in group mode should not propagate to children; hide group badge/edit for single-child groups (#929) 2026-05-16 16:52:06 +08:00
Will Miao
612612f1c7 feat(ui): add Open Source URL action to recipe modal header, align header styles with model modal 2026-05-16 16:11:14 +08:00
34 changed files with 1034 additions and 343 deletions

View File

@@ -15,215 +15,222 @@
"Phil", "Phil",
"Carl G.", "Carl G.",
"Arlecchino Shion", "Arlecchino Shion",
"stone9k",
"$MetaSamsara", "$MetaSamsara",
"Rob Williams",
"stone9k",
"runte3221",
"Kiba",
"Mozzel",
"itismyelement", "itismyelement",
"Gingko Biloba", "Gingko Biloba",
"onesecondinosaur", "onesecondinosaur",
"Christian Byrne",
"DM",
"Sen314",
"Estragon",
"Takkan", "Takkan",
"Charles Blakemore", "Charles Blakemore",
"Rob Williams",
"Rosenthal", "Rosenthal",
"ClockDaemon",
"Francisco Tatis", "Francisco Tatis",
"Tobi_Swagg", "Tobi_Swagg",
"SG",
"jmack",
"Andrew Wilson", "Andrew Wilson",
"Greybush", "Greybush",
"iamresist",
"Wolffen",
"Ricky Carter", "Ricky Carter",
"JongWon Han", "JongWon Han",
"VantAI", "VantAI",
"runte3221", "Tim",
"Michael Wong",
"Illrigger", "Illrigger",
"Tom Corrigan",
"JackieWang",
"FreelancerZ", "FreelancerZ",
"fnkylove",
"Echo",
"Lilleman",
"Robert Stacey",
"PM",
"Edgar Tejeda", "Edgar Tejeda",
"Jorge Hussni", "Jorge Hussni",
"Liam MacDougal", "Liam MacDougal",
"Sterilized",
"Fraser Cross", "Fraser Cross",
"Polymorphic Indeterminate", "Polymorphic Indeterminate",
"Marc Whiffen", "Marc Whiffen",
"Birdy", "Birdy",
"Skalabananen", "Skalabananen",
"Kiba", "quarz",
"Reno Lam", "Reno Lam",
"Mozzel", "JSST",
"sig", "sig",
"Christian Byrne",
"DM",
"Sen314",
"Estragon",
"J\\B/ 8r0wns0n", "J\\B/ 8r0wns0n",
"Snaggwort", "Snaggwort",
"ClockDaemon", "Baekdoosixt",
"Jonathan Ross", "Jonathan Ross",
"KD", "KD",
"Omnidex", "Omnidex",
"Nazono_hito", "Nazono_hito",
"Melville Parrish",
"daniel dove",
"Lustre",
"Tyler Trebuchon", "Tyler Trebuchon",
"Release Cabrakan", "Release Cabrakan",
"JW Sin",
"contrite831", "contrite831",
"SG", "Alex",
"carozzz", "carozzz",
"Marlon Daniels",
"James Dooley", "James Dooley",
"zenbound", "zenbound",
"Buzzard", "Buzzard",
"jmack",
"Adam Shaw", "Adam Shaw",
"Mark Corneglio", "Mark Corneglio",
"SarcasticHashtag", "SarcasticHashtag",
"Anthony Rizzo", "Anthony Rizzo",
"iamresist",
"Gooohokrbe", "Gooohokrbe",
"RedrockVP", "RedrockVP",
"Wolffen",
"James Todd", "James Todd",
"ASLPro3D",
"OldBones", "OldBones",
"FinalyFree",
"Steven Pfeiffer", "Steven Pfeiffer",
"Tim",
"Timmy", "Timmy",
"Johnny", "Johnny",
"Lisster",
"Michael Wong",
"whudunit",
"Tom Corrigan",
"dl0901dm",
"JackieWang",
"fnkylove",
"Yushio",
"Vik71it",
"Echo",
"Lilleman",
"Robert Stacey",
"PM",
"Todd Keck",
"Briton Heilbrun",
"Aleksander Wujczyk",
"BadassArabianMofo",
"Sterilized",
"Pascal Dahle",
"quarz",
"Penfore",
"Greg",
"JSST",
"lmsupporter",
"zounic",
"wfpearl",
"Baekdoosixt",
"Jack B Nimble",
"Melville Parrish",
"daniel dove",
"Lustre",
"JW Sin",
"Alex",
"bh",
"Marlon Daniels",
"Starkselle",
"Aaron Bleuer",
"LacesOut!",
"greebles",
"Cosmosis",
"M Postkasse",
"FloPro4Sho",
"ASLPro3D",
"Jacob Hoehler",
"FinalyFree",
"Weasyl",
"Lex Song",
"Cory Paza",
"Tak", "Tak",
"Gonzalo Andre Allendes Lopez", "Lisster",
"Zach Gonser", "Zach Gonser",
"Big Red", "Big Red",
"Jimmy Ledbetter", "whudunit",
"Luc Job", "Luc Job",
"Philip Hempel", "dl0901dm",
"corde", "corde",
"Nick Walker", "Nick Walker",
"Julian V", "Yushio",
"Steven Owens", "Vik71it",
"Bishoujoker", "Bishoujoker",
"aai", "Todd Keck",
"Briton Heilbrun",
"Tori", "Tori",
"wildnut", "wildnut",
"jean jahren", "jean jahren",
"Aleksander Wujczyk",
"AM Kuro", "AM Kuro",
"ViperC", "BadassArabianMofo",
"Ran C", "Pascal Dahle",
"Sangheili460", "Penfore",
"Greg",
"MagnaInsomnia", "MagnaInsomnia",
"Karl P.",
"Akira_HentAI", "Akira_HentAI",
"Gordon Cole", "Gordon Cole",
"yuxz69", "AbstractAss",
"esthe", "lmsupporter",
"andrew.tappan", "andrew.tappan",
"N/A", "N/A",
"Greenmoustache",
"zounic",
"wfpearl",
"Eldithor",
"Jack B Nimble",
"JaxMax",
"bh",
"Jwk0205",
"Starkselle",
"Olive",
"Aaron Bleuer",
"LacesOut!",
"greebles",
"Some Guy Named Barry",
"Cosmosis",
"M Postkasse",
"FloPro4Sho",
"wamekukyouzin",
"Jacob Hoehler",
"Matt Wenzel",
"Weasyl",
"Lex Song",
"Cory Paza",
"Gonzalo Andre Allendes Lopez",
"Serge Bekenkamp",
"Jimmy Ledbetter",
"Philip Hempel",
"ApathyJones",
"Julian V",
"Steven Owens",
"dan",
"aai",
"Mouthlessman",
"otaku fra",
"ViperC",
"Ran C",
"MiraiKuriyamaSy",
"Sangheili460",
"Karl P.",
"yuxz69",
"Adam Taylor",
"Weird_With_A_Beard",
"esthe",
"The Spawn", "The Spawn",
"graysock", "graysock",
"Pozadine1", "Pozadine1",
"Greenmoustache",
"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",
"AELOX",
"Dankin",
"Nicfit23",
"wamekukyouzin",
"drum matthieu",
"Dogmaster",
"Matt Wenzel",
"Frank Nitty",
"Pronredn",
"Christopher Michel",
"Serge Bekenkamp",
"DougPeterson",
"LeoZero",
"Antonio Pontes",
"ApathyJones",
"nahinahi9",
"lh qwe",
"Kevin John Duck",
"conner",
"Dustin Chen",
"dan",
"Blackfish95",
"Mouthlessman",
"Princess Bright Eyes",
"Paul Kroll",
"AbstractAss",
"otaku fra",
"Felipe dos Santos",
"Bas Imagineer",
"Markus",
"MiraiKuriyamaSy",
"Adam Taylor",
"Douglas Gaspar",
"Weird_With_A_Beard",
"AlexDuKaNa",
"George",
"dw",
"Qarob", "Qarob",
"AIGooner", "AIGooner",
"Luc", "Luc",
"ProtonPrince", "ProtonPrince",
"DiffDuck", "DiffDuck",
"fancypants",
"IamAyam",
"Joboshy",
"Digital",
"takyamtom",
"Bohemian Corporal",
"Dan",
"confiscated Zyra",
"Bro Xie",
"yer fey",
"batblue",
"carey6409",
"太郎 ゲーム",
"Roslynd",
"Tee Gee",
"jinxedx",
"tarek helmi",
"Neco28",
"Max Marklund",
"AELOX",
"Dankin",
"Nicfit23",
"Cristian Vazquez",
"drum matthieu",
"Dogmaster",
"Frank Nitty",
"Magic Noob",
"Pronredn",
"Christopher Michel",
"DougPeterson",
"LeoZero",
"Antonio Pontes",
"Bruce",
"nahinahi9",
"lh qwe",
"Kevin John Duck",
"conner",
"Dustin Chen",
"Blackfish95",
"Princess Bright Eyes",
"Paul Kroll",
"Felipe dos Santos",
"Bas Imagineer",
"Markus",
"John Statham",
"Douglas Gaspar",
"AlexDuKaNa",
"George",
"dw",
"decoy",
"elu3199", "elu3199",
"Hasturkun", "Hasturkun",
"Jon Sandman", "Jon Sandman",
@@ -233,56 +240,59 @@
"wundershark", "wundershark",
"mr_dinosaur", "mr_dinosaur",
"Tyrswood", "Tyrswood",
"Ray Wing",
"Ranzitho",
"Gus",
"MJG",
"David LaVallee",
"linnfrey", "linnfrey",
"Pkrsky",
"奚明 刘", "奚明 刘",
"Josef Lanzl", "Josef Lanzl",
"Nerezza", "Nerezza",
"sanborondon",
"Griffin Dahlberg", "Griffin Dahlberg",
"준희 김", "준희 김",
"Error_Rule34_Not_found", "Error_Rule34_Not_found",
"Taylor Funk",
"aezin",
"jcay015",
"Gerald Welly", "Gerald Welly",
"Roslynd", "Erik Lopez",
"Mateo Curić",
"Geolog", "Geolog",
"Neco28", "Eris3D",
"Tomohiro Baba", "Tomohiro Baba",
"David Ortega", "David Ortega",
"Noora", "Noora",
"Cristian Vazquez",
"Mattssn", "Mattssn",
"Magic Noob", "a _",
"Jeff", "Jeff",
"Bruce", "James Coleman",
"Kevin Christopher", "Kevin Christopher",
"Emil Andersson",
"Ouro Boros", "Ouro Boros",
"Chad Idk", "Chad Idk",
"Yaboi",
"dd", "dd",
"Steam Steam", "Steam Steam",
"CryptoTraderJK", "CryptoTraderJK",
"Davaitamin", "Davaitamin",
"Dušan Ryban", "Dušan Ryban",
"tedcor", "tedcor",
"Sam",
"Fotek Design", "Fotek Design",
"sjon kreutz", "sjon kreutz",
"John Statham",
"MadSpin", "MadSpin",
"Metryman55", "Metryman55",
"inbijiburu", "inbijiburu",
"decoy",
"Nick “Loadstone” D", "Nick “Loadstone” D",
"Ray Wing",
"Ranzitho",
"Gus",
"地獄の禄", "地獄の禄",
"MJG",
"David LaVallee",
"ae", "ae",
"Tr4shP4nda", "Tr4shP4nda",
"Gamalonia", "Gamalonia",
"WRL_SPR", "WRL_SPR",
"capn", "capn",
"Joseph", "Joseph",
"momokai",
"Mirko Katzula", "Mirko Katzula",
"dan", "dan",
"Piccio08", "Piccio08",
@@ -296,54 +306,57 @@
"kudari", "kudari",
"Naomi Hale Danchi", "Naomi Hale Danchi",
"dc7431", "dc7431",
"epicgamer0020690",
"Joshua Porrata",
"SuBu",
"RedPIXel",
"Vir", "Vir",
"Richard",
"Andrew",
"Brian M", "Brian M",
"sanborondon", "Robert Wegemund",
"Seth Christensen", "Littlehuggy",
"Draven T", "Draven T",
"Taylor Funk", "mrjuan",
"aezin", "Brian Buie",
"Thought2Form", "Thought2Form",
"jcay015",
"Kevin Picco", "Kevin Picco",
"Erik Lopez", "Sadlip",
"Mateo Curić",
"Aquatic Coffee", "Aquatic Coffee",
"Eris3D",
"m", "m",
"ethanfel", "ethanfel",
"Pierce McBride", "Pierce McBride",
"Joshua Gray", "Joshua Gray",
"Focuschannel", "Focuschannel",
"Mikko Hemilä", "Mikko Hemilä",
"Jacob McDaniel",
"Jamie Ogletree", "Jamie Ogletree",
"a _", "Temikus",
"James Coleman", "Artokun",
"Michael Taylor",
"Derek Baker",
"Martial", "Martial",
"Anthony Faxlandez", "Anthony Faxlandez",
"battu", "battu",
"Emil Andersson", "Michael Anthony Scott",
"Atilla Berke Pekduyar",
"Decx _",
"Yuji Kaneko", "Yuji Kaneko",
"Pat Hen", "Pat Hen",
"semicolon drainpipe",
"Jordan Shaw", "Jordan Shaw",
"Rops Alot", "Rops Alot",
"Thesharingbrother", "Thesharingbrother",
"Sam",
"Ace Ventura", "Ace Ventura",
"ResidentDeviant", "ResidentDeviant",
"四糸凜音",
"Nihongasuki", "Nihongasuki",
"JC", "JC",
"Prompt Pirate", "Prompt Pirate",
"uwutismxd", "uwutismxd",
"momokai",
"zenobeus", "zenobeus",
"ken", "ken",
"epicgamer0020690", "Crocket",
"Joshua Porrata",
"keemun", "keemun",
"SuBu",
"RedPIXel",
"Wind", "Wind",
"Jackthemind", "Jackthemind",
"Nexus", "Nexus",
@@ -362,21 +375,26 @@
"socrasteeze", "socrasteeze",
"OrganicArtifact", "OrganicArtifact",
"Stryker", "Stryker",
"ResidentDeviant",
"MudkipMedkitz", "MudkipMedkitz",
"deanbrian",
"Alex Wortman",
"Cody",
"smart.edge5178",
"InformedViewz",
"CHKeeho80",
"Bubbafett",
"leaf",
"Menard",
"Skyfire83",
"Adam Rinehart",
"gzmzmvp", "gzmzmvp",
"raf8osz", "raf8osz",
"ElitaSSJ4", "ElitaSSJ4",
"Richard",
"blikkies", "blikkies",
"Andrew",
"Chris", "Chris",
"Robert Wegemund",
"Littlehuggy",
"Gregory Kozhemiak", "Gregory Kozhemiak",
"mrjuan",
"Brian Buie",
"Shock Shockor", "Shock Shockor",
"Sadlip",
"Goldwaters", "Goldwaters",
"Eric Whitney", "Eric Whitney",
"Joey Callahan", "Joey Callahan",
@@ -390,30 +408,20 @@
"Theerat Jiramate", "Theerat Jiramate",
"aRtFuL_DodGeR", "aRtFuL_DodGeR",
"Noah", "Noah",
"Jacob McDaniel",
"X", "X",
"Sloan Steddy", "Sloan Steddy",
"Temikus", "hexxish",
"Artokun",
"Michael Taylor",
"Derek Baker",
"CrimsonDX",
"Michael Anthony Scott",
"DarkSunset", "DarkSunset",
"Atilla Berke Pekduyar",
"Nathan", "Nathan",
"Billy Gladky", "Billy Gladky",
"NICHOLAS BAXLEY", "NICHOLAS BAXLEY",
"Decx _",
"Probis", "Probis",
"Ed Wang", "Ed Wang",
"ItsGeneralButtNaked", "ItsGeneralButtNaked",
"Nimess",
"SRDB", "SRDB",
"g unit", "g unit",
"Distortik", "Distortik",
"Youguang", "Youguang",
"四糸凜音",
"Saya", "Saya",
"andrewzpong", "andrewzpong",
"FrxzenSnxw", "FrxzenSnxw",
@@ -421,40 +429,38 @@
"lrdchs", "lrdchs",
"Tree Tagger", "Tree Tagger",
"Inversity", "Inversity",
"Crocket",
"AIVORY3D", "AIVORY3D",
"Kevinj", "Kevinj",
"Mitchell Robson", "Mitchell Robson",
"Whitepinetrader", "Whitepinetrader",
"ResidentDeviant",
"deanbrian",
"POPPIN", "POPPIN",
"Alex Wortman", "Ginnie",
"Cody",
"Raku", "Raku",
"smart.edge5178", "emadsultan",
"InformedViewz",
"CHKeeho80",
"Bubbafett",
"leaf",
"Menard",
"Skyfire83",
"Adam Rinehart",
"Pitpe11", "Pitpe11",
"TheD1rtyD03", "TheD1rtyD03",
"moonpetal", "moonpetal",
"SomeDude", "SomeDude",
"g9p0o", "g9p0o",
"Pkrsky",
"TheHolySheep", "TheHolySheep",
"Monte Won", "Monte Won",
"SpringBootisTrash", "SpringBootisTrash",
"carsten", "carsten",
"ikok", "ikok",
"quantenmecha",
"Jason+Nash",
"BillyBoy84",
"DarkRoast",
"letzte",
"Nasty+Hobbit",
"Sora+Yori",
"lrdchs2",
"Duk3+Rand0m",
"Nathen+Choi", "Nathen+Choi",
"T", "T",
"LarsesFPC", "LarsesFPC",
"cocona", "cocona",
"sfasdfasfdsa",
"Buecyb99", "Buecyb99",
"Welkor", "Welkor",
"David Schenck", "David Schenck",
@@ -463,15 +469,15 @@
"Ink Temptation", "Ink Temptation",
"moranqianlong", "moranqianlong",
"Kalli Core", "Kalli Core",
"Time Valentine",
"elleshar666", "elleshar666",
"ACTUALLY_the_Real_Willem_Dafoe", "ACTUALLY_the_Real_Willem_Dafoe",
"Haru Yotu", "Михал Михалыч",
"Matt",
"Kauffy", "Kauffy",
"EpicElric",
"Kyron Mahan", "Kyron Mahan",
"Edward Kennedy", "Edward Kennedy",
"Justin Blaylock", "Justin Blaylock",
"Matura Arbeit",
"Nick Kage", "Nick Kage",
"TBitz33", "TBitz33",
"Anonym dkjglfleeoeldldldlkf", "Anonym dkjglfleeoeldldldlkf",
@@ -480,12 +486,14 @@
"Cyrus Fett", "Cyrus Fett",
"Ezokewn", "Ezokewn",
"SendingRavens", "SendingRavens",
"hexxish", "Xenon Xue",
"notedfakes", "notedfakes",
"Michael Docherty", "Michael Docherty",
"Michael Scott", "Michael Scott",
"Paul Hartsuyker", "Paul Hartsuyker",
"Henrique Faiolli",
"elitassj", "elitassj",
"Solixer",
"Jacob Winter", "Jacob Winter",
"Ryan Presley Ng", "Ryan Presley Ng",
"Wes Sims", "Wes Sims",
@@ -494,7 +502,6 @@
"David", "David",
"Meilo", "Meilo",
"Filippo Ferrari", "Filippo Ferrari",
"Pen Bouryoung",
"shinonomeiro", "shinonomeiro",
"Snille", "Snille",
"MaartenAlbers", "MaartenAlbers",
@@ -511,12 +518,21 @@
"Kalnei", "Kalnei",
"Scott", "Scott",
"Muratoraccio", "Muratoraccio",
"Ginnie",
"emadsultan",
"D", "D",
"nanana", "nanana",
"Dark_Pest",
"Alex",
"Jacky+Ho",
"Karru",
"ghoulars",
"ChaChanoKo",
"null",
"Beau",
"redcarrot",
"powerbot99",
"Fthehappy", "Fthehappy",
"rsamerica", "rsamerica",
"sfasdfasfdsa",
"Alan+Cano", "Alan+Cano",
"FeralOpticsAI", "FeralOpticsAI",
"Pavlaki", "Pavlaki",
@@ -524,60 +540,50 @@
"Doug+Rintoul", "Doug+Rintoul",
"Noor", "Noor",
"Yorunai", "Yorunai",
"quantenmecha",
"abattoirblues", "abattoirblues",
"Jason+Nash",
"BillyBoy84",
"zounik", "zounik",
"DarkRoast",
"letzte",
"Nasty+Hobbit",
"Sora+Yori",
"lrdchs2",
"Duk3+Rand0m",
"4IXplr0r3r", "4IXplr0r3r",
"hayden", "hayden",
"ahoystan", "ahoystan",
"Leland Saunders",
"Bob Barker", "Bob Barker",
"edk", "edk",
"JBsuede", "JBsuede",
"Time Valentine", "Christian Schäfer",
"Aeternyx",
"YOU SINWOO",
"りん あめ", "りん あめ",
"ja s", "ja s",
"Михал Михалыч",
"Matt",
"Doug Mason", "Doug Mason",
"Jeremy Townsend", "Jeremy Townsend",
"Locrospiel",
"Frogmilk", "Frogmilk",
"Sean voets", "Sean voets",
"Owen Gwosdz", "Owen Gwosdz",
"SPJ", "SPJ",
"Thomas Wanner", "Kor",
"Joseph Hanson",
"Bryan Rutkowski", "Bryan Rutkowski",
"Devil Lude", "Devil Lude",
"David Murcko", "David Murcko",
"kevin stoddard",
"Jack Dole", "Jack Dole",
"max blo", "max blo",
"Xenon Xue", "Steven",
"CptNeo", "CptNeo",
"JackJohnnyJim", "JackJohnnyJim",
"TenaciousD",
"Dmitry Ryzhov", "Dmitry Ryzhov",
"Khánh Đặng",
"Maso", "Maso",
"Edward Ten Eyck", "Edward Ten Eyck",
"Eric Ketchum", "Eric Ketchum",
"Kevin Wallace", "Kevin Wallace",
"Matheus Couto", "Jimmy Borup",
"ChicRic", "ChicRic",
"Henrique Faiolli",
"mercur", "mercur",
"Solixer", "Pete Pain",
"J C", "RHopkirk",
"jinksta187", "jinksta187",
"Andrew Wilkinson", "Andrew Wilkinson",
"Yavizu3d",
"Maxim",
"Manu Thetug", "Manu Thetug",
"Karlanx", "Karlanx",
"Yves Poezevara", "Yves Poezevara",
@@ -629,6 +635,20 @@
"SelfishMedic", "SelfishMedic",
"adderleighn", "adderleighn",
"EnragedAntelope", "EnragedAntelope",
"Drizzly",
"Sildoren",
"Darvidous",
"Seon+Song",
"2turbo",
"balut+omelette",
"Nebuleux",
"Dmitry+Viznesenskiy",
"Tanjin90",
"Somebody",
"sternenkrieger",
"eriick",
"Join+Chun",
"Pascalou",
"lighthawke", "lighthawke",
"Terraformer", "Terraformer",
"GDS+DEV", "GDS+DEV",
@@ -651,77 +671,66 @@
"D", "D",
"datasl4ve", "datasl4ve",
"Somebody", "Somebody",
"Dark_Pest",
"Aza",
"Jacky+Ho",
"koopa990", "koopa990",
"Karru",
"ChaChanoKo",
"null",
"bo",
"The+Forgetful+Dev", "The+Forgetful+Dev",
"redcarrot",
"powerbot99",
"Mateusz+Kosela", "Mateusz+Kosela",
"Bula", "Bula",
"KUJYAKU", "KUJYAKU",
"Coeur+de+cochon", "Coeur+de+cochon",
"han b", "han b",
"Nico", "Nico",
"Maximilian Krischan",
"Banana Joe", "Banana Joe",
"_ G3n", "_ G3n",
"Donovan Jenkins", "Donovan Jenkins",
"Tú Nguyễn Lý Hoàng", "Tú Nguyễn Lý Hoàng",
"shira1011",
"Michael Eid", "Michael Eid",
"beersandbacon", "beersandbacon",
"Maximilian Pyko",
"Invis",
"Bob barker", "Bob barker",
"Ben D", "Ben D",
"Garrett Wood", "G",
"Ronan Delevacq", "Ronan Delevacq",
"james", "james",
"Christian Schäfer",
"OrochiNights",
"Michael Zhu", "Michael Zhu",
"gonzalo", "Nemisu",
"Seraphy", "Seraphy",
"雨の心 落", "雨の心 落",
"AllTimeNoobie", "AllTimeNoobie",
"Leslie Andrew Ridings",
"jumpd", "jumpd",
"John C", "John C",
"Rim", "Rim",
"Dave Abraham", "Dave Abraham",
"Joaquin Hierrezuelo", "Joaquin Hierrezuelo",
"Dismem",
"Locrospiel",
"Jairus Knudsen", "Jairus Knudsen",
"Jarrid Lee", "Jarrid Lee",
"Poophead27 Blyat",
"Xan Dionysus", "Xan Dionysus",
"Nathan lee", "Nathan lee",
"Kor",
"Joseph Hanson",
"Mewtora",
"Middo", "Middo",
"Forbidden Atelier", "Forbidden Atelier",
"John Rednoulf", "John Rednoulf",
"Spire", "Spire",
"DrB",
"AZ Party Oasis",
"Adictedtohumping", "Adictedtohumping",
"Boba Smith", "Boba Smith",
"Towelie", "Towelie",
"MR.Bear", "MR.Bear",
"matt",
"dsffsdfsdfsdfsdfsdf", "dsffsdfsdfsdfsdfsdf",
"somethingtosay8",
"Jean-françois SEMA", "Jean-françois SEMA",
"Kurt", "Kurt",
"ivistorm", "ivistorm",
"Sauv", "Sauv",
"Steven", "jimyjomson",
"TenaciousD", "Borte",
"Khánh Đặng",
"Chase Kwon", "Chase Kwon",
"Ted Cart", "Ted Cart",
"Sage Himeros",
"Inyoshu", "Inyoshu",
"Goober719",
"Chad Barnes", "Chad Barnes",
"Person Y", "Person Y",
"David Spearing", "David Spearing",
@@ -740,7 +749,8 @@
"dxjaymz", "dxjaymz",
"L C", "L C",
"Dude", "Dude",
"Somebody",
"CK" "CK"
], ],
"totalCount": 739 "totalCount": 749
} }

View File

@@ -233,6 +233,7 @@
"noCreditRequired": "Kein Credit erforderlich", "noCreditRequired": "Kein Credit erforderlich",
"allowSellingGeneratedContent": "Verkauf erlaubt", "allowSellingGeneratedContent": "Verkauf erlaubt",
"noTags": "Keine Tags", "noTags": "Keine Tags",
"autoTags": "Auto-Tags",
"noBaseModelMatches": "Keine Basismodelle entsprechen der aktuellen Suche.", "noBaseModelMatches": "Keine Basismodelle entsprechen der aktuellen Suche.",
"clearAll": "Alle Filter löschen", "clearAll": "Alle Filter löschen",
"any": "Beliebig", "any": "Beliebig",

View File

@@ -233,6 +233,7 @@
"noCreditRequired": "No Credit Required", "noCreditRequired": "No Credit Required",
"allowSellingGeneratedContent": "Allow Selling", "allowSellingGeneratedContent": "Allow Selling",
"noTags": "No tags", "noTags": "No tags",
"autoTags": "Auto Tags",
"noBaseModelMatches": "No base models match the current search.", "noBaseModelMatches": "No base models match the current search.",
"clearAll": "Clear All Filters", "clearAll": "Clear All Filters",
"any": "Any", "any": "Any",

View File

@@ -233,6 +233,7 @@
"noCreditRequired": "Sin crédito requerido", "noCreditRequired": "Sin crédito requerido",
"allowSellingGeneratedContent": "Venta permitida", "allowSellingGeneratedContent": "Venta permitida",
"noTags": "Sin etiquetas", "noTags": "Sin etiquetas",
"autoTags": "Etiquetas automáticas",
"noBaseModelMatches": "Ningún modelo base coincide con la búsqueda actual.", "noBaseModelMatches": "Ningún modelo base coincide con la búsqueda actual.",
"clearAll": "Limpiar todos los filtros", "clearAll": "Limpiar todos los filtros",
"any": "Cualquiera", "any": "Cualquiera",

View File

@@ -233,6 +233,7 @@
"noCreditRequired": "Crédit non requis", "noCreditRequired": "Crédit non requis",
"allowSellingGeneratedContent": "Vente autorisée", "allowSellingGeneratedContent": "Vente autorisée",
"noTags": "Aucun tag", "noTags": "Aucun tag",
"autoTags": "Auto-Tags",
"noBaseModelMatches": "Aucun modèle de base ne correspond à la recherche actuelle.", "noBaseModelMatches": "Aucun modèle de base ne correspond à la recherche actuelle.",
"clearAll": "Effacer tous les filtres", "clearAll": "Effacer tous les filtres",
"any": "N'importe quel", "any": "N'importe quel",

View File

@@ -233,6 +233,7 @@
"noCreditRequired": "ללא קרדיט נדרש", "noCreditRequired": "ללא קרדיט נדרש",
"allowSellingGeneratedContent": "אפשר מכירה", "allowSellingGeneratedContent": "אפשר מכירה",
"noTags": "ללא תגיות", "noTags": "ללא תגיות",
"autoTags": "תגיות אוטומטיות",
"noBaseModelMatches": "אין מודלי בסיס התואמים לחיפוש הנוכחי.", "noBaseModelMatches": "אין מודלי בסיס התואמים לחיפוש הנוכחי.",
"clearAll": "נקה את כל המסננים", "clearAll": "נקה את כל המסננים",
"any": "כלשהו", "any": "כלשהו",

View File

@@ -233,6 +233,7 @@
"noCreditRequired": "クレジット不要", "noCreditRequired": "クレジット不要",
"allowSellingGeneratedContent": "販売許可", "allowSellingGeneratedContent": "販売許可",
"noTags": "タグなし", "noTags": "タグなし",
"autoTags": "自動タグ",
"noBaseModelMatches": "現在の検索に一致するベースモデルはありません。", "noBaseModelMatches": "現在の検索に一致するベースモデルはありません。",
"clearAll": "すべてのフィルタをクリア", "clearAll": "すべてのフィルタをクリア",
"any": "いずれか", "any": "いずれか",

View File

@@ -233,6 +233,7 @@
"noCreditRequired": "크레딧 표기 없음", "noCreditRequired": "크레딧 표기 없음",
"allowSellingGeneratedContent": "판매 허용", "allowSellingGeneratedContent": "판매 허용",
"noTags": "태그 없음", "noTags": "태그 없음",
"autoTags": "자동 태그",
"noBaseModelMatches": "현재 검색과 일치하는 베이스 모델이 없습니다.", "noBaseModelMatches": "현재 검색과 일치하는 베이스 모델이 없습니다.",
"clearAll": "모든 필터 지우기", "clearAll": "모든 필터 지우기",
"any": "아무", "any": "아무",

View File

@@ -233,6 +233,7 @@
"noCreditRequired": "Без указания авторства", "noCreditRequired": "Без указания авторства",
"allowSellingGeneratedContent": "Продажа разрешена", "allowSellingGeneratedContent": "Продажа разрешена",
"noTags": "Без тегов", "noTags": "Без тегов",
"autoTags": "Авто-теги",
"noBaseModelMatches": "Нет базовых моделей, соответствующих текущему поиску.", "noBaseModelMatches": "Нет базовых моделей, соответствующих текущему поиску.",
"clearAll": "Очистить все фильтры", "clearAll": "Очистить все фильтры",
"any": "Любой", "any": "Любой",

View File

@@ -233,6 +233,7 @@
"noCreditRequired": "无需署名", "noCreditRequired": "无需署名",
"allowSellingGeneratedContent": "允许销售", "allowSellingGeneratedContent": "允许销售",
"noTags": "无标签", "noTags": "无标签",
"autoTags": "自动标签",
"noBaseModelMatches": "没有基础模型符合当前搜索。", "noBaseModelMatches": "没有基础模型符合当前搜索。",
"clearAll": "清除所有筛选", "clearAll": "清除所有筛选",
"any": "任一", "any": "任一",

View File

@@ -233,6 +233,7 @@
"noCreditRequired": "無需署名", "noCreditRequired": "無需署名",
"allowSellingGeneratedContent": "允許銷售", "allowSellingGeneratedContent": "允許銷售",
"noTags": "無標籤", "noTags": "無標籤",
"autoTags": "自動標籤",
"noBaseModelMatches": "沒有基礎模型符合目前的搜尋。", "noBaseModelMatches": "沒有基礎模型符合目前的搜尋。",
"clearAll": "清除所有篩選", "clearAll": "清除所有篩選",
"any": "任一", "any": "任一",

View File

@@ -301,6 +301,15 @@ class ModelListingHandler:
for tag in exclude_tags: for tag in exclude_tags:
if tag: if tag:
tag_filters[tag] = "exclude" tag_filters[tag] = "exclude"
auto_tag_filters: Dict[str, str] = {}
for tag in request.query.getall("auto_tag_include", []):
if tag:
auto_tag_filters[tag] = "include"
for tag in request.query.getall("auto_tag_exclude", []):
if tag:
auto_tag_filters[tag] = "exclude"
favorites_only = request.query.get("favorites_only", "false").lower() == "true" favorites_only = request.query.get("favorites_only", "false").lower() == "true"
search_options = { search_options = {
@@ -367,6 +376,7 @@ class ModelListingHandler:
"fuzzy_search": fuzzy_search, "fuzzy_search": fuzzy_search,
"base_models": base_models, "base_models": base_models,
"tags": tag_filters, "tags": tag_filters,
"auto_tags": auto_tag_filters,
"tag_logic": tag_logic, "tag_logic": tag_logic,
"search_options": search_options, "search_options": search_options,
"hash_filters": hash_filters, "hash_filters": hash_filters,

View File

@@ -871,28 +871,47 @@ class RecipeManagementHandler:
"Failed to extract embedded metadata during import: %s", exc "Failed to extract embedded metadata during import: %s", exc
) )
# Fallback: if EXIF extraction yielded nothing, parse Civitai API meta directly # Parse CivitAI API meta to discover all resources from modelVersionIds
# (same approach as analyze_remote_image — downloaded Civitai images often # (modelVersionIds is injected at root level by _download_remote_media).
# have no embedded EXIF but the API meta contains resources/hashes) # Run unconditionally — EXIF parsing may succeed for gen_params but miss
if parsed_embedded is None and civitai_meta_raw: # LoRAs since modelVersionIds is NOT embedded in the image EXIF.
civitai_parsed = None
if civitai_meta_raw:
civitai_inner_meta = civitai_meta_raw civitai_inner_meta = civitai_meta_raw
if isinstance(civitai_meta_raw, dict) and "meta" in civitai_meta_raw: if isinstance(civitai_meta_raw, dict) and "meta" in civitai_meta_raw:
civitai_inner_meta = civitai_meta_raw["meta"] civitai_inner_meta = civitai_meta_raw["meta"]
# modelVersionIds lives at outer meta level; propagate after unwrap
_mvids = civitai_meta_raw.get("modelVersionIds")
if _mvids and isinstance(civitai_inner_meta, dict):
civitai_inner_meta["modelVersionIds"] = _mvids
if isinstance(civitai_inner_meta, dict): if isinstance(civitai_inner_meta, dict):
parser = self._analysis_service._recipe_parser_factory.create_parser( parser = self._analysis_service._recipe_parser_factory.create_parser(
civitai_inner_meta civitai_inner_meta
) )
if parser: if parser:
parsed_embedded = await parser.parse_metadata( civitai_parsed = await parser.parse_metadata(
civitai_inner_meta, recipe_scanner=recipe_scanner civitai_inner_meta, recipe_scanner=recipe_scanner
) )
if parsed_embedded and "gen_params" in parsed_embedded: if civitai_parsed and "gen_params" in civitai_parsed:
embedded_gen_params = parsed_embedded["gen_params"] # Merge: API gen_params override EXIF at field level,
# EXIF fills in fields the API doesn't have.
embedded_gen_params = {
**(embedded_gen_params or {}),
**civitai_parsed["gen_params"],
}
if embedded_gen_params: if embedded_gen_params:
metadata["gen_params"] = embedded_gen_params metadata["gen_params"] = embedded_gen_params
if parsed_embedded: # Merge LoRAs: prefer frontend resources, supplement with CivitAI modelVersionIds
if civitai_parsed:
civitai_loras = civitai_parsed.get("loras", [])
if civitai_loras and not metadata.get("loras"):
metadata["loras"] = civitai_loras
civitai_model = civitai_parsed.get("model")
if civitai_model and not metadata.get("checkpoint"):
metadata["checkpoint"] = civitai_model
elif parsed_embedded:
parsed_loras = parsed_embedded.get("loras") parsed_loras = parsed_embedded.get("loras")
if parsed_loras and not metadata.get("loras"): if parsed_loras and not metadata.get("loras"):
metadata["loras"] = parsed_loras metadata["loras"] = parsed_loras
@@ -1270,16 +1289,29 @@ class RecipeManagementHandler:
with open(temp_path, "rb") as file_obj: with open(temp_path, "rb") as file_obj:
model_ver_id = None model_ver_id = None
civitai_meta_raw = (
image_info.get("meta") if civitai_image_id and image_info else None
)
if civitai_image_id and image_info: if civitai_image_id and image_info:
model_ver_id = image_info.get("modelVersionId") model_ver_id = image_info.get("modelVersionId")
if not model_ver_id: if not model_ver_id:
ids = image_info.get("modelVersionIds") ids = image_info.get("modelVersionIds")
if isinstance(ids, list) and ids: if isinstance(ids, list) and ids:
model_ver_id = ids[0] model_ver_id = ids[0]
# Inject root-level modelVersionIds into meta so downstream
# parsers (CivitaiApiMetadataParser) can discover ALL resources
# (checkpoint + LoRAs), not just the first model version ID.
# CivitAI API returns modelVersionIds at the root level of
# the image response, NOT inside the meta object.
mvids = image_info.get("modelVersionIds")
if mvids and isinstance(civitai_meta_raw, dict):
civitai_meta_raw["modelVersionIds"] = mvids
return ( return (
file_obj.read(), file_obj.read(),
extension, extension,
image_info.get("meta") if civitai_image_id and image_info else None, civitai_meta_raw,
model_ver_id, model_ver_id,
) )
except RecipeDownloadError: except RecipeDownloadError:
@@ -1467,20 +1499,34 @@ class RecipeManagementHandler:
"Failed to extract embedded metadata: %s", exc "Failed to extract embedded metadata: %s", exc
) )
if parsed_embedded is None and civitai_meta_raw: # Parse CivitAI API meta to discover all resources from modelVersionIds.
# Run unconditionally — EXIF parsing succeeds for gen_params but misses
# LoRAs (modelVersionIds is NOT in the image EXIF).
civitai_parsed = None
if civitai_meta_raw:
civitai_inner_meta = civitai_meta_raw civitai_inner_meta = civitai_meta_raw
if isinstance(civitai_meta_raw, dict) and "meta" in civitai_meta_raw: if isinstance(civitai_meta_raw, dict) and "meta" in civitai_meta_raw:
civitai_inner_meta = civitai_meta_raw["meta"] civitai_inner_meta = civitai_meta_raw["meta"]
# Propagate modelVersionIds into unwrapped meta — it lives
# at the outer meta level in the CivitAI API response.
_mvids = civitai_meta_raw.get("modelVersionIds")
if _mvids and isinstance(civitai_inner_meta, dict):
civitai_inner_meta["modelVersionIds"] = _mvids
if isinstance(civitai_inner_meta, dict): if isinstance(civitai_inner_meta, dict):
parser = self._analysis_service._recipe_parser_factory.create_parser( parser = self._analysis_service._recipe_parser_factory.create_parser(
civitai_inner_meta civitai_inner_meta
) )
if parser: if parser:
parsed_embedded = await parser.parse_metadata( civitai_parsed = await parser.parse_metadata(
civitai_inner_meta, recipe_scanner=recipe_scanner civitai_inner_meta, recipe_scanner=recipe_scanner
) )
if parsed_embedded and "gen_params" in parsed_embedded: if civitai_parsed and "gen_params" in civitai_parsed:
embedded_gen_params = parsed_embedded["gen_params"] # Merge: API gen_params override EXIF at field level,
# EXIF fills in fields the API doesn't have.
embedded_gen_params = {
**(embedded_gen_params or {}),
**civitai_parsed["gen_params"],
}
metadata: Dict[str, Any] = { metadata: Dict[str, Any] = {
"base_model": "", "base_model": "",
@@ -1489,7 +1535,14 @@ class RecipeManagementHandler:
"source_path": image_url, "source_path": image_url,
} }
if parsed_embedded: if civitai_parsed:
civitai_loras = civitai_parsed.get("loras", [])
if civitai_loras and not metadata.get("loras"):
metadata["loras"] = civitai_loras
civitai_model = civitai_parsed.get("model")
if civitai_model and not metadata.get("checkpoint"):
metadata["checkpoint"] = civitai_model
elif parsed_embedded:
parsed_loras = parsed_embedded.get("loras") parsed_loras = parsed_embedded.get("loras")
if parsed_loras and not metadata.get("loras"): if parsed_loras and not metadata.get("loras"):
metadata["loras"] = parsed_loras metadata["loras"] = parsed_loras

View File

@@ -0,0 +1,121 @@
"""
Auto-tag extraction service for model cards.
Extracts implicit model attributes (HIGH/LOW, I2V/T2V/TI2V, Lightning, Turbo)
from filename, base_model, and CivitAI version name — no manual tagging required.
"""
from __future__ import annotations
import re
from typing import Dict, List, Set
# ── Tag category definitions ──────────────────────────────────────────
# Each category maps a display label to a regex pattern.
# Patterns are case-insensitive and matched against filename, base_model,
# and civitai version name.
# Use (?<![a-zA-Z0-9]) and (?![a-zA-Z0-9]) instead of \b because
# Python's \b treats underscore as a word character, so \bHIGH\b
# won't match '_HIGH_' in filenames.
_B = r"(?<![a-zA-Z0-9])" # left boundary
_E = r"(?![a-zA-Z0-9])" # right boundary
AUTO_TAG_CATEGORIES: Dict[str, str] = {
"HIGH": _B + r"HIGH" + _E,
"LOW": _B + r"(?<!F)LOW" + _E,
"I2V": _B + r"I2V" + _E,
"T2V": _B + r"T2V" + _E,
"TI2V": _B + r"TI2V" + _E,
"Lightning": _B + r"Lightning" + _E,
"Turbo": _B + r"Turbo" + _E,
}
# Tags that belong to the "mode" group (HIGH/LOW)
MODE_TAGS = {"HIGH", "LOW"}
# Tags that belong to the "video mode" group (I2V/T2V/TI2V)
VIDEO_MODE_TAGS = {"I2V", "T2V", "TI2V"}
# Tags that belong to the "speed/optimization" group
SPEED_TAGS = {"Lightning", "Turbo"}
# ── Display category groups (for settings UI) ─────────────────────────
AUTO_TAG_GROUPS = {
"mode": {"HIGH", "LOW"},
"video": {"I2V", "T2V", "TI2V"},
"speed": {"Lightning", "Turbo"},
}
# Default enabled categories
DEFAULT_ENABLED_GROUPS = {"mode", "video"}
def _collect_sources(model_data: Dict) -> List[str]:
"""Collect all text sources from model data for tag matching."""
sources: List[str] = []
file_name = model_data.get("file_name", "")
if file_name:
sources.append(file_name)
base_model = model_data.get("base_model", "")
if base_model:
sources.append(base_model)
civitai = model_data.get("civitai", {})
if isinstance(civitai, dict):
version_name = civitai.get("name", "")
if version_name:
sources.append(version_name)
return sources
def extract_auto_tags(model_data: Dict) -> List[str]:
"""Extract auto-detected tags from model metadata.
Matches predefined patterns against filename, base_model, and
CivitAI version name. Returns a sorted, deduplicated list of tag labels.
HIGH/LOW tags are only returned when the base_model indicates a Wan
family model — no other model architecture uses this distinction.
Args:
model_data: Model metadata dict with keys:
file_name, base_model, civitai (with optional 'name' field).
Returns:
Sorted list of unique auto-tag strings (e.g. ["I2V"]).
"""
sources = _collect_sources(model_data)
if not sources:
return []
base_model = model_data.get("base_model", "")
is_wan = "wan" in base_model.lower()
found: Set[str] = set()
for label, pattern in AUTO_TAG_CATEGORIES.items():
# HIGH/LOW are Wan-specific — skip for non-Wan to avoid noise
if label in ("HIGH", "LOW"):
if not is_wan:
continue
# Use case-insensitive character class + case-sensitive boundary,
# so "HighNoise" (camelCase) matches but "highlight" doesn't.
# Boundary: not followed by lowercase letter (= word has ended).
ci = "".join(f"[{c.lower()}{c.upper()}]" for c in label)
if label == "LOW":
regex = re.compile(r"(?<![Ff])" + ci + r"(?![a-z])")
else:
regex = re.compile(ci + r"(?![a-z])")
else:
regex = re.compile(pattern, re.IGNORECASE)
for source in sources:
if regex.search(source):
found.add(label)
break
return sorted(found)

View File

@@ -77,6 +77,7 @@ class BaseModelService(ABC):
base_models: list = None, base_models: list = None,
model_types: list = None, model_types: list = None,
tags: Optional[Dict[str, str]] = None, tags: Optional[Dict[str, str]] = None,
auto_tags: Optional[Dict[str, str]] = None,
search_options: dict = None, search_options: dict = None,
hash_filters: dict = None, hash_filters: dict = None,
favorites_only: bool = False, favorites_only: bool = False,
@@ -95,6 +96,11 @@ class BaseModelService(ABC):
sorted_data = await self._fetch_with_usage_sort(sort_params) sorted_data = await self._fetch_with_usage_sort(sort_params)
else: else:
sorted_data = await self.cache_repository.fetch_sorted(sort_params) sorted_data = await self.cache_repository.fetch_sorted(sort_params)
# Pre-compute auto_tags for every item — needed for both filtering
# and display. Computation is cheap (string regex on 2-3 fields).
from .auto_tag_service import extract_auto_tags
for item in sorted_data:
item["auto_tags"] = extract_auto_tags(item)
fetch_duration = time.perf_counter() - t0 fetch_duration = time.perf_counter() - t0
initial_count = len(sorted_data) initial_count = len(sorted_data)
@@ -110,6 +116,7 @@ class BaseModelService(ABC):
base_models=base_models, base_models=base_models,
model_types=model_types, model_types=model_types,
tags=tags, tags=tags,
auto_tags=auto_tags,
favorites_only=favorites_only, favorites_only=favorites_only,
search_options=search_options, search_options=search_options,
tag_logic=tag_logic, tag_logic=tag_logic,
@@ -354,6 +361,7 @@ class BaseModelService(ABC):
base_models: list = None, base_models: list = None,
model_types: list = None, model_types: list = None,
tags: Optional[Dict[str, str]] = None, tags: Optional[Dict[str, str]] = None,
auto_tags: Optional[Dict[str, str]] = None,
favorites_only: bool = False, favorites_only: bool = False,
search_options: dict = None, search_options: dict = None,
tag_logic: str = "any", tag_logic: str = "any",
@@ -367,6 +375,7 @@ class BaseModelService(ABC):
base_models=base_models, base_models=base_models,
model_types=model_types, model_types=model_types,
tags=tags, tags=tags,
auto_tags=auto_tags,
favorites_only=favorites_only, favorites_only=favorites_only,
search_options=normalized_options, search_options=normalized_options,
tag_logic=tag_logic, tag_logic=tag_logic,

View File

@@ -3,6 +3,7 @@ import logging
from typing import Dict from typing import Dict
from .base_model_service import BaseModelService from .base_model_service import BaseModelService
from .auto_tag_service import extract_auto_tags
from ..utils.models import CheckpointMetadata from ..utils.models import CheckpointMetadata
from ..config import config from ..config import config
@@ -45,7 +46,8 @@ class CheckpointService(BaseModelService):
"exclude": bool(checkpoint_data.get("exclude", False)), "exclude": bool(checkpoint_data.get("exclude", False)),
"update_available": bool(checkpoint_data.get("update_available", False)), "update_available": bool(checkpoint_data.get("update_available", False)),
"skip_metadata_refresh": bool(checkpoint_data.get("skip_metadata_refresh", False)), "skip_metadata_refresh": bool(checkpoint_data.get("skip_metadata_refresh", False)),
"civitai": self.filter_civitai_data(checkpoint_data.get("civitai", {}), minimal=True) "civitai": self.filter_civitai_data(checkpoint_data.get("civitai", {}), minimal=True),
"auto_tags": checkpoint_data.get("auto_tags") or extract_auto_tags(checkpoint_data),
} }
def find_duplicate_hashes(self) -> Dict: def find_duplicate_hashes(self) -> Dict:

View File

@@ -257,7 +257,7 @@ class CivitaiClient:
"GET", "GET",
f"{self.base_url}/models", f"{self.base_url}/models",
use_auth=True, use_auth=True,
params={"ids": query}, params={"ids": query, "nsfw": "true"},
) )
if not success: if not success:
return None return None
@@ -640,7 +640,7 @@ class CivitaiClient:
"GET", "GET",
f"{self.base_url}/models", f"{self.base_url}/models",
use_auth=True, use_auth=True,
params={"username": username}, params={"username": username, "nsfw": "true"},
) )
if not success: if not success:

View File

@@ -3,6 +3,7 @@ import logging
from typing import Dict from typing import Dict
from .base_model_service import BaseModelService from .base_model_service import BaseModelService
from .auto_tag_service import extract_auto_tags
from ..utils.models import EmbeddingMetadata from ..utils.models import EmbeddingMetadata
from ..config import config from ..config import config
@@ -45,7 +46,8 @@ class EmbeddingService(BaseModelService):
"exclude": bool(embedding_data.get("exclude", False)), "exclude": bool(embedding_data.get("exclude", False)),
"update_available": bool(embedding_data.get("update_available", False)), "update_available": bool(embedding_data.get("update_available", False)),
"skip_metadata_refresh": bool(embedding_data.get("skip_metadata_refresh", False)), "skip_metadata_refresh": bool(embedding_data.get("skip_metadata_refresh", False)),
"civitai": self.filter_civitai_data(embedding_data.get("civitai", {}), minimal=True) "civitai": self.filter_civitai_data(embedding_data.get("civitai", {}), minimal=True),
"auto_tags": embedding_data.get("auto_tags") or extract_auto_tags(embedding_data),
} }
def find_duplicate_hashes(self) -> Dict: def find_duplicate_hashes(self) -> Dict:

View File

@@ -5,6 +5,7 @@ from typing import Dict, List, Optional
from .base_model_service import BaseModelService from .base_model_service import BaseModelService
from .model_query import resolve_sub_type from .model_query import resolve_sub_type
from .auto_tag_service import extract_auto_tags
from ..utils.models import LoraMetadata from ..utils.models import LoraMetadata
from ..config import config from ..config import config
@@ -57,6 +58,7 @@ class LoraService(BaseModelService):
"civitai": self.filter_civitai_data( "civitai": self.filter_civitai_data(
lora_data.get("civitai", {}), minimal=True lora_data.get("civitai", {}), minimal=True
), ),
"auto_tags": lora_data.get("auto_tags") or extract_auto_tags(lora_data),
} }
async def _apply_specific_filters(self, data: List[Dict], **kwargs) -> List[Dict]: async def _apply_specific_filters(self, data: List[Dict], **kwargs) -> List[Dict]:

View File

@@ -96,6 +96,7 @@ class FilterCriteria:
folder_exclude: Optional[Sequence[str]] = None folder_exclude: Optional[Sequence[str]] = None
base_models: Optional[Sequence[str]] = None base_models: Optional[Sequence[str]] = None
tags: Optional[Dict[str, str]] = None tags: Optional[Dict[str, str]] = None
auto_tags: Optional[Dict[str, str]] = None
favorites_only: bool = False favorites_only: bool = False
search_options: Optional[Dict[str, Any]] = None search_options: Optional[Dict[str, Any]] = None
model_types: Optional[Sequence[str]] = None model_types: Optional[Sequence[str]] = None
@@ -359,10 +360,37 @@ class ModelFilterSet:
] ]
model_types_duration = time.perf_counter() - t0 model_types_duration = time.perf_counter() - t0
auto_tags_duration = 0
auto_tag_filters = criteria.auto_tags or {}
if auto_tag_filters:
t0 = time.perf_counter()
include_at = set()
exclude_at = set()
for tag, state in auto_tag_filters.items():
if not tag:
continue
if state == "exclude":
exclude_at.add(tag)
else:
include_at.add(tag)
if include_at:
items = [
item for item in items
if any(tag in include_at for tag in (item.get("auto_tags") or []))
]
if exclude_at:
items = [
item for item in items
if not any(tag in exclude_at for tag in (item.get("auto_tags") or []))
]
auto_tags_duration = time.perf_counter() - t0
duration = time.perf_counter() - overall_start duration = time.perf_counter() - overall_start
if duration > 0.1: # Only log if it's potentially slow if duration > 0.1: # Only log if it's potentially slow
logger.debug( logger.debug(
"ModelFilterSet.apply took %.3fs (sfw: %.3fs, fav: %.3fs, folder: %.3fs, base: %.3fs, tags: %.3fs, types: %.3fs). " "ModelFilterSet.apply took %.3fs (sfw: %.3fs, fav: %.3fs, folder: %.3fs, base: %.3fs, tags: %.3fs, types: %.3fs, auto_tags: %.3fs). "
"Count: %d -> %d", "Count: %d -> %d",
duration, duration,
sfw_duration, sfw_duration,
@@ -371,6 +399,7 @@ class ModelFilterSet:
base_models_duration, base_models_duration,
tags_duration, tags_duration,
model_types_duration, model_types_duration,
auto_tags_duration,
initial_count, initial_count,
len(items), len(items),
) )

View File

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

View File

@@ -507,21 +507,96 @@
background: rgba(0,0,0,0.18); /* Optional: subtle background for contrast */ background: rgba(0,0,0,0.18); /* Optional: subtle background for contrast */
} }
/* Version row — flex container for badges + version names */
.version-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 3px;
margin-top: 2px;
}
/* Badge + version-name binding: they wrap as a single unit */
.badge-version-unit {
display: inline-flex;
align-items: center;
gap: 3px;
min-width: 0;
flex-shrink: 0;
}
/* Medium density adjustments for version name */ /* Medium density adjustments for version name */
.medium-density .version-name { .medium-density .version-name {
font-size: 0.8em; font-size: 0.8em;
} }
.medium-density .badge-version-unit .version-name {
max-width: 90px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Compact density adjustments for version name */ /* Compact density adjustments for version name */
.compact-density .version-name { .compact-density .version-name {
font-size: 0.75em; font-size: 0.75em;
} }
/* Hide civitai version name when setting is disabled */ .compact-density .badge-version-unit .version-name {
body.hide-card-version .civitai-version { max-width: 70px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.medium-density .version-row {
gap: 2px;
}
/* HIGH / LOW badges — shown inline before version name in card footer */
.hl-badge {
display: inline-block;
font-size: 0.7em;
font-weight: 600;
line-height: 1.1;
padding: 1px 5px;
border-radius: var(--border-radius-xs);
border: 1px solid rgba(255, 255, 255, 0.2);
white-space: nowrap;
}
.hl-badge--high {
color: oklch(75% 0.12 230);
background: oklch(55% 0.15 240 / 0.25);
border-color: oklch(60% 0.18 250 / 0.3);
}
.hl-badge--low {
color: oklch(78% 0.10 185);
background: oklch(50% 0.10 190 / 0.25);
border-color: oklch(55% 0.12 195 / 0.3);
}
.medium-density .hl-badge {
font-size: 0.65em;
}
.compact-density .hl-badge {
font-size: 0.62em;
padding: 0px 4px;
}
/* Hide version-related elements when setting is disabled */
body.hide-card-version .civitai-version,
body.hide-card-version .hl-badge {
display: none; display: none;
} }
/* Compact density adjustments for version name */
.compact-density .version-name {
font-size: 0.75em;
}
/* Prevent text selection on cards and interactive elements */ /* Prevent text selection on cards and interactive elements */
.model-card, .model-card,
.model-card *, .model-card *,

View File

@@ -4,15 +4,20 @@
justify-content: flex-start; justify-content: flex-start;
align-items: flex-start; align-items: flex-start;
border-bottom: 1px solid var(--lora-border); border-bottom: 1px solid var(--lora-border);
padding-bottom: 10px; padding-bottom: var(--space-2);
margin-bottom: 10px; margin-bottom: var(--space-3);
position: relative;
} }
.recipe-modal-header h2 { .recipe-modal-header h2 {
font-size: 1.4em; /* Reduced from default h2 size */ margin: 0 0 var(--space-1);
line-height: 1.3; padding: var(--space-1);
margin: 0; border-radius: var(--border-radius-xs);
max-height: 2.6em; /* Limit to 2 lines */ font-size: 1.5em;
font-weight: 600;
line-height: 1.2;
color: var(--text-color);
max-height: 2.8em;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
display: -webkit-box; display: -webkit-box;
@@ -127,7 +132,7 @@
/* Recipe Tags styles */ /* Recipe Tags styles */
.recipe-tags-container { .recipe-tags-container {
position: relative; position: relative;
margin-top: 6px; margin-top: 0;
margin-bottom: 10px; margin-bottom: 10px;
} }
@@ -225,6 +230,62 @@
overflow: hidden; overflow: hidden;
} }
/* Recipe Header Actions */
.recipe-header-actions {
display: flex;
align-items: center;
gap: var(--space-2);
flex-wrap: wrap;
width: 100%;
margin-bottom: var(--space-1);
flex-shrink: 0;
min-height: 0;
}
.recipe-header-actions:empty {
display: none;
}
.recipe-source-url-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: rgba(0, 0, 0, 0.03);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: var(--border-radius-sm);
color: var(--text-color);
cursor: pointer;
font-weight: 500;
font-size: 0.9em;
transition: all 0.2s;
white-space: nowrap;
}
[data-theme="dark"] .recipe-source-url-btn {
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--lora-border);
}
.recipe-source-url-btn:hover {
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
border-color: var(--lora-accent);
transform: translateY(-1px);
}
.recipe-source-url-btn i {
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
}
@media (max-height: 860px) {
.recipe-header-actions {
padding-bottom: 4px;
}
}
/* Top Section: Preview and Gen Params */ /* Top Section: Preview and Gen Params */
.recipe-top-section { .recipe-top-section {
display: grid; display: grid;
@@ -1083,13 +1144,13 @@
} }
.recipe-modal-header { .recipe-modal-header {
padding-bottom: 6px; padding-bottom: var(--space-1);
margin-bottom: 8px; margin-bottom: var(--space-2);
} }
.recipe-modal-header h2 { .recipe-modal-header h2 {
font-size: 1.25em; font-size: 1.3em;
max-height: 2.5em; max-height: 2.4em;
} }
.recipe-tags-container { .recipe-tags-container {

View File

@@ -978,6 +978,16 @@ export class BaseModelApiClient {
}); });
} }
if (pageState.filters.autoTags && Object.keys(pageState.filters.autoTags).length > 0) {
Object.entries(pageState.filters.autoTags).forEach(([tag, state]) => {
if (state === 'include') {
params.append('auto_tag_include', tag);
} else if (state === 'exclude') {
params.append('auto_tag_exclude', tag);
}
});
}
if (pageState.filters.baseModel && pageState.filters.baseModel.length > 0) { if (pageState.filters.baseModel && pageState.filters.baseModel.length > 0) {
// Check for empty wildcard marker - if present, no models should match // Check for empty wildcard marker - if present, no models should match
const EMPTY_WILDCARD_MARKER = '__EMPTY_WILDCARD_RESULT__'; const EMPTY_WILDCARD_MARKER = '__EMPTY_WILDCARD_RESULT__';

View File

@@ -383,6 +383,7 @@ class RecipeModal {
this.syncGenerationParams(hydratedRecipe.gen_params); this.syncGenerationParams(hydratedRecipe.gen_params);
this.syncResourcesSection(hydratedRecipe); this.syncResourcesSection(hydratedRecipe);
this.syncSourceUrlAction();
// Show the modal // Show the modal
modalManager.showModal('recipeModal'); modalManager.showModal('recipeModal');
@@ -515,6 +516,7 @@ class RecipeModal {
} else { } else {
this.updateSourceUrlDisplay(this.currentRecipe.source_path || ''); this.updateSourceUrlDisplay(this.currentRecipe.source_path || '');
} }
this.syncSourceUrlAction();
} }
getPreviewMediaUrl(recipe = {}) { getPreviewMediaUrl(recipe = {}) {
@@ -582,6 +584,30 @@ class RecipeModal {
} }
} }
syncSourceUrlAction() {
const actionsContainer = document.getElementById('recipeHeaderActions');
if (!actionsContainer) {
return;
}
actionsContainer.innerHTML = '';
const sourcePath = this.currentRecipe?.source_path || '';
const isValidUrl = sourcePath.startsWith('http://') || sourcePath.startsWith('https://');
if (!isValidUrl) {
return;
}
const btn = document.createElement('button');
btn.className = 'recipe-source-url-btn';
btn.title = sourcePath;
btn.innerHTML = '<i class="fas fa-globe"></i> Open Source URL';
btn.addEventListener('click', () => {
window.open(sourcePath, '_blank');
});
actionsContainer.appendChild(btn);
}
syncTagsDisplay(tags) { syncTagsDisplay(tags) {
const tagsContainer = document.getElementById('recipeTagsCompact'); const tagsContainer = document.getElementById('recipeTagsCompact');
if (!tagsContainer) { if (!tagsContainer) {
@@ -1316,6 +1342,7 @@ class RecipeModal {
// Update source URL in the UI // Update source URL in the UI
this.commitField('source_path'); this.commitField('source_path');
this.updateSourceUrlDisplay(newSourceUrl, { forceInputSync: true }); this.updateSourceUrlDisplay(newSourceUrl, { forceInputSync: true });
this.syncSourceUrlAction();
// Update the current recipe object // Update the current recipe object
this.currentRecipe.source_path = newSourceUrl; this.currentRecipe.source_path = newSourceUrl;

View File

@@ -644,8 +644,23 @@ export function createModelCard(model, modelType) {
<div class="card-footer"> <div class="card-footer">
<div class="model-info"> <div class="model-info">
<span class="model-name" title="${getDisplayName(model).replace(/"/g, '&quot;')}">${getDisplayName(model)}</span> <span class="model-name" title="${getDisplayName(model).replace(/"/g, '&quot;')}">${getDisplayName(model)}</span>
<div> <div class="version-row">
${model.civitai?.name ? `<span class="version-name civitai-version">${model.civitai.name}</span>` : ''} ${(() => {
const autoTags = model.auto_tags || [];
const hlTags = autoTags.filter(t => t === 'HIGH' || t === 'LOW');
const hasVersionName = model.civitai?.name;
if (!hlTags.length && !hasVersionName) return '';
const density = state.global.settings.display_density || 'default';
const shortLabels = density === 'medium' || density === 'compact';
const badges = hlTags.map(t => {
const cls = t === 'HIGH' ? 'hl-badge hl-badge--high' : 'hl-badge hl-badge--low';
const label = shortLabels ? (t === 'HIGH' ? 'H' : 'L') : t;
const titleAttr = shortLabels ? ` title="${t}"` : '';
return `<span class="${cls}"${titleAttr}>${label}</span>`;
}).join('');
const versionHtml = hasVersionName ? `<span class="version-name civitai-version">${model.civitai.name}</span>` : '';
return `<span class="badge-version-unit">${badges}${versionHtml}</span>`;
})()}
${hasUsageCount ? `<span class="version-name" title="${translate('modelCard.usage.timesUsed', {}, 'Times used')}">${model.usage_count}×</span>` : ''} ${hasUsageCount ? `<span class="version-name" title="${translate('modelCard.usage.timesUsed', {}, 'Times used')}">${model.usage_count}×</span>` : ''}
</div> </div>
</div> </div>

View File

@@ -70,6 +70,9 @@ export class FilterManager {
// Initialize tag logic toggle // Initialize tag logic toggle
this.initializeTagLogicToggle(); this.initializeTagLogicToggle();
// Create auto-tag filter section (I2V, T2V, TI2V, Lightning, Turbo)
this.createAutoTagFilters();
// Add click handler for filter button // Add click handler for filter button
if (this.filterButton) { if (this.filterButton) {
this.filterButton.addEventListener('click', () => { this.filterButton.addEventListener('click', () => {
@@ -480,6 +483,58 @@ export class FilterManager {
} }
} }
AUTO_TAG_FILTER_TAGS = ['I2V', 'T2V', 'TI2V', 'Lightning', 'Turbo'];
createAutoTagFilters() {
const container = document.getElementById('autoTagFilterTags');
if (container) return;
const modelTypeSection = document.getElementById('modelTypeTags')?.closest('.filter-section');
if (!modelTypeSection) return;
const section = document.createElement('div');
section.className = 'filter-section';
section.innerHTML = `
<h4>${translate('header.filter.autoTags', {}, 'Auto Tags')}</h4>
<div class="filter-tags" id="autoTagFilterTags"></div>
`;
modelTypeSection.parentNode.insertBefore(section, modelTypeSection.nextSibling);
const tagsContainer = document.getElementById('autoTagFilterTags');
this.AUTO_TAG_FILTER_TAGS.forEach(tag => {
const el = document.createElement('div');
el.className = 'filter-tag auto-tag-filter';
el.dataset.autoTag = tag;
el.textContent = tag;
// Restore previous state
const state = (this.filters.autoTags && this.filters.autoTags[tag]) || 'none';
this._applyTriState(el, state);
el.addEventListener('click', async () => {
const current = (this.filters.autoTags && this.filters.autoTags[tag]) || 'none';
const next = current === 'none' ? 'include' : current === 'include' ? 'exclude' : 'none';
if (!this.filters.autoTags) this.filters.autoTags = {};
if (next === 'none') {
delete this.filters.autoTags[tag];
} else {
this.filters.autoTags[tag] = next;
}
this._applyTriState(el, next);
this.updateActiveFiltersCount();
await this.applyFilters(false);
});
tagsContainer.appendChild(el);
});
}
_applyTriState(el, state) {
el.classList.remove('active', 'exclude');
if (state === 'include') el.classList.add('active');
else if (state === 'exclude') el.classList.add('exclude');
}
toggleFilterPanel() { toggleFilterPanel() {
if (this.filterPanel) { if (this.filterPanel) {
const isHidden = this.filterPanel.classList.contains('hidden'); const isHidden = this.filterPanel.classList.contains('hidden');
@@ -540,6 +595,13 @@ export class FilterManager {
this.updateLicenseSelections(); this.updateLicenseSelections();
} }
this.updateModelTypeSelections(); this.updateModelTypeSelections();
const autoTagEls = document.querySelectorAll('.auto-tag-filter');
autoTagEls.forEach(el => {
const tag = el.dataset.autoTag;
const state = (this.filters.autoTags && this.filters.autoTags[tag]) || 'none';
this._applyTriState(el, state);
});
} }
updateModelTypeSelections() { updateModelTypeSelections() {
@@ -556,11 +618,12 @@ export class FilterManager {
updateActiveFiltersCount() { updateActiveFiltersCount() {
const tagFilterCount = this.filters.tags ? Object.keys(this.filters.tags).length : 0; const tagFilterCount = this.filters.tags ? Object.keys(this.filters.tags).length : 0;
const autoTagFilterCount = this.filters.autoTags ? Object.keys(this.filters.autoTags).length : 0;
const licenseFilterCount = this.filters.license ? Object.keys(this.filters.license).length : 0; const licenseFilterCount = this.filters.license ? Object.keys(this.filters.license).length : 0;
const modelTypeFilterCount = this.filters.modelTypes.length; const modelTypeFilterCount = this.filters.modelTypes.length;
// Exclude EMPTY_WILDCARD_MARKER from base model count // Exclude EMPTY_WILDCARD_MARKER from base model count
const baseModelCount = this.filters.baseModel.filter(m => m !== EMPTY_WILDCARD_MARKER).length; const baseModelCount = this.filters.baseModel.filter(m => m !== EMPTY_WILDCARD_MARKER).length;
const totalActiveFilters = baseModelCount + tagFilterCount + licenseFilterCount + modelTypeFilterCount; const totalActiveFilters = baseModelCount + tagFilterCount + autoTagFilterCount + licenseFilterCount + modelTypeFilterCount;
if (this.activeFiltersCount) { if (this.activeFiltersCount) {
if (totalActiveFilters > 0) { if (totalActiveFilters > 0) {
@@ -652,6 +715,7 @@ export class FilterManager {
...this.filters, ...this.filters,
baseModel: [], baseModel: [],
tags: {}, tags: {},
autoTags: {},
license: {}, license: {},
modelTypes: [], modelTypes: [],
tagLogic: 'any' tagLogic: 'any'
@@ -721,6 +785,7 @@ export class FilterManager {
hasActiveFilters() { hasActiveFilters() {
const tagCount = this.filters.tags ? Object.keys(this.filters.tags).length : 0; const tagCount = this.filters.tags ? Object.keys(this.filters.tags).length : 0;
const autoTagCount = this.filters.autoTags ? Object.keys(this.filters.autoTags).length : 0;
const licenseCount = this.filters.license ? Object.keys(this.filters.license).length : 0; const licenseCount = this.filters.license ? Object.keys(this.filters.license).length : 0;
const modelTypeCount = this.filters.modelTypes.length; const modelTypeCount = this.filters.modelTypes.length;
// Exclude EMPTY_WILDCARD_MARKER from base model count // Exclude EMPTY_WILDCARD_MARKER from base model count
@@ -728,6 +793,7 @@ export class FilterManager {
return ( return (
baseModelCount > 0 || baseModelCount > 0 ||
tagCount > 0 || tagCount > 0 ||
autoTagCount > 0 ||
licenseCount > 0 || licenseCount > 0 ||
modelTypeCount > 0 modelTypeCount > 0
); );
@@ -739,6 +805,7 @@ export class FilterManager {
...source, ...source,
baseModel: Array.isArray(source.baseModel) ? [...source.baseModel] : [], baseModel: Array.isArray(source.baseModel) ? [...source.baseModel] : [],
tags: this.normalizeTagFilters(source.tags), tags: this.normalizeTagFilters(source.tags),
autoTags: this.normalizeTagFilters(source.autoTags),
license: this.shouldShowLicenseFilters() ? this.normalizeLicenseFilters(source.license) : {}, license: this.shouldShowLicenseFilters() ? this.normalizeLicenseFilters(source.license) : {},
modelTypes: this.normalizeModelTypeFilters(source.modelTypes), modelTypes: this.normalizeModelTypeFilters(source.modelTypes),
tagLogic: source.tagLogic || 'any' tagLogic: source.tagLogic || 'any'
@@ -822,6 +889,7 @@ export class FilterManager {
...this.filters, ...this.filters,
baseModel: [...(this.filters.baseModel || [])], baseModel: [...(this.filters.baseModel || [])],
tags: { ...(this.filters.tags || {}) }, tags: { ...(this.filters.tags || {}) },
autoTags: { ...(this.filters.autoTags || {}) },
license: { ...(this.filters.license || {}) }, license: { ...(this.filters.license || {}) },
modelTypes: [...(this.filters.modelTypes || [])], modelTypes: [...(this.filters.modelTypes || [])],
tagLogic: this.filters.tagLogic || 'any' tagLogic: this.filters.tagLogic || 'any'

View File

@@ -500,6 +500,18 @@ export function clearDynamicBaseModels() {
dynamicBaseModelsTimestamp = null; dynamicBaseModelsTimestamp = null;
} }
export const AUTO_TAG_GROUPS = {
mode: new Set(['HIGH', 'LOW']),
video: new Set(['I2V', 'T2V', 'TI2V']),
speed: new Set(['Lightning', 'Turbo']),
};
export const AUTO_TAG_GROUP_LABELS = {
mode: 'High / Low',
video: 'I2V / T2V / TI2V',
speed: 'Lightning / Turbo',
};
/** /**
* Check if dynamic base models cache is valid * Check if dynamic base models cache is valid
* @returns {boolean} * @returns {boolean}

View File

@@ -4,6 +4,8 @@
<header class="recipe-modal-header"> <header class="recipe-modal-header">
<h2 id="recipeModalTitle">Recipe Details</h2> <h2 id="recipeModalTitle">Recipe Details</h2>
<!-- Header Actions: populated dynamically in RecipeModal.js -->
<div class="recipe-header-actions" id="recipeHeaderActions"></div>
<!-- Recipe Tags Container --> <!-- Recipe Tags Container -->
<div class="recipe-tags-container"> <div class="recipe-tags-container">
<div class="recipe-tags-compact" id="recipeTagsCompact"></div> <div class="recipe-tags-compact" id="recipeTagsCompact"></div>

View File

@@ -785,10 +785,16 @@ async def test_import_remote_recipe_merges_metadata(
async def parse_metadata(self, raw, recipe_scanner=None): async def parse_metadata(self, raw, recipe_scanner=None):
return json.loads(raw[len("Recipe metadata: ") :]) return json.loads(raw[len("Recipe metadata: ") :])
class MockApiParser:
async def parse_metadata(self, raw, recipe_scanner=None):
return {"gen_params": raw, "loras": []}
class MockFactory: class MockFactory:
def create_parser(self, raw): def create_parser(self, raw):
if raw.startswith("Recipe metadata: "): if isinstance(raw, str) and raw.startswith("Recipe metadata: "):
return MockParser() return MockParser()
if isinstance(raw, dict):
return MockApiParser()
return None return None
# 4. Setup Harness and run test # 4. Setup Harness and run test

View File

@@ -222,7 +222,7 @@ async def test_get_model_versions_raises_on_other_errors(monkeypatch, downloader
async def test_get_model_versions_bulk_success(monkeypatch, downloader): async def test_get_model_versions_bulk_success(monkeypatch, downloader):
async def fake_make_request(method, url, use_auth=True, **kwargs): async def fake_make_request(method, url, use_auth=True, **kwargs):
assert url.endswith("/models") assert url.endswith("/models")
assert kwargs.get("params") == {"ids": "1,2"} assert kwargs.get("params") == {"ids": "1,2", "nsfw": "true"}
return True, { return True, {
"items": [ "items": [
{ {

View File

@@ -0,0 +1,151 @@
import pytest
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "py"))
from services.auto_tag_service import extract_auto_tags, AUTO_TAG_CATEGORIES
class TestExtractAutoTags:
def test_file_name_high_i2v(self):
result = extract_auto_tags({
"file_name": "Shirt_lift_Wan2.2_14B_I2V_HIGH_v1.0",
"base_model": "Wan Video 2.2 I2V-A14B",
"civitai": {},
})
assert set(result) == {"HIGH", "I2V"}
def test_file_name_t2v_low(self):
result = extract_auto_tags({
"file_name": "my_wan_t2v_low_v2",
"base_model": "Wan 2.1",
"civitai": {},
})
assert set(result) == {"LOW", "T2V"}
def test_file_name_ti2v_high(self):
result = extract_auto_tags({
"file_name": "wan_ti2v_high_quality",
"base_model": "Wan 2.2",
"civitai": {},
})
assert set(result) == {"HIGH", "TI2V"}
def test_file_name_lightning_turbo(self):
result = extract_auto_tags({
"file_name": "sdxl_lightning_turbo_v3",
"base_model": "SDXL",
"civitai": {},
})
assert set(result) == {"Lightning", "Turbo"}
def test_base_model_source(self):
result = extract_auto_tags({
"file_name": "my_lora_v1",
"base_model": "Wan Video 2.2 I2V-A14B",
"civitai": {},
})
assert "I2V" in result
def test_civitai_name_source(self):
result = extract_auto_tags({
"file_name": "model_v1",
"base_model": "Wan",
"civitai": {"name": "HIGH Quality"},
})
assert "HIGH" in result
def test_no_false_match_flow(self):
result = extract_auto_tags({
"file_name": "flux_dev_model",
"base_model": "Flux.1 D",
"civitai": {},
})
assert "LOW" not in result
def test_no_false_match_glow(self):
result = extract_auto_tags({
"file_name": "glow_style_lora",
"base_model": "SDXL",
"civitai": {},
})
assert "LOW" not in result
def test_high_low_only_for_wan(self):
"""HIGH/LOW should not appear for non-Wan models even in filename."""
result = extract_auto_tags({
"file_name": "my_model_high_quality_v2",
"base_model": "Flux.1 D",
"civitai": {"name": "HIGH"},
})
assert "HIGH" not in result
assert "LOW" not in result
def test_no_distilled(self):
result = extract_auto_tags({
"file_name": "ltx-2.3-22b-distilled-lora-384",
"base_model": "LTXV 2.3",
"civitai": {},
})
assert result == []
def test_empty(self):
result = extract_auto_tags({
"file_name": "generic_lora_v1",
"base_model": "SDXL",
"civitai": {},
})
assert result == []
def test_missing_fields(self):
result = extract_auto_tags({})
assert result == []
def test_dash_separated(self):
result = extract_auto_tags({
"file_name": "wan-i2v-high-v2",
"base_model": "Wan 2.2",
"civitai": {},
})
assert set(result) == {"HIGH", "I2V"}
def test_dot_separated(self):
result = extract_auto_tags({
"file_name": "wan.i2v.high.v2",
"base_model": "Wan 2.2",
"civitai": {},
})
assert set(result) == {"HIGH", "I2V"}
def test_case_insensitive(self):
result = extract_auto_tags({
"file_name": "WAN_i2v_High",
"base_model": "Wan 2.2",
"civitai": {},
})
assert set(result) == {"HIGH", "I2V"}
class TestAutoTagCategories:
def test_all_patterns_compile(self):
import re
for label, pattern in AUTO_TAG_CATEGORIES.items():
re.compile(pattern, re.IGNORECASE)
def test_mode_group_tags(self):
from services.auto_tag_service import MODE_TAGS
assert "HIGH" in MODE_TAGS
assert "LOW" in MODE_TAGS
def test_video_group_tags(self):
from services.auto_tag_service import VIDEO_MODE_TAGS
assert "I2V" in VIDEO_MODE_TAGS
assert "T2V" in VIDEO_MODE_TAGS
assert "TI2V" in VIDEO_MODE_TAGS
def test_default_enabled_groups(self):
from services.auto_tag_service import DEFAULT_ENABLED_GROUPS
assert "mode" in DEFAULT_ENABLED_GROUPS
assert "video" in DEFAULT_ENABLED_GROUPS
assert "speed" not in DEFAULT_ENABLED_GROUPS

View File

@@ -658,32 +658,34 @@ export function addTagsWidget(node, name, opts, callback, wheelSensitivity = 0.0
textEl.style.maxWidth = "140px"; textEl.style.maxWidth = "140px";
} }
const countBadge = document.createElement("span"); if (tagData.items.length > 1) {
countBadge.className = "lm-trigger-count-badge"; const countBadge = document.createElement("span");
countBadge.textContent = `${groupState.activeChildren}/${groupState.totalChildren}`; countBadge.className = "lm-trigger-count-badge";
Object.assign(countBadge.style, { countBadge.textContent = `${groupState.activeChildren}/${groupState.totalChildren}`;
fontSize: "11px",
padding: "1px 6px",
borderRadius: "999px",
backgroundColor: "rgba(255,255,255,0.12)",
color: "inherit",
flexShrink: "0",
boxSizing: "border-box",
minWidth: "42px",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
lineHeight: "1",
fontVariantNumeric: "tabular-nums",
});
if (groupState.hasInactiveChildren) {
countBadge.classList.add("lm-trigger-count-badge--edited");
Object.assign(countBadge.style, { Object.assign(countBadge.style, {
backgroundColor: "rgba(255,255,255,0.08)", fontSize: "11px",
boxShadow: "inset 0 0 0 1px rgba(255,255,255,0.28)", padding: "1px 6px",
borderRadius: "999px",
backgroundColor: "rgba(255,255,255,0.12)",
color: "inherit",
flexShrink: "0",
boxSizing: "border-box",
minWidth: "42px",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
lineHeight: "1",
fontVariantNumeric: "tabular-nums",
}); });
if (groupState.hasInactiveChildren) {
countBadge.classList.add("lm-trigger-count-badge--edited");
Object.assign(countBadge.style, {
backgroundColor: "rgba(255,255,255,0.08)",
boxShadow: "inset 0 0 0 1px rgba(255,255,255,0.28)",
});
}
groupChip.appendChild(countBadge);
} }
groupChip.appendChild(countBadge);
if (showStrengthInfo) { if (showStrengthInfo) {
const strengthBadge = createStrengthBadge(); const strengthBadge = createStrengthBadge();
@@ -697,39 +699,43 @@ export function addTagsWidget(node, name, opts, callback, wheelSensitivity = 0.0
groupChip.title = activePreview ? `${tagData.text}\nActive: ${activePreview}` : tagData.text; groupChip.title = activePreview ? `${tagData.text}\nActive: ${activePreview}` : tagData.text;
} }
const editButton = document.createElement("button"); let editButton = null;
editButton.type = "button";
editButton.className = "lm-trigger-group-edit-button";
editButton.textContent = "⋯";
Object.assign(editButton.style, {
border: "none",
background: "transparent",
color: "inherit",
cursor: "pointer",
fontSize: "14px",
lineHeight: "1",
padding: "0 2px",
marginLeft: "2px",
opacity: groupState.hasInactiveChildren ? "0.9" : "0.72",
flexShrink: "0",
});
editButton.title = "Edit group tags";
const openEditor = (event) => { if (tagData.items.length > 1) {
event.preventDefault(); editButton = document.createElement("button");
event.stopPropagation(); editButton.type = "button";
toggleGroupEditor(widget, index, groupChip); editButton.className = "lm-trigger-group-edit-button";
renderGroupEditor(widget, tagData, index); editButton.textContent = "⋯";
}; Object.assign(editButton.style, {
border: "none",
background: "transparent",
color: "inherit",
cursor: "pointer",
fontSize: "14px",
lineHeight: "1",
padding: "0 2px",
marginLeft: "2px",
opacity: groupState.hasInactiveChildren ? "0.9" : "0.72",
flexShrink: "0",
});
editButton.title = "Edit group tags";
editButton.addEventListener("click", openEditor); const openEditor = (event) => {
groupChip.addEventListener("contextmenu", openEditor); event.preventDefault();
event.stopPropagation();
toggleGroupEditor(widget, index, groupChip);
renderGroupEditor(widget, tagData, index);
};
groupChip.appendChild(editButton); editButton.addEventListener("click", openEditor);
groupChip.addEventListener("contextmenu", openEditor);
groupChip.appendChild(editButton);
}
groupChip.addEventListener("click", (e) => { groupChip.addEventListener("click", (e) => {
e.stopPropagation(); e.stopPropagation();
if (e.target === editButton) { if (editButton && e.target === editButton) {
return; return;
} }
updateWidgetValue(widget, (updatedTags) => { updateWidgetValue(widget, (updatedTags) => {
@@ -740,7 +746,7 @@ export function addTagsWidget(node, name, opts, callback, wheelSensitivity = 0.0
if (showStrengthInfo) { if (showStrengthInfo) {
groupChip.addEventListener("wheel", (e) => { groupChip.addEventListener("wheel", (e) => {
if (e.target === editButton) { if (editButton && e.target === editButton) {
return; return;
} }
e.preventDefault(); e.preventDefault();

View File

@@ -303,6 +303,8 @@ app.registerExtension({
return; return;
} }
const groupMode = groupModeWidget?.value ?? false;
const updatedTags = node.tagWidget.value.map((tag) => { const updatedTags = node.tagWidget.value.map((tag) => {
if (!Array.isArray(tag.items)) { if (!Array.isArray(tag.items)) {
return { return {
@@ -311,6 +313,15 @@ app.registerExtension({
}; };
} }
// In group mode, default_active only controls the group-level switch.
// Children's individual active states are managed exclusively via the group editor.
if (groupMode) {
return {
...tag,
active: value,
};
}
return { return {
...tag, ...tag,
active: value, active: value,
@@ -320,7 +331,6 @@ app.registerExtension({
})), })),
}; };
}); });
node.tagWidget.value = updatedTags; node.tagWidget.value = updatedTags;
node.applyTriggerHighlightState?.(); node.applyTriggerHighlightState?.();
}; };